Lead Radar: исходный код

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
yuriandClaude Opus 5.5 committed 2026-10-25 23:11:04 +04:00
commit 9749da5ae7
201 files changed
+97685

No files matched your search

+17
View File
@@ -0,0 +1,17 @@
<!doctype html>
<html lang="ru">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<meta name="theme-color" content="#020617" />
<title>Y&amp;P Lead Radar</title>
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Geologica:wght@300..700&family=Noto+Sans+Armenian:wght@300..700&display=swap" rel="stylesheet" />
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+28
View File
@@ -0,0 +1,28 @@
{
"name": "@lr/web",
"version": "0.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -p tsconfig.json --noEmit && vite build",
"typecheck": "tsc -p tsconfig.json --noEmit",
"preview": "vite preview"
},
"dependencies": {
"@lr/shared": "workspace:*",
"@tanstack/react-query": "^5.104.1",
"lucide-react": "^1.53.0",
"react": "^19.3.0",
"react-dom": "^19.3.0",
"react-router": "^8.4.0"
},
"devDependencies": {
"@tailwindcss/vite": "^4.3.3",
"@types/react": "^19.3.0",
"@types/react-dom": "^19.3.0",
"@vitejs/plugin-react": "^6.1.2",
"tailwindcss": "^4.3.3",
"vite": "^8.3.4"
}
}
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><rect width="32" height="32" rx="8" fill="#020617"/><circle cx="16" cy="16" r="10" fill="none" stroke="#22D3EE" stroke-opacity=".45" stroke-width="1.5"/><circle cx="16" cy="16" r="5" fill="none" stroke="#22D3EE" stroke-opacity=".7" stroke-width="1.5"/><circle cx="22" cy="10" r="2.5" fill="#34D399"/></svg>

After

Width:  |  Height:  |  Size: 367 B

+110
View File
@@ -0,0 +1,110 @@
import { Navigate, Route, Routes, useLocation, useSearchParams } from 'react-router';
import { SECTIONS, Shell } from './components/Shell.tsx';
import { useMe } from './hooks.ts';
import { useLang } from './i18n.tsx';
import { AdminPage } from './pages/Admin.tsx';
import { ForgotPasswordPage, LoginPage, nextPath, ResetPasswordPage, VerifyEmailPage } from './pages/AuthPages.tsx';
import { InvitePage, OnboardingPage } from './pages/Onboarding.tsx';
import { SettingsPage } from './pages/Settings.tsx';
import { BillingPage } from './account/BillingPage.tsx';
import { InvoicesPage } from './account/InvoicesPage.tsx';
import { MessagesPage } from './account/MessagesPage.tsx';
import { HelpPage, SupportPage } from './account/SupportPage.tsx';
import { TelegramOnlyPage } from './account/TelegramOnly.tsx';
import { Wizard } from './account/Wizard.tsx';
import { TeamPage } from './pages/Team.tsx';
import { ShowcasePage } from './public/ShowcasePage.tsx';
import { AnalyticsPage } from './crm/AnalyticsPage.tsx';
import { ArchivePage } from './crm/ArchivePage.tsx';
import { ClientsPage } from './crm/ClientsPage.tsx';
import { FiltersPage } from './crm/FiltersPage.tsx';
import { LeadsPage } from './crm/LeadsPage.tsx';
import { ListingsPage } from './crm/ListingsPage.tsx';
import { ParserPage } from './crm/ParserPage.tsx';
export function App() {
const { t } = useLang();
const me = useMe();
const location = useLocation();
const [params] = useSearchParams();
if (me.isPending) return <p className="p-8 text-muted">{t.common.loading}</p>;
const user = me.data;
// Доступно без входа и до создания компании.
const open = (
<>
<Route path="/invite/:token" element={<InvitePage />} />
<Route path="/verify-email" element={<VerifyEmailPage />} />
<Route path="/reset-password" element={<ResetPasswordPage />} />
{/* Страница подборки для клиента: <компания>/<код>. Адреса разделов кабинета точнее и побеждают её. */}
<Route path="/:company/:token" element={<ShowcasePage />} />
</>
);
if (!user) {
return (
<Routes>
{open}
<Route path="/login" element={<LoginPage mode="login" />} />
<Route path="/register" element={<LoginPage mode="register" />} />
<Route path="/forgot-password" element={<ForgotPasswordPage />} />
<Route path="*" element={<Navigate to="/login" replace state={{ from: location.pathname }} />} />
</Routes>
);
}
// Только что вошёл: возвращаем туда, откуда пришёл (например, на страницу приглашения).
if (['/login', '/register', '/forgot-password'].includes(location.pathname)) {
return <Navigate to={nextPath(params)} replace />;
}
if (!user.company && !user.user.isSuperadmin) {
return (
<Routes>
{open}
<Route path="*" element={<OnboardingPage />} />
</Routes>
);
}
// Мастер первой настройки — один раз, когда доступ уже открыт и рабочие разделы доступны.
const canWork = user.company && !user.webBlocked && (user.company.access === 'active' || user.company.access === 'overdue');
if (canWork && !user.user.onboarded && !user.impersonating) {
return (
<Routes>
{open}
<Route path="*" element={<Wizard me={user} />} />
</Routes>
);
}
const visible = SECTIONS.filter((s) => s.visible(user));
const allowed = (path: string) => visible.some((s) => s.path === path);
const home = visible[0]?.path ?? '/settings';
return (
<Routes>
{open}
<Route element={<Shell me={user} />}>
{allowed('/leads') && <Route path="/leads/:id?" element={<LeadsPage me={user} />} />}
{allowed('/clients') && <Route path="/clients/:id?" element={<ClientsPage me={user} />} />}
{allowed('/archive') && <Route path="/archive" element={<ArchivePage me={user} />} />}
{allowed('/analytics') && <Route path="/analytics" element={<AnalyticsPage />} />}
{allowed('/filters') && <Route path="/filters" element={<FiltersPage me={user} />} />}
{allowed('/listings') && <Route path="/listings" element={<ListingsPage />} />}
{allowed('/parser') && <Route path="/parser" element={<ParserPage />} />}
{allowed('/team') && <Route path="/team" element={<TeamPage me={user} />} />}
{allowed('/billing') && <Route path="/billing" element={<BillingPage me={user} />} />}
{allowed('/admin') && <Route path="/admin" element={<AdminPage />} />}
{allowed('/invoices') && <Route path="/invoices" element={<InvoicesPage />} />}
{allowed('/messages') && <Route path="/messages" element={<MessagesPage />} />}
{allowed('/support') && <Route path="/support/:id?" element={<SupportPage />} />}
{allowed('/help') && <Route path="/help" element={<HelpPage me={user} />} />}
{allowed('/telegram') && <Route path="/telegram" element={<TelegramOnlyPage me={user} />} />}
<Route path="/settings" element={<SettingsPage key={user.company?.id ?? 0} me={user} />} />
<Route path="*" element={<Navigate to={home} replace />} />
</Route>
</Routes>
);
}
+301
View File
@@ -0,0 +1,301 @@
import { PLAN_SPECS, PLANS, type BillingModel, type Plan } from '@lr/shared';
import { useQuery } from '@tanstack/react-query';
import { Fragment, useState, type ReactNode } from 'react';
import { api, type Billing, type Invoice, type Me, type PercentDeal } from '../api.ts';
import { AccessChip, ErrorNote, PageTitle } from '../components/ui.tsx';
import { Dialog } from '../crm/parts.tsx';
import { useAction } from '../hooks.ts';
import { useAcc } from './i18n.ts';
const LOCALES = { ru: 'ru-RU', hy: 'hy-AM', en: 'en-GB' } as const;
export const INVOICE_TONE = {
pending: 'bg-warn/15 text-warn',
paid: 'bg-capture/15 text-capture',
canceled: 'bg-raised text-muted',
} as const;
/** «За что» счёт: вид и период. */
export function useInvoiceLabel() {
const { a, date } = useAcc();
return (i: Pick<Invoice, 'kind' | 'periodStart' | 'periodEnd'>) => {
const period =
i.periodStart && i.periodEnd ? a.bill.period(date(i.periodStart), date(i.periodEnd)) : i.kind === 'fixed' ? a.bill.firstMonth : '';
return [a.bill.kind[i.kind], period].filter(Boolean).join(', ');
};
}
/** Расшифровка счёта по комиссии: сделки, курс, сумма. */
export function DealsTable({ deals, rates }: { deals: PercentDeal[]; rates?: Record<string, number> }) {
const { a, lang, usd } = useAcc();
const d = a.bill.deal;
const shortDate = (day: string) => new Date(day).toLocaleDateString(LOCALES[lang], { day: 'numeric', month: 'short' });
if (!deals.length) return <p className="text-sm text-muted">{a.bill.noDeals}</p>;
const used = [...new Set(deals.map((x) => x.currency))].filter((c) => c !== 'USD' && rates?.[c]);
return (
<div className="overflow-x-auto">
<table className="w-full text-left text-sm sm:min-w-[30rem]">
<thead className="text-muted">
<tr>
{[d.date, d.title, d.agent, d.commission, d.usd].map((h, i) => (
<th key={h} className={`py-1.5 pr-3 font-normal last:pr-0 ${i > 2 ? 'text-right' : ''} ${i === 2 ? 'hidden sm:table-cell' : ''}`}>{h}</th>
))}
</tr>
</thead>
<tbody>
{deals.map((x) => (
<tr key={x.dealId} className="border-t border-line">
<td className="py-1.5 pr-3 tabular-nums whitespace-nowrap">{shortDate(x.date)}</td>
<td className="py-1.5 pr-3">{x.title || '—'}<span className="block text-xs text-muted sm:hidden">{x.agent}</span></td>
<td className="hidden py-1.5 pr-3 sm:table-cell">{x.agent}</td>
<td className="py-1.5 pr-3 text-right tabular-nums whitespace-nowrap">{x.commission.toLocaleString('ru-RU')} {x.currency}</td>
<td className="py-1.5 text-right tabular-nums whitespace-nowrap">{usd(x.usd)}</td>
</tr>
))}
</tbody>
</table>
{used.length > 0 && <p className="mt-2 text-xs text-muted">{used.map((c) => a.bill.rate(c, rates![c]!)).join(' · ')}</p>}
</div>
);
}
function PlanDialog({ bill, members, onClose }: { bill: Billing; members: number; onClose: () => void }) {
const { a, t, date } = useAcc();
const b = a.bill;
const [plan, setPlan] = useState<Plan>(bill.planned?.plan ?? bill.plan);
const [model, setModel] = useState<BillingModel>(bill.planned?.billingModel ?? bill.billingModel);
const spec = PLAN_SPECS[plan];
const billingModel = spec.commissionPercent === null ? 'fixed' : model;
const save = useAction(() => api('/billing/plan', { method: 'POST', body: { plan, billingModel } }));
const same = plan === bill.plan && billingModel === bill.billingModel;
const setupDue = spec.setupFeeUsd > 0 && PLAN_SPECS[bill.plan].setupFeeUsd === 0;
return (
<Dialog title={b.changeTitle} onClose={onClose} wide>
<form className="space-y-5" onSubmit={(e) => { e.preventDefault(); save.mutate(undefined, { onSuccess: onClose }); }}>
<fieldset className="grid gap-3 sm:grid-cols-3">
<legend className="sr-only">{t.billing.plan}</legend>
{PLANS.map((p) => {
const s = PLAN_SPECS[p];
const active = plan === p;
const tooSmall = members > s.maxMembers;
return (
<label
key={p}
className={`card flex flex-col p-4 transition-colors has-focus-visible:outline-2 has-focus-visible:outline-signal ${
tooSmall ? 'opacity-45' : 'cursor-pointer'
} ${active ? 'border-capture/50 bg-raised' : 'hover:border-line-strong'}`}
>
<input type="radio" name="plan" className="sr-only" disabled={tooSmall} checked={active} onChange={() => setPlan(p)} />
<span className="font-medium">{t.plans[p]}</span>
<span className="text-sm text-muted">{s.maxMembers === 1 ? t.onboarding.members1 : t.onboarding.membersUpTo(s.maxMembers)}</span>
<span className="mt-2 text-lg font-semibold tabular-nums">{t.onboarding.perMonth(s.monthlyUsd)}</span>
{s.commissionPercent && <span className="text-sm text-muted">{t.onboarding.orPercent(s.commissionPercent)}</span>}
</label>
);
})}
</fieldset>
<fieldset>
<legend className="label">{t.billing.model}</legend>
<div className="flex flex-col gap-2">
{(['fixed', 'percent'] as const).map((m) => {
const disabled = m === 'percent' && spec.commissionPercent === null;
return (
<label key={m} className={`flex items-center gap-2.5 ${disabled ? 'opacity-45' : 'cursor-pointer'}`}>
<input type="radio" name="model" className="accent-(--capture)" disabled={disabled} checked={billingModel === m} onChange={() => setModel(m)} />
{m === 'fixed' ? `${t.onboarding.fixed} — $${spec.monthlyUsd}` : `${t.onboarding.percent}${spec.commissionPercent ? ` — ${spec.commissionPercent}%` : ''}`}
</label>
);
})}
</div>
</fieldset>
<p className="text-sm text-muted">
{same ? b.sameAsNow : bill.changeFrom ? b.effectFrom(date(bill.changeFrom)) : b.effectNow}
{!same && setupDue && ` ${b.setupNote(spec.setupFeeUsd)}`}
</p>
<ErrorNote error={save.error} />
<div className="flex justify-end gap-2">
<button type="button" className="btn btn-ghost" onClick={onClose}>{t.common.cancel}</button>
<button className="btn btn-primary" disabled={save.isPending || (same && !bill.planned)}>
{bill.changeFrom ? b.schedule : b.applyNow}
</button>
</div>
</form>
</Dialog>
);
}
export function BillingPage({ me }: { me: Me }) {
const { a, t, date, usd } = useAcc();
const b = a.bill;
const label = useInvoiceLabel();
const bill = useQuery({ queryKey: ['billing'], queryFn: () => api<Billing>('/billing') });
const cancelPlanned = useAction(() => api('/billing/plan', { method: 'DELETE' }));
const pay = useAction((id: number) => api<{ url: string }>(`/invoices/${id}/pay`, { method: 'POST' }), ['none']);
const [dialog, setDialog] = useState(false);
const [open, setOpen] = useState<number | null>(null);
const c = me.company!;
const data = bill.data;
if (!data) return <><PageTitle title={t.billing.title} /><ErrorNote error={bill.error} /></>;
const spec = PLAN_SPECS[data.plan];
const modelName = (m: string, p: Plan) =>
m === 'fixed' ? `${t.onboarding.fixed} — $${PLAN_SPECS[p].monthlyUsd}` : `${t.onboarding.percent} — ${PLAN_SPECS[p].commissionPercent}%`;
const rows: [string, ReactNode][] = [
[t.billing.plan, t.plans[data.plan]],
[t.billing.model, modelName(data.billingModel, data.plan)],
[t.billing.status, <AccessChip access={data.access} />],
[t.billing.until, date(data.subscriptionEndsAt)],
[t.billing.members, `${c.members} ${t.common.of} ${spec.maxMembers}`],
];
const hasPending = data.invoices.some((i) => i.status === 'pending');
return (
<>
<PageTitle title={t.billing.title}>
<button className="btn btn-ghost" onClick={() => setDialog(true)}>{b.change}</button>
</PageTitle>
<ErrorNote error={cancelPlanned.error ?? pay.error} />
<div className="grid items-start gap-6 lg:grid-cols-2">
<div className="min-w-0">
<dl className="card divide-y divide-line">
{rows.map(([k, v]) => (
<div key={k} className="flex items-center justify-between gap-4 px-5 py-3.5">
<dt className="text-muted">{k}</dt>
<dd className="text-right font-medium">{v}</dd>
</div>
))}
</dl>
{data.planned && (
<div className="mt-3 flex flex-wrap items-center justify-between gap-3 rounded-xl border border-signal/30 bg-signal/10 px-4 py-3 text-sm">
<span>{b.planned(t.plans[data.planned.plan], modelName(data.planned.billingModel, data.planned.plan), date(data.planned.from))}</span>
<button className="btn btn-ghost btn-sm" disabled={cancelPlanned.isPending} onClick={() => cancelPlanned.mutate()}>{b.cancelPlanned}</button>
</div>
)}
</div>
{data.preview && (
<section className="card min-w-0 p-5">
<h2 className="text-lg font-medium tracking-tight">{b.month}</h2>
<p className="mt-1 text-sm text-muted">{b.monthSub(date(data.preview.from), date(data.preview.to))}</p>
<dl className="mt-4 grid grid-cols-2 gap-4">
<div>
<dt className="text-sm text-muted">{b.commissions}</dt>
<dd className="text-2xl font-semibold tabular-nums tracking-tight">{usd(data.preview.total)}</dd>
</div>
<div>
<dt className="text-sm text-muted">{b.toPay(data.preview.percent)}</dt>
<dd className="text-2xl font-semibold tabular-nums tracking-tight text-capture">{usd(data.preview.amount)}</dd>
</div>
</dl>
<div className="mt-4 border-t border-line pt-3">
<DealsTable deals={data.preview.deals} rates={data.preview.rates} />
</div>
</section>
)}
</div>
<section className="mt-8">
<h2 className="mb-3 text-lg font-medium tracking-tight">{b.invoices}</h2>
{hasPending && !data.onlinePayments && (
<p className="mb-4 max-w-3xl rounded-xl border border-line bg-surface px-4 py-3 text-sm text-muted">{b.payHint}</p>
)}
{data.invoices.length === 0 ? (
<p className="text-sm text-muted">{b.noInvoices}</p>
) : (
<>
{/* Телефон и Mini App: счета карточками, без горизонтальной прокрутки. */}
<ul className="space-y-3 sm:hidden">
{data.invoices.map((i) => (
<li key={i.id} className="card p-4">
<div className="flex items-start justify-between gap-3">
<p className="font-medium">{label(i)}</p>
<p className="font-semibold tabular-nums">{usd(i.amount)}</p>
</div>
<p className="mt-2 flex flex-wrap items-center gap-2 text-sm text-muted">
<span className={`chip whitespace-nowrap ${INVOICE_TONE[i.status]}`}>{b.status[i.status]}</span>
<span className="tabular-nums">№ {i.id} · {date(i.status === 'paid' ? i.paidAt : i.createdAt)}</span>
</p>
{(i.kind === 'percent' || (i.status === 'pending' && data.onlinePayments)) && (
<div className="mt-3 flex gap-2">
{i.kind === 'percent' && (
<button className="btn btn-ghost btn-sm" aria-expanded={open === i.id} onClick={() => setOpen(open === i.id ? null : i.id)}>
{open === i.id ? b.hide : b.details}
</button>
)}
{i.status === 'pending' && data.onlinePayments && (
<button className="btn btn-primary btn-sm" disabled={pay.isPending} onClick={() => pay.mutate(i.id, { onSuccess: (r) => { window.location.href = r.url; } })}>
{b.pay}
</button>
)}
</div>
)}
{open === i.id && (
<div className="mt-3 border-t border-line pt-3">
<DealsTable deals={i.details?.deals ?? []} rates={i.details?.rates} />
</div>
)}
</li>
))}
</ul>
<div className="card hidden overflow-x-auto sm:block">
<table className="w-full min-w-[38rem] text-left text-sm">
<thead className="text-muted">
<tr className="border-b border-line">
{[b.col.num, b.col.date, b.col.what, b.col.amount, b.col.status, ''].map((h, i) => (
<th key={i} className={`px-4 py-3 font-normal ${i === 3 ? 'text-right' : ''}`}>{h}</th>
))}
</tr>
</thead>
<tbody className="divide-y divide-line">
{data.invoices.map((i) => (
<Fragment key={i.id}>
<tr>
<td className="px-4 py-3 tabular-nums text-muted">{i.id}</td>
<td className="px-4 py-3 tabular-nums whitespace-nowrap">{date(i.createdAt)}</td>
<td className="px-4 py-3">{label(i)}</td>
<td className="px-4 py-3 text-right font-medium tabular-nums">{usd(i.amount)}</td>
<td className="px-4 py-3">
<span className={`chip whitespace-nowrap ${INVOICE_TONE[i.status]}`}>{b.status[i.status]}</span>
{i.status === 'paid' && <span className="ml-2 text-xs text-muted whitespace-nowrap">{date(i.paidAt)}</span>}
</td>
<td className="px-4 py-3">
<div className="flex justify-end gap-2">
{i.kind === 'percent' && (
<button className="btn btn-ghost btn-sm" aria-expanded={open === i.id} onClick={() => setOpen(open === i.id ? null : i.id)}>
{open === i.id ? b.hide : b.details}
</button>
)}
{i.status === 'pending' && data.onlinePayments && (
<button
className="btn btn-primary btn-sm"
disabled={pay.isPending}
onClick={() => pay.mutate(i.id, { onSuccess: (r) => { window.location.href = r.url; } })}
>
{b.pay}
</button>
)}
</div>
</td>
</tr>
{open === i.id && (
<tr>
<td colSpan={6} className="bg-ink/40 px-4 py-3">
<DealsTable deals={i.details?.deals ?? []} rates={i.details?.rates} />
</td>
</tr>
)}
</Fragment>
))}
</tbody>
</table>
</div>
</>
)}
</section>
{dialog && <PlanDialog bill={data} members={c.members} onClose={() => setDialog(false)} />}
</>
);
}
+87
View File
@@ -0,0 +1,87 @@
import { useQuery } from '@tanstack/react-query';
import { Paperclip } from 'lucide-react';
import { useEffect, useRef, useState } from 'react';
import { api, ApiError } from '../api.ts';
import { ErrorNote } from '../components/ui.tsx';
import { useAction } from '../hooks.ts';
import { useAcc } from './i18n.ts';
const LANGS = ['ru', 'hy', 'en'] as const;
type Texts = Record<(typeof LANGS)[number], string>;
interface BotStart { text: Texts; files: { fileId: string; kind: 'photo' | 'file'; name: string }[] }
const MAX_FILE = 20 * 1024 * 1024;
const KEY = ['admin', 'bot-start'];
/** Приветствие бота: текст по языкам и файлы, которые он присылает в ответ на /start. */
export function BotStartSettings() {
const { a, t } = useAcc();
const b = a.bot;
const data = useQuery({ queryKey: KEY, queryFn: () => api<BotStart>('/admin/bot-start') });
const [text, setText] = useState<Texts | null>(null);
useEffect(() => { if (data.data && !text) setText(data.data.text); }, [data.data, text]);
const picker = useRef<HTMLInputElement>(null);
const save = useAction(() => api('/admin/bot-start', { method: 'PATCH', body: { text } }), KEY);
// Файл уходит телом запроса как есть — так же, как вложения в поддержке.
const upload = useAction(async (file: File) => {
if (file.size > MAX_FILE) throw new ApiError(400, 'FILE_TOO_LARGE');
const query = new URLSearchParams({ name: file.name, type: file.type || 'application/octet-stream' });
const res = await fetch(`/api/admin/bot-start/files?${query}`, { method: 'POST', credentials: 'same-origin', headers: { 'content-type': 'application/octet-stream' }, body: file });
if (!res.ok) throw new ApiError(res.status, ((await res.json().catch(() => ({}))) as { code?: string }).code ?? 'INTERNAL');
return res.json();
}, KEY);
const remove = useAction((index: number) => api(`/admin/bot-start/files/${index}`, { method: 'DELETE' }), KEY);
const files = data.data?.files ?? [];
return (
<section className="mt-10">
<h2 className="text-lg font-medium tracking-tight">{b.title}</h2>
<p className="mb-3 mt-1 max-w-2xl text-sm text-muted">{b.sub}</p>
<ErrorNote error={data.error} />
{text && (
<div className="grid items-start gap-6 lg:grid-cols-2">
<form className="card min-w-0 space-y-4 p-5" onSubmit={(e) => { e.preventDefault(); save.mutate(); }}>
{LANGS.map((lang) => (
<div key={lang}>
<label className="label" htmlFor={`bot-text-${lang}`}>{b.text[lang]}</label>
<textarea
id={`bot-text-${lang}`} rows={lang === 'ru' ? 6 : 3} maxLength={3500} className="field" placeholder={b.standard}
value={text[lang]} onChange={(e) => { setText({ ...text, [lang]: e.target.value }); save.reset(); }}
/>
</div>
))}
<p className="text-xs text-muted">{b.textHint}</p>
<ErrorNote error={save.error} />
<div className="flex items-center justify-end gap-3">
{save.isSuccess && <span className="text-sm text-capture" role="status">{b.saved}</span>}
<button className="btn btn-primary" disabled={save.isPending}>{b.save}</button>
</div>
</form>
<div className="card min-w-0 space-y-3 p-5">
<h3 className="font-medium">{b.files}</h3>
{files.length ? (
<ul className="divide-y divide-line">
{files.map((f, i) => (
<li key={f.fileId} className="flex items-center justify-between gap-3 py-2 text-sm">
<span className="min-w-0 truncate">{f.name} <span className="text-muted">· {b.kind[f.kind]}</span></span>
<button type="button" className="btn btn-ghost btn-sm" disabled={remove.isPending} onClick={() => remove.mutate(i)}>{b.remove}</button>
</li>
))}
</ul>
) : (
<p className="text-sm text-muted">{b.noFiles}</p>
)}
<input
ref={picker} type="file" className="hidden" tabIndex={-1} aria-hidden
onChange={(e) => { const f = e.target.files?.[0]; e.target.value = ''; if (f) upload.mutate(f); }}
/>
<button type="button" className="btn btn-ghost" disabled={upload.isPending || files.length >= 10} onClick={() => picker.current?.click()}>
<Paperclip size={16} aria-hidden /> {upload.isPending ? t.common.loading : b.add}
</button>
<p className="text-xs text-muted">{b.filesHint}</p>
<ErrorNote error={upload.error ?? remove.error} />
</div>
</div>
)}
</section>
);
}
+177
View File
@@ -0,0 +1,177 @@
import { useQuery } from '@tanstack/react-query';
import { Check, X } from 'lucide-react';
import { Fragment, useEffect, useState } from 'react';
import { api, type AdminInvoice } from '../api.ts';
import { ErrorNote, PageTitle } from '../components/ui.tsx';
import { useAction } from '../hooks.ts';
import { DealsTable, INVOICE_TONE, useInvoiceLabel } from './BillingPage.tsx';
import { useAcc } from './i18n.ts';
interface Summary {
active: number;
overdue: number;
restricted: number;
revenueMonth: number;
pendingAmount: number;
pendingCount: number;
}
/** Сводка суперадмина: активные компании, выручка за месяц, просроченные подписки. */
export function AdminSummary() {
const { a, usd } = useAcc();
const s = a.inv.sum;
const summary = useQuery({ queryKey: ['admin', 'summary'], queryFn: () => api<Summary>('/admin/summary') });
const d = summary.data;
if (!d) return null;
const cards: [string, string, string][] = [
[s.active, String(d.active), 'text-capture'],
[s.overdue, String(d.overdue), d.overdue ? 'text-warn' : ''],
[s.restricted, String(d.restricted), d.restricted ? 'text-danger' : ''],
[s.revenue, usd(d.revenueMonth), ''],
[s.pending, `${usd(d.pendingAmount)} · ${d.pendingCount}`, ''],
];
return (
<dl className="mb-6 grid grid-cols-2 gap-3 md:grid-cols-5">
{cards.map(([k, v, tone]) => (
<div key={k} className="card px-4 py-3">
<dt className="text-sm text-muted">{k}</dt>
<dd className={`mt-1 text-xl font-semibold tabular-nums tracking-tight ${tone}`}>{v}</dd>
</div>
))}
</dl>
);
}
const CURRENCIES = ['AMD', 'RUB', 'EUR'] as const;
function Rates() {
const { a, t } = useAcc();
const rates = useQuery({ queryKey: ['admin', 'rates'], queryFn: () => api<{ rates: Record<string, number> }>('/admin/billing/settings') });
const [f, setF] = useState<Record<string, string>>({});
useEffect(() => {
if (rates.data) setF(Object.fromEntries(Object.entries(rates.data.rates).map(([k, v]) => [k, String(v)])));
}, [rates.data]);
const save = useAction(() =>
api('/admin/billing/settings', { method: 'PATCH', body: { rates: Object.fromEntries(CURRENCIES.map((c) => [c, Number(f[c])])) } }),
);
return (
<form className="card mt-8 max-w-2xl space-y-4 p-5" onSubmit={(e) => { e.preventDefault(); save.mutate(); }}>
<div>
<h2 className="text-lg font-medium tracking-tight">{a.inv.rates}</h2>
<p className="mt-1 text-sm text-muted">{a.inv.ratesSub}</p>
</div>
<div className="grid grid-cols-3 gap-3">
{CURRENCIES.map((c) => (
<div key={c}>
<label className="label" htmlFor={`rate-${c}`}>{c}</label>
<input id={`rate-${c}`} type="number" step="any" min="0.0001" required className="field tabular-nums" value={f[c] ?? ''} onChange={(e) => setF({ ...f, [c]: e.target.value })} />
</div>
))}
</div>
<ErrorNote error={save.error} />
<div className="flex items-center gap-3">
<button className="btn btn-primary" disabled={save.isPending}>{t.common.save}</button>
{save.isSuccess && <span className="text-sm text-capture" role="status">{t.settings.saved}</span>}
</div>
</form>
);
}
const FILTERS = ['pending', 'paid', 'canceled', 'all'] as const;
export function InvoicesPage() {
const { a, date, usd } = useAcc();
const x = a.inv;
const label = useInvoiceLabel();
const [status, setStatus] = useState<(typeof FILTERS)[number]>('pending');
const [open, setOpen] = useState<number | null>(null);
const list = useQuery({
queryKey: ['admin', 'invoices', status],
queryFn: () => api<AdminInvoice[]>(`/admin/invoices${status === 'all' ? '' : `?status=${status}`}`),
});
const pay = useAction((id: number) => api(`/admin/invoices/${id}/pay`, { method: 'POST' }));
const cancel = useAction((id: number) => api(`/admin/invoices/${id}/cancel`, { method: 'POST' }));
return (
<>
<PageTitle title={x.title} sub={x.sub} />
<AdminSummary />
<div className="mb-4 flex flex-wrap gap-2" role="group" aria-label={a.bill.col.status}>
{FILTERS.map((f) => (
<button key={f} aria-pressed={status === f} onClick={() => setStatus(f)} className={`btn btn-sm ${status === f ? 'bg-raised text-text' : 'btn-ghost text-muted'}`}>
{x.filter[f]}
</button>
))}
</div>
<ErrorNote error={list.error ?? pay.error ?? cancel.error} />
{list.data?.length === 0 && <p className="text-muted">{x.empty}</p>}
{Boolean(list.data?.length) && (
<div className="card overflow-x-auto">
<table className="w-full min-w-[46rem] text-left text-sm">
<thead className="text-muted">
<tr className="border-b border-line">
{[a.bill.col.num, x.company, a.bill.col.what, a.bill.col.date, a.bill.col.amount, a.bill.col.status, ''].map((h, i) => (
<th key={i} className={`px-4 py-3 font-normal ${i === 4 ? 'text-right' : ''}`}>{h}</th>
))}
</tr>
</thead>
<tbody className="divide-y divide-line">
{list.data!.map((i) => (
<Fragment key={i.id}>
<tr>
<td className="px-4 py-3 tabular-nums text-muted">{i.id}</td>
<td className="px-4 py-3 font-medium">{i.company}</td>
<td className="px-4 py-3">
{i.kind === 'percent' ? (
<button className="cursor-pointer text-left text-signal hover:underline" aria-expanded={open === i.id} onClick={() => setOpen(open === i.id ? null : i.id)}>{label(i)}</button>
) : label(i)}
</td>
<td className="px-4 py-3 tabular-nums whitespace-nowrap">{date(i.createdAt)}</td>
<td className="px-4 py-3 text-right font-medium tabular-nums">{usd(i.amount)}</td>
<td className="px-4 py-3">
<span className={`chip whitespace-nowrap ${INVOICE_TONE[i.status]}`}>{a.bill.status[i.status]}</span>
{i.status === 'paid' && (
<span className="ml-2 text-xs text-muted whitespace-nowrap">{date(i.paidAt)}{i.paidMethod ? ` · ${a.bill.method[i.paidMethod]}` : ''}</span>
)}
</td>
<td className="px-4 py-3">
{i.status === 'pending' && (
<div className="flex justify-end gap-2">
<button
className="btn btn-primary btn-sm"
disabled={pay.isPending}
onClick={() => confirm(x.confirmPaid(i.company, usd(i.amount))) && pay.mutate(i.id)}
>
<Check size={14} aria-hidden="true" />
{x.markPaid}
</button>
<button
className="btn btn-danger btn-sm"
aria-label={`${x.cancel}: ${i.id}`}
title={x.cancel}
disabled={cancel.isPending}
onClick={() => confirm(x.confirmCancel) && cancel.mutate(i.id)}
>
<X size={14} aria-hidden="true" />
</button>
</div>
)}
</td>
</tr>
{open === i.id && (
<tr>
<td colSpan={7} className="bg-ink/40 px-4 py-3">
<DealsTable deals={i.details?.deals ?? []} rates={i.details?.rates} />
</td>
</tr>
)}
</Fragment>
))}
</tbody>
</table>
</div>
)}
<Rates />
</>
);
}
+162
View File
@@ -0,0 +1,162 @@
import { useQuery } from '@tanstack/react-query';
import { Send } from 'lucide-react';
import { useState } from 'react';
import { api, type AdminCompany } from '../api.ts';
import { ErrorNote, PageTitle } from '../components/ui.tsx';
import { useCrm } from '../crm/i18n.ts';
import { useAction } from '../hooks.ts';
import { BotStartSettings } from './BotStart.tsx';
import { useAcc } from './i18n.ts';
type Audience = 'all' | 'company' | 'managers' | 'user';
interface FoundUser {
id: number;
name: string;
role: 'manager' | 'agent';
company: string;
telegramLinked: boolean;
}
interface Message {
id: number;
text: string;
audience: Audience;
company: string | null;
user: string | null;
recipients: number;
unreachable: number;
sentAt: string;
}
/** Сообщения суперадмина пользователям в Telegram и журнал отправленных. */
export function MessagesPage() {
const { a, t } = useAcc();
const { dateTime } = useCrm();
const m = a.msg;
const [audience, setAudience] = useState<Audience>('all');
const [companyId, setCompanyId] = useState('');
const [q, setQ] = useState('');
const [user, setUser] = useState<FoundUser | null>(null);
const [text, setText] = useState('');
const companies = useQuery({ queryKey: ['admin', 'companies'], queryFn: () => api<AdminCompany[]>('/admin/companies') });
const found = useQuery({
queryKey: ['admin', 'users', q],
queryFn: () => api<FoundUser[]>(`/admin/users?q=${encodeURIComponent(q)}`),
enabled: audience === 'user' && !user,
});
const journal = useQuery({ queryKey: ['admin', 'messages'], queryFn: () => api<Message[]>('/admin/messages') });
const send = useAction(() =>
api<{ recipients: number; unreachable: number }>('/admin/messages', {
method: 'POST',
body: {
text,
audience,
companyId: (audience === 'company' || audience === 'managers') && companyId ? Number(companyId) : undefined,
userId: audience === 'user' ? user?.id : undefined,
},
}),
);
const companyName = companies.data?.find((c) => String(c.id) === companyId)?.name;
const target = (aud: Audience, company: string | null | undefined, name: string | null | undefined) =>
aud === 'user' ? `${m.a.user}: ${name ?? '—'}` : company ? `${m.a[aud]}: ${company}` : m.a[aud];
const ready = text.trim() && (audience !== 'company' || companyId) && (audience !== 'user' || user);
return (
<>
<PageTitle title={m.title} sub={m.sub} />
<div className="grid items-start gap-6 lg:grid-cols-2">
<form
className="card min-w-0 space-y-4 p-5"
onSubmit={(e) => {
e.preventDefault();
if (!confirm(m.confirm(target(audience, companyName, user?.name)))) return;
send.mutate(undefined, { onSuccess: () => setText('') });
}}
>
<div className="grid gap-4 sm:grid-cols-2">
<div>
<label className="label" htmlFor="m-aud">{m.audience}</label>
<select id="m-aud" className="field" value={audience} onChange={(e) => { setAudience(e.target.value as Audience); send.reset(); }}>
{(['all', 'company', 'managers', 'user'] as const).map((x) => <option key={x} value={x}>{m.a[x]}</option>)}
</select>
</div>
{(audience === 'company' || audience === 'managers') && (
<div>
<label className="label" htmlFor="m-company">{m.company}</label>
<select id="m-company" className="field" required={audience === 'company'} value={companyId} onChange={(e) => setCompanyId(e.target.value)}>
<option value="">{audience === 'company' ? '—' : m.anyCompany}</option>
{companies.data?.map((c) => <option key={c.id} value={c.id}>{c.name}</option>)}
</select>
</div>
)}
</div>
{audience === 'user' && (
<div>
<label className="label" htmlFor="m-user">{m.user}</label>
{user ? (
<div className="flex items-center justify-between gap-3 rounded-xl border border-line-strong px-3.5 py-2.5">
<span className="min-w-0 truncate">
{user.name} <span className="text-muted">· {user.company}</span>
{!user.telegramLinked && <span className="ml-2 chip bg-warn/15 text-warn">{m.noTg}</span>}
</span>
<button type="button" className="btn btn-ghost btn-sm" onClick={() => setUser(null)}>{t.common.cancel}</button>
</div>
) : (
<>
<input id="m-user" className="field" placeholder={m.userSearch} value={q} onChange={(e) => setQ(e.target.value)} />
<ul className="mt-2 max-h-56 divide-y divide-line overflow-y-auto rounded-xl border border-line">
{found.data?.map((u) => (
<li key={u.id}>
<button type="button" className="flex w-full cursor-pointer items-center justify-between gap-3 px-3.5 py-2.5 text-left text-sm hover:bg-raised/60" onClick={() => setUser(u)}>
<span className="min-w-0 truncate">{u.name} <span className="text-muted">· {u.company} · {t.roles[u.role]}</span></span>
{!u.telegramLinked && <span className="chip shrink-0 bg-warn/15 text-warn">{m.noTg}</span>}
</button>
</li>
))}
{found.data?.length === 0 && <li className="px-3.5 py-2.5 text-sm text-muted">—</li>}
</ul>
</>
)}
</div>
)}
<div>
<label className="label" htmlFor="m-text">{m.text}</label>
<textarea id="m-text" className="field min-h-36 py-2.5" required maxLength={3500} value={text} onChange={(e) => setText(e.target.value)} />
</div>
<ErrorNote error={send.error} />
<div className="flex flex-wrap items-center gap-3">
<button className="btn btn-primary" disabled={!ready || send.isPending}>
<Send size={16} aria-hidden="true" />
{m.send}
</button>
{send.data && <span className={`text-sm ${send.data.recipients ? 'text-capture' : 'text-warn'}`} role="status">{m.sent(send.data.recipients, send.data.unreachable)}</span>}
</div>
</form>
<section className="min-w-0">
<h2 className="mb-3 text-lg font-medium tracking-tight">{m.journal}</h2>
<ErrorNote error={journal.error} />
{journal.data?.length === 0 && <p className="text-sm text-muted">{m.empty}</p>}
<ul className="space-y-3">
{journal.data?.map((x) => (
<li key={x.id} className="card p-4">
<p className="flex flex-wrap items-baseline justify-between gap-x-3 text-sm">
<span className="font-medium">{target(x.audience, x.company, x.user)}</span>
<span className="tabular-nums text-muted">{dateTime(x.sentAt)}</span>
</p>
<p className="mt-2 whitespace-pre-wrap break-words">{x.text}</p>
<p className="mt-2 text-xs text-muted">
{m.recipients(x.recipients)}
{x.unreachable > 0 && <span className="text-warn"> · {m.unreachable(x.unreachable)}</span>}
</p>
</li>
))}
</ul>
</section>
</div>
<BotStartSettings />
</>
);
}
+259
View File
@@ -0,0 +1,259 @@
import { useQuery } from '@tanstack/react-query';
import { ArrowLeft, Check, Paperclip, Send, X } from 'lucide-react';
import { useEffect, useRef, useState } from 'react';
import { Link, useNavigate, useParams } from 'react-router';
import { api, ApiError, type Me } from '../api.ts';
import { ErrorNote, PageTitle } from '../components/ui.tsx';
import { useCrm } from '../crm/i18n.ts';
import { useAction } from '../hooks.ts';
import { TelegramLink } from '../pages/Settings.tsx';
import { useAcc } from './i18n.ts';
interface Ticket {
id: number;
status: 'open' | 'closed';
unread: boolean;
name: string;
tgUsername: string | null;
hasAccount: boolean;
role: 'manager' | 'agent' | 'superadmin' | null;
company: string | null;
last: string | null;
lastFromSupport: boolean;
lastMessageAt: string;
closedAt: string | null;
}
interface Message {
id: number;
fromSupport: boolean;
text: string | null;
fileKind: 'photo' | 'file' | null;
fileName: string | null;
at: string;
}
const MAX_FILE = 20 * 1024 * 1024;
const size = (bytes: number) => (bytes < 1024 * 1024 ? `${Math.max(1, Math.round(bytes / 1024))} КБ` : `${(bytes / 1024 / 1024).toFixed(1)} МБ`);
function who(t: Ticket, noAccount: string) {
return [t.tgUsername && `@${t.tgUsername}`, t.company ?? (t.hasAccount ? null : noAccount)].filter(Boolean).join(' · ');
}
function Chat({ id }: { id: number }) {
const { a } = useAcc();
const { dateTime } = useCrm();
const s = a.sup;
const ticket = useQuery({
queryKey: ['admin', 'support', 'ticket', id],
queryFn: () => api<Ticket & { messages: Message[] }>(`/admin/support/${id}`),
refetchInterval: 5_000,
});
const [text, setText] = useState('');
const [file, setFile] = useState<File | null>(null);
const picker = useRef<HTMLInputElement>(null);
// Файл уходит телом запроса как есть; текст в этом случае становится подписью к нему.
const reply = useAction(async () => {
if (!file) return api(`/admin/support/${id}/reply`, { method: 'POST', body: { text } });
if (file.size > MAX_FILE) throw new ApiError(400, 'FILE_TOO_LARGE');
const query = new URLSearchParams({ name: file.name, type: file.type || 'application/octet-stream' });
if (text.trim()) query.set('text', text.trim().slice(0, 900));
const res = await fetch(`/api/admin/support/${id}/file?${query}`, {
method: 'POST',
credentials: 'same-origin',
headers: { 'content-type': 'application/octet-stream' },
body: file,
});
if (!res.ok) throw new ApiError(res.status, ((await res.json().catch(() => ({}))) as { code?: string }).code ?? 'INTERNAL');
return res.json();
}, ['admin', 'support']);
const close = useAction(() => api(`/admin/support/${id}/close`, { method: 'POST' }), ['admin', 'support']);
const end = useRef<HTMLDivElement>(null);
const count = ticket.data?.messages.length ?? 0;
useEffect(() => { end.current?.scrollIntoView({ block: 'end' }); }, [count, id]);
const ready = Boolean(text.trim() || file);
const send = () => ready && !reply.isPending && reply.mutate(undefined, { onSuccess: () => { setText(''); setFile(null); } });
const d = ticket.data;
if (!d) return <div className="card p-5"><ErrorNote error={ticket.error} /></div>;
return (
<section className="card flex h-[calc(100dvh-13rem)] min-h-96 min-w-0 flex-col lg:h-[calc(100dvh-11rem)]">
<header className="flex flex-wrap items-center justify-between gap-3 border-b border-line px-4 py-3">
<div className="flex min-w-0 items-center gap-2">
<Link to="/support" className="flex size-9 items-center justify-center rounded-lg text-muted hover:text-text lg:hidden" aria-label={s.back}>
<ArrowLeft size={18} />
</Link>
<div className="min-w-0">
<p className="truncate font-medium">{d.name}</p>
<p className="truncate text-sm text-muted">{who(d, s.noAccount) || '—'}</p>
</div>
</div>
{d.status === 'open' ? (
<button className="btn btn-primary btn-sm" disabled={close.isPending} onClick={() => confirm(s.confirmSolve) && close.mutate()}>
<Check size={15} aria-hidden="true" />
{s.solve}
</button>
) : (
<span className="chip bg-capture/15 text-capture">{s.solved(dateTime(d.closedAt))}</span>
)}
</header>
<ol className="flex-1 space-y-3 overflow-y-auto px-4 py-4">
{d.messages.map((m) => (
<li key={m.id} className={`flex ${m.fromSupport ? 'justify-end' : 'justify-start'}`}>
<div className={`max-w-[85%] rounded-2xl px-3.5 py-2.5 ${m.fromSupport ? 'bg-capture/15' : 'bg-raised'}`}>
{m.fileKind === 'photo' && (
<a href={`/api/admin/support/messages/${m.id}/file`} target="_blank" rel="noopener noreferrer">
<img src={`/api/admin/support/messages/${m.id}/file`} alt={s.photo} loading="lazy" className="mb-1.5 max-h-72 rounded-lg" />
</a>
)}
{m.fileKind === 'file' && (
<a href={`/api/admin/support/messages/${m.id}/file`} className="mb-1.5 flex items-center gap-2 text-signal hover:underline">
<Paperclip size={15} aria-hidden="true" />
{m.fileName ?? s.file}
</a>
)}
{m.text && <p className="whitespace-pre-wrap break-words">{m.text}</p>}
<p className="mt-1 text-right text-[11px] tabular-nums text-muted">{dateTime(m.at)}</p>
</div>
</li>
))}
<div ref={end} />
</ol>
{d.status === 'open' && (
<form className="border-t border-line p-3" onSubmit={(e) => { e.preventDefault(); send(); }}>
<ErrorNote error={reply.error ?? close.error} />
{file && (
<p className="mb-2 flex items-center gap-2 text-sm">
<Paperclip size={15} className="shrink-0 text-signal" aria-hidden="true" />
<span className="min-w-0 truncate">{file.name}</span>
<span className="shrink-0 tabular-nums text-muted">{size(file.size)}</span>
<button type="button" className="flex size-8 shrink-0 cursor-pointer items-center justify-center rounded-lg text-muted hover:text-text" aria-label={s.removeFile} title={s.removeFile} onClick={() => setFile(null)}>
<X size={15} />
</button>
</p>
)}
<div className="flex items-end gap-2">
<input ref={picker} type="file" className="sr-only" tabIndex={-1} aria-hidden="true" onChange={(e) => { setFile(e.target.files?.[0] ?? null); e.target.value = ''; }} />
<button type="button" className="btn btn-ghost px-3" aria-label={s.attach} title={s.attach} onClick={() => picker.current?.click()}>
<Paperclip size={17} aria-hidden="true" />
</button>
<textarea
className="field max-h-40 min-h-11 flex-1 py-2.5"
rows={1}
aria-label={s.reply}
placeholder={file ? s.caption : s.reply}
onPaste={(e) => {
// Скриншот из буфера обмена прикладывается как файл.
const pasted = [...e.clipboardData.files][0];
if (pasted) { e.preventDefault(); setFile(pasted); }
}}
maxLength={3500}
value={text}
onChange={(e) => setText(e.target.value)}
onKeyDown={(e) => { if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) send(); }}
/>
<button className="btn btn-primary" disabled={!ready || reply.isPending} title={s.sendHint}>
<Send size={16} aria-hidden="true" />
<span className="sr-only sm:not-sr-only">{s.send}</span>
</button>
</div>
</form>
)}
</section>
);
}
/** Суперадмин: обращения из бота. Слева список, справа переписка. */
export function SupportPage() {
const { a } = useAcc();
const { dateTime } = useCrm();
const s = a.sup;
const navigate = useNavigate();
const { id } = useParams();
const current = id ? Number(id) : null;
const [status, setStatus] = useState<'open' | 'closed'>('open');
const list = useQuery({
queryKey: ['admin', 'support', 'list', status],
queryFn: () => api<Ticket[]>(`/admin/support?status=${status}`),
refetchInterval: 5_000,
});
return (
<>
<PageTitle title={s.title} sub={s.sub} />
<div className="grid items-start gap-4 lg:grid-cols-[22rem_1fr]">
<div className={`min-w-0 ${current ? 'hidden lg:block' : ''}`}>
<div className="mb-3 flex gap-2" role="group" aria-label={s.title}>
{(['open', 'closed'] as const).map((x) => (
<button key={x} aria-pressed={status === x} onClick={() => setStatus(x)} className={`btn btn-sm ${status === x ? 'bg-raised text-text' : 'btn-ghost text-muted'}`}>
{s[x]}
</button>
))}
</div>
<ErrorNote error={list.error} />
{list.data?.length === 0 && <p className="text-sm text-muted">{s.empty}</p>}
<ul className="space-y-2">
{list.data?.map((x) => (
<li key={x.id}>
<button
onClick={() => navigate(`/support/${x.id}`)}
aria-current={current === x.id}
className={`card w-full cursor-pointer p-3.5 text-left transition-colors hover:border-line-strong ${current === x.id ? 'border-signal/50 bg-raised' : ''}`}
>
<span className="flex items-baseline justify-between gap-3">
<span className="flex min-w-0 items-center gap-2 font-medium">
{x.unread && <span className="size-2 shrink-0 rounded-full bg-signal" aria-label="•" />}
<span className="truncate">{x.name}</span>
</span>
<span className="shrink-0 text-xs tabular-nums text-muted">{dateTime(x.lastMessageAt)}</span>
</span>
<span className="block truncate text-xs text-muted">{who(x, s.noAccount) || '—'}</span>
<span className={`mt-1.5 block truncate text-sm ${x.unread ? '' : 'text-muted'}`}>
{x.lastFromSupport && s.you}
{x.last}
</span>
</button>
</li>
))}
</ul>
</div>
{current ? <Chat key={current} id={current} /> : <p className="card hidden p-8 text-center text-muted lg:block">{s.pick}</p>}
</div>
</>
);
}
/** Пользователю: как написать в поддержку. */
export function HelpPage({ me }: { me: Me }) {
const { a } = useAcc();
const h = a.help;
const { data } = useQuery({
queryKey: ['config'],
queryFn: () => api<{ telegramBotUsername: string | null }>('/config'),
staleTime: Infinity,
});
const bot = data?.telegramBotUsername;
return (
<>
<PageTitle title={h.title} sub={h.lead} />
<ol className="max-w-2xl space-y-4">
<li className="card p-5">
<p className="mb-3 font-medium"><span className="mr-2 text-signal tabular-nums">1</span>{h.step1}</p>
<TelegramLink linked={me.user.telegramLinked} />
</li>
<li className="card p-5">
<p className="font-medium"><span className="mr-2 text-signal tabular-nums">2</span>{h.step2}</p>
<p className="mb-4 mt-2 text-sm text-muted">{h.step2sub}</p>
{bot && (
<a className="btn btn-primary" href={`https://t.me/${bot}`} target="_blank" rel="noopener noreferrer">
<Send size={16} aria-hidden="true" />
{h.open}
</a>
)}
</li>
</ol>
</>
);
}
+39
View File
@@ -0,0 +1,39 @@
import { useQuery } from '@tanstack/react-query';
import { Send } from 'lucide-react';
import { api, type Me } from '../api.ts';
import { PageTitle } from '../components/ui.tsx';
import { TelegramLink } from '../pages/Settings.tsx';
import { useAcc } from './i18n.ts';
/** Тариф без веб-кабинета, открыт браузер: объясняем, что работа идёт в Telegram, и помогаем туда попасть. */
export function TelegramOnlyPage({ me }: { me: Me }) {
const { a } = useAcc();
const m = a.mini;
const { data } = useQuery({
queryKey: ['config'],
queryFn: () => api<{ telegramBotUsername: string | null }>('/config'),
staleTime: Infinity,
});
const bot = data?.telegramBotUsername;
return (
<>
<PageTitle title={m.title} sub={m.sub} />
<ol className="max-w-2xl space-y-4">
<li className="card p-5">
<p className="mb-3 font-medium"><span className="mr-2 text-signal tabular-nums">1</span>{m.step1}</p>
<TelegramLink linked={me.user.telegramLinked} />
</li>
<li className="card p-5">
<p className="mb-3 font-medium"><span className="mr-2 text-signal tabular-nums">2</span>{m.step2}</p>
{bot && (
<a className="btn btn-primary" href={`https://t.me/${bot}`} target="_blank" rel="noopener noreferrer">
<Send size={16} aria-hidden="true" />
{m.open}
</a>
)}
</li>
</ol>
<p className="mt-6 max-w-2xl text-sm text-muted">{m.upgrade}</p>
</>
);
}
+130
View File
@@ -0,0 +1,130 @@
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { Check, Link2, Send, SlidersHorizontal, Users } from 'lucide-react';
import { useState, type ReactNode } from 'react';
import { api, type Me } from '../api.ts';
import { ErrorNote, LangSwitcher, Logo } from '../components/ui.tsx';
import { useFilters } from '../crm/api.ts';
import { FilterDialog } from '../crm/FiltersPage.tsx';
import { ME_KEY, useAction } from '../hooks.ts';
import { TelegramLink } from '../pages/Settings.tsx';
import { CopyButton } from '../pages/Team.tsx';
import { useAcc } from './i18n.ts';
type Step = 'filter' | 'tg' | 'team';
/** Мастер первой настройки: первый фильтр, бот, приглашение агентов. Показывается один раз. */
export function Wizard({ me }: { me: Me }) {
const { a, t } = useAcc();
const w = a.wiz;
const qc = useQueryClient();
const isManager = me.user.role === 'manager';
const steps: Step[] = isManager ? (me.company!.maxMembers > 1 ? ['filter', 'tg', 'team'] : ['filter', 'tg']) : ['tg'];
const [index, setIndex] = useState(0);
const [filterDialog, setFilterDialog] = useState(false);
const step = steps[index]!;
const last = index === steps.length - 1;
const filters = useFilters();
const invites = useQuery({
queryKey: ['invites'],
queryFn: () => api<{ token: string; url: string }[]>('/invites'),
enabled: step === 'team',
});
const invite = useAction(() => api('/invites', { method: 'POST' }), ['invites']);
const finish = useAction(() => api('/me', { method: 'PATCH', body: { onboarded: true } }), ME_KEY);
const next = () => (last ? finish.mutate() : setIndex(index + 1));
const mine = filters.data?.filter((f) => f.editable).length ?? 0;
const done = { filter: mine > 0, tg: me.user.telegramLinked, team: Boolean(invites.data?.length) }[step];
const body: Record<Step, { icon: ReactNode; title: string; sub: string; content: ReactNode }> = {
filter: {
icon: <SlidersHorizontal size={20} aria-hidden="true" />,
title: w.filter,
sub: w.filterSub,
content: (
<div className="flex flex-wrap items-center gap-3">
<button type="button" className={`btn ${mine ? 'btn-ghost' : 'btn-primary'}`} onClick={() => setFilterDialog(true)}>{w.filterAdd}</button>
{mine > 0 && <span className="flex items-center gap-1.5 text-sm text-capture"><Check size={16} aria-hidden="true" />{w.filterDone(mine)}</span>}
</div>
),
},
tg: {
icon: <Send size={20} aria-hidden="true" />,
title: w.tg,
sub: isManager ? w.tgSub : w.agentSub,
content: me.user.telegramLinked ? (
<p className="flex items-center gap-1.5 text-sm text-capture"><Check size={16} aria-hidden="true" />{w.tgDone}</p>
) : (
<div className="space-y-3">
<TelegramLink linked={false} />
<button type="button" className="btn btn-ghost btn-sm" onClick={() => qc.invalidateQueries({ queryKey: ME_KEY })}>{w.tgCheck}</button>
</div>
),
},
team: {
icon: <Users size={20} aria-hidden="true" />,
title: w.team,
sub: w.teamSub,
content: invites.data?.length ? (
<div className="flex flex-wrap items-center gap-3">
<code className="min-w-0 flex-1 basis-56 truncate rounded-xl bg-ink px-3 py-2.5 text-sm">{invites.data[0]!.url}</code>
<CopyButton text={invites.data[0]!.url} />
</div>
) : (
<button type="button" className="btn btn-primary" disabled={invite.isPending} onClick={() => invite.mutate()}>
<Link2 size={16} aria-hidden="true" />
{w.teamLink}
</button>
),
},
};
const current = body[step];
return (
<div className="radar-rings flex min-h-dvh flex-col">
<header className="flex items-center justify-between px-4 py-4 sm:px-6">
<Logo />
<LangSwitcher />
</header>
<main className="flex flex-1 items-start justify-center px-4 pb-16 pt-6 sm:items-center">
<div className="w-full max-w-xl">
<h1 className="text-3xl font-semibold tracking-tight">{w.title}</h1>
{steps.length > 1 && <p className="mt-2 text-muted">{w.sub}</p>}
{steps.length > 1 && (
<ol className="mt-6 flex gap-2" aria-label={w.step(index + 1, steps.length)}>
{steps.map((s, i) => (
<li key={s} className={`h-1 flex-1 rounded-full ${i <= index ? 'bg-capture' : 'bg-raised'}`} aria-current={i === index ? 'step' : undefined} />
))}
</ol>
)}
<section className="card mt-6 p-5 sm:p-6">
{steps.length > 1 && <p className="text-sm text-muted">{w.step(index + 1, steps.length)}</p>}
<h2 className="mt-1 flex items-center gap-2.5 text-xl font-medium tracking-tight">
<span className="text-signal">{current.icon}</span>
{current.title}
</h2>
<p className="mt-2 text-muted">{current.sub}</p>
<div className="mt-5">{current.content}</div>
<div className="mt-4"><ErrorNote error={invite.error ?? finish.error} /></div>
</section>
<div className="mt-6 flex flex-wrap items-center gap-3">
{index > 0 && <button type="button" className="btn btn-ghost" onClick={() => setIndex(index - 1)}>{t.common.back}</button>}
<button type="button" className={`btn ${done ? 'btn-primary' : 'btn-ghost'}`} disabled={finish.isPending} onClick={next}>
{last ? (done ? w.finish : w.later) : done ? w.next : w.skip}
</button>
{!last && (
<button type="button" className="ml-auto cursor-pointer text-sm text-muted hover:text-text" disabled={finish.isPending} onClick={() => finish.mutate()}>
{w.later}
</button>
)}
</div>
</div>
</main>
{filterDialog && <FilterDialog filter={null} isManager={isManager} onClose={() => setFilterDialog(false)} />}
</div>
);
}
+262
View File
@@ -0,0 +1,262 @@
import { useLang, type Lang } from '../i18n.tsx';
// Тексты разделов «Подписка», «Счета», «Сообщения», мастера настройки и Mini App.
const ru = {
bill: {
change: 'Сменить тариф', changeTitle: 'Смена тарифа', cancelPlanned: 'Отменить смену',
planned: (p: string, m: string, d: string) => `Запланировано с ${d}: ${p}, ${m}`,
effectFrom: (d: string) => `Изменение вступит в силу с ${d}. До этого действует текущий тариф.`,
effectNow: 'Оплаченного периода сейчас нет, поэтому тариф изменится сразу, а неоплаченный счёт на подписку пересчитается.',
schedule: 'Запланировать', applyNow: 'Изменить', sameAsNow: 'Это ваш текущий тариф.',
setupNote: (n: number) => `При переходе начисляется разовая плата за установку — $${n}.`,
invoices: 'Счета', noInvoices: 'Счетов пока нет.',
col: { num: '№', date: 'Выставлен', what: 'За что', amount: 'Сумма', status: 'Статус' },
kind: { setup: 'Установка', fixed: 'Подписка', percent: 'Комиссия со сделок' },
period: (a: string, b: string) => `${a} — ${b}`, firstMonth: 'первый месяц',
status: { pending: 'Ожидает оплаты', paid: 'Оплачен', canceled: 'Отменён' },
method: { manual: 'вручную', online: 'онлайн', auto: 'без оплаты' },
pay: 'Оплатить', details: 'Расшифровка', hide: 'Скрыть',
payHint: 'Онлайн-оплата скоро появится. Пока счёт оплачивается по согласованию с менеджером Y&P — после поступления оплаты доступ откроется сразу.',
month: 'Текущий месяц', monthSub: (a: string, b: string) => `Сделки за ${a} – ${b}: счёт придёт 1-го числа, оплатить нужно до 8-го.`,
commissions: 'Комиссии по сделкам', toPay: (p: number) => `К оплате, ${p}%`, noDeals: 'Сделок за период нет.',
deal: { date: 'Дата', title: 'Сделка', agent: 'Агент', commission: 'Комиссия', usd: 'В долларах' },
rate: (c: string, r: number) => `1 $ = ${r} ${c}`,
},
inv: {
title: 'Счета', sub: 'Счета всех компаний. Отметка оплаты продлевает подписку.',
filter: { pending: 'Ожидают оплаты', paid: 'Оплаченные', canceled: 'Отменённые', all: 'Все' },
company: 'Компания', markPaid: 'Отметить оплату', cancel: 'Отменить счёт', empty: 'Счетов нет.',
confirmPaid: (c: string, a: string) => `Отметить счёт компании «${c}» на ${a} оплаченным? Подписка продлится.`,
confirmCancel: 'Отменить счёт?',
rates: 'Курсы валют для счёта по комиссии', ratesSub: 'Комиссия по сделке вводится в любой валюте, счёт выставляется в долларах. Сколько единиц валюты в одном долларе:',
sum: { active: 'Активные компании', overdue: 'Просрочено', restricted: 'Доступ ограничен', revenue: 'Выручка за месяц', pending: 'Ожидает оплаты' },
},
bot: {
title: 'Приветствие бота', sub: 'Что бот отвечает на /start тому, кто ещё не привязал Telegram к аккаунту: текст и файлы следом за ним.',
text: { ru: 'Текст на русском', hy: 'Текст на армянском', en: 'Текст на английском' },
textHint: 'Пустое поле — стандартный текст бота. Следом за приветствием бот спрашивает язык кнопками. Пока человек не выбрал язык, вместо незаполненного армянского или английского уходит русский текст; после выбора — текст на выбранном языке, а если он пуст — стандартный.',
standard: 'Стандартный текст бота', save: 'Сохранить текст', saved: 'Сохранено',
files: 'Файлы', noFiles: 'Файлов нет.', add: 'Добавить файл', remove: 'Убрать',
filesHint: 'До 10 файлов, каждый до 20 МБ. При добавлении бот пришлёт файл вам в Telegram — так он получает его у себя.',
kind: { photo: 'фото', file: 'файл' },
},
msg: {
title: 'Сообщения', sub: 'Сообщение уйдёт через бота тем, у кого привязан Telegram.',
audience: 'Кому', a: { all: 'Всем пользователям', company: 'Одной компании', managers: 'Только руководителям', user: 'Одному пользователю' },
company: 'Компания', anyCompany: 'Все компании', user: 'Пользователь', userSearch: 'Имя, email, компания', pick: 'Выберите пользователя',
text: 'Текст сообщения', send: 'Отправить', confirm: (n: string) => `Отправить сообщение: ${n}?`,
sent: (n: number, m: number) => `Отправлено: ${n}.${m ? ` Без Telegram, не получат: ${m}.` : ''}`,
journal: 'Журнал', empty: 'Сообщений пока не было.', recipients: (n: number) => `получателей: ${n}`, unreachable: (n: number) => `без Telegram: ${n}`,
noTg: 'Telegram не привязан',
},
wiz: {
title: 'Быстрая настройка', sub: 'Пара шагов, чтобы объекты начали приходить. Любой можно пропустить и вернуться к нему позже.',
step: (n: number, of: number) => `Шаг ${n} из ${of}`,
filter: 'Первый фильтр', filterSub: 'Фильтр решает, какие объявления List.am станут вашими объектами: тип сделки, район, цена, комнаты.',
filterAdd: 'Создать фильтр', filterDone: (n: number) => `Фильтров создано: ${n}`,
tg: 'Подключите бота', tgSub: 'Новые объекты приходят в Telegram с кнопкой «Беру». Без привязки уведомлений не будет.',
tgDone: 'Telegram привязан', tgCheck: 'Я нажал Start — проверить',
team: 'Пригласите агентов', teamSub: 'Отправьте агентам ссылку. Они входят через Telegram или email и попадают в вашу компанию.',
teamLink: 'Создать ссылку-приглашение',
next: 'Дальше', skip: 'Пропустить', finish: 'Готово', later: 'Настрою позже',
agentSub: 'Привяжите Telegram, чтобы получать новые объекты первым.',
},
mini: {
title: 'Ваш тариф работает в Telegram', sub: 'Лента объектов, карточка, смена статуса и фильтры открываются в боте. В браузере доступны подписка и настройки профиля.',
open: 'Открыть бота', step1: 'Привяжите Telegram к аккаунту', step2: 'Откройте бота и нажмите кнопку Lead Radar',
upgrade: 'Веб-кабинет, аналитика и команда доступны на тарифах Team и Agency.',
more: 'Ещё',
},
sup: {
title: 'Поддержка', sub: 'Всё, что пользователи пишут боту. Ответ уходит им в Telegram.',
open: 'Открытые', closed: 'Решённые', empty: 'Обращений нет.', pick: 'Выберите обращение слева.',
noAccount: 'без аккаунта', you: 'Вы: ', reply: 'Ответ пользователю', send: 'Отправить', sendHint: 'Ctrl + Enter',
solve: 'Проблема решена', confirmSolve: 'Закрыть обращение? Пользователь получит сообщение, что оно решено.',
solved: (d: string) => `Решено ${d}`, file: 'Файл', photo: 'Фото', back: 'К списку',
attach: 'Приложить файл', removeFile: 'Убрать файл', caption: 'Подпись к файлу (необязательно)',
},
help: {
title: 'Поддержка', lead: 'Напишите нам в Telegram — ответит человек.',
step1: 'Подключите Telegram к аккаунту', step2: 'Откройте бота и просто начните писать',
step2sub: 'Опишите вопрос обычным сообщением, можно приложить скриншот. Поддержка подключится и ответит в том же чате.',
open: 'Открыть бота',
},
};
type Dict = typeof ru;
const hy: Dict = {
bill: {
change: 'Փոխել սակագինը', changeTitle: 'Սակագնի փոփոխություն', cancelPlanned: 'Չեղարկել փոփոխությունը',
planned: (p, m, d) => `Պլանավորված է ${d}-ից՝ ${p}, ${m}`,
effectFrom: (d) => `Փոփոխությունն ուժի մեջ կմտնի ${d}-ից։ Մինչ այդ գործում է ընթացիկ սակագինը։`,
effectNow: 'Այժմ վճարված ժամանակահատված չկա, ուստի սակագինը կփոխվի անմիջապես, իսկ բաժանորդագրության չվճարված հաշիվը կվերահաշվարկվի։',
schedule: 'Պլանավորել', applyNow: 'Փոխել', sameAsNow: 'Սա ձեր ընթացիկ սակագինն է։',
setupNote: (n) => `Անցման դեպքում գանձվում է տեղադրման միանվագ վճար՝ $${n}։`,
invoices: 'Հաշիվներ', noInvoices: 'Հաշիվներ դեռ չկան։',
col: { num: '№', date: 'Դուրս է գրվել', what: 'Ինչի համար', amount: 'Գումար', status: 'Կարգավիճակ' },
kind: { setup: 'Տեղադրում', fixed: 'Բաժանորդագրություն', percent: 'Գործարքների միջնորդավճար' },
period: (a, b) => `${a} — ${b}`, firstMonth: 'առաջին ամիս',
status: { pending: 'Սպասում է վճարման', paid: 'Վճարված է', canceled: 'Չեղարկված է' },
method: { manual: 'ձեռքով', online: 'առցանց', auto: 'առանց վճարման' },
pay: 'Վճարել', details: 'Մանրամասն', hide: 'Թաքցնել',
payHint: 'Առցանց վճարումը շուտով հասանելի կլինի։ Առայժմ հաշիվը վճարվում է Y&P-ի մենեջերի հետ համաձայնեցմամբ. վճարումը ստանալուց հետո մուտքը կբացվի անմիջապես։',
month: 'Ընթացիկ ամիս', monthSub: (a, b) => `Գործարքներ՝ ${a} – ${b}. հաշիվը կգա ամսի 1-ին, վճարել պետք է մինչև 8-ը։`,
commissions: 'Գործարքների միջնորդավճարներ', toPay: (p) => `Վճարման ենթակա, ${p}%`, noDeals: 'Այս ժամանակահատվածում գործարքներ չկան։',
deal: { date: 'Ամսաթիվ', title: 'Գործարք', agent: 'Գործակալ', commission: 'Միջնորդավճար', usd: 'Դոլարով' },
rate: (c, r) => `1 $ = ${r} ${c}`,
},
inv: {
title: 'Հաշիվներ', sub: 'Բոլոր ընկերությունների հաշիվները։ Վճարման նշումը երկարացնում է բաժանորդագրությունը։',
filter: { pending: 'Սպասում են վճարման', paid: 'Վճարված', canceled: 'Չեղարկված', all: 'Բոլորը' },
company: 'Ընկերություն', markPaid: 'Նշել վճարված', cancel: 'Չեղարկել հաշիվը', empty: 'Հաշիվներ չկան։',
confirmPaid: (c, a) => `Նշե՞լ «${c}» ընկերության ${a} հաշիվը վճարված։ Բաժանորդագրությունը կերկարացվի։`,
confirmCancel: 'Չեղարկե՞լ հաշիվը',
rates: 'Արժույթի փոխարժեքներ միջնորդավճարի հաշվի համար', ratesSub: 'Գործարքի միջնորդավճարը մուտքագրվում է ցանկացած արժույթով, հաշիվը դուրս է գրվում դոլարով։ Քանի միավոր արժույթ է մեկ դոլարում.',
sum: { active: 'Ակտիվ ընկերություններ', overdue: 'Ժամկետանց', restricted: 'Մուտքը սահմանափակված է', revenue: 'Ամսվա եկամուտ', pending: 'Սպասում է վճարման' },
},
bot: {
title: 'Բոտի ողջույնը', sub: 'Ինչ է բոտը պատասխանում /start-ին նրան, ով դեռ չի կապել Telegram-ը հաշվին. տեքստ և դրան հաջորդող ֆայլեր։',
text: { ru: 'Տեքստ ռուսերեն', hy: 'Տեքստ հայերեն', en: 'Տեքստ անգլերեն' },
textHint: 'Դատարկ դաշտ՝ բոտի ստանդարտ տեքստը։ Ողջույնից հետո բոտը կոճակներով հարցնում է լեզուն։ Քանի դեռ մարդը լեզու չի ընտրել, չլրացված հայերենի կամ անգլերենի փոխարեն ուղարկվում է ռուսերեն տեքստը, ընտրելուց հետո՝ ընտրված լեզվով տեքստը, իսկ եթե այն դատարկ է՝ ստանդարտը։',
standard: 'Բոտի ստանդարտ տեքստը', save: 'Պահպանել տեքստը', saved: 'Պահպանված է',
files: 'Ֆայլեր', noFiles: 'Ֆայլեր չկան։', add: 'Ավելացնել ֆայլ', remove: 'Հեռացնել',
filesHint: 'Մինչև 10 ֆայլ, յուրաքանչյուրը՝ մինչև 20 ՄԲ։ Ավելացնելիս բոտը ֆայլը կուղարկի ձեզ Telegram-ով. այդպես այն պահվում է նրա մոտ։',
kind: { photo: 'լուսանկար', file: 'ֆայլ' },
},
msg: {
title: 'Հաղորդագրություններ', sub: 'Հաղորդագրությունը բոտի միջոցով կուղարկվի նրանց, ում Telegram-ը կապված է։',
audience: 'Ում', a: { all: 'Բոլոր օգտատերերին', company: 'Մեկ ընկերության', managers: 'Միայն ղեկավարներին', user: 'Մեկ օգտատիրոջ' },
company: 'Ընկերություն', anyCompany: 'Բոլոր ընկերությունները', user: 'Օգտատեր', userSearch: 'Անուն, էլ. փոստ, ընկերություն', pick: 'Ընտրեք օգտատիրոջը',
text: 'Հաղորդագրության տեքստ', send: 'Ուղարկել', confirm: (n) => `Ուղարկե՞լ հաղորդագրությունը՝ ${n}`,
sent: (n, m) => `Ուղարկված է՝ ${n}։${m ? ` Առանց Telegram-ի, չեն ստանա՝ ${m}։` : ''}`,
journal: 'Մատյան', empty: 'Հաղորդագրություններ դեռ չեն եղել։', recipients: (n) => `ստացողներ՝ ${n}`, unreachable: (n) => `առանց Telegram-ի՝ ${n}`,
noTg: 'Telegram-ը կապված չէ',
},
wiz: {
title: 'Արագ կարգավորում', sub: 'Մի քանի քայլ, որպեսզի օբյեկտները սկսեն գալ։ Ցանկացածը կարելի է բաց թողնել և վերադառնալ ավելի ուշ։',
step: (n, of) => `Քայլ ${n} / ${of}`,
filter: 'Առաջին ֆիլտրը', filterSub: 'Ֆիլտրը որոշում է, թե List.am-ի որ հայտարարությունները կդառնան ձեր օբյեկտները՝ գործարքի տեսակ, շրջան, գին, սենյակներ։',
filterAdd: 'Ստեղծել ֆիլտր', filterDone: (n) => `Ստեղծված ֆիլտրեր՝ ${n}`,
tg: 'Միացրեք բոտը', tgSub: 'Նոր օբյեկտները գալիս են Telegram՝ «Վերցնում եմ» կոճակով։ Առանց կապման ծանուցումներ չեն լինի։',
tgDone: 'Telegram-ը կապված է', tgCheck: 'Սեղմել եմ Start — ստուգել',
team: 'Հրավիրեք գործակալներին', teamSub: 'Ուղարկեք գործակալներին հղումը։ Նրանք մուտք են գործում Telegram-ով կամ էլ. փոստով և միանում ձեր ընկերությանը։',
teamLink: 'Ստեղծել հրավերի հղում',
next: 'Հաջորդը', skip: 'Բաց թողնել', finish: 'Պատրաստ է', later: 'Կկարգավորեմ ավելի ուշ',
agentSub: 'Կապեք Telegram-ը՝ նոր օբյեկտներն առաջինը ստանալու համար։',
},
mini: {
title: 'Ձեր սակագինն աշխատում է Telegram-ում', sub: 'Օբյեկտների ցանկը, քարտը, կարգավիճակի փոփոխությունը և ֆիլտրերը բացվում են բոտում։ Դիտարկիչում հասանելի են բաժանորդագրությունը և պրոֆիլի կարգավորումները։',
open: 'Բացել բոտը', step1: 'Կապեք Telegram-ը հաշվին', step2: 'Բացեք բոտը և սեղմեք Lead Radar կոճակը',
upgrade: 'Վեբ-կաբինետը, վերլուծությունը և թիմը հասանելի են Team և Agency սակագներում։',
more: 'Ավելին',
},
sup: {
title: 'Աջակցություն', sub: 'Այն ամենը, ինչ օգտատերերը գրում են բոտին։ Պատասխանը գնում է նրանց Telegram։',
open: 'Բաց', closed: 'Լուծված', empty: 'Դիմումներ չկան։', pick: 'Ընտրեք դիմումը ձախից։',
noAccount: 'առանց հաշվի', you: 'Դուք՝ ', reply: 'Պատասխան օգտատիրոջը', send: 'Ուղարկել', sendHint: 'Ctrl + Enter',
solve: 'Խնդիրը լուծված է', confirmSolve: 'Փակե՞լ դիմումը։ Օգտատերը կստանա հաղորդագրություն, որ այն լուծված է։',
solved: (d) => `Լուծվել է ${d}`, file: 'Ֆայլ', photo: 'Լուսանկար', back: 'Ցուցակ',
attach: 'Կցել ֆայլ', removeFile: 'Հեռացնել ֆայլը', caption: 'Ֆայլի մակագրություն (ոչ պարտադիր)',
},
help: {
title: 'Աջակցություն', lead: 'Գրեք մեզ Telegram-ում՝ կպատասխանի մարդ։',
step1: 'Կապեք Telegram-ը հաշվին', step2: 'Բացեք բոտը և պարզապես սկսեք գրել',
step2sub: 'Նկարագրեք հարցը սովորական հաղորդագրությամբ, կարող եք կցել սքրինշոթ։ Աջակցությունը կմիանա և կպատասխանի նույն չատում։',
open: 'Բացել բոտը',
},
};
const en: Dict = {
bill: {
change: 'Change plan', changeTitle: 'Change plan', cancelPlanned: 'Cancel the change',
planned: (p, m, d) => `Scheduled from ${d}: ${p}, ${m}`,
effectFrom: (d) => `The change takes effect on ${d}. Until then the current plan applies.`,
effectNow: 'There is no paid period right now, so the plan changes right away and the unpaid subscription invoice is recalculated.',
schedule: 'Schedule', applyNow: 'Change', sameAsNow: 'This is your current plan.',
setupNote: (n) => `Switching adds a one-time setup fee of $${n}.`,
invoices: 'Invoices', noInvoices: 'No invoices yet.',
col: { num: '#', date: 'Issued', what: 'For', amount: 'Amount', status: 'Status' },
kind: { setup: 'Setup', fixed: 'Subscription', percent: 'Deal commission' },
period: (a, b) => `${a} — ${b}`, firstMonth: 'first month',
status: { pending: 'Awaiting payment', paid: 'Paid', canceled: 'Canceled' },
method: { manual: 'manually', online: 'online', auto: 'nothing to pay' },
pay: 'Pay', details: 'Breakdown', hide: 'Hide',
payHint: 'Online payment is coming soon. For now invoices are paid by arrangement with your Y&P manager — access opens as soon as the payment arrives.',
month: 'Current month', monthSub: (a, b) => `Deals for ${a} – ${b}: the invoice arrives on the 1st and is due by the 8th.`,
commissions: 'Deal commissions', toPay: (p) => `To pay, ${p}%`, noDeals: 'No deals in this period.',
deal: { date: 'Date', title: 'Deal', agent: 'Agent', commission: 'Commission', usd: 'In USD' },
rate: (c, r) => `$1 = ${r} ${c}`,
},
inv: {
title: 'Invoices', sub: 'Invoices of all companies. Marking one as paid extends the subscription.',
filter: { pending: 'Awaiting payment', paid: 'Paid', canceled: 'Canceled', all: 'All' },
company: 'Company', markPaid: 'Mark as paid', cancel: 'Cancel invoice', empty: 'No invoices.',
confirmPaid: (c, a) => `Mark the ${a} invoice of “${c}” as paid? The subscription will be extended.`,
confirmCancel: 'Cancel the invoice?',
rates: 'Exchange rates for commission invoices', ratesSub: 'A deal commission can be entered in any currency; the invoice is issued in USD. Units of currency per one dollar:',
sum: { active: 'Active companies', overdue: 'Overdue', restricted: 'Access restricted', revenue: 'Revenue this month', pending: 'Awaiting payment' },
},
bot: {
title: 'Bot greeting', sub: 'What the bot replies to /start for someone who has not linked Telegram to an account yet: a text and files after it.',
text: { ru: 'Text in Russian', hy: 'Text in Armenian', en: 'Text in English' },
textHint: 'An empty field means the bot’s standard text. After the greeting the bot asks for a language with buttons. Until a language is chosen, the Russian text is sent in place of an empty Armenian or English one; after the choice, the text in the chosen language is sent, or the standard one if it is empty.',
standard: 'The bot’s standard text', save: 'Save text', saved: 'Saved',
files: 'Files', noFiles: 'No files.', add: 'Add a file', remove: 'Remove',
filesHint: 'Up to 10 files, 20 MB each. When you add one, the bot sends it to you in Telegram — that is how it gets stored there.',
kind: { photo: 'photo', file: 'file' },
},
msg: {
title: 'Messages', sub: 'The message goes through the bot to everyone with Telegram linked.',
audience: 'To', a: { all: 'All users', company: 'One company', managers: 'Managers only', user: 'One user' },
company: 'Company', anyCompany: 'All companies', user: 'User', userSearch: 'Name, email, company', pick: 'Choose a user',
text: 'Message text', send: 'Send', confirm: (n) => `Send the message: ${n}?`,
sent: (n, m) => `Sent: ${n}.${m ? ` Without Telegram, will not receive it: ${m}.` : ''}`,
journal: 'Log', empty: 'No messages yet.', recipients: (n) => `recipients: ${n}`, unreachable: (n) => `without Telegram: ${n}`,
noTg: 'Telegram not linked',
},
wiz: {
title: 'Quick setup', sub: 'A few steps to get properties flowing. You can skip any of them and come back later.',
step: (n, of) => `Step ${n} of ${of}`,
filter: 'First filter', filterSub: 'A filter decides which List.am listings become your properties: deal type, district, price, rooms.',
filterAdd: 'Create a filter', filterDone: (n) => `Filters created: ${n}`,
tg: 'Connect the bot', tgSub: 'New properties arrive in Telegram with a “Take” button. Without linking there are no notifications.',
tgDone: 'Telegram is linked', tgCheck: 'I pressed Start — check',
team: 'Invite your agents', teamSub: 'Send agents the link. They log in with Telegram or email and join your company.',
teamLink: 'Create an invite link',
next: 'Next', skip: 'Skip', finish: 'Done', later: 'I will set it up later',
agentSub: 'Link Telegram to get new properties first.',
},
mini: {
title: 'Your plan works in Telegram', sub: 'The property feed, cards, status changes and filters open in the bot. In the browser you can manage the subscription and your profile.',
open: 'Open the bot', step1: 'Link Telegram to your account', step2: 'Open the bot and press the Lead Radar button',
upgrade: 'The web app, analytics and team are available on Team and Agency.',
more: 'More',
},
sup: {
title: 'Support', sub: 'Everything users write to the bot. Your reply goes to their Telegram.',
open: 'Open', closed: 'Solved', empty: 'No requests.', pick: 'Choose a request on the left.',
noAccount: 'no account', you: 'You: ', reply: 'Reply to the user', send: 'Send', sendHint: 'Ctrl + Enter',
solve: 'Problem solved', confirmSolve: 'Close the request? The user will be told it is solved.',
solved: (d) => `Solved ${d}`, file: 'File', photo: 'Photo', back: 'Back to list',
attach: 'Attach a file', removeFile: 'Remove the file', caption: 'File caption (optional)',
},
help: {
title: 'Support', lead: 'Write to us in Telegram — a person will answer.',
step1: 'Link Telegram to your account', step2: 'Open the bot and just start writing',
step2sub: 'Describe the question in a plain message; you can attach a screenshot. Support will join and reply in the same chat.',
open: 'Open the bot',
},
};
const dict: Record<Lang, Dict> = { ru, hy, en };
export function useAcc() {
const base = useLang();
return {
...base,
a: dict[base.lang],
usd: (n: number) => `$${n.toLocaleString('ru-RU', { maximumFractionDigits: 2 })}`,
};
}
+136
View File
@@ -0,0 +1,136 @@
export class ApiError extends Error {
constructor(
readonly status: number,
readonly code: string,
) {
super(code);
}
}
export async function api<T = unknown>(path: string, init?: { method?: string; body?: unknown }): Promise<T> {
const res = await fetch(`/api${path}`, {
method: init?.method ?? 'GET',
credentials: 'same-origin',
headers: init?.body !== undefined ? { 'content-type': 'application/json' } : undefined,
body: init?.body !== undefined ? JSON.stringify(init.body) : undefined,
});
const data = (await res.json().catch(() => ({}))) as { code?: string };
if (!res.ok) throw new ApiError(res.status, data.code ?? 'INTERNAL');
return data as T;
}
export interface Me {
user: {
id: number;
name: string;
email: string | null;
emailVerified: boolean;
telegramLinked: boolean;
hasPassword: boolean;
lang: 'ru' | 'hy' | 'en';
role: 'superadmin' | 'manager' | 'agent';
isSuperadmin: boolean;
quietFrom: string | null;
quietTo: string | null;
notificationsPaused: boolean;
onboarded: boolean;
/** Отдел сотрудника: он видит только объекты и клиентов этого отдела. */
departmentId: number | null;
departmentName: string | null;
};
impersonating: boolean;
/** Тариф без веб-кабинета, а открыт браузер: доступны только подписка и профиль. */
webBlocked: boolean;
company: {
id: number;
name: string;
slug: string | null;
plan: 'solo' | 'team' | 'agency';
billingModel: 'fixed' | 'percent';
planned: { plan: string; billingModel: string; from: string } | null;
subscriptionEndsAt: string | null;
graceDays: number;
access: 'active' | 'overdue' | 'restricted' | 'disabled';
modules: string[];
maxMembers: number;
members: number;
/** Настроен бот группы компании в Telegram. */
groupBot: boolean;
settings: {
agentsSeeAll: boolean;
autoRejectRemoved?: boolean;
poolReturnHours: number;
agentRules: { useAgencyBadge: boolean; phoneThreshold: number };
commission?: import('@lr/shared').CommissionSettings;
reports?: { weekly: boolean; monthly: boolean };
};
} | null;
}
export interface Member {
id: number;
name: string;
role: 'manager' | 'agent';
email: string | null;
telegramUsername: string | null;
telegramLinked: boolean;
departmentId: number | null;
/** Условия оплаты: видны руководителю, агенту — только свои. */
pay: import('@lr/shared').AgentPay | null;
}
export interface AdminCompany {
id: number;
name: string;
plan: 'solo' | 'team' | 'agency';
billingModel: 'fixed' | 'percent';
members: number;
maxMembers: number;
access: 'active' | 'overdue' | 'restricted' | 'disabled';
accessEnabled: boolean;
subscriptionEndsAt: string | null;
graceDays: number;
lastActivityAt: string | null;
}
export interface PercentDeal {
dealId: number;
date: string;
title: string;
agent: string;
commission: number;
currency: string;
usd: number;
}
export interface Invoice {
id: number;
kind: 'setup' | 'fixed' | 'percent';
periodStart: string | null;
periodEnd: string | null;
amount: number;
currency: string;
status: 'pending' | 'paid' | 'canceled';
paidAt: string | null;
paidMethod: 'manual' | 'online' | 'auto' | null;
details: { plan?: string; percent?: number; total?: number; rates?: Record<string, number>; deals?: PercentDeal[] } | null;
createdAt: string;
}
export interface Billing {
plan: 'solo' | 'team' | 'agency';
billingModel: 'fixed' | 'percent';
access: 'active' | 'overdue' | 'restricted' | 'disabled';
subscriptionEndsAt: string | null;
planned: { plan: 'solo' | 'team' | 'agency'; billingModel: 'fixed' | 'percent'; from: string } | null;
/** С какой даты вступит в силу смена тарифа; null — сразу. */
changeFrom: string | null;
onlinePayments: boolean;
invoices: Invoice[];
preview: { from: string; to: string; amount: number; percent: number; total: number; rates: Record<string, number>; deals: PercentDeal[] } | null;
}
export interface AdminInvoice extends Invoice {
companyId: number;
company: string;
}
+202
View File
@@ -0,0 +1,202 @@
import {
Activity, BarChart3, Database, Building2, CreditCard, Archive, LogOut, LifeBuoy, Megaphone, Menu, Radar, Receipt, Send, Settings, SlidersHorizontal, UserRound, Users, X,
type LucideIcon,
} from 'lucide-react';
import { useQuery } from '@tanstack/react-query';
import { useState } from 'react';
import { NavLink, Outlet } from 'react-router';
import { api, type Me } from '../api.ts';
import { useAction } from '../hooks.ts';
import { useLang } from '../i18n.tsx';
import { LangSwitcher, Logo, ThemeToggle } from './ui.tsx';
interface Section {
path: string;
key: 'leads' | 'clients' | 'archive' | 'analytics' | 'filters' | 'team' | 'billing' | 'settings' | 'admin' | 'parser' | 'listings' | 'invoices' | 'messages' | 'telegram' | 'support' | 'help';
icon: LucideIcon;
visible: (me: Me) => boolean;
}
/** Рабочие разделы: тарифу без веб-кабинета они открыты только в Telegram Mini App. */
const inCompany = (me: Me) => Boolean(me.company) && !me.webBlocked;
const isManager = (me: Me) => inCompany(me) && me.user.role === 'manager';
const isAdmin = (me: Me) => me.user.isSuperadmin && !me.impersonating;
/** Разделы кабинета. Недоступные по тарифу или роли модули скрыты (и закрыты на сервере). */
export const SECTIONS: Section[] = [
{ path: '/admin', key: 'admin', icon: Building2, visible: isAdmin },
{ path: '/invoices', key: 'invoices', icon: Receipt, visible: isAdmin },
{ path: '/support', key: 'support', icon: LifeBuoy, visible: isAdmin },
{ path: '/messages', key: 'messages', icon: Megaphone, visible: isAdmin },
{ path: '/listings', key: 'listings', icon: Database, visible: isAdmin },
{ path: '/parser', key: 'parser', icon: Activity, visible: isAdmin },
{ path: '/telegram', key: 'telegram', icon: Send, visible: (me) => me.webBlocked },
{ path: '/leads', key: 'leads', icon: Radar, visible: inCompany },
{ path: '/clients', key: 'clients', icon: UserRound, visible: inCompany },
{ path: '/archive', key: 'archive', icon: Archive, visible: (me) => inCompany(me) && me.company!.modules.includes('archive') },
{ path: '/analytics', key: 'analytics', icon: BarChart3, visible: (me) => isManager(me) && me.company!.modules.includes('analytics') },
{ path: '/filters', key: 'filters', icon: SlidersHorizontal, visible: inCompany },
{ path: '/team', key: 'team', icon: Users, visible: isManager },
{ path: '/billing', key: 'billing', icon: CreditCard, visible: (me) => Boolean(me.company) && me.user.role === 'manager' },
{ path: '/settings', key: 'settings', icon: Settings, visible: () => true },
{ path: '/help', key: 'help', icon: LifeBuoy, visible: (me) => Boolean(me.company) && !me.impersonating },
];
function Banner({ me }: { me: Me }) {
const { t } = useLang();
const exit = useAction(() => api('/admin/impersonate', { method: 'POST', body: { companyId: null } }));
const c = me.company;
if (!c) return null;
const daysLeft = c.subscriptionEndsAt
? Math.max(0, Math.ceil((new Date(c.subscriptionEndsAt).getTime() + c.graceDays * 86_400_000 - Date.now()) / 86_400_000))
: 0;
const text =
c.access === 'disabled' ? t.banner.disabled
: c.access === 'overdue' ? t.banner.overdue(daysLeft)
: c.access === 'restricted' ? t.banner.restricted
: null;
return (
<>
{me.impersonating && (
<div className="flex flex-wrap items-center justify-between gap-2 border-b border-signal/30 bg-signal/10 px-4 py-2 text-sm sm:px-6">
{t.banner.impersonating(c.name)}
<button className="btn btn-ghost btn-sm" onClick={() => exit.mutate()}>{t.banner.exit}</button>
</div>
)}
{text && (
<div role="status" className={`border-b px-4 py-2.5 text-sm sm:px-6 ${c.access === 'overdue' ? 'border-warn/30 bg-warn/10 text-warn' : 'border-danger/30 bg-danger/10 text-danger'}`}>
{text}
</div>
)}
</>
);
}
export function Shell({ me }: { me: Me }) {
const { t } = useLang();
const [open, setOpen] = useState(false);
const logout = useAction(() => api('/auth/logout', { method: 'POST' }));
const sections = SECTIONS.filter((s) => s.visible(me));
// Счётчик непрочитанных обращений в поддержку — только суперадмину.
const support = useQuery({
queryKey: ['admin', 'support', 'unread'],
queryFn: () => api<{ count: number }>('/admin/support/unread'),
enabled: isAdmin(me),
refetchInterval: 15_000,
});
const unread = support.data?.count ?? 0;
// Нижняя панель на телефоне: лента, клиенты, фильтры. Суперадмину и Solo в браузере она не нужна.
const tabs = sections.filter((s) => ['/leads', '/clients', '/filters'].includes(s.path));
const nav = (
<nav className="flex flex-1 flex-col gap-0.5" aria-label={t.common.menu}>
{sections.map(({ path, key, icon: Icon }) => (
<NavLink
key={path}
to={path}
onClick={() => setOpen(false)}
className={({ isActive }) =>
`flex min-h-11 items-center gap-3 rounded-xl px-3 text-[15px] transition-colors ${
isActive ? 'bg-raised text-text' : 'text-muted hover:bg-raised/60 hover:text-text'
}`
}
>
{({ isActive }) => (
<>
<Icon size={18} strokeWidth={1.7} className={isActive ? 'text-signal' : ''} aria-hidden="true" />
{t.nav[key]}
{key === 'support' && unread > 0 && (
<span className="ml-auto rounded-full bg-signal px-2 py-0.5 text-xs font-semibold text-ink tabular-nums">{unread}</span>
)}
</>
)}
</NavLink>
))}
</nav>
);
const footer = (
<div className="border-t border-line pt-3">
<p className="truncate px-3 text-sm">{me.user.name}</p>
<p className="truncate px-3 text-xs text-muted">
{me.company ? `${me.company.name} · ` : ''}
{t.roles[me.user.isSuperadmin && !me.impersonating ? 'superadmin' : me.user.role]}
</p>
<div className="mt-3 flex items-center gap-2 px-1">
<LangSwitcher />
<ThemeToggle />
<button
type="button"
onClick={() => logout.mutate()}
aria-label={t.common.logout}
title={t.common.logout}
className="ml-auto flex size-9 cursor-pointer items-center justify-center rounded-lg border border-line text-muted hover:text-text"
>
<LogOut size={16} />
</button>
</div>
</div>
);
return (
<div className="min-h-dvh lg:pl-64">
<aside className="fixed inset-y-0 left-0 hidden w-64 flex-col gap-4 border-r border-line bg-surface p-3 lg:flex">
<div className="px-2 py-2"><Logo /></div>
{nav}
{footer}
</aside>
<header className="sticky top-0 z-40 flex h-14 items-center justify-between border-b border-line bg-ink/80 px-4 backdrop-blur-xl lg:hidden">
<Logo />
{tabs.length <= 1 && (
<button
type="button"
className="flex size-11 cursor-pointer items-center justify-center rounded-xl text-muted"
aria-expanded={open}
aria-label={t.common.menu}
onClick={() => setOpen((v) => !v)}
>
{open ? <X size={20} /> : <Menu size={20} />}
</button>
)}
</header>
{open && (
<div className={`fixed inset-x-0 top-14 z-30 flex flex-col gap-4 overflow-y-auto bg-ink p-3 lg:hidden ${tabs.length > 1 ? 'bottom-[calc(3.5rem+env(safe-area-inset-bottom))]' : 'bottom-0'}`}>
{nav}
{footer}
</div>
)}
<Banner me={me} />
<main className={`mx-auto max-w-6xl px-4 py-6 sm:px-6 lg:py-8 ${tabs.length > 1 ? 'pb-24 lg:pb-8' : ''}`}>
<Outlet />
</main>
{/* Телефон и Mini App: главные разделы под большим пальцем, остальное — в «Ещё». */}
{tabs.length > 1 && (
<nav className="fixed inset-x-0 bottom-0 z-40 flex border-t border-line bg-ink/90 pb-[env(safe-area-inset-bottom)] backdrop-blur-xl lg:hidden" aria-label={t.common.menu}>
{tabs.map(({ path, key, icon: Icon }) => (
<NavLink
key={path}
to={path}
onClick={() => setOpen(false)}
className={({ isActive }) => `flex min-h-14 flex-1 flex-col items-center justify-center gap-0.5 text-[11px] ${isActive && !open ? 'text-signal' : 'text-muted'}`}
>
<Icon size={20} strokeWidth={1.7} aria-hidden="true" />
{t.nav[key]}
</NavLink>
))}
<button
type="button"
aria-expanded={open}
onClick={() => setOpen((v) => !v)}
className={`flex min-h-14 flex-1 cursor-pointer flex-col items-center justify-center gap-0.5 text-[11px] ${open ? 'text-signal' : 'text-muted'}`}
>
{open ? <X size={20} strokeWidth={1.7} aria-hidden="true" /> : <Menu size={20} strokeWidth={1.7} aria-hidden="true" />}
{t.common.more}
</button>
</nav>
)}
</div>
);
}
+102
View File
@@ -0,0 +1,102 @@
import { Moon, Sun } from 'lucide-react';
import type { ReactNode } from 'react';
import { useTheme } from '../hooks.ts';
import { LANGS, useLang } from '../i18n.tsx';
export function Logo({ compact = false }: { compact?: boolean }) {
return (
<span className="flex items-center gap-2.5">
<svg width="28" height="28" viewBox="0 0 32 32" aria-hidden="true">
<circle cx="16" cy="16" r="13" fill="none" stroke="var(--signal)" strokeOpacity=".35" strokeWidth="1.5" />
<circle cx="16" cy="16" r="7" fill="none" stroke="var(--signal)" strokeOpacity=".65" strokeWidth="1.5" />
<circle cx="16" cy="16" r="1.8" fill="var(--signal)" />
<circle cx="24" cy="9" r="3" fill="var(--capture)" />
</svg>
<span className="text-[15px] font-semibold tracking-tight">
Y&amp;P {!compact && <span className="font-normal text-muted">Lead Radar</span>}
</span>
</span>
);
}
export function LangSwitcher() {
const { lang, setLang, t } = useLang();
return (
<div className="flex rounded-lg border border-line p-0.5" role="group" aria-label={t.common.lang}>
{LANGS.map((l) => (
<button
key={l.code}
type="button"
title={l.name}
aria-pressed={lang === l.code}
onClick={() => setLang(l.code)}
className={`min-h-8 cursor-pointer rounded-md px-2 text-xs font-medium transition-colors ${
lang === l.code ? 'bg-raised text-text' : 'text-muted hover:text-text'
}`}
>
{l.label}
</button>
))}
</div>
);
}
export function ThemeToggle() {
const [theme, toggle] = useTheme();
const { t } = useLang();
return (
<button
type="button"
onClick={toggle}
aria-label={t.common.theme}
title={t.common.theme}
className="flex size-9 cursor-pointer items-center justify-center rounded-lg border border-line text-muted hover:text-text"
>
{theme === 'dark' ? <Sun size={16} /> : <Moon size={16} />}
</button>
);
}
export function ErrorNote({ error }: { error: unknown }) {
const { err } = useLang();
if (!error) return null;
return (
<p role="alert" className="rounded-xl border border-danger/40 bg-danger/10 px-3.5 py-2.5 text-sm text-danger">
{err(error)}
</p>
);
}
export function PageTitle({ title, sub, children }: { title: string; sub?: ReactNode; children?: ReactNode }) {
return (
<div className="mb-6 flex flex-wrap items-end justify-between gap-3">
<div>
<h1 className="text-2xl font-semibold tracking-tight">{title}</h1>
{sub && <p className="mt-1 text-sm text-muted">{sub}</p>}
</div>
{children}
</div>
);
}
const ACCESS_TONE = {
active: 'bg-capture/15 text-capture',
overdue: 'bg-warn/15 text-warn',
restricted: 'bg-danger/15 text-danger',
disabled: 'bg-raised text-muted',
} as const;
export function AccessChip({ access }: { access: keyof typeof ACCESS_TONE }) {
const { t } = useLang();
return <span className={`chip ${ACCESS_TONE[access]}`}>{t.access[access]}</span>;
}
export function Toggle({ checked, onChange, label }: { checked: boolean; onChange: (v: boolean) => void; label: string }) {
return (
<label className="flex cursor-pointer items-start gap-3">
<input type="checkbox" className="peer sr-only" checked={checked} onChange={(e) => onChange(e.target.checked)} />
<span className="relative mt-0.5 h-6 w-10 shrink-0 rounded-full bg-raised ring-1 ring-line-strong transition-colors after:absolute after:left-0.5 after:top-0.5 after:size-5 after:rounded-full after:bg-muted after:transition-transform peer-checked:bg-capture/30 peer-checked:after:translate-x-4 peer-checked:after:bg-capture peer-focus-visible:outline-2 peer-focus-visible:outline-signal" />
<span className="leading-snug">{label}</span>
</label>
);
}
+238
View File
@@ -0,0 +1,238 @@
import type { Pipeline } from '@lr/shared';
import { useQuery } from '@tanstack/react-query';
import { Download } from 'lucide-react';
import { useMemo, useState } from 'react';
import { api } from '../api.ts';
import { ErrorNote, PageTitle } from '../components/ui.tsx';
import { qs } from './api.ts';
import { useCrm } from './i18n.ts';
interface AgentRow {
userId: number;
name: string;
taken: number;
reactionSec: number | null;
poolReturns: number;
showings: number;
cohort: number;
reached: Record<number, number>;
deals: number;
commission: Record<string, number>;
reasons: Record<number, number>;
}
interface Analytics {
statuses: { id: number; code: string | null; name: string }[];
reasons: { id: number; name: string }[];
summary: { created: number; taken: number; open: number; funnel: Record<number, number>; cohort: number };
agents: AgentRow[];
totals: AgentRow;
}
type Period = 'today' | 'week' | 'month' | 'custom';
const day = 86_400_000;
const isoDay = (d: Date) => new Date(d.getTime() - d.getTimezoneOffset() * 60_000).toISOString().slice(0, 10);
export function saveCsv(name: string, rows: (string | number)[][]) {
const cell = (v: string | number) => (/[";\n]/.test(String(v)) ? `"${String(v).replaceAll('"', '""')}"` : String(v));
// «;» и метка BOM — чтобы Excel открыл файл с кириллицей и колонками без настройки.
const blob = new Blob(['' + rows.map((r) => r.map(cell).join(';')).join('\r\n')], { type: 'text/csv;charset=utf-8' });
const a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = name;
a.click();
URL.revokeObjectURL(a.href);
}
/** Аналитика по сотрудникам: сводка по компании, воронка, таблица по агентам, причины отказа. */
export function AnalyticsPage() {
const { c, t, price } = useCrm();
const x = c.an;
const [pipeline, setPipeline] = useState<Pipeline>('object');
const [period, setPeriod] = useState<Period>('month');
const [from, setFrom] = useState(isoDay(new Date(Date.now() - 30 * day)));
const [to, setTo] = useState(isoDay(new Date()));
const range = useMemo(() => {
const start = new Date();
start.setHours(0, 0, 0, 0);
const end = new Date(start.getTime() + day - 1);
if (period === 'today') return { from: start, to: end };
if (period === 'week') return { from: new Date(start.getTime() - 6 * day), to: end };
if (period === 'month') return { from: new Date(start.getTime() - 29 * day), to: end };
return { from: new Date(`${from}T00:00:00`), to: new Date(`${to}T23:59:59.999`) };
}, [period, from, to]);
const valid = !Number.isNaN(range.from.getTime()) && !Number.isNaN(range.to.getTime()) && range.from <= range.to;
const params = { pipeline, from: range.from.toISOString(), to: range.to.toISOString() };
const report = useQuery({ queryKey: ['analytics', params], queryFn: () => api<Analytics>(`/analytics${qs(params)}`), enabled: valid });
const d = report.data;
const isObject = pipeline === 'object';
const money = (m: Record<string, number>) => Object.entries(m).map(([cur, n]) => price(n, cur)).join(' + ') || '—';
const duration = (sec: number | null) => {
if (sec == null) return '—';
if (sec < 90) return `${sec} ${x.sec}`;
if (sec < 5400) return `${Math.round(sec / 60)} ${x.min}`;
return `${Math.floor(sec / 3600)} ${x.hour} ${Math.round((sec % 3600) / 60)} ${x.min}`;
};
const share = (n: number, of: number) => (of ? `${Math.round((n / of) * 100)}%` : '—');
// Этапы для конверсии: стартовые («Новый», «Взят в работу») проходят все — их не показываем.
const stages = d?.statuses.filter((s) => s.code !== 'new' && s.code !== 'taken') ?? [];
const head = d
? [x.agent, isObject ? x.taken : x.newRequests, ...(isObject ? [x.reaction, x.poolReturns] : [x.showings]), ...stages.map((s) => s.name), x.deals, x.commission]
: [];
const line = (r: AgentRow, name: string, csv = false): (string | number)[] => [
name,
r.taken,
...(isObject ? [csv ? (r.reactionSec ?? '') : duration(r.reactionSec), r.poolReturns] : [r.showings]),
...stages.map((s) => (csv ? share(r.reached[s.id] ?? 0, r.cohort) : `${share(r.reached[s.id] ?? 0, r.cohort)}`)),
r.deals,
csv ? Object.entries(r.commission).map(([cur, n]) => `${n} ${cur}`).join(' + ') : money(r.commission),
];
const usedReasons = d?.reasons.filter((r) => d.totals.reasons[r.id]) ?? [];
const exportCsv = () => {
if (!d) return;
const csvHead = head.map((h, i) => (isObject && i === 2 ? `${h}, ${x.sec}` : h));
saveCsv(`analytics-${pipeline}-${isoDay(range.from)}-${isoDay(range.to)}.csv`, [
csvHead,
...d.agents.map((r) => line(r, r.name, true)),
line(d.totals, x.total, true),
[],
[x.reasons, ...d.agents.map((a) => a.name), x.total],
...usedReasons.map((r) => [r.name, ...d.agents.map((a) => a.reasons[r.id] ?? 0), d.totals.reasons[r.id] ?? 0]),
]);
};
const maxFunnel = Math.max(1, ...(d ? d.statuses.map((s) => d.summary.funnel[s.id] ?? 0) : [1]));
const cards: [string, number][] = d
? isObject
? [[x.came, d.summary.created], [x.takenTotal, d.summary.taken], [x.inPool, d.summary.open], [x.deals, d.totals.deals]]
: [[x.newRequests, d.summary.created], [x.inWork, d.summary.open], [x.showings, d.totals.showings], [x.deals, d.totals.deals]]
: [];
return (
<>
<PageTitle title={t.nav.analytics}>
<button className="btn btn-ghost" disabled={!d} onClick={exportCsv}>
<Download size={16} aria-hidden="true" />CSV
</button>
</PageTitle>
<div className="mb-5 flex flex-wrap items-center gap-3">
<div className="flex rounded-xl border border-line p-1" role="tablist">
{(['object', 'client'] as const).map((v) => (
<button key={v} role="tab" aria-selected={pipeline === v} onClick={() => setPipeline(v)} className={`min-h-9 cursor-pointer rounded-lg px-3 text-sm transition-colors ${pipeline === v ? 'bg-raised text-text' : 'text-muted hover:text-text'}`}>
{x.pipeline[v]}
</button>
))}
</div>
<div className="flex rounded-xl border border-line p-1">
{(['today', 'week', 'month', 'custom'] as const).map((v) => (
<button key={v} aria-pressed={period === v} onClick={() => setPeriod(v)} className={`min-h-9 cursor-pointer rounded-lg px-3 text-sm transition-colors ${period === v ? 'bg-raised text-text' : 'text-muted hover:text-text'}`}>
{x.period[v]}
</button>
))}
</div>
{period === 'custom' && (
<div className="flex items-center gap-2 text-sm text-muted">
<input type="date" className="field w-auto" aria-label={c.f.from} value={from} max={to} onChange={(e) => setFrom(e.target.value)} />
—
<input type="date" className="field w-auto" aria-label={c.f.to} value={to} min={from} onChange={(e) => setTo(e.target.value)} />
</div>
)}
</div>
<ErrorNote error={report.error} />
{report.isPending && valid && <p className="text-muted">{t.common.loading}</p>}
{d && (
<div className="space-y-6">
<div className="grid grid-cols-2 gap-3 lg:grid-cols-4">
{cards.map(([label, value]) => (
<div key={label} className="card p-4">
<p className="text-sm text-muted">{label}</p>
<p className="mt-1 text-3xl font-semibold tabular-nums tracking-tight">{value}</p>
</div>
))}
</div>
<section className="card p-5">
<h2 className="font-medium">{x.funnel}</h2>
<p className="mb-4 mt-1 text-sm text-muted">{isObject ? x.funnelHintObject : x.funnelHintClient}</p>
{d.summary.cohort === 0 ? (
<p className="text-sm text-muted">{x.noData}</p>
) : (
<ul className="space-y-2">
{d.statuses.map((s) => {
const n = d.summary.funnel[s.id] ?? 0;
return (
<li key={s.id} className="grid grid-cols-[minmax(0,11rem)_1fr_5.5rem] items-center gap-3 text-sm">
<span className="truncate">{s.name}</span>
<span className="h-2.5 overflow-hidden rounded-full bg-raised">
<span className={`block h-full rounded-full ${s.code === 'rejected' ? 'bg-danger/70' : s.code === 'deal' ? 'bg-capture' : 'bg-signal/70'}`} style={{ width: `${(n / maxFunnel) * 100}%` }} />
</span>
<span className="text-right tabular-nums">{n} <span className="text-muted">· {share(n, d.summary.cohort)}</span></span>
</li>
);
})}
</ul>
)}
</section>
<section>
<h2 className="mb-1 font-medium">{x.byAgent}</h2>
<p className="mb-3 text-sm text-muted">{isObject ? x.tableHintObject : x.tableHintClient}</p>
<div className="card overflow-x-auto">
<table className="w-full text-left text-sm">
<thead className="text-muted">
<tr className="border-b border-line">
{head.map((h, i) => <th key={i} className={`px-4 py-3 font-normal ${i ? 'text-right' : ''}`}>{h}</th>)}
</tr>
</thead>
<tbody className="divide-y divide-line">
{d.agents.map((r) => (
<tr key={r.userId}>
{line(r, r.name).map((v, i) => <td key={i} className={`whitespace-nowrap px-4 py-3 ${i ? 'text-right tabular-nums' : 'font-medium'}`}>{v}</td>)}
</tr>
))}
<tr className="bg-raised/40 font-medium">
{line(d.totals, x.total).map((v, i) => <td key={i} className={`whitespace-nowrap px-4 py-3 ${i ? 'text-right tabular-nums' : ''}`}>{v}</td>)}
</tr>
</tbody>
</table>
</div>
</section>
<section>
<h2 className="mb-3 font-medium">{x.reasons}</h2>
{usedReasons.length === 0 ? (
<p className="text-sm text-muted">{x.noRejects}</p>
) : (
<div className="card overflow-x-auto">
<table className="w-full text-left text-sm">
<thead className="text-muted">
<tr className="border-b border-line">
<th className="px-4 py-3 font-normal">{x.reason}</th>
{d.agents.map((a) => <th key={a.userId} className="px-4 py-3 text-right font-normal">{a.name}</th>)}
<th className="px-4 py-3 text-right font-normal">{x.total}</th>
</tr>
</thead>
<tbody className="divide-y divide-line">
{usedReasons.map((r) => (
<tr key={r.id}>
<td className="px-4 py-3">{r.name}</td>
{d.agents.map((a) => <td key={a.userId} className="px-4 py-3 text-right tabular-nums">{a.reasons[r.id] ?? '—'}</td>)}
<td className="px-4 py-3 text-right font-medium tabular-nums">{d.totals.reasons[r.id]}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</section>
</div>
)}
</>
);
}
+184
View File
@@ -0,0 +1,184 @@
import { useQuery } from '@tanstack/react-query';
import { Search } from 'lucide-react';
import { useEffect, useState } from 'react';
import { api, type Me } from '../api.ts';
import { ErrorNote, PageTitle } from '../components/ui.tsx';
import { qs, useMembers } from './api.ts';
import { ClientDrawer } from './ClientDrawer.tsx';
import { useCrm } from './i18n.ts';
import { LeadDrawer } from './LeadDrawer.tsx';
interface ArchiveLead {
id: number;
title: string;
dealType: string | null;
district: string | null;
region: string | null;
address: string | null;
price: number | null;
currency: string | null;
photo: string | null;
assigneeName: string | null;
closedAt: string;
/** deal, rejected — закрыт; другой код — объект снят с List.am, но не закрыт. */
result: string;
reason: string | null;
commission: number | null;
dealCurrency: string | null;
removedAt: string | null;
}
interface Contact {
key: string;
type: 'buyer' | 'tenant';
clientId: number;
name: string | null;
phone: string | null;
result?: 'deal' | 'rejected';
reason?: string | null;
closedAt: string;
assigneeName: string | null;
objects: { id: number; title: string }[];
}
function Result({ result, note }: { result: string | undefined; note?: string | null }) {
const { c } = useCrm();
if (!result) return null;
// Объект снят с List.am, а карточка не закрыта: в «Базе» он с отметкой «Снят».
if (result !== 'deal' && result !== 'rejected') return <span className="chip bg-warn/15 text-warn">{c.dp.removed}</span>;
return (
<>
<span className={`chip ${result === 'deal' ? 'bg-capture/15 text-capture' : 'bg-danger/15 text-danger'}`}>{c.ar.result[result]}</span>
{note && <span className="mt-1 block text-xs text-muted">{note}</span>}
</>
);
}
/** База бывших клиентов и объектов: всё, что закрыто сделкой или отказом. */
export function ArchivePage({ me }: { me: Me }) {
const { c, t, date, price } = useCrm();
const x = c.ar;
const seeAll = me.user.role === 'manager' || Boolean(me.company?.settings.agentsSeeAll);
const members = useMembers();
const [tab, setTab] = useState<'leads' | 'contacts'>('leads');
const [search, setSearch] = useState('');
const [query, setQuery] = useState('');
const [result, setResult] = useState('');
const [assigneeId, setAssigneeId] = useState('');
const [openLead, setOpenLead] = useState<number | null>(null);
const [openClient, setOpenClient] = useState<number | null>(null);
useEffect(() => {
const timer = setTimeout(() => setQuery(search.trim()), 300);
return () => clearTimeout(timer);
}, [search]);
const params = { q: query, result, assigneeId };
const leads = useQuery({ queryKey: ['archive', 'leads', params], queryFn: () => api<ArchiveLead[]>(`/archive/leads${qs(params)}`), enabled: tab === 'leads' });
const contacts = useQuery({ queryKey: ['archive', 'contacts', params], queryFn: () => api<Contact[]>(`/archive/contacts${qs(params)}`), enabled: tab === 'contacts' });
const list = tab === 'leads' ? leads : contacts;
return (
<>
<PageTitle title={t.nav.archive} sub={x.sub} />
<div className="mb-4 flex flex-wrap items-center gap-3">
<div className="flex rounded-xl border border-line p-1" role="tablist">
{(['leads', 'contacts'] as const).map((v) => (
<button key={v} role="tab" aria-selected={tab === v} onClick={() => { setTab(v); if (v === 'contacts' && result === 'removed') setResult(''); }} className={`min-h-9 cursor-pointer rounded-lg px-3 text-sm transition-colors ${tab === v ? 'bg-raised text-text' : 'text-muted hover:text-text'}`}>
{x.tabs[v]}
</button>
))}
</div>
<div className="relative min-w-48 flex-1">
<Search size={16} className="pointer-events-none absolute left-3.5 top-1/2 -translate-y-1/2 text-muted" aria-hidden="true" />
<input className="field pl-10" type="search" placeholder={tab === 'leads' ? c.search : c.cl.search} aria-label={tab === 'leads' ? c.search : c.cl.search} value={search} onChange={(e) => setSearch(e.target.value)} />
</div>
<select className="field w-auto" aria-label={x.resultLabel} value={result} onChange={(e) => setResult(e.target.value)}>
<option value="">{x.anyResult}</option>
<option value="deal">{x.result.deal}</option>
<option value="rejected">{x.result.rejected}</option>
{tab === 'leads' && <option value="removed">{c.dp.removed}</option>}
</select>
{seeAll && (
<select className="field w-auto" aria-label={c.f.agent} value={assigneeId} onChange={(e) => setAssigneeId(e.target.value)}>
<option value="">{c.f.agent}: {c.f.any.toLowerCase()}</option>
{members.data?.map((m) => <option key={m.id} value={m.id}>{m.name}</option>)}
</select>
)}
</div>
<ErrorNote error={list.error} />
{list.data?.length === 0 && (
<div className="card flex min-h-40 items-center justify-center p-8 text-center text-muted">{query || result || assigneeId ? c.cl.notFound : x.empty}</div>
)}
{tab === 'leads' && Boolean(leads.data?.length) && (
<div className="card overflow-x-auto">
<table className="w-full min-w-[44rem] text-left text-sm">
<thead className="text-muted">
<tr className="border-b border-line">
{[c.col.object, c.col.price, x.resultLabel, c.col.agent, x.closed].map((h) => <th key={h} className="px-4 py-3 font-normal">{h}</th>)}
</tr>
</thead>
<tbody className="divide-y divide-line">
{leads.data!.map((l) => (
<tr key={l.id} className="cursor-pointer transition-colors hover:bg-raised/50" onClick={() => setOpenLead(l.id)}>
<td className="px-4 py-2.5">
<div className="flex items-center gap-3">
{l.photo ? <img src={l.photo} alt="" loading="lazy" referrerPolicy="no-referrer" className="size-12 shrink-0 rounded-lg bg-raised object-cover" /> : <div className="size-12 shrink-0 rounded-lg bg-raised" aria-hidden="true" />}
<div className="min-w-0 max-w-sm">
<button type="button" className="block max-w-full cursor-pointer truncate text-left font-medium" onClick={(e) => { e.stopPropagation(); setOpenLead(l.id); }}>{l.title}</button>
<p className="truncate text-xs text-muted">{[l.district ?? l.region, l.address].filter(Boolean).join(', ')}</p>
{l.removedAt && <p className="text-xs text-warn">{x.removed(date(l.removedAt))}</p>}
</div>
</div>
</td>
<td className="whitespace-nowrap px-4 py-2.5 font-medium tabular-nums">{price(l.price, l.currency, l.dealType)}</td>
<td className="px-4 py-2.5">
<Result result={l.result} note={l.result === 'deal' ? (l.commission != null ? `${c.dlg.commission}: ${price(l.commission, l.dealCurrency)}` : null) : l.reason} />
</td>
<td className="px-4 py-2.5">{l.assigneeName ?? '—'}</td>
<td className="whitespace-nowrap px-4 py-2.5 tabular-nums text-muted">{date(l.closedAt)}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{tab === 'contacts' && Boolean(contacts.data?.length) && (
<div className="card overflow-x-auto">
<table className="w-full min-w-[44rem] text-left text-sm">
<thead className="text-muted">
<tr className="border-b border-line">
{[x.contact, c.dlg.phone, x.objects, x.resultLabel, c.col.agent, x.closed].map((h) => <th key={h} className="px-4 py-3 font-normal">{h}</th>)}
</tr>
</thead>
<tbody className="divide-y divide-line">
{contacts.data!.map((k) => (
<tr key={k.key} className="align-top">
<td className="px-4 py-3">
<button type="button" className="cursor-pointer text-left font-medium hover:underline" onClick={() => setOpenClient(k.clientId)}>{k.name}</button>
<p className="text-xs text-muted">{c.cl.kind[k.type]}</p>
</td>
<td className="whitespace-nowrap px-4 py-3 tabular-nums">{k.phone ? <a href={`tel:${k.phone}`} className="hover:underline">{k.phone}</a> : '—'}</td>
<td className="max-w-xs px-4 py-3">
{k.objects.length === 0 && '—'}
{k.objects.map((o) => (
<button key={o.id} type="button" className="block max-w-full cursor-pointer truncate text-left text-signal hover:underline" onClick={() => setOpenLead(o.id)}>{o.title}</button>
))}
</td>
<td className="px-4 py-3"><Result result={k.result} note={k.reason} /></td>
<td className="px-4 py-3">{k.assigneeName ?? '—'}</td>
<td className="whitespace-nowrap px-4 py-3 tabular-nums text-muted">{date(k.closedAt)}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{openLead && <LeadDrawer key={`l${openLead}`} id={openLead} me={me} onClose={() => setOpenLead(null)} />}
{openClient && <ClientDrawer key={`c${openClient}`} id={openClient} me={me} onClose={() => setOpenClient(null)} />}
</>
);
}
+228
View File
@@ -0,0 +1,228 @@
import { useQuery } from '@tanstack/react-query';
import { MessageCircle, Pencil, Phone, Plus, Send, Trash2, X } from 'lucide-react';
import { useState } from 'react';
import { Link } from 'react-router';
import { api, type Me } from '../api.ts';
import { ErrorNote } from '../components/ui.tsx';
import { useAction } from '../hooks.ts';
import { useMembers, usePipeline, type ClientCard, type ClientLead } from './api.ts';
import { ClientDialog, useRequestLine } from './ClientsPage.tsx';
import { CommissionBlock } from './commission.tsx';
import { ClientPhotos, useHouseholdLine } from './household.tsx';
import { ClientPage } from './showcase.tsx';
import { useCrm } from './i18n.ts';
import { CommentBox, DealNote, Drawer, History, NextAction, StatusChip, useStatusChange } from './parts.tsx';
function LeadRow({ lead, children }: { lead: ClientLead; children: React.ReactNode }) {
const { price } = useCrm();
return (
<li className="flex items-center gap-3 rounded-xl bg-raised p-2">
{lead.photo ? (
<img src={lead.photo} alt="" loading="lazy" referrerPolicy="no-referrer" className="size-12 shrink-0 rounded-lg object-cover" />
) : (
<div className="size-12 shrink-0 rounded-lg bg-surface" aria-hidden="true" />
)}
<Link to={`/leads/${lead.id}`} className="min-w-0 flex-1 hover:underline">
<span className="block text-sm font-medium tabular-nums">{price(lead.price, lead.currency, lead.dealType)}</span>
<span className="block truncate text-sm">{lead.title}</span>
<span className="block truncate text-xs text-muted">{[lead.district ?? lead.region, lead.address].filter(Boolean).join(', ')}</span>
</Link>
{children}
</li>
);
}
/** Карточка клиента: контакт, запрос, статус, объекты (предложенные, показанные, подходящие), история. */
export function ClientDrawer({ id, me, onClose }: { id: number; me: Me; onClose: () => void }) {
const { c, t, err } = useCrm();
const x = c.cl;
const requestLine = useRequestLine();
const householdLine = useHouseholdLine();
const pipeline = usePipeline('client');
const members = useMembers();
const isManager = me.user.role === 'manager';
const client = useQuery({ queryKey: ['client', id], queryFn: () => api<ClientCard>(`/clients/${id}`), retry: false });
const suggestions = useQuery({ queryKey: ['client', id, 'suggestions'], queryFn: () => api<ClientLead[]>(`/clients/${id}/suggestions`), retry: false });
const status = useStatusChange(pipeline.data, 'clients');
const post = (path: string, body: object) => api(`/clients/${id}/${path}`, { method: 'POST', body });
const assign = useAction((userId: number) => post('assign', { userId }));
const comment = useAction((text: string) => post('comments', { text }));
const next = useAction((v: { at: string | null; note: string | null }) => post('next-action', v));
const link = useAction((v: { leadId: number; state: 'offered' | 'shown' }) => post('leads', v));
const unlink = useAction((leadId: number) => api(`/clients/${id}/leads/${leadId}`, { method: 'DELETE' }));
const remove = useAction(() => api(`/clients/${id}`, { method: 'DELETE' }), ['clients']);
const [editing, setEditing] = useState(false);
const cl = client.data;
const readOnly = me.company?.access === 'restricted' || me.company?.access === 'disabled';
const canWork = !readOnly && Boolean(cl) && (isManager || cl!.assigneeId === me.user.id);
const current = pipeline.data?.statuses.find((s) => s.id === cl?.statusId);
const reason = pipeline.data?.reasons.find((r) => r.id === cl?.rejectReasonId);
const error = assign.error ?? comment.error ?? next.error ?? link.error ?? unlink.error ?? status.error;
const phoneDigits = cl?.phone?.replace(/\D/g, '');
const request = cl ? requestLine(cl.request) : '';
const living = householdLine(cl?.household);
return (
<Drawer
label={cl?.name ?? ''}
onClose={onClose}
header={
<>
<StatusChip status={current} />
{cl && <span className="chip bg-raised text-muted">{x.kind[cl.kind]}</span>}
</>
}
>
{client.isPending && <p className="p-5 text-muted">{t.common.loading}</p>}
{client.isError && <p className="p-5 text-danger">{err(client.error)}</p>}
{cl && (
<div className="flex-1 space-y-6 overflow-y-auto px-5 py-5">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<h2 className="text-2xl font-semibold tracking-tight">{cl.name}</h2>
{cl.source && <p className="mt-1 text-sm text-muted">{x.source}: {cl.source}</p>}
</div>
{canWork && (
<button type="button" className="btn btn-ghost btn-sm shrink-0" onClick={() => setEditing(true)}>
<Pencil size={14} aria-hidden="true" />{c.dlg.edit}
</button>
)}
</div>
<ErrorNote error={error} />
{cl.deal && (
<DealNote
deal={cl.deal}
canEdit={isManager && !readOnly}
pair={cl.deal.leadId && <>{x.dealLead}: <Link to={`/leads/${cl.deal.leadId}`} className="text-signal hover:underline">{cl.deal.leadTitle}</Link></>}
/>
)}
{current?.code === 'rejected' && reason && (
<p className="rounded-xl border border-danger/30 bg-danger/10 px-3.5 py-2.5 text-sm text-danger">{c.dlg.reject}: {reason.name}</p>
)}
<section>
<h3 className="label">{c.card.contact}</h3>
{cl.phone || cl.telegram ? (
<div className="space-y-3">
<p className="text-lg font-medium tabular-nums">{[cl.phone, cl.telegram].filter(Boolean).join(' · ')}</p>
<div className="flex flex-wrap gap-2">
{cl.phone && <a className="btn btn-primary btn-sm" href={`tel:${cl.phone}`}><Phone size={15} aria-hidden="true" />{c.card.call}</a>}
{cl.phone && <a className="btn btn-ghost btn-sm" href={`https://wa.me/${phoneDigits}`} target="_blank" rel="noopener noreferrer"><MessageCircle size={15} aria-hidden="true" />WhatsApp</a>}
{/* Ник в Telegram надёжнее номера: по номеру чат открывается, только если он не скрыт. */}
<a className="btn btn-ghost btn-sm" href={cl.telegram?.startsWith('@') ? `https://t.me/${cl.telegram.slice(1)}` : `https://t.me/+${phoneDigits}`} target="_blank" rel="noopener noreferrer"><Send size={15} aria-hidden="true" />Telegram</a>
</div>
</div>
) : (
<p className="text-sm text-muted">{cl.phoneHidden ? x.phoneHidden : x.noPhone}</p>
)}
</section>
<section>
<h3 className="label">{x.request}</h3>
<p className={request ? '' : 'text-sm text-muted'}>{request || x.noRequest}</p>
{cl.kind === 'tenant' && cl.rentTerm && <p className="mt-1 text-sm text-muted">{c.x.rentTerm}: {c.x.term[cl.rentTerm]}</p>}
</section>
<section className="space-y-4">
<div>
<h3 className="label">{c.hh.title}</h3>
<p className={living ? '' : 'text-sm text-muted'}>{living || c.hh.empty}</p>
</div>
<ClientPhotos clientId={id} photos={cl.photos} canEdit={canWork} />
</section>
{(canWork || isManager) && <CommissionBlock kind="clients" id={id} info={cl.commission} rent={cl.kind === 'tenant'} canEdit={canWork} />}
<ClientPage clientId={id} url={cl.pageUrl} viewings={cl.viewings} canEdit={canWork} />
<section className="grid gap-4 sm:grid-cols-2">
<div>
<label className="label" htmlFor="c-status">{c.card.status}</label>
<select id="c-status" className="field" disabled={!canWork} value={cl.statusId} onChange={(e) => status.request(id, Number(e.target.value))}>
{pipeline.data?.statuses.map((s) => <option key={s.id} value={s.id}>{s.name}</option>)}
</select>
</div>
<div>
<label className="label" htmlFor="c-assignee">{c.card.assignee}</label>
{isManager && !readOnly ? (
<select id="c-assignee" className="field" value={cl.assigneeId} onChange={(e) => assign.mutate(Number(e.target.value))}>
{members.data?.map((m) => <option key={m.id} value={m.id}>{m.name}</option>)}
</select>
) : (
<p id="c-assignee" className="flex min-h-11 items-center">{cl.assigneeName ?? '—'}</p>
)}
</div>
</section>
<section>
<h3 className="label">{x.objectsCol}</h3>
{cl.leads.length === 0 && <p className="text-sm text-muted">{x.noObjects}</p>}
<ul className="space-y-1.5">
{cl.leads.map((l) => (
<LeadRow key={l.id} lead={l}>
{canWork ? (
<>
<select
className="field h-9 min-h-9 w-auto shrink-0 px-2 text-sm"
aria-label={c.card.status}
value={l.state}
onChange={(e) => link.mutate({ leadId: l.id, state: e.target.value as 'offered' | 'shown' })}
>
<option value="offered">{x.state.offered}</option>
<option value="shown">{x.state.shown}</option>
</select>
<button type="button" aria-label={x.detach} title={x.detach} className="flex size-9 shrink-0 cursor-pointer items-center justify-center rounded-lg text-muted hover:text-danger" onClick={() => unlink.mutate(l.id)}>
<X size={16} />
</button>
</>
) : (
<span className="chip shrink-0 bg-surface text-muted">{x.state[l.state!]}</span>
)}
</LeadRow>
))}
</ul>
</section>
{canWork && current?.code !== 'deal' && current?.code !== 'rejected' && Boolean(suggestions.data?.length) && (
<section>
<h3 className="label">{x.suggested}</h3>
<ul className="space-y-1.5">
{suggestions.data!.map((l) => (
<LeadRow key={l.id} lead={l}>
<button type="button" className="btn btn-ghost btn-sm shrink-0" disabled={link.isPending} onClick={() => link.mutate({ leadId: l.id, state: 'offered' })}>
<Plus size={14} aria-hidden="true" />{x.attach}
</button>
</LeadRow>
))}
</ul>
</section>
)}
{canWork && <NextAction at={cl.nextActionAt} note={cl.nextActionNote} pending={next.isPending} onSave={(v) => next.mutate(v)} />}
{canWork && <CommentBox pending={comment.isPending} onSave={(text, done) => comment.mutate(text, { onSuccess: done })} />}
<History events={cl.events} />
{/* Удалить карточку может руководитель; карточку со сделкой — только суперадмин. */}
{isManager && !readOnly && (
<div className="space-y-2 border-t border-line pt-5">
{cl.deal && !me.user.isSuperadmin ? (
<p className="text-sm text-muted">{c.x.delDeal}</p>
) : (
<button className="btn btn-ghost btn-sm text-danger" disabled={remove.isPending} onClick={() => confirm(c.x.delConfirm) && remove.mutate(undefined, { onSuccess: onClose })}>
<Trash2 size={15} aria-hidden="true" />{c.x.del}
</button>
)}
<ErrorNote error={remove.error} />
</div>
)}
</div>
)}
{status.dialog}
{editing && cl && <ClientDialog client={cl} onClose={() => setEditing(false)} />}
</Drawer>
);
}
+366
View File
@@ -0,0 +1,366 @@
import { CLIENT_KINDS, CURRENCIES, PROPERTY_TYPES, REGIONS, RENT_TERMS, YEREVAN_DISTRICTS, type ClientKind, type ClientRequest, type RentTerm } from '@lr/shared';
import { useQuery } from '@tanstack/react-query';
import { Columns3, List, Plus, Search, Send } from 'lucide-react';
import { useEffect, useState } from 'react';
import { useNavigate, useParams } from 'react-router';
import { api, type Me } from '../api.ts';
import { ErrorNote, PageTitle } from '../components/ui.tsx';
import { useAction } from '../hooks.ts';
import { qs, useDepartments, useMembers, usePipeline, type Client } from './api.ts';
import { ClientDrawer } from './ClientDrawer.tsx';
import { Chips, ROOMS } from './FiltersPage.tsx';
import { householdForm, householdValue, HouseholdFields } from './household.tsx';
import { IntakeDialog } from './showcase.tsx';
import { useCrm } from './i18n.ts';
import { Dialog, Kanban, StatusChip, useStatusChange } from './parts.tsx';
type Tab = 'mine' | 'all';
const num = (v: string) => (v === '' ? undefined : Number(v));
const str = (v: number | undefined) => (v === undefined ? '' : String(v));
/** Запрос клиента одной строкой: «Квартира · Кентрон · до $200 000 · 2, 3 комн.». */
export function useRequestLine() {
const { c, price } = useCrm();
return (r: Partial<ClientRequest>) => {
const cur = r.priceCurrency ?? 'USD';
const range = (from: number | undefined, to: number | undefined, fmt: (n: number) => string) =>
from !== undefined && to !== undefined ? `${fmt(from)} – ${fmt(to)}`
: from !== undefined ? `${c.filters.from} ${fmt(from)}`
: to !== undefined ? `${c.filters.to} ${fmt(to)}`
: null;
return [
r.propertyTypes?.map((p) => c.ptype[p]).join(', '),
r.districts?.length ? r.districts.join(', ') : r.region,
range(r.priceFrom, r.priceTo, (n) => price(n, cur)),
r.rooms?.length ? `${[...r.rooms].sort((a, b) => a - b).join(', ')} ${c.unit.rooms}` : null,
range(r.areaFrom, r.areaTo, (n) => `${n} м²`),
]
.filter(Boolean)
.join(' · ');
};
}
/** Создание и правка клиента: контакт и запрос (что он ищет). */
export function ClientDialog({ client, onClose }: { client: Client | null; onClose: (id?: number) => void }) {
const { c, t } = useCrm();
const x = c.cl;
const r = client?.request;
const [name, setName] = useState(client?.name ?? '');
const [phone, setPhone] = useState(client?.phone ?? '');
const [telegram, setTelegram] = useState(client?.telegram ?? '');
const [rentTerm, setRentTerm] = useState<RentTerm | ''>(client?.rentTerm ?? '');
const [kind, setKind] = useState<ClientKind>(client?.kind ?? 'buyer');
const [source, setSource] = useState(client?.source ?? '');
const [types, setTypes] = useState(r?.propertyTypes ?? []);
const [region, setRegion] = useState(r?.region ?? '');
const [districts, setDistricts] = useState<string[]>(r?.districts ?? []);
const [priceFrom, setPriceFrom] = useState(str(r?.priceFrom));
const [priceTo, setPriceTo] = useState(str(r?.priceTo));
const [currency, setCurrency] = useState<string>(r?.priceCurrency ?? 'USD');
const [rooms, setRooms] = useState(r?.rooms ?? []);
const [areaFrom, setAreaFrom] = useState(str(r?.areaFrom));
const [areaTo, setAreaTo] = useState(str(r?.areaTo));
const [household, setHousehold] = useState(() => householdForm(client?.household));
const save = useAction(() => {
const body = {
name, phone, telegram, kind, source,
// Срок аренды — только у арендатора.
rentTerm: kind === 'tenant' && rentTerm ? rentTerm : null,
household: householdValue(household),
request: {
propertyTypes: types,
region: region || undefined,
districts: districts.map((d) => d.trim()).filter(Boolean),
priceFrom: num(priceFrom), priceTo: num(priceTo), priceCurrency: currency,
rooms, areaFrom: num(areaFrom), areaTo: num(areaTo),
},
};
return client
? api<{ id?: number }>(`/clients/${client.id}`, { method: 'PATCH', body })
: api<{ id?: number }>('/clients', { method: 'POST', body });
});
return (
<Dialog title={client ? client.name : x.add} onClose={() => onClose()} wide>
<form className="space-y-5" onSubmit={(e) => { e.preventDefault(); save.mutate(undefined, { onSuccess: (res) => onClose(res.id ?? client?.id) }); }}>
<div className="grid gap-4 sm:grid-cols-2">
<div>
<label className="label" htmlFor="cl-name">{x.name}</label>
<input id="cl-name" className="field" required maxLength={120} autoFocus value={name} onChange={(e) => setName(e.target.value)} />
</div>
<div>
<label className="label" htmlFor="cl-phone">{c.dlg.phone}</label>
<input id="cl-phone" type="tel" className="field" maxLength={40} value={phone} onChange={(e) => setPhone(e.target.value)} />
</div>
<div>
<label className="label" htmlFor="cl-telegram">{c.x.telegram}</label>
<input id="cl-telegram" className="field" maxLength={64} autoCapitalize="none" placeholder={c.x.telegramPh} value={telegram} onChange={(e) => setTelegram(e.target.value)} />
</div>
<div>
<label className="label" htmlFor="cl-kind">{x.kindLabel}</label>
<select id="cl-kind" className="field" value={kind} onChange={(e) => setKind(e.target.value as ClientKind)}>
{CLIENT_KINDS.map((k) => <option key={k} value={k}>{x.kind[k]}</option>)}
</select>
</div>
{kind === 'tenant' && (
<div>
<label className="label" htmlFor="cl-term">{c.x.rentTerm}</label>
<select id="cl-term" className="field" value={rentTerm} onChange={(e) => setRentTerm(e.target.value as RentTerm | '')}>
<option value="">—</option>
{RENT_TERMS.map((v) => <option key={v} value={v}>{c.x.term[v]}</option>)}
</select>
</div>
)}
<div>
<label className="label" htmlFor="cl-source">{x.source}</label>
<input id="cl-source" className="field" maxLength={120} placeholder={x.sourceHint} value={source} onChange={(e) => setSource(e.target.value)} />
</div>
</div>
<h3 className="border-t border-line pt-4 font-medium">{c.hh.title}</h3>
<HouseholdFields value={household} onChange={setHousehold} />
<h3 className="border-t border-line pt-4 font-medium">{x.request}</h3>
<Chips label={`${c.filters.types} (${c.filters.anyType})`} options={PROPERTY_TYPES.map((v) => [v, c.ptype[v]])} value={types} onChange={setTypes} />
<div>
<label className="label" htmlFor="cl-region">{c.filters.region}</label>
<select id="cl-region" className="field" value={region} onChange={(e) => { setRegion(e.target.value); setDistricts([]); }}>
<option value="">{c.filters.allArmenia}</option>
{REGIONS.map((v) => <option key={v}>{v}</option>)}
</select>
</div>
{region === 'Ереван' ? (
<Chips label={c.filters.districts} options={YEREVAN_DISTRICTS.map((d) => [d as string, d as string])} value={districts} onChange={setDistricts} />
) : (
<div>
<label className="label" htmlFor="cl-loc">{c.filters.localities}</label>
<input id="cl-loc" className="field" value={districts.join(', ')} onChange={(e) => setDistricts(e.target.value.split(','))} />
</div>
)}
<div className="grid grid-cols-2 gap-4 sm:grid-cols-[1fr_1fr_7rem]">
<div>
<label className="label" htmlFor="cl-pf">{x.budget}, {c.filters.from}</label>
<input id="cl-pf" type="number" min={0} className="field" value={priceFrom} onChange={(e) => setPriceFrom(e.target.value)} />
</div>
<div>
<label className="label" htmlFor="cl-pt">{x.budget}, {c.filters.to}</label>
<input id="cl-pt" type="number" min={0} className="field" value={priceTo} onChange={(e) => setPriceTo(e.target.value)} />
</div>
<div className="col-span-2 sm:col-span-1">
<label className="label" htmlFor="cl-cur">{c.dlg.currency}</label>
<select id="cl-cur" className="field" value={currency} onChange={(e) => setCurrency(e.target.value)}>
{CURRENCIES.map((v) => <option key={v}>{v}</option>)}
</select>
</div>
</div>
<Chips label={`${c.filters.rooms} (${c.filters.any})`} options={ROOMS.map((v) => [v, String(v)])} value={rooms} onChange={setRooms} />
<div className="grid grid-cols-2 gap-4">
<div>
<label className="label" htmlFor="cl-af">{c.filters.area}, {c.filters.from}</label>
<input id="cl-af" type="number" min={0} className="field" value={areaFrom} onChange={(e) => setAreaFrom(e.target.value)} />
</div>
<div>
<label className="label" htmlFor="cl-at">{c.filters.area}, {c.filters.to}</label>
<input id="cl-at" type="number" min={0} className="field" value={areaTo} onChange={(e) => setAreaTo(e.target.value)} />
</div>
</div>
<ErrorNote error={save.error} />
<div className="flex justify-end gap-2">
<button type="button" className="btn btn-ghost" onClick={() => onClose()}>{t.common.cancel}</button>
<button className="btn btn-primary" disabled={save.isPending}>{client ? t.common.save : t.common.create}</button>
</div>
</form>
</Dialog>
);
}
export function ClientsPage({ me }: { me: Me }) {
const { c, t, date } = useCrm();
const x = c.cl;
const navigate = useNavigate();
const { id: openId } = useParams();
const requestLine = useRequestLine();
const isManager = me.user.role === 'manager';
const seeAll = isManager || Boolean(me.company?.settings.agentsSeeAll);
const readOnly = me.company?.access === 'restricted' || me.company?.access === 'disabled';
const [tab, setTab] = useState<Tab>('mine');
const [view, setView] = useState<'table' | 'kanban'>(() => (localStorage.getItem('lr_clients_view') === 'table' ? 'table' : 'kanban'));
const [search, setSearch] = useState('');
const [query, setQuery] = useState('');
const [statusId, setStatusId] = useState('');
const [kind, setKind] = useState('');
const [assigneeId, setAssigneeId] = useState('');
const [departmentId, setDepartmentId] = useState('');
const departments = useDepartments();
const [adding, setAdding] = useState(false);
const [intake, setIntake] = useState(false);
useEffect(() => {
const timer = setTimeout(() => setQuery(search.trim()), 300);
return () => clearTimeout(timer);
}, [search]);
useEffect(() => localStorage.setItem('lr_clients_view', view), [view]);
const pipeline = usePipeline('client');
const members = useMembers();
const params = { tab, q: query, statusId, kind, assigneeId: tab === 'all' ? assigneeId : '', departmentId: isManager ? departmentId : '' };
const clients = useQuery({ queryKey: ['clients', params], queryFn: () => api<Client[]>(`/clients${qs(params)}`) });
const summary = useQuery({ queryKey: ['clients', 'summary'], queryFn: () => api<Record<Tab, number>>('/clients-summary') });
const status = useStatusChange(pipeline.data, 'clients');
const statusOf = (cl: Client) => pipeline.data?.statuses.find((s) => s.id === cl.statusId);
const open = (id: number) => navigate(`/clients/${id}`);
const tabs: Tab[] = seeAll ? ['mine', 'all'] : ['mine'];
const canMove = (cl: Client) => !readOnly && (isManager || cl.assigneeId === me.user.id);
return (
<>
<PageTitle title={t.nav.clients} sub={x.sub}>
{!readOnly && (
<div className="flex flex-wrap gap-2">
<button className="btn btn-ghost" onClick={() => setIntake(true)}>
<Send size={16} aria-hidden="true" />
{c.sh.intake}
</button>
<button className="btn btn-primary" onClick={() => setAdding(true)}>
<Plus size={17} aria-hidden="true" />
{x.add}
</button>
</div>
)}
</PageTitle>
{intake && <IntakeDialog onClose={() => setIntake(false)} />}
<div className="mb-4 flex flex-wrap items-center gap-3">
{tabs.length > 1 && (
<div className="flex rounded-xl border border-line p-1" role="tablist">
{tabs.map((v) => (
<button
key={v}
role="tab"
aria-selected={tab === v}
onClick={() => setTab(v)}
className={`min-h-9 cursor-pointer rounded-lg px-3 text-sm transition-colors ${tab === v ? 'bg-raised text-text' : 'text-muted hover:text-text'}`}
>
{c.tabs[v]}
{summary.data && <span className="ml-1.5 tabular-nums text-muted">{summary.data[v]}</span>}
</button>
))}
</div>
)}
<div className="relative min-w-48 flex-1">
<Search size={16} className="pointer-events-none absolute left-3.5 top-1/2 -translate-y-1/2 text-muted" aria-hidden="true" />
<input className="field pl-10" type="search" placeholder={x.search} aria-label={x.search} value={search} onChange={(e) => setSearch(e.target.value)} />
</div>
<select className="field w-auto" aria-label={x.kindLabel} value={kind} onChange={(e) => setKind(e.target.value)}>
<option value="">{x.anyKind}</option>
{CLIENT_KINDS.map((k) => <option key={k} value={k}>{x.kind[k]}</option>)}
</select>
{view === 'table' && (
<select className="field w-auto" aria-label={c.f.status} value={statusId} onChange={(e) => setStatusId(e.target.value)}>
<option value="">{c.f.status}: {c.f.any.toLowerCase()}</option>
{pipeline.data?.statuses.map((s) => <option key={s.id} value={s.id}>{s.name}</option>)}
</select>
)}
{/* Руководитель видит клиентов всех отделов и может выбрать один. */}
{isManager && Boolean(departments.data?.length) && (
<select className="field w-auto" aria-label={c.dp.department} value={departmentId} onChange={(e) => setDepartmentId(e.target.value)}>
<option value="">{c.dp.allDepartments}</option>
{departments.data!.map((d) => <option key={d.id} value={d.id}>{d.name}</option>)}
<option value="none">{c.dp.noDepartment}</option>
</select>
)}
{tab === 'all' && (
<select className="field w-auto" aria-label={c.f.agent} value={assigneeId} onChange={(e) => setAssigneeId(e.target.value)}>
<option value="">{c.f.agent}: {c.f.any.toLowerCase()}</option>
{members.data?.map((m) => <option key={m.id} value={m.id}>{m.name}</option>)}
</select>
)}
<div className="flex rounded-xl border border-line p-1">
{(['kanban', 'table'] as const).map((v) => (
<button
key={v}
aria-pressed={view === v}
aria-label={c.view[v]}
title={c.view[v]}
onClick={() => { setView(v); if (v === 'kanban') setStatusId(''); }}
className={`flex size-9 cursor-pointer items-center justify-center rounded-lg ${view === v ? 'bg-raised text-text' : 'text-muted hover:text-text'}`}
>
{v === 'table' ? <List size={16} /> : <Columns3 size={16} />}
</button>
))}
</div>
</div>
<ErrorNote error={clients.error ?? status.error} />
{clients.data?.length === 0 && (
<div className="card flex min-h-40 items-center justify-center p-8 text-center text-muted">{query || kind || statusId || assigneeId ? x.notFound : x.empty}</div>
)}
{Boolean(clients.data?.length) && view === 'kanban' && pipeline.data && (
<Kanban items={clients.data!} statuses={pipeline.data.statuses} onOpen={open} onMove={status.request} canDrag={canMove}>
{(cl) => (
<>
<p className="font-medium">{cl.name}</p>
<p className="mt-0.5 text-xs text-muted">{x.kind[cl.kind]}{cl.leadsCount ? ` · ${x.objects(cl.leadsCount)}` : ''}</p>
{requestLine(cl.request) && <p className="mt-1.5 line-clamp-2 text-sm leading-snug text-muted">{requestLine(cl.request)}</p>}
{tab === 'all' && <p className="mt-1.5 truncate text-xs text-muted">{cl.assigneeName}</p>}
</>
)}
</Kanban>
)}
{Boolean(clients.data?.length) && view === 'table' && (
<>
<ul className="space-y-2 md:hidden">
{clients.data!.map((cl) => (
<li key={cl.id}>
<button type="button" className="card flex w-full cursor-pointer items-start justify-between gap-3 p-3 text-left" onClick={() => open(cl.id)}>
<span className="min-w-0">
<span className="block font-medium">{cl.name}</span>
<span className="block truncate text-xs text-muted">{[x.kind[cl.kind], requestLine(cl.request)].filter(Boolean).join(' · ')}</span>
</span>
<StatusChip status={statusOf(cl)} />
</button>
</li>
))}
</ul>
<div className="card hidden overflow-x-auto md:block">
<table className="w-full text-left text-sm">
<thead className="text-muted">
<tr className="border-b border-line">
{[x.one, x.request, x.objectsCol, c.col.status, c.col.agent, c.col.created].map((h) => (
<th key={h} className="px-4 py-3 font-normal">{h}</th>
))}
</tr>
</thead>
<tbody className="divide-y divide-line">
{clients.data!.map((cl) => (
<tr key={cl.id} className="cursor-pointer transition-colors hover:bg-raised/50" onClick={() => open(cl.id)}>
<td className="px-4 py-3">
<button type="button" className="block cursor-pointer text-left font-medium" onClick={(e) => { e.stopPropagation(); open(cl.id); }}>{cl.name}</button>
<p className="text-xs text-muted">{[x.kind[cl.kind], cl.phone].filter(Boolean).join(' · ')}</p>
</td>
<td className="max-w-xs px-4 py-3 text-muted"><span className="line-clamp-2">{requestLine(cl.request) || '—'}</span></td>
<td className="px-4 py-3 tabular-nums">{cl.leadsCount || '—'}</td>
<td className="px-4 py-3"><StatusChip status={statusOf(cl)} /></td>
<td className="px-4 py-3">{cl.assigneeName ?? '—'}</td>
<td className="whitespace-nowrap px-4 py-3 tabular-nums text-muted">{date(cl.createdAt)}</td>
</tr>
))}
</tbody>
</table>
</div>
</>
)}
{status.dialog}
{adding && <ClientDialog client={null} onClose={(id) => { setAdding(false); if (id) open(id); }} />}
{openId && <ClientDrawer key={openId} id={Number(openId)} me={me} onClose={() => navigate('/clients')} />}
</>
);
}
+278
View File
@@ -0,0 +1,278 @@
import { CURRENCIES, DEAL_TYPES, PROPERTY_TYPES, REGIONS, YEREVAN_DISTRICTS, type FilterParams } from '@lr/shared';
import { Pencil, Plus, Trash2 } from 'lucide-react';
import { useState } from 'react';
import { api, type Me } from '../api.ts';
import { ErrorNote, PageTitle, Toggle } from '../components/ui.tsx';
import { useAction } from '../hooks.ts';
import { useDepartments, useFilters, type Filter } from './api.ts';
import { useCrm } from './i18n.ts';
import { Dialog } from './parts.tsx';
export const ROOMS = [1, 2, 3, 4, 5, 6];
export function Chips<T extends string | number>({ label, options, value, onChange }: { label: string; options: [T, string][]; value: T[]; onChange: (v: T[]) => void }) {
return (
<fieldset>
<legend className="label">{label}</legend>
<div className="flex flex-wrap gap-2">
{options.map(([v, text]) => {
const on = value.includes(v);
return (
<label key={String(v)} className={`flex min-h-9 cursor-pointer items-center rounded-lg border px-3 text-sm transition-colors has-focus-visible:outline-2 has-focus-visible:outline-signal ${on ? 'border-capture/60 bg-capture/15 text-text' : 'border-line-strong text-muted hover:text-text'}`}>
<input type="checkbox" className="sr-only" checked={on} onChange={() => onChange(on ? value.filter((x) => x !== v) : [...value, v])} />
{text}
</label>
);
})}
</div>
</fieldset>
);
}
const num = (v: string) => (v === '' ? undefined : Number(v));
const str = (v: number | undefined) => (v === undefined ? '' : String(v));
export function FilterDialog({ filter, isManager, onClose }: { filter: Filter | null; isManager: boolean; onClose: (fromBase?: number) => void }) {
const { c, t } = useCrm();
const x = c.filters;
const p = filter?.params;
const [name, setName] = useState(filter?.name ?? '');
// Для кого фильтр: вся компания, отдел (`d:<id>`) или только я.
const initialScope = filter ? (filter.scope === 'department' ? `d:${filter.departmentId}` : filter.scope) : isManager ? 'company' : 'personal';
const [scope, setScope] = useState<string>(initialScope);
const departments = useDepartments();
const [dealType, setDealType] = useState<string>(p?.dealType ?? '');
const [types, setTypes] = useState(p?.propertyTypes ?? []);
const [region, setRegion] = useState(p?.region ?? '');
const [districts, setDistricts] = useState<string[]>(p?.districts ?? []);
const [priceFrom, setPriceFrom] = useState(str(p?.priceFrom));
const [priceTo, setPriceTo] = useState(str(p?.priceTo));
const [currency, setCurrency] = useState<string>(p?.priceCurrency ?? 'USD');
const [rooms, setRooms] = useState(p?.rooms ?? []);
const [areaFrom, setAreaFrom] = useState(str(p?.areaFrom));
const [areaTo, setAreaTo] = useState(str(p?.areaTo));
const [ownersOnly, setOwnersOnly] = useState(p?.ownersOnly ?? true);
const save = useAction(() => {
const params = {
dealType: dealType || undefined,
propertyTypes: types,
region: region || undefined,
districts: districts.map((d) => d.trim()).filter(Boolean),
priceFrom: num(priceFrom),
priceTo: num(priceTo),
priceCurrency: currency,
rooms,
areaFrom: num(areaFrom),
areaTo: num(areaTo),
ownersOnly,
} as FilterParams;
const departmentId = scope.startsWith('d:') ? Number(scope.slice(2)) : null;
// Руководитель может перенести фильтр компании в отдел и обратно; у личного фильтра отдела нет.
const moved = filter && filter.scope !== 'personal' && scope !== initialScope ? { departmentId } : {};
return filter
? api<{ fromBase?: number }>(`/filters/${filter.id}`, { method: 'PATCH', body: { name, params, ...moved } })
: api<{ fromBase?: number }>('/filters', {
method: 'POST',
body: { name, scope: departmentId ? 'department' : scope, ...(departmentId ? { departmentId } : {}), params },
});
});
return (
<Dialog title={filter ? filter.name : x.add} onClose={() => onClose()} wide>
<form className="space-y-5" onSubmit={(e) => { e.preventDefault(); save.mutate(undefined, { onSuccess: (r) => onClose(r.fromBase) }); }}>
<div className="grid gap-4 sm:grid-cols-2">
<div>
<label className="label" htmlFor="fl-name">{x.name}</label>
<input id="fl-name" className="field" required maxLength={80} autoFocus value={name} onChange={(e) => setName(e.target.value)} />
</div>
{isManager && (!filter || filter.scope !== 'personal') && (
<div>
<label className="label" htmlFor="fl-scope">{x.scope}</label>
<select id="fl-scope" className="field" value={scope} onChange={(e) => setScope(e.target.value)}>
<option value="company">{x.scopeCompany}</option>
{departments.data?.map((d) => <option key={d.id} value={`d:${d.id}`}>{c.dp.scope}: {d.name}</option>)}
{!filter && <option value="personal">{x.scopePersonal}</option>}
</select>
</div>
)}
</div>
<div className="grid gap-4 sm:grid-cols-2">
<div>
<label className="label" htmlFor="fl-deal">{x.dealType}</label>
<select id="fl-deal" className="field" value={dealType} onChange={(e) => setDealType(e.target.value)}>
<option value="">{x.any}</option>
{DEAL_TYPES.map((d) => <option key={d} value={d}>{c.deal[d]}</option>)}
</select>
</div>
<div>
<label className="label" htmlFor="fl-region">{x.region}</label>
<select id="fl-region" className="field" value={region} onChange={(e) => { setRegion(e.target.value); setDistricts([]); }}>
<option value="">{x.allArmenia}</option>
{REGIONS.map((r) => <option key={r}>{r}</option>)}
</select>
</div>
</div>
<Chips label={`${x.types} (${x.anyType})`} options={PROPERTY_TYPES.map((v) => [v, c.ptype[v]])} value={types} onChange={setTypes} />
{region === 'Ереван' ? (
<Chips label={x.districts} options={YEREVAN_DISTRICTS.map((d) => [d as string, d as string])} value={districts} onChange={setDistricts} />
) : (
<div>
<label className="label" htmlFor="fl-loc">{x.localities}</label>
<input id="fl-loc" className="field" value={districts.join(', ')} onChange={(e) => setDistricts(e.target.value.split(','))} />
</div>
)}
<div className="grid grid-cols-2 gap-4 sm:grid-cols-[1fr_1fr_7rem]">
<div>
<label className="label" htmlFor="fl-pf">{x.price}, {x.from}</label>
<input id="fl-pf" type="number" min={0} className="field" value={priceFrom} onChange={(e) => setPriceFrom(e.target.value)} />
</div>
<div>
<label className="label" htmlFor="fl-pt">{x.price}, {x.to}</label>
<input id="fl-pt" type="number" min={0} className="field" value={priceTo} onChange={(e) => setPriceTo(e.target.value)} />
</div>
<div className="col-span-2 sm:col-span-1">
<label className="label" htmlFor="fl-cur">{c.dlg.currency}</label>
<select id="fl-cur" className="field" value={currency} onChange={(e) => setCurrency(e.target.value)}>
{CURRENCIES.map((v) => <option key={v}>{v}</option>)}
</select>
</div>
</div>
<Chips label={`${x.rooms} (${x.any})`} options={ROOMS.map((r) => [r, String(r)])} value={rooms} onChange={setRooms} />
<div className="grid grid-cols-2 gap-4">
<div>
<label className="label" htmlFor="fl-af">{x.area}, {x.from}</label>
<input id="fl-af" type="number" min={0} className="field" value={areaFrom} onChange={(e) => setAreaFrom(e.target.value)} />
</div>
<div>
<label className="label" htmlFor="fl-at">{x.area}, {x.to}</label>
<input id="fl-at" type="number" min={0} className="field" value={areaTo} onChange={(e) => setAreaTo(e.target.value)} />
</div>
</div>
<fieldset>
<legend className="label">{x.author}</legend>
<div className="flex flex-wrap gap-x-6 gap-y-2">
{[true, false].map((v) => (
<label key={String(v)} className="flex min-h-9 cursor-pointer items-center gap-2.5">
<input type="radio" name="owners" className="accent-(--capture)" checked={ownersOnly === v} onChange={() => setOwnersOnly(v)} />
{v ? x.ownersOnly : x.everyone}
</label>
))}
</div>
</fieldset>
<ErrorNote error={save.error} />
<div className="flex justify-end gap-2">
<button type="button" className="btn btn-ghost" onClick={() => onClose()}>{t.common.cancel}</button>
<button className="btn btn-primary" disabled={save.isPending}>{t.common.save}</button>
</div>
</form>
</Dialog>
);
}
/** Краткое описание параметров фильтра одной строкой. */
function useSummary() {
const { c, price } = useCrm();
const x = c.filters;
return (p: FilterParams) => {
const range = (a?: number, b?: number, fmt: (n: number) => string = String) =>
a !== undefined && b !== undefined ? `${fmt(a)}–${fmt(b)}` : a !== undefined ? `${x.from} ${fmt(a)}` : b !== undefined ? `${x.to} ${fmt(b)}` : null;
const money = (n: number) => price(n, p.priceCurrency);
const area = range(p.areaFrom, p.areaTo);
return [
p.dealType ? c.deal[p.dealType] : null,
p.propertyTypes.length ? p.propertyTypes.map((v) => c.ptype[v]).join(', ') : null,
p.districts.length ? p.districts.join(', ') : (p.region ?? x.allArmenia),
range(p.priceFrom, p.priceTo, money),
p.rooms.length ? `${[...p.rooms].sort((a, b) => a - b).join(', ')} ${c.unit.rooms}` : null,
area ? `${area} м²` : null,
p.ownersOnly ? x.ownersOnly : x.everyone,
]
.filter(Boolean)
.join(' · ');
};
}
export function FiltersPage({ me }: { me: Me }) {
const { c } = useCrm();
const x = c.filters;
const isManager = me.user.role === 'manager';
const readOnly = me.company?.access === 'restricted' || me.company?.access === 'disabled';
const filters = useFilters();
const summary = useSummary();
const toggle = useAction((f: Filter) => api(`/filters/${f.id}`, { method: 'PATCH', body: { enabled: !f.enabled } }));
const remove = useAction((id: number) => api(`/filters/${id}`, { method: 'DELETE' }));
const [dialog, setDialog] = useState<Filter | 'new' | null>(null);
const [fromBase, setFromBase] = useState(0);
const departments = useDepartments();
// Сотрудник отдела работает по фильтрам отдела: общие фильтры компании ему не приходят.
const inDepartment = !isManager && me.user.departmentId !== null;
const groups: [string, Filter[]][] = [
...(inDepartment ? [] : [[x.company, filters.data?.filter((f) => f.scope === 'company') ?? []] as [string, Filter[]]]),
...(departments.data ?? []).map((d) => [c.dp.filtersOf(d.name), filters.data?.filter((f) => f.scope === 'department' && f.departmentId === d.id) ?? []] as [string, Filter[]]),
[x.personal, filters.data?.filter((f) => f.ownerUserId === me.user.id) ?? []],
];
const others = filters.data?.filter((f) => f.scope === 'personal' && f.ownerUserId !== me.user.id) ?? [];
if (others.length) groups.push([x.others, others]);
// В Solo общий и личный фильтр — одно и то же: показываем один список.
const shown = me.company?.maxMembers === 1 ? groups.slice(0, 1) : groups;
return (
<>
<PageTitle title={x.title} sub={x.sub}>
{!readOnly && (
<button className="btn btn-primary" onClick={() => setDialog('new')}>
<Plus size={17} aria-hidden="true" />
{x.add}
</button>
)}
</PageTitle>
<ErrorNote error={filters.error ?? toggle.error ?? remove.error} />
{fromBase > 0 && (
<p role="status" className="mb-4 rounded-xl border border-capture/40 bg-capture/10 px-3.5 py-2.5 text-sm text-capture">{x.fromBase(fromBase)}</p>
)}
<div className="space-y-8">
{shown.map(([title, list]) => (
<section key={title}>
<h2 className="mb-3 text-lg font-medium tracking-tight">{title}</h2>
{list.length === 0 ? (
<p className="text-sm text-muted">{x.none}</p>
) : (
<ul className="card divide-y divide-line">
{list.map((f) => (
<li key={f.id} className="flex flex-wrap items-center gap-x-4 gap-y-2 px-5 py-3.5">
<div className={`min-w-0 flex-1 basis-64 ${f.enabled ? '' : 'opacity-55'}`}>
<p className="font-medium">
{f.name}
{f.ownerName && f.ownerUserId !== me.user.id && <span className="font-normal text-muted"> · {f.ownerName}</span>}
</p>
<p className="text-sm text-muted">{summary(f.params)}</p>
</div>
{f.editable && !readOnly && (
<div className="flex items-center gap-2">
<Toggle checked={f.enabled} onChange={() => toggle.mutate(f)} label="" />
<span className="sr-only">{x.enabled}</span>
<button className="btn btn-ghost btn-sm" aria-label={`${f.name}: ✎`} onClick={() => setDialog(f)}><Pencil size={14} aria-hidden="true" /></button>
<button className="btn btn-danger btn-sm" aria-label={`${f.name}: ×`} onClick={() => confirm(x.confirmDelete) && remove.mutate(f.id)}><Trash2 size={14} aria-hidden="true" /></button>
</div>
)}
</li>
))}
</ul>
)}
</section>
))}
</div>
{dialog && <FilterDialog filter={dialog === 'new' ? null : dialog} isManager={isManager} onClose={(n) => { setDialog(null); setFromBase(n ?? 0); }} />}
</>
);
}
+468
View File
@@ -0,0 +1,468 @@
import { CURRENCIES, DEAL_TYPES, PROPERTY_TYPES, REGIONS } from '@lr/shared';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { Bot, Copy, ExternalLink, EyeOff, MessageCircle, Pencil, Phone, Send, Star, Trash2, Undo2, UserX } from 'lucide-react';
import { useEffect, useState } from 'react';
import { Link } from 'react-router';
import { api, type Me } from '../api.ts';
import { TelegramLink } from '../pages/Settings.tsx';
import { ErrorNote } from '../components/ui.tsx';
import { useAction } from '../hooks.ts';
import { useMembers, usePipeline, type LeadCard } from './api.ts';
import { CommissionBlock } from './commission.tsx';
import { Features } from './household.tsx';
import { useCrm } from './i18n.ts';
import { CommentBox, DealNote, Dialog, Drawer, History, NextAction, StatusChip, useSpecs, useStatusChange } from './parts.tsx';
/** Насколько номеру можно доверять: совпал у двух компаний — 80 %, у трёх и больше — 100 %. */
function Confidence({ v }: { v: { votes: number; confidence: number | null } }) {
const { c } = useCrm();
return (
<span className={`block text-xs ${v.confidence === 100 ? 'text-capture' : v.confidence ? 'text-warn' : 'text-muted'}`}>
{v.confidence ? c.sh.confidence(v.confidence, v.votes) : c.sh.unconfirmed}
</span>
);
}
const EDIT_FIELDS = ['title', 'description', 'dealType', 'propertyType', 'region', 'district', 'address', 'price', 'currency', 'rooms', 'area', 'floor', 'floorsTotal', 'authorName'] as const;
/** Правка данных объекта. Отправляются только изменённые поля; пустое поле стирает значение. */
function LeadEditDialog({ lead, onClose }: { lead: LeadCard; onClose: () => void }) {
const { c, t } = useCrm();
const d = c.dlg;
const initial = Object.fromEntries(EDIT_FIELDS.map((k) => [k, lead[k] == null ? '' : String(lead[k])])) as Record<(typeof EDIT_FIELDS)[number], string>;
const [f, setF] = useState(initial);
const save = useAction(
() => api(`/leads/${lead.id}`, { method: 'PATCH', body: Object.fromEntries(EDIT_FIELDS.filter((k) => f[k] !== initial[k]).map((k) => [k, f[k]])) }),
['lead', lead.id],
);
const changed = EDIT_FIELDS.some((k) => f[k] !== initial[k]);
const input = (key: (typeof EDIT_FIELDS)[number], label: string, props: React.InputHTMLAttributes<HTMLInputElement> = {}) => (
<div>
<label className="label" htmlFor={`e-${key}`}>{label}</label>
<input id={`e-${key}`} className="field" value={f[key]} onChange={(e) => setF({ ...f, [key]: e.target.value })} {...props} />
</div>
);
const select = (key: (typeof EDIT_FIELDS)[number], label: string, options: [string, string][]) => (
<div>
<label className="label" htmlFor={`e-${key}`}>{label}</label>
<select id={`e-${key}`} className="field" value={f[key]} onChange={(e) => setF({ ...f, [key]: e.target.value })}>
{options.map(([v, l]) => <option key={v} value={v}>{l}</option>)}
</select>
</div>
);
// Значение из объявления, которого нет в списке (например, регион), остаётся выбранным.
const withCurrent = (key: (typeof EDIT_FIELDS)[number], options: [string, string][]) =>
initial[key] && !options.some(([v]) => v === initial[key]) ? [...options, [initial[key], initial[key]] as [string, string]] : options;
return (
<Dialog title={c.card.editTitle} onClose={onClose} wide>
<form className="space-y-4" onSubmit={(e) => { e.preventDefault(); if (changed) save.mutate(undefined, { onSuccess: onClose }); else onClose(); }}>
{input('title', d.title, { required: true, maxLength: 200 })}
<div className="grid gap-4 sm:grid-cols-2">
{select('dealType', c.filters.dealType, [['', '—'], ...DEAL_TYPES.map((x) => [x, c.deal[x]] as [string, string])])}
{select('propertyType', c.filters.types, withCurrent('propertyType', [['', '—'], ...PROPERTY_TYPES.map((x) => [x, c.ptype[x]] as [string, string])]))}
{select('region', c.filters.region, withCurrent('region', [['', '—'], ...REGIONS.map((r) => [r, r] as [string, string])]))}
{input('district', c.col.place, { maxLength: 60 })}
</div>
{input('address', d.address, { maxLength: 300 })}
<div className="grid grid-cols-2 gap-4 sm:grid-cols-4">
{input('price', d.price, { type: 'number', min: 0, step: 'any' })}
{select('currency', d.currency, withCurrent('currency', CURRENCIES.map((x) => [x, x])))}
{input('rooms', d.rooms, { type: 'number', min: 0, max: 50 })}
{input('area', d.area, { type: 'number', min: 0, step: 'any' })}
{input('floor', d.floor, { type: 'number', min: -5, max: 200 })}
{input('floorsTotal', d.floors, { type: 'number', min: 0, max: 200 })}
</div>
{input('authorName', d.author, { maxLength: 100 })}
<div>
<label className="label" htmlFor="e-description">{d.description}</label>
<textarea id="e-description" className="field min-h-32" maxLength={10_000} value={f.description} onChange={(e) => setF({ ...f, description: e.target.value })} />
</div>
<ErrorNote error={save.error} />
<div className="flex justify-end gap-2">
<button type="button" className="btn btn-ghost" onClick={onClose}>{t.common.cancel}</button>
<button className="btn btn-primary" disabled={save.isPending}>{t.common.save}</button>
</div>
</form>
</Dialog>
);
}
/**
* Не удалось отправить объект в бот. Telegram не привязан — инструкция и кнопка привязки;
* бот заблокирован или чат не начат — как это исправить; остальное — обычная ошибка.
*/
function BotSendError({ error, linked }: { error: unknown; linked: boolean }) {
const { c } = useCrm();
const code = (error as { code?: string } | null)?.code;
if (code === 'TELEGRAM_NOT_LINKED') {
return (
<div className="mt-3 space-y-2 rounded-xl border border-warn/30 bg-warn/10 px-3.5 py-3 text-sm" role="alert">
<p className="font-medium text-warn">{c.dp.linkTitle}</p>
<p>{c.dp.linkText}</p>
<TelegramLink linked={linked} />
</div>
);
}
if (code === 'BOT_CHAT_UNAVAILABLE') {
return <p className="mt-3 rounded-xl border border-warn/30 bg-warn/10 px-3.5 py-2.5 text-sm text-warn" role="alert">{c.dp.blockedText}</p>;
}
return <ErrorNote error={error} />;
}
/** Карточка лида: данные объявления, контакт, ответственный, статус, напоминание, комментарии, история. */
export function LeadDrawer({ id, me, onClose }: { id: number; me: Me; onClose: () => void }) {
const { c, t, err, price, date } = useCrm();
const specs = useSpecs();
const pipeline = usePipeline();
const isManager = me.user.role === 'manager';
const members = useMembers();
const lead = useQuery({
queryKey: ['lead', id],
queryFn: () => api<LeadCard>(`/leads/${id}`),
retry: false,
// Пока фото догружаются в фоне, карточка перечитывается сама: первые две минуты часто, потом реже —
// в очереди за ридером объект может ждать дольше. Не дольше ~15 минут.
refetchInterval: (q) =>
q.state.data?.gallery !== 'pending' ? false : q.state.dataUpdateCount < 24 ? 5_000 : q.state.dataUpdateCount < 64 ? 20_000 : false,
});
const savePhone = useAction((phone: string) => post('phone', { phone }), ['lead', id]);
const [phoneDraft, setPhoneDraft] = useState<string | null>(null);
const [copied, setCopied] = useState(false);
const status = useStatusChange(pipeline.data);
const post = (path: string, body?: object) => api(`/leads/${id}/${path}`, { method: 'POST', body: body ?? {} });
const take = useAction(() => post('take'));
const release = useAction(() => post('release'));
const agent = useAction(() => post('agent'));
const gone = useAction(() => post('removed'));
const remove = useAction(() => api(`/leads/${id}`, { method: 'DELETE' }), ['leads']);
const toGroup = useAction(() => post('group'));
// «Отправить в бот»: объект приходит в личный чат с ботом тому, кто нажал.
const toBot = useAction(() => post('bot'), ['none']);
// Избранное меняет и карточку, и списки — обновляем всё.
const favorite = useAction((on: boolean) => post('favorite', { on }));
const [editing, setEditing] = useState(false);
const assign = useAction((userId: number) => post('assign', { userId }));
const comment = useAction((text: string) => post('comments', { text }));
const next = useAction((v: { at: string | null; note: string | null }) => post('next-action', v));
// К клиенту объект прикрепляет тот, кто ведёт клиента: в списке — свои клиенты в работе.
const clientOptions = useQuery({ queryKey: ['clients', 'options'], queryFn: () => api<{ id: number; name: string }[]>('/clients-options') });
const attach = useAction((clientId: number) => api(`/clients/${clientId}/leads`, { method: 'POST', body: { leadId: id } }));
const l = lead.data;
// Открытая карточка считается просмотренной: список перечитывается, чтобы снять отметку «Не просмотрен».
const qc = useQueryClient();
const loaded = Boolean(l);
useEffect(() => {
if (loaded) void qc.invalidateQueries({ queryKey: ['leads'] });
}, [loaded, qc]);
const readOnly = me.company?.access === 'restricted' || me.company?.access === 'disabled';
const mine = l?.assigneeId === me.user.id;
const canWork = !readOnly && Boolean(l) && l!.assigneeId !== null && (mine || isManager);
const inPool = l?.assigneeId === null;
// Данные объекта правит ответственный и руководитель (руководитель — и объект из пула).
const canEdit = !readOnly && Boolean(l) && (mine || isManager);
const current = pipeline.data?.statuses.find((s) => s.id === l?.statusId);
const error = take.error ?? release.error ?? agent.error ?? gone.error ?? assign.error ?? comment.error ?? next.error ?? attach.error ?? status.error;
const freeClients = clientOptions.data?.filter((o) => !l?.clients.some((x) => x.id === o.id)) ?? [];
const phoneDigits = l?.phone?.replace(/\D/g, '');
const ownVariant = l?.phoneVariants.find((v) => v.phone === l.phone);
const otherVariants = l?.phoneVariants.filter((v) => v.phone !== l.phone) ?? [];
return (
<Drawer
label={l?.title ?? ''}
onClose={onClose}
header={
<>
<StatusChip status={current} />
{l?.source === 'manual' && <span className="chip bg-raised text-muted">{c.card.manual}</span>}
{l?.departmentName && <span className="chip bg-raised text-muted">{l.departmentName}</span>}
</>
}
>
{lead.isPending && <p className="p-5 text-muted">{t.common.loading}</p>}
{lead.isError && <p className="p-5 text-danger">{err(lead.error)}</p>}
{l && (
<div className="flex-1 space-y-6 overflow-y-auto px-5 py-5">
{l.photos.length > 0 && (
<div>
<div className="relative">
{l.photos.length > 1 && <span className="absolute right-2 top-2 z-10 rounded-md bg-ink/75 px-2 py-0.5 text-xs tabular-nums">1 / {l.photos.length} →</span>}
<div className="flex snap-x snap-mandatory gap-1 overflow-x-auto rounded-xl bg-ink">
{l.photos.map((src, i) => (
<img key={src} src={src} alt="" loading={i ? 'lazy' : 'eager'} referrerPolicy="no-referrer" onError={(e) => { e.currentTarget.style.display = 'none'; }} className="aspect-[16/10] w-full shrink-0 snap-center object-cover" />
))}
</div>
</div>
{l.gallery === 'pending' && <p className="mt-1.5 text-xs text-muted" role="status">{c.sh.photosLoading}</p>}
</div>
)}
<div>
<p className="text-2xl font-semibold tabular-nums tracking-tight">{price(l.price, l.currency, l.dealType)}</p>
<h2 className="mt-1 text-lg font-medium leading-snug">{l.title}</h2>
<p className="mt-1 text-muted">{[l.district ?? l.region, l.address].filter(Boolean).join(', ')}</p>
<p className="mt-1 text-sm text-muted">
{[l.dealType && c.deal[l.dealType], l.propertyType && (c.ptype as Record<string, string>)[l.propertyType], specs(l)].filter(Boolean).join(' · ')}
</p>
<div className="mt-3 flex flex-wrap items-center gap-x-5 gap-y-2">
{l.url && (
<a href={l.url} target="_blank" rel="noopener noreferrer" className="inline-flex items-center gap-1.5 text-signal hover:underline">
<ExternalLink size={15} aria-hidden="true" />
{c.card.onListAm}
</a>
)}
{canEdit && (
<button type="button" className="inline-flex cursor-pointer items-center gap-1.5 text-muted hover:text-text" onClick={() => setEditing(true)}>
<Pencil size={15} aria-hidden="true" />
{c.dlg.edit}
</button>
)}
{/* Избранное — своё у каждого пользователя. */}
<button
type="button" aria-pressed={l.favorite} disabled={favorite.isPending}
className={`inline-flex cursor-pointer items-center gap-1.5 hover:text-text ${l.favorite ? 'text-warn' : 'text-muted'}`}
onClick={() => favorite.mutate(!l.favorite)}
>
<Star size={15} aria-hidden="true" fill={l.favorite ? 'currentColor' : 'none'} />
{l.favorite ? c.dp.removeFav : c.dp.addFav}
</button>
{/* Объект в группу компании в Telegram — у компаний с настроенным ботом. */}
{me.company?.groupBot ? (
<button
type="button" className="inline-flex cursor-pointer items-center gap-1.5 text-signal hover:underline disabled:cursor-default disabled:opacity-60"
disabled={readOnly || toGroup.isPending} onClick={() => toGroup.mutate()}
>
<Send size={15} aria-hidden="true" />
{toGroup.isPending ? c.x.sendingTg : toGroup.isSuccess ? c.x.sentTg : c.x.sendTg}
</button>
) : (
<span className="inline-flex items-center gap-1.5 text-sm text-muted"><Send size={15} aria-hidden="true" />{c.x.botNotSet}</span>
)}
<button
type="button" className="inline-flex cursor-pointer items-center gap-1.5 text-signal hover:underline disabled:cursor-default disabled:opacity-60"
disabled={toBot.isPending} onClick={() => toBot.mutate()}
>
<Bot size={15} aria-hidden="true" />
{toBot.isPending ? c.dp.sendingBot : toBot.isSuccess ? c.dp.sentBot : c.dp.sendBot}
</button>
</div>
<ErrorNote error={toGroup.error} />
<BotSendError error={toBot.error} linked={me.user.telegramLinked} />
</div>
{l.agentPhrase && (
<p className="rounded-xl border border-warn/30 bg-warn/10 px-3.5 py-2.5 text-sm text-warn">
{c.x.phrase[l.agentPhrase.kind]}: «{l.agentPhrase.phrase}»
</p>
)}
{l.removedAt && (
<p className="rounded-xl border border-warn/30 bg-warn/10 px-3.5 py-2.5 text-sm text-warn">{c.ar.removed(date(l.removedAt))}</p>
)}
{l.previous && (
<section className="rounded-xl border border-warn/30 bg-warn/10 px-3.5 py-3 text-sm">
<h3 className="font-medium text-warn">{c.card.previous}</h3>
<p className="mt-1">
{l.previous.id ? <Link to={`/leads/${l.previous.id}`} className="text-signal hover:underline">{l.previous.title}</Link> : l.previous.title}
</p>
<p className="mt-1 text-muted">
{[
`${c.card.status}: ${l.previous.status}`,
l.previous.assigneeName && `${c.card.assignee}: ${l.previous.assigneeName}`,
l.previous.removedAt && c.ar.removed(date(l.previous.removedAt)),
].filter(Boolean).join(' · ')}
</p>
</section>
)}
<ErrorNote error={error} />
{l.deal && (
<DealNote
deal={l.deal}
canEdit={isManager && !readOnly}
pair={l.deal.clientId && <>{c.cl.one}: <Link to={`/clients/${l.deal.clientId}`} className="text-signal hover:underline">{l.deal.clientName}</Link></>}
/>
)}
{inPool && !readOnly && (
<button className="btn btn-primary w-full" disabled={take.isPending} onClick={() => take.mutate()}>{c.take}</button>
)}
<section>
<h3 className="label">{c.card.contact}</h3>
{l.phone ? (
<div className="space-y-3">
<p className="text-lg font-medium tabular-nums">{l.authorName ? `${l.authorName} · ` : ''}{l.phone}</p>
{ownVariant && <Confidence v={ownVariant} />}
<div className="flex flex-wrap gap-2">
<a className="btn btn-primary btn-sm" href={`tel:${l.phone}`}><Phone size={15} aria-hidden="true" />{c.card.call}</a>
<a className="btn btn-ghost btn-sm" href={`https://wa.me/${phoneDigits}`} target="_blank" rel="noopener noreferrer"><MessageCircle size={15} aria-hidden="true" />WhatsApp</a>
<a className="btn btn-ghost btn-sm" href={`https://t.me/+${phoneDigits}`} target="_blank" rel="noopener noreferrer"><Send size={15} aria-hidden="true" />Telegram</a>
</div>
</div>
) : (
<p className="text-sm text-muted">{l.phoneHidden ? c.card.phoneHidden : c.card.noPhone}</p>
)}
{/* Номер агент берёт из объявления сам и вносит сюда: дальше он хранится в карточке. */}
{canWork && (phoneDraft !== null || !l.phone) && (
<form
className="mt-3 space-y-2"
onSubmit={(e) => { e.preventDefault(); savePhone.mutate(phoneDraft ?? '', { onSuccess: () => setPhoneDraft(null) }); }}
>
<label className="label" htmlFor="l-phone">{c.sh.ownerPhone}</label>
<div className="flex gap-2">
<input id="l-phone" type="tel" inputMode="tel" className="field" maxLength={40} placeholder="091 123 456" value={phoneDraft ?? ''} onChange={(e) => setPhoneDraft(e.target.value)} />
<button className="btn btn-primary shrink-0" disabled={savePhone.isPending || (!phoneDraft && !l.phone)}>{c.sh.savePhone}</button>
</div>
{!l.phone && <p className="text-xs text-muted">{c.sh.ownerPhoneHint}</p>}
<ErrorNote error={savePhone.error} />
</form>
)}
{/* Другие варианты номера по этому объявлению: чем больше агентов указали один и тот же, тем он надёжнее. */}
{otherVariants.length > 0 && (
<div className="mt-4">
<p className="text-sm text-muted">{c.sh.variants}</p>
<ul className="mt-2 space-y-2">
{otherVariants.map((v) => (
<li key={v.phone} className="flex flex-wrap items-center justify-between gap-2 rounded-xl bg-raised px-3 py-2">
<span>
<span className="font-medium tabular-nums">{v.phone}</span>
<Confidence v={v} />
</span>
{canWork && <button type="button" className="btn btn-ghost btn-sm" disabled={savePhone.isPending} onClick={() => savePhone.mutate(v.phone)}>{c.sh.useVariant}</button>}
</li>
))}
</ul>
<p className="mt-1.5 text-xs text-muted">{c.sh.variantsHint}</p>
</div>
)}
{canWork && l.phone && phoneDraft === null && (
<button type="button" className="mt-3 cursor-pointer text-sm text-muted hover:text-text" onClick={() => setPhoneDraft(l.phone ?? '')}>{c.sh.changePhone}</button>
)}
</section>
{canWork && <CommissionBlock kind="leads" id={id} info={l.commission} rent={l.dealType === 'rent'} canEdit={canWork} />}
{l.clientText && (
<section>
<div className="mb-1.5 flex items-center justify-between gap-3">
<h3 className="label mb-0">{c.sh.clientText}</h3>
<button
type="button"
className="btn btn-ghost btn-sm"
onClick={async () => { await navigator.clipboard.writeText(l.clientText); setCopied(true); setTimeout(() => setCopied(false), 1500); }}
>
<Copy size={14} aria-hidden="true" />
{copied ? t.common.copied : c.sh.copyText}
</button>
</div>
<p className="whitespace-pre-wrap break-words rounded-xl bg-raised p-3.5 text-sm leading-relaxed">{l.clientText}</p>
</section>
)}
{(l.description || l.features) && (
<details>
<summary className="cursor-pointer text-sm text-muted hover:text-text">{c.sh.original}</summary>
<div className="mt-3 space-y-4">
{l.description && <p className="whitespace-pre-wrap break-words text-sm leading-relaxed text-muted">{l.description}</p>}
<Features features={l.features} />
</div>
</details>
)}
{!inPool && (
<section className="grid gap-4 sm:grid-cols-2">
<div>
<label className="label" htmlFor="l-status">{c.card.status}</label>
<select id="l-status" className="field" disabled={!canWork} value={l.statusId} onChange={(e) => status.request(id, Number(e.target.value))}>
{pipeline.data?.statuses.filter((s) => s.code !== 'new').map((s) => <option key={s.id} value={s.id}>{s.name}</option>)}
</select>
</div>
<div>
<label className="label" htmlFor="l-assignee">{c.card.assignee}</label>
{isManager && !readOnly ? (
<select id="l-assignee" className="field" value={l.assigneeId ?? ''} onChange={(e) => assign.mutate(Number(e.target.value))}>
{members.data?.map((m) => <option key={m.id} value={m.id}>{m.name}</option>)}
</select>
) : (
<p id="l-assignee" className="flex min-h-11 items-center">{l.assigneeName ?? '—'}</p>
)}
</div>
</section>
)}
{inPool && isManager && !readOnly && (
<div>
<label className="label" htmlFor="l-assign">{c.card.assignee}</label>
<select id="l-assign" className="field" value="" onChange={(e) => e.target.value && assign.mutate(Number(e.target.value))}>
<option value="">{c.pool}</option>
{members.data?.map((m) => <option key={m.id} value={m.id}>{m.name}</option>)}
</select>
</div>
)}
{(l.clients.length > 0 || (!readOnly && freeClients.length > 0)) && (
<section>
<h3 className="label">{c.card.clients}</h3>
<ul className="space-y-1.5">
{l.clients.map((x) => (
<li key={x.id} className="flex items-center justify-between gap-3 rounded-xl bg-raised px-3 py-2">
<Link to={`/clients/${x.id}`} className="min-w-0 truncate hover:underline">{x.name}</Link>
<span className={`chip shrink-0 ${x.state === 'shown' ? 'bg-capture/15 text-capture' : 'bg-surface text-muted'}`}>{c.cl.state[x.state]}</span>
</li>
))}
</ul>
{!readOnly && freeClients.length > 0 && (
<select className="field mt-2" aria-label={c.card.attach} value="" disabled={attach.isPending} onChange={(e) => e.target.value && attach.mutate(Number(e.target.value))}>
<option value="">{c.card.attach}</option>
{freeClients.map((o) => <option key={o.id} value={o.id}>{o.name}</option>)}
</select>
)}
</section>
)}
{canWork && <NextAction at={l.nextActionAt} note={l.nextActionNote} pending={next.isPending} onSave={(v) => next.mutate(v)} />}
{canWork && <CommentBox pending={comment.isPending} onSave={(text, done) => comment.mutate(text, { onSuccess: done })} />}
<History events={l.events} />
{!readOnly && (inPool || canWork) && current?.code !== 'rejected' && current?.code !== 'deal' && (
<div className="flex flex-wrap gap-2 border-t border-line pt-5">
{!inPool && (
<button className="btn btn-ghost btn-sm" disabled={release.isPending} onClick={() => confirm(c.card.confirmPool) && release.mutate()}>
<Undo2 size={15} aria-hidden="true" />{c.card.toPool}
</button>
)}
{l.source === 'listam' && !l.removedAt && (
<button className="btn btn-ghost btn-sm" disabled={gone.isPending} onClick={() => confirm(c.card.confirmGone) && gone.mutate()}>
<EyeOff size={15} aria-hidden="true" />{c.card.gone}
</button>
)}
<button className="btn btn-danger btn-sm" disabled={agent.isPending} onClick={() => confirm(c.card.confirmAgent) && agent.mutate()}>
<UserX size={15} aria-hidden="true" />{c.card.isAgent}
</button>
</div>
)}
{/* Удалить карточку может руководитель; карточку со сделкой — только суперадмин. */}
{isManager && !readOnly && (
<div className="space-y-2 border-t border-line pt-5">
{l.deal && !me.user.isSuperadmin ? (
<p className="text-sm text-muted">{c.x.delDeal}</p>
) : (
<button className="btn btn-ghost btn-sm text-danger" disabled={remove.isPending} onClick={() => confirm(c.x.delConfirm) && remove.mutate(undefined, { onSuccess: onClose })}>
<Trash2 size={15} aria-hidden="true" />{c.x.del}
</button>
)}
<ErrorNote error={remove.error} />
</div>
)}
</div>
)}
{status.dialog}
{editing && l && <LeadEditDialog lead={l} onClose={() => setEditing(false)} />}
</Drawer>
);
}
+458
View File
@@ -0,0 +1,458 @@
import { CURRENCIES, DEAL_TYPES, PROPERTY_TYPES, REGIONS, YEREVAN_DISTRICTS } from '@lr/shared';
import { useQuery } from '@tanstack/react-query';
import { Columns3, EyeOff, Link2, List, Plus, Search, SlidersHorizontal, Star } from 'lucide-react';
import { useEffect, useState } from 'react';
import { Link, useNavigate, useParams } from 'react-router';
import { api, type Me } from '../api.ts';
import { ErrorNote, PageTitle } from '../components/ui.tsx';
import { useAction } from '../hooks.ts';
import { qs, useDepartments, useFilters, useMembers, usePipeline, useRates, type Lead } from './api.ts';
import { useCrm } from './i18n.ts';
import { Chips } from './FiltersPage.tsx';
import { LeadDrawer } from './LeadDrawer.tsx';
import { Dialog, Kanban, StatusChip, useSpecs, useStatusChange } from './parts.tsx';
type Tab = 'pool' | 'mine' | 'all';
function ManualLeadDialog({ onClose }: { onClose: (id?: number) => void }) {
const { c, t } = useCrm();
const d = c.dlg;
const [f, setF] = useState<Record<string, string>>({ currency: 'USD', dealType: 'sale', propertyType: 'apartment' });
const create = useAction(() => {
const body = Object.fromEntries(Object.entries(f).filter(([, v]) => v !== ''));
return api<{ id: number }>('/leads', { method: 'POST', body });
});
const input = (key: string, label: string, props: React.InputHTMLAttributes<HTMLInputElement> = {}) => (
<div>
<label className="label" htmlFor={`m-${key}`}>{label}</label>
<input id={`m-${key}`} className="field" value={f[key] ?? ''} onChange={(e) => setF({ ...f, [key]: e.target.value })} {...props} />
</div>
);
const select = (key: string, label: string, options: [string, string][]) => (
<div>
<label className="label" htmlFor={`m-${key}`}>{label}</label>
<select id={`m-${key}`} className="field" value={f[key] ?? ''} onChange={(e) => setF({ ...f, [key]: e.target.value })}>
{options.map(([v, l]) => <option key={v} value={v}>{l}</option>)}
</select>
</div>
);
return (
<Dialog title={c.add} onClose={() => onClose()} wide>
<form className="space-y-4" onSubmit={(e) => { e.preventDefault(); create.mutate(undefined, { onSuccess: (r) => onClose(r.id) }); }}>
{input('title', d.title, { required: true, maxLength: 200, autoFocus: true })}
<div className="grid gap-4 sm:grid-cols-2">
{select('dealType', c.filters.dealType, DEAL_TYPES.map((x) => [x, c.deal[x]]))}
{select('propertyType', c.filters.types, PROPERTY_TYPES.map((x) => [x, c.ptype[x]]))}
{select('region', c.filters.region, [['', '—'], ...REGIONS.map((r) => [r, r] as [string, string])])}
{input('district', c.col.place, { maxLength: 60 })}
</div>
{input('address', d.address, { maxLength: 300 })}
<div className="grid grid-cols-2 gap-4 sm:grid-cols-4">
{input('price', d.price, { type: 'number', min: 0 })}
{select('currency', d.currency, CURRENCIES.map((x) => [x, x]))}
{input('rooms', d.rooms, { type: 'number', min: 0, max: 50 })}
{input('area', d.area, { type: 'number', min: 0, step: 'any' })}
</div>
<div className="grid gap-4 sm:grid-cols-2">
{input('authorName', d.author, { maxLength: 100 })}
{input('phone', d.phone, { type: 'tel', maxLength: 40 })}
</div>
{input('url', d.link, { type: 'url', maxLength: 1000 })}
<ErrorNote error={create.error} />
<div className="flex justify-end gap-2">
<button type="button" className="btn btn-ghost" onClick={() => onClose()}>{t.common.cancel}</button>
<button className="btn btn-primary" disabled={create.isPending}>{t.common.create}</button>
</div>
</form>
</Dialog>
);
}
type ByUrlResult = { result: 'taken' | 'created' | 'mine'; id: number } | { result: 'busy'; id: number | null; assigneeName: string | null };
/**
* Объект по ссылке на List.am: свободный из CRM закрепляется за агентом, занятый — показывает чей,
* отсутствующий — скачивается с List.am и заводится на агента.
*/
function ByUrlDialog({ onClose, onOpen }: { onClose: () => void; onOpen: (id: number) => void }) {
const { c, t } = useCrm();
const [url, setUrl] = useState('');
const check = useAction(() => api<ByUrlResult>('/leads/by-url', { method: 'POST', body: { url } }), ['leads']);
const r = check.data;
return (
<Dialog title={c.x.byUrlTitle} onClose={onClose}>
<form className="space-y-4" onSubmit={(e) => { e.preventDefault(); check.mutate(); }}>
<div>
<label className="label" htmlFor="by-url">{c.x.byUrlLabel}</label>
<input
id="by-url" type="url" inputMode="url" className="field" required maxLength={1000} autoFocus
placeholder="https://www.list.am/item/…" value={url} onChange={(e) => { setUrl(e.target.value); check.reset(); }}
/>
<p className="mt-1.5 text-xs text-muted">{c.x.byUrlHint}</p>
</div>
{check.isPending && <p className="text-sm text-muted" role="status">{c.x.byUrlLoading}</p>}
<ErrorNote error={check.error} />
{r && (
<p role="status" className={`rounded-xl border px-3.5 py-2.5 text-sm ${r.result === 'busy' ? 'border-warn/30 bg-warn/10 text-warn' : 'border-capture/30 bg-capture/10'}`}>
{r.result === 'busy' ? c.x.busy(r.assigneeName) : c.x[r.result]}
</p>
)}
<div className="flex justify-end gap-2">
<button type="button" className="btn btn-ghost" onClick={onClose}>{t.common.cancel}</button>
{r?.id ? (
<button type="button" className="btn btn-primary" onClick={() => onOpen(r.id!)}>{c.x.open}</button>
) : (
<button className="btn btn-primary" disabled={check.isPending || Boolean(r)}>{c.x.byUrlGo}</button>
)}
</div>
</form>
</Dialog>
);
}
/** Звёздочка «Избранное» в строке списка. */
function FavoriteButton({ lead, className = '' }: { lead: Lead; className?: string }) {
const { c } = useCrm();
const toggle = useAction(() => api(`/leads/${lead.id}/favorite`, { method: 'POST', body: { on: !lead.favorite } }), ['leads']);
return (
<button
type="button" aria-pressed={lead.favorite} aria-label={lead.favorite ? c.dp.removeFav : c.dp.addFav} title={lead.favorite ? c.dp.removeFav : c.dp.addFav}
disabled={toggle.isPending}
className={`flex size-9 shrink-0 cursor-pointer items-center justify-center rounded-lg hover:bg-raised ${lead.favorite ? 'text-warn' : 'text-muted hover:text-text'} ${className}`}
onClick={(e) => { e.stopPropagation(); toggle.mutate(); }}
>
<Star size={16} aria-hidden="true" fill={lead.favorite ? 'currentColor' : 'none'} />
</button>
);
}
/** Точка у объекта, карточку которого пользователь ещё не открывал. */
function UnseenDot({ lead }: { lead: Lead }) {
const { c } = useCrm();
if (lead.seen !== false) return null;
return <span className="mr-1.5 inline-block size-2 shrink-0 rounded-full bg-signal align-middle" role="img" aria-label={c.dp.unseenMark} title={c.dp.unseenMark} />;
}
type PriceCurrency = 'AMD' | 'USD';
function Thumb({ lead, className }: { lead: Lead; className: string }) {
return lead.photos[0] ? (
<img src={lead.photos[0]} alt="" loading="lazy" referrerPolicy="no-referrer" className={`${className} rounded-lg bg-raised object-cover`} />
) : (
<div className={`${className} rounded-lg bg-raised`} aria-hidden="true" />
);
}
export function LeadsPage({ me }: { me: Me }) {
const { c, t, date, price } = useCrm();
const specs = useSpecs();
const navigate = useNavigate();
const { id: openId } = useParams();
const isManager = me.user.role === 'manager';
const seeAll = isManager || Boolean(me.company?.settings.agentsSeeAll);
const readOnly = me.company?.access === 'restricted' || me.company?.access === 'disabled';
const [tab, setTab] = useState<Tab>('pool');
const [view, setView] = useState<'table' | 'kanban'>(() => (localStorage.getItem('lr_leads_view') === 'kanban' ? 'kanban' : 'table'));
const [search, setSearch] = useState('');
const [query, setQuery] = useState('');
const [showFilters, setShowFilters] = useState(false);
const empty = { statusId: '', assigneeId: '', priceFrom: '', priceTo: '', departmentId: '' };
const [f, setF] = useState(empty);
// Валюта границ цены: цены объектов в другой валюте сервер пересчитывает по курсу.
const [currency, setCurrency] = useState<PriceCurrency>(() => (localStorage.getItem('lr_price_currency') === 'AMD' ? 'AMD' : 'USD'));
const [favorite, setFavorite] = useState(false);
const [unseen, setUnseen] = useState(false);
const rates = useRates();
const departments = useDepartments();
const [districts, setDistricts] = useState<string[]>([]);
const [adding, setAdding] = useState(false);
const [byUrl, setByUrl] = useState(false);
// Поиск срабатывает через паузу после ввода.
useEffect(() => {
const timer = setTimeout(() => setQuery(search.trim()), 300);
return () => clearTimeout(timer);
}, [search]);
useEffect(() => localStorage.setItem('lr_leads_view', view), [view]);
useEffect(() => localStorage.setItem('lr_price_currency', currency), [currency]);
/** Смена валюты пересчитывает уже введённые границы по курсу. */
const switchCurrency = (next: PriceCurrency) => {
if (next === currency) return;
const perUsd = rates.data ?? { USD: 1, AMD: 385 };
const k = (perUsd[next] ?? 1) / (perUsd[currency] ?? 1);
// Драмы округляем до тысяч, доллары — до целых.
const convert = (v: string) => (v === '' ? '' : String(next === 'AMD' ? Math.round((Number(v) * k) / 1000) * 1000 : Math.round(Number(v) * k)));
setF({ ...f, priceFrom: convert(f.priceFrom), priceTo: convert(f.priceTo) });
setCurrency(next);
};
const pipeline = usePipeline();
const members = useMembers();
const filters = useFilters();
const hasPrice = f.priceFrom !== '' || f.priceTo !== '';
const params = {
tab, q: query, ...f, districts: districts.join(','),
priceCurrency: hasPrice ? currency : '',
favorite: favorite ? '1' : '', unseen: unseen ? '1' : '',
departmentId: isManager ? f.departmentId : '',
};
const leads = useQuery({
queryKey: ['leads', params],
queryFn: () => api<Lead[]>(`/leads${qs(params)}`),
// Пул меняется сам: новые объекты приходят из парсера, их берут другие агенты.
refetchInterval: 15_000,
});
const summary = useQuery({
queryKey: ['leads', 'summary'],
queryFn: () => api<Record<Tab, number>>('/leads-summary'),
refetchInterval: 15_000,
});
const take = useAction((id: number) => api(`/leads/${id}/take`, { method: 'POST' }));
const status = useStatusChange(pipeline.data);
const statusOf = (l: Lead) => pipeline.data?.statuses.find((s) => s.id === l.statusId);
const open = (id: number) => navigate(`/leads/${id}`);
const tabs: Tab[] = seeAll ? ['pool', 'mine', 'all'] : ['pool', 'mine'];
const kanban = view === 'kanban' && tab !== 'pool';
const noFilters = filters.data?.length === 0;
return (
<>
<PageTitle title={t.nav.leads}>
{!readOnly && (
<>
<button className="btn btn-ghost" onClick={() => setByUrl(true)}>
<Link2 size={17} aria-hidden="true" />
{c.x.byUrl}
</button>
<button className="btn btn-primary" onClick={() => setAdding(true)}>
<Plus size={17} aria-hidden="true" />
{c.add}
</button>
</>
)}
</PageTitle>
<div className="mb-4 flex flex-wrap items-center gap-3">
<div className="flex rounded-xl border border-line p-1" role="tablist">
{tabs.map((x) => (
<button
key={x}
role="tab"
aria-selected={tab === x}
onClick={() => setTab(x)}
className={`min-h-9 cursor-pointer rounded-lg px-3 text-sm transition-colors ${tab === x ? 'bg-raised text-text' : 'text-muted hover:text-text'}`}
>
{c.tabs[x]}
{summary.data && (
<span className={`ml-1.5 tabular-nums ${x === 'pool' && summary.data.pool > 0 ? 'text-signal' : 'text-muted'}`}>{summary.data[x]}</span>
)}
</button>
))}
</div>
<div className="relative min-w-48 flex-1">
<Search size={16} className="pointer-events-none absolute left-3.5 top-1/2 -translate-y-1/2 text-muted" aria-hidden="true" />
<input className="field pl-10" type="search" placeholder={c.search} aria-label={c.search} value={search} onChange={(e) => setSearch(e.target.value)} />
</div>
{/* «Избранное» и «Не просмотренные» включаются вместе и со всеми остальными фильтрами. */}
<button className={`btn btn-ghost ${favorite ? 'border-warn/50 bg-warn/10 text-warn' : ''}`} aria-pressed={favorite} onClick={() => setFavorite((v) => !v)}>
<Star size={16} aria-hidden="true" fill={favorite ? 'currentColor' : 'none'} />
<span className="hidden sm:inline">{c.dp.favorite}</span>
</button>
<button className={`btn btn-ghost ${unseen ? 'border-signal/50 bg-signal/10 text-signal' : ''}`} aria-pressed={unseen} onClick={() => setUnseen((v) => !v)}>
<EyeOff size={16} aria-hidden="true" />
<span className="hidden sm:inline">{c.dp.unseen}</span>
</button>
<button className={`btn btn-ghost ${showFilters ? 'bg-raised' : ''}`} aria-expanded={showFilters} onClick={() => setShowFilters((v) => !v)}>
<SlidersHorizontal size={16} aria-hidden="true" />
<span className="hidden sm:inline">{c.f.more}</span>
</button>
{tab !== 'pool' && (
<div className="flex rounded-xl border border-line p-1">
{(['table', 'kanban'] as const).map((v) => (
<button
key={v}
aria-pressed={view === v}
aria-label={c.view[v]}
title={c.view[v]}
onClick={() => setView(v)}
className={`flex size-9 cursor-pointer items-center justify-center rounded-lg ${view === v ? 'bg-raised text-text' : 'text-muted hover:text-text'}`}
>
{v === 'table' ? <List size={16} /> : <Columns3 size={16} />}
</button>
))}
</div>
)}
</div>
{showFilters && (
<div className="card mb-4 grid grid-cols-2 gap-3 p-4 md:grid-cols-4 lg:grid-cols-7">
{tab !== 'pool' && (
<div>
<label className="label" htmlFor="f-status">{c.f.status}</label>
<select id="f-status" className="field" value={f.statusId} onChange={(e) => setF({ ...f, statusId: e.target.value })}>
<option value="">{c.f.any}</option>
{/* Закрытые сделкой и отказом объекты — в «Базе», в рабочем списке их нет. */}
{pipeline.data?.statuses.filter((s) => s.code !== 'deal' && s.code !== 'rejected').map((s) => <option key={s.id} value={s.id}>{s.name}</option>)}
</select>
</div>
)}
{tab === 'all' && (
<div>
<label className="label" htmlFor="f-agent">{c.f.agent}</label>
<select id="f-agent" className="field" value={f.assigneeId} onChange={(e) => setF({ ...f, assigneeId: e.target.value })}>
<option value="">{c.f.any}</option>
{members.data?.map((m) => <option key={m.id} value={m.id}>{m.name}</option>)}
</select>
</div>
)}
{isManager && Boolean(departments.data?.length) && (
<div>
<label className="label" htmlFor="f-dept">{c.dp.department}</label>
<select id="f-dept" className="field" value={f.departmentId} onChange={(e) => setF({ ...f, departmentId: e.target.value })}>
<option value="">{c.dp.allDepartments}</option>
{departments.data!.map((d) => <option key={d.id} value={d.id}>{d.name}</option>)}
<option value="none">{c.dp.noDepartment}</option>
</select>
</div>
)}
<div>
<label className="label" htmlFor="f-pf">{c.f.priceFrom}</label>
<input id="f-pf" type="number" min={0} inputMode="numeric" className="field" value={f.priceFrom} onChange={(e) => setF({ ...f, priceFrom: e.target.value })} />
</div>
<div>
<label className="label" htmlFor="f-pt">{c.f.priceTo}</label>
<input id="f-pt" type="number" min={0} inputMode="numeric" className="field" value={f.priceTo} onChange={(e) => setF({ ...f, priceTo: e.target.value })} />
</div>
<fieldset>
<legend className="label">{c.dp.currency}</legend>
<div className="flex rounded-xl border border-line p-1" title={c.dp.priceHint}>
{(['AMD', 'USD'] as const).map((cur) => (
<button
key={cur} type="button" aria-pressed={currency === cur} onClick={() => switchCurrency(cur)}
className={`min-h-9 flex-1 cursor-pointer rounded-lg px-3 text-sm ${currency === cur ? 'bg-raised text-text' : 'text-muted hover:text-text'}`}
>
{c.dp.cur[cur]}
</button>
))}
</div>
</fieldset>
<div className="col-span-2 md:col-span-4 lg:col-span-7">
<Chips label={c.x.districts} options={YEREVAN_DISTRICTS.map((d) => [d as string, d as string])} value={districts} onChange={setDistricts} />
</div>
<div className="col-span-2 flex items-end md:col-span-4 lg:col-span-7">
<button className="btn btn-ghost btn-sm" onClick={() => { setF(empty); setDistricts([]); setFavorite(false); setUnseen(false); }}>{c.f.reset}</button>
</div>
</div>
)}
<ErrorNote error={leads.error ?? take.error ?? status.error} />
{noFilters && tab === 'pool' && (
<div className="card mb-4 flex flex-wrap items-center justify-between gap-3 border-signal/30 p-4">
<p>{c.noFilters}</p>
<Link to="/filters" className="btn btn-primary btn-sm">{c.toFilters}</Link>
</div>
)}
{leads.data?.length === 0 && !(noFilters && tab === 'pool') && (
<div className="card flex min-h-40 items-center justify-center p-8 text-center text-muted">{c.empty[tab]}</div>
)}
{Boolean(leads.data?.length) && kanban && pipeline.data && (
<Kanban items={leads.data!} statuses={pipeline.data.statuses} onOpen={open} onMove={status.request} locked="new" canDrag={(l) => l.assigneeId !== null}>
{(l) => (
<>
<p className="font-medium tabular-nums">{price(l.price, l.currency, l.dealType)}</p>
<p className="mt-0.5 line-clamp-2 text-sm leading-snug">{l.title}</p>
<p className="mt-1.5 truncate text-xs text-muted">{l.assigneeName ?? c.pool}</p>
</>
)}
</Kanban>
)}
{Boolean(leads.data?.length) && !kanban && (
<>
{/* Телефон: карточки */}
<ul className="space-y-2 md:hidden">
{leads.data!.map((l) => (
<li key={l.id} className="card flex gap-3 p-3">
<button type="button" className="flex min-w-0 flex-1 cursor-pointer gap-3 text-left" onClick={() => open(l.id)}>
<Thumb lead={l} className="size-20 shrink-0" />
<span className="min-w-0">
<span className="block font-semibold tabular-nums"><UnseenDot lead={l} />{price(l.price, l.currency, l.dealType)}</span>
<span className="line-clamp-2 block text-sm leading-snug">{l.title}</span>
<span className="mt-0.5 block truncate text-xs text-muted">{[l.district ?? l.region, specs(l)].filter(Boolean).join(' · ')}</span>
</span>
</button>
<div className="flex flex-col items-end justify-between gap-1">
<FavoriteButton lead={l} className="-mr-1.5 -mt-1.5" />
{l.assigneeId === null && !readOnly ? (
<button className="btn btn-primary btn-sm" disabled={take.isPending} onClick={() => take.mutate(l.id)}>{c.take}</button>
) : (
<StatusChip status={statusOf(l)} />
)}
</div>
</li>
))}
</ul>
{/* Компьютер: таблица */}
<div className="card hidden overflow-x-auto md:block">
<table className="w-full text-left text-sm">
<thead className="text-muted">
<tr className="border-b border-line">
<th className="w-12 px-2 py-3"><span className="sr-only">{c.dp.favorite}</span></th>
{[c.col.object, c.col.price, c.col.place, c.col.status, c.col.agent, c.col.created].map((h) => (
<th key={h} className="px-4 py-3 font-normal">{h}</th>
))}
</tr>
</thead>
<tbody className="divide-y divide-line">
{leads.data!.map((l) => (
<tr key={l.id} className="cursor-pointer transition-colors hover:bg-raised/50" onClick={() => open(l.id)}>
<td className="px-2 py-2.5"><FavoriteButton lead={l} /></td>
<td className="px-4 py-2.5">
<div className="flex items-center gap-3">
<Thumb lead={l} className="size-12 shrink-0" />
<div className="min-w-0 max-w-sm">
<button type="button" className={`block max-w-full cursor-pointer truncate text-left ${l.seen === false ? 'font-semibold' : 'font-medium'}`} onClick={(e) => { e.stopPropagation(); open(l.id); }}>
<UnseenDot lead={l} />
{l.title}
</button>
<p className="truncate text-xs text-muted">{[l.address, specs(l)].filter(Boolean).join(' · ')}</p>
</div>
</div>
</td>
<td className="whitespace-nowrap px-4 py-2.5 font-medium tabular-nums">{price(l.price, l.currency, l.dealType)}</td>
<td className="px-4 py-2.5">
{l.district ?? l.region ?? '—'}
{isManager && l.departmentName && <span className="block text-xs text-muted">{l.departmentName}</span>}
</td>
<td className="px-4 py-2.5"><StatusChip status={statusOf(l)} /></td>
<td className="px-4 py-2.5">
{l.assigneeId === null && !readOnly ? (
<button className="btn btn-primary btn-sm" disabled={take.isPending} onClick={(e) => { e.stopPropagation(); take.mutate(l.id); }}>{c.take}</button>
) : (
(l.assigneeName ?? <span className="text-muted">{c.pool}</span>)
)}
</td>
<td className="whitespace-nowrap px-4 py-2.5 tabular-nums text-muted">
{l.bumpedAt ? <span className="text-capture" title={c.bumped}>↑ {date(l.bumpedAt)}</span> : date(l.createdAt)}
</td>
</tr>
))}
</tbody>
</table>
</div>
</>
)}
{status.dialog}
{adding && <ManualLeadDialog onClose={(id) => { setAdding(false); if (id) open(id); }} />}
{byUrl && <ByUrlDialog onClose={() => setByUrl(false)} onOpen={(id) => { setByUrl(false); open(id); }} />}
{openId && <LeadDrawer key={openId} id={Number(openId)} me={me} onClose={() => navigate('/leads')} />}
</>
);
}
+197
View File
@@ -0,0 +1,197 @@
import { DEAL_TYPES, PROPERTY_TYPES, REGIONS } from '@lr/shared';
import { keepPreviousData, useQuery } from '@tanstack/react-query';
import { ExternalLink, Search } from 'lucide-react';
import { useEffect, useState } from 'react';
import { api } from '../api.ts';
import { ErrorNote, PageTitle } from '../components/ui.tsx';
import { qs } from './api.ts';
import { useCrm } from './i18n.ts';
import { Dialog, useSpecs } from './parts.tsx';
interface Listing {
id: number;
externalId: string;
url: string;
title: string;
dealType: 'sale' | 'rent' | null;
propertyType: string | null;
region: string | null;
district: string | null;
address: string | null;
price: number | null;
currency: string | null;
rooms: number | null;
area: number | null;
floor: number | null;
floorsTotal: number | null;
photo: string | null;
isAgency: boolean;
phone: string | null;
publishedAt: string | null;
bumpedAt: string | null;
createdAt: string;
active: boolean;
removedAt: string | null;
leads: number;
}
interface ListingCard extends Omit<Listing, 'leads' | 'photo'> {
description: string | null;
photos: string[];
authorName: string | null;
leads: { id: number; company: string; status: string; assignee: string | null; createdAt: string }[];
}
function ListingDialog({ id, onClose }: { id: number; onClose: () => void }) {
const { c, t, err, price, date, dateTime } = useCrm();
const x = c.ls;
const specs = useSpecs();
const card = useQuery({ queryKey: ['admin', 'listing', id], queryFn: () => api<ListingCard>(`/admin/listings/${id}`) });
const l = card.data;
return (
<Dialog title={l?.title ?? t.common.loading} onClose={onClose} wide>
{card.isError && <p className="text-danger">{err(card.error)}</p>}
{l && (
<div className="space-y-5">
{l.photos[0] && <img src={l.photos[0]} alt="" className="aspect-[16/10] w-full rounded-xl object-cover" referrerPolicy="no-referrer" />}
<div>
<p className="text-2xl font-semibold tabular-nums tracking-tight">{price(l.price, l.currency, l.dealType)}</p>
<p className="mt-1 text-muted">{[l.region, l.district, l.address].filter(Boolean).join(', ') || '—'}</p>
<p className="mt-1 text-sm text-muted">
{[l.dealType && c.deal[l.dealType], l.propertyType && (c.ptype as Record<string, string>)[l.propertyType], specs(l), l.isAgency ? c.card.agency : null].filter(Boolean).join(' · ')}
</p>
<p className="mt-1 text-sm text-muted">
{x.number} {l.externalId} · {x.col.published}: {dateTime(l.publishedAt)} · {x.col.found}: {dateTime(l.createdAt)}
</p>
{!l.active && <p className="mt-2 text-sm text-warn">{c.ar.removed(date(l.removedAt))}</p>}
<a href={l.url} target="_blank" rel="noopener noreferrer" className="mt-3 inline-flex items-center gap-1.5 text-signal hover:underline">
<ExternalLink size={15} aria-hidden="true" />{c.card.onListAm}
</a>
</div>
{(l.authorName || l.phone) && <p className="tabular-nums">{[l.authorName, l.phone].filter(Boolean).join(' · ')}</p>}
{l.description && <p className="whitespace-pre-wrap break-words text-sm leading-relaxed text-muted">{l.description}</p>}
<section>
<h3 className="label">{x.sentTo}</h3>
{l.leads.length === 0 ? (
<p className="text-sm text-muted">{x.noLeads}</p>
) : (
<ul className="divide-y divide-line rounded-xl border border-line text-sm">
{l.leads.map((s) => (
<li key={s.id} className="flex flex-wrap items-center justify-between gap-x-4 gap-y-1 px-3.5 py-2.5">
<span className="font-medium">{s.company}</span>
<span className="text-muted">{s.status} · {s.assignee ?? x.pool} · {dateTime(s.createdAt)}</span>
</li>
))}
</ul>
)}
</section>
</div>
)}
</Dialog>
);
}
/** Суперадмин: все объявления, собранные парсером. */
export function ListingsPage() {
const { c, t, price, dateTime } = useCrm();
const x = c.ls;
const specs = useSpecs();
// По умолчанию — только объявления, которые есть на List.am; снятые суперадмин открывает фильтром «Состояние».
const empty = { dealType: '', propertyType: '', region: '', state: 'active', leads: '', author: '' };
const [f, setF] = useState(empty);
const [search, setSearch] = useState('');
const [query, setQuery] = useState('');
const [page, setPage] = useState(1);
const [open, setOpen] = useState<number | null>(null);
useEffect(() => {
const timer = setTimeout(() => setQuery(search.trim()), 300);
return () => clearTimeout(timer);
}, [search]);
useEffect(() => setPage(1), [query, f]);
const params = { ...f, q: query, page };
const list = useQuery({
queryKey: ['admin', 'listings', params],
queryFn: () => api<{ total: number; pageSize: number; items: Listing[] }>(`/admin/listings${qs(params)}`),
placeholderData: keepPreviousData,
// На первой странице видно, как парсер приносит новые объявления.
refetchInterval: page === 1 ? 20_000 : false,
});
const d = list.data;
const pages = d ? Math.max(1, Math.ceil(d.total / d.pageSize)) : 1;
const select = (key: keyof typeof empty, label: string, options: [string, string][]) => (
<select className="field w-auto" aria-label={label} value={f[key]} onChange={(e) => setF({ ...f, [key]: e.target.value })}>
<option value="">{label}</option>
{options.map(([v, text]) => <option key={v} value={v}>{text}</option>)}
</select>
);
return (
<>
<PageTitle title={t.nav.listings} sub={x.sub} />
<div className="mb-4 flex flex-wrap items-center gap-3">
<div className="relative min-w-64 flex-1">
<Search size={16} className="pointer-events-none absolute left-3.5 top-1/2 -translate-y-1/2 text-muted" aria-hidden="true" />
<input className="field pl-10" type="search" placeholder={x.search} aria-label={x.search} value={search} onChange={(e) => setSearch(e.target.value)} />
</div>
{select('dealType', x.anyDeal, DEAL_TYPES.map((v) => [v, c.deal[v]]))}
{select('propertyType', c.filters.anyType, PROPERTY_TYPES.map((v) => [v, c.ptype[v]]))}
{select('region', x.anyRegion, REGIONS.map((v) => [v, v]))}
{select('author', x.author.any, [['owner', x.author.owner], ['agency', x.author.agency]])}
{select('state', x.state.any, [['active', x.state.active], ['removed', x.state.removed]])}
{select('leads', x.leads.any, [['yes', x.leads.yes], ['no', x.leads.no]])}
</div>
<ErrorNote error={list.error} />
{d && <p className="mb-3 text-sm text-muted">{x.total(d.total)}</p>}
{d?.items.length === 0 && <div className="card flex min-h-40 items-center justify-center p-8 text-muted">{x.empty}</div>}
{Boolean(d?.items.length) && (
<div className={`card overflow-x-auto ${list.isPlaceholderData ? 'opacity-60' : ''}`}>
<table className="w-full min-w-[52rem] text-left text-sm">
<thead className="text-muted">
<tr className="border-b border-line">
{[x.col.listing, x.col.price, x.col.place, x.col.published, x.col.found, x.col.leads].map((h) => <th key={h} className="px-4 py-3 font-normal">{h}</th>)}
</tr>
</thead>
<tbody className="divide-y divide-line">
{d!.items.map((l) => (
<tr key={l.id} className="cursor-pointer transition-colors hover:bg-raised/50" onClick={() => setOpen(l.id)}>
<td className="px-4 py-2.5">
<div className="flex items-center gap-3">
{l.photo ? <img src={l.photo} alt="" loading="lazy" referrerPolicy="no-referrer" className="size-12 shrink-0 rounded-lg bg-raised object-cover" /> : <div className="size-12 shrink-0 rounded-lg bg-raised" aria-hidden="true" />}
<div className="min-w-0 max-w-sm">
<button type="button" className="block max-w-full cursor-pointer truncate text-left font-medium" onClick={(e) => { e.stopPropagation(); setOpen(l.id); }}>{l.title}</button>
<p className="truncate text-xs text-muted">
{[`№ ${l.externalId}`, l.dealType && c.deal[l.dealType], specs(l), l.isAgency ? c.card.agency : null].filter(Boolean).join(' · ')}
</p>
{!l.active && <p className="text-xs text-warn">{x.state.removed}</p>}
</div>
</div>
</td>
<td className="whitespace-nowrap px-4 py-2.5 font-medium tabular-nums">{price(l.price, l.currency, l.dealType)}</td>
<td className="px-4 py-2.5">{l.district ?? l.region ?? '—'}</td>
<td className="whitespace-nowrap px-4 py-2.5 tabular-nums text-muted">
{dateTime(l.publishedAt)}
{l.bumpedAt && <span className="block text-xs text-capture" title={c.bumped}>↑ {dateTime(l.bumpedAt)}</span>}
</td>
<td className="whitespace-nowrap px-4 py-2.5 tabular-nums text-muted">{dateTime(l.createdAt)}</td>
<td className={`px-4 py-2.5 tabular-nums ${l.leads ? 'text-capture' : 'text-muted'}`}>{l.leads || '—'}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{d && pages > 1 && (
<div className="mt-4 flex items-center justify-between gap-3 text-sm">
<button className="btn btn-ghost btn-sm" disabled={page <= 1} onClick={() => setPage(page - 1)}>{x.prev}</button>
<span className="text-muted">{x.page(page, pages)}</span>
<button className="btn btn-ghost btn-sm" disabled={page >= pages} onClick={() => setPage(page + 1)}>{x.next}</button>
</div>
)}
{open && <ListingDialog id={open} onClose={() => setOpen(null)} />}
</>
);
}
+85
View File
@@ -0,0 +1,85 @@
import { useQuery } from '@tanstack/react-query';
import { Play } from 'lucide-react';
import { api } from '../api.ts';
import { ErrorNote, PageTitle } from '../components/ui.tsx';
import { useAction } from '../hooks.ts';
import { useCrm } from './i18n.ts';
import { ParserSettings } from './ParserSettings.tsx';
interface ParserInfo {
enabled: boolean;
source: string | null;
lastOk: string | null;
listings24h: number;
listingsTotal: number;
cycleSec: number | null;
delayMedianSec: number | null;
delayCount: number;
runs: { id: number; startedAt: string; finishedAt: string | null; status: 'running' | 'ok' | 'failed'; foundNew: number; errors: string | null }[];
}
/** Состояние парсера для суперадмина: последний успешный проход, объём за сутки, ошибки. */
export function ParserPage() {
const { c, dateTime } = useCrm();
const p = c.parser;
const info = useQuery({ queryKey: ['admin', 'parser'], queryFn: () => api<ParserInfo>('/admin/parser'), refetchInterval: 10_000 });
const run = useAction(() => api('/admin/parser/run', { method: 'POST' }), ['admin', 'parser']);
const d = info.data;
const tone = { ok: 'bg-capture/15 text-capture', failed: 'bg-danger/15 text-danger', running: 'bg-signal/15 text-signal' };
return (
<>
<PageTitle title={p.title} sub={d?.source ?? undefined}>
{d?.enabled && (
<button className="btn btn-ghost" disabled={run.isPending} onClick={() => run.mutate()}>
<Play size={16} aria-hidden="true" />
{p.run}
</button>
)}
</PageTitle>
<ErrorNote error={info.error ?? run.error} />
{d && !d.enabled && <p className="text-muted">{p.off}</p>}
{d && (
<>
<dl className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{[
[p.lastOk, dateTime(d.lastOk)],
[p.day, d.listings24h.toLocaleString('ru-RU')],
[p.total, d.listingsTotal.toLocaleString('ru-RU')],
[p.cycle, d.cycleSec == null ? '—' : `${d.cycleSec} ${p.sec}`],
[`${p.delay} (${p.delayN(d.delayCount)})`, d.delayMedianSec == null ? '—' : `${d.delayMedianSec} ${p.sec}`],
].map(([k, v]) => (
<div key={k} className="card p-5">
<dt className="text-sm text-muted">{k}</dt>
<dd className="mt-1 text-2xl font-semibold tabular-nums tracking-tight">{v}</dd>
</div>
))}
</dl>
<h2 className="mb-3 mt-8 text-lg font-medium tracking-tight">{p.runs}</h2>
<div className="card overflow-x-auto">
<table className="w-full min-w-[36rem] text-left text-sm">
<thead className="text-muted">
<tr className="border-b border-line">
{[p.started, p.took, p.status, p.found, p.errors].map((h) => <th key={h} className="px-4 py-3 font-normal">{h}</th>)}
</tr>
</thead>
<tbody className="divide-y divide-line">
{d.runs.map((r) => (
<tr key={r.id} className="align-top">
<td className="whitespace-nowrap px-4 py-2.5 tabular-nums">{dateTime(r.startedAt)}</td>
<td className="px-4 py-2.5 tabular-nums">
{r.finishedAt ? `${Math.round((new Date(r.finishedAt).getTime() - new Date(r.startedAt).getTime()) / 1000)} ${p.sec}` : '—'}
</td>
<td className="px-4 py-2.5"><span className={`chip ${tone[r.status]}`}>{p[r.status]}</span></td>
<td className="px-4 py-2.5 tabular-nums">{r.foundNew}</td>
<td className="max-w-md whitespace-pre-wrap break-words px-4 py-2.5 text-xs text-muted">{r.errors ?? ''}</td>
</tr>
))}
</tbody>
</table>
</div>
</>
)}
<ParserSettings />
</>
);
}
+116
View File
@@ -0,0 +1,116 @@
import { useQuery } from '@tanstack/react-query';
import { useEffect, useState } from 'react';
import { api } from '../api.ts';
import { ErrorNote, Toggle } from '../components/ui.tsx';
import { useAction } from '../hooks.ts';
import { useCrm } from './i18n.ts';
interface Settings {
enabled: boolean;
freshDays: number;
pauseSec: number;
maxPages: number;
categories: number[];
useKey: boolean;
removalCheck: boolean;
removalBatch: number;
recheckHours: number;
jinaKey: { source: 'settings' | 'env' | 'none'; tail: string | null };
allCategories: { id: number; propertyType: 'apartment' | 'house' | 'commercial' | 'land' | 'garage'; dealType: 'sale' | 'rent' }[];
}
/** Настройки парсера для суперадмина. Применяются со следующего прохода, без перезапуска сервиса. */
export function ParserSettings() {
const { c, t } = useCrm();
const x = c.ps;
const loaded = useQuery({ queryKey: ['admin', 'parser', 'settings'], queryFn: () => api<Settings>('/admin/parser/settings') });
const [f, setF] = useState<Settings | null>(null);
const [key, setKey] = useState('');
useEffect(() => { if (loaded.data) setF(loaded.data); }, [loaded.data]);
const save = useAction(
(body: object) => api<Settings>('/admin/parser/settings', { method: 'PATCH', body }),
['admin', 'parser'],
);
if (!f) return <ErrorNote error={loaded.error} />;
const number = (field: 'freshDays' | 'pauseSec' | 'maxPages' | 'removalBatch' | 'recheckHours', label: string, min: number, max: number, hint?: string) => (
<div>
<label className="label" htmlFor={`ps-${field}`}>{label}</label>
<input id={`ps-${field}`} type="number" required min={min} max={max} className="field" value={f[field]} onChange={(e) => setF({ ...f, [field]: Number(e.target.value) })} />
{hint && <p className="mt-1.5 text-xs text-muted">{hint}</p>}
</div>
);
const toggleCategory = (id: number) =>
setF({ ...f, categories: f.categories.includes(id) ? f.categories.filter((v) => v !== id) : [...f.categories, id] });
return (
<form
className="card mt-8 space-y-6 p-5 sm:p-6"
onSubmit={(e) => {
e.preventDefault();
const { jinaKey: _jinaKey, allCategories: _all, ...body } = f;
save.mutate({ ...body, ...(key.trim() ? { jinaApiKey: key.trim() } : {}) }, { onSuccess: () => setKey('') });
}}
>
<div>
<h2 className="text-lg font-medium tracking-tight">{x.title}</h2>
<p className="mt-1 text-sm text-muted">{x.hint}</p>
</div>
<Toggle checked={f.enabled} onChange={(v) => setF({ ...f, enabled: v })} label={x.enabled} />
<div className="grid gap-4 sm:grid-cols-3">
{number('freshDays', x.freshDays, 1, 30, x.freshHint)}
{number('pauseSec', x.pauseSec, 0, 3600)}
{number('maxPages', x.maxPages, 1, 10, x.pagesHint)}
</div>
<fieldset>
<legend className="label">{x.categories}</legend>
<div className="flex flex-wrap gap-2">
{f.allCategories.map((cat) => {
const on = f.categories.includes(cat.id);
return (
<label key={cat.id} className={`flex min-h-9 cursor-pointer items-center rounded-lg border px-3 text-sm transition-colors has-focus-visible:outline-2 has-focus-visible:outline-signal ${on ? 'border-capture/60 bg-capture/15 text-text' : 'border-line-strong text-muted hover:text-text'}`}>
<input type="checkbox" className="sr-only" checked={on} onChange={() => toggleCategory(cat.id)} />
{c.ptype[cat.propertyType]} · {c.deal[cat.dealType].toLowerCase()}
</label>
);
})}
</div>
</fieldset>
<div className="space-y-4 border-t border-line pt-5">
<h3 className="font-medium">{x.keyTitle}</h3>
<p className="text-sm text-muted">{x.keyHint}</p>
<Toggle checked={f.useKey} onChange={(v) => setF({ ...f, useKey: v })} label={x.useKey} />
<div className="grid items-end gap-3 sm:grid-cols-[1fr_auto]">
<div>
<label className="label" htmlFor="ps-key">
{x.key}: {f.jinaKey.source === 'none' ? x.keyNone : `…${f.jinaKey.tail} (${f.jinaKey.source === 'env' ? x.keyEnv : x.keySet})`}
</label>
<input id="ps-key" type="password" autoComplete="off" className="field" maxLength={200} placeholder={x.keyNew} value={key} onChange={(e) => setKey(e.target.value)} />
</div>
{f.jinaKey.source === 'settings' && (
<button type="button" className="btn btn-ghost" disabled={save.isPending} onClick={() => confirm(x.keyConfirm) && save.mutate({ jinaApiKey: '' })}>{x.keyClear}</button>
)}
</div>
</div>
<div className="space-y-4 border-t border-line pt-5">
<h3 className="font-medium">{x.removalTitle}</h3>
<Toggle checked={f.removalCheck} onChange={(v) => setF({ ...f, removalCheck: v })} label={x.removalCheck} />
<div className="grid gap-4 sm:grid-cols-3">
{number('recheckHours', x.recheckHours, 1, 720)}
{number('removalBatch', x.removalBatch, 1, 100, x.batchHint(f.removalBatch * 144))}
</div>
</div>
<ErrorNote error={save.error} />
<div className="flex items-center gap-3">
<button className="btn btn-primary" disabled={save.isPending}>{t.common.save}</button>
{save.isSuccess && <span className="text-sm text-capture" role="status">{t.settings.saved}</span>}
</div>
</form>
);
}
+189
View File
@@ -0,0 +1,189 @@
import type { Pipeline } from '@lr/shared';
import { ArrowDown, ArrowUp, Lock, Plus, Trash2 } from 'lucide-react';
import { useEffect, useState } from 'react';
import { api } from '../api.ts';
import { ErrorNote } from '../components/ui.tsx';
import { useAction } from '../hooks.ts';
import { usePipeline, type Status } from './api.ts';
import { useCrm } from './i18n.ts';
import { Dialog } from './parts.tsx';
/** Название, которое сохраняется при уходе из поля или по Enter. */
function NameField({ value, label, onSave }: { value: string; label: string; onSave: (name: string) => void }) {
const [text, setText] = useState(value);
useEffect(() => setText(value), [value]);
const commit = () => {
const name = text.trim();
if (name && name !== value) onSave(name);
else setText(value);
};
return (
<input
className="field min-w-0 flex-1"
maxLength={60}
aria-label={label}
value={text}
onChange={(e) => setText(e.target.value)}
onBlur={commit}
onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); e.currentTarget.blur(); } }}
/>
);
}
function IconButton({ label, onClick, disabled, danger, children }: { label: string; onClick: () => void; disabled?: boolean; danger?: boolean; children: React.ReactNode }) {
return (
<button
type="button"
aria-label={label}
title={label}
disabled={disabled}
onClick={onClick}
className={`flex size-9 shrink-0 cursor-pointer items-center justify-center rounded-lg text-muted disabled:cursor-default disabled:opacity-30 ${danger ? 'enabled:hover:text-danger' : 'enabled:hover:text-text'}`}
>
{children}
</button>
);
}
function AddRow({ placeholder, onAdd, pending }: { placeholder: string; onAdd: (name: string, done: () => void) => void; pending: boolean }) {
const { c } = useCrm();
const [name, setName] = useState('');
return (
<form className="mt-3 flex gap-2" onSubmit={(e) => { e.preventDefault(); if (name.trim()) onAdd(name.trim(), () => setName('')); }}>
<input className="field min-w-0 flex-1" maxLength={60} placeholder={placeholder} aria-label={placeholder} value={name} onChange={(e) => setName(e.target.value)} />
<button className="btn btn-ghost shrink-0" disabled={pending || !name.trim()}>
<Plus size={16} aria-hidden="true" />{c.pl.add}
</button>
</form>
);
}
const move = <T,>(list: T[], from: number, to: number) => {
const next = [...list];
next.splice(to, 0, ...next.splice(from, 1));
return next;
};
/**
* Настройка воронки руководителем: статусы и причины отказа. Системные статусы можно только
* переименовать — на них завязаны пул, аналитика и биллинг.
*/
export function PipelineSettings({ pipeline }: { pipeline: Pipeline }) {
const { c, t } = useCrm();
const x = c.pl;
const info = usePipeline(pipeline);
const call = (path: string, method: string, body?: object) => api(path, { method, body });
const act = useAction((v: { path: string; method: string; body?: object }) => call(v.path, v.method, v.body));
const [removing, setRemoving] = useState<Status | null>(null);
const [moveTo, setMoveTo] = useState('');
const statuses = info.data?.statuses ?? [];
const reasons = info.data?.reasons ?? [];
const middle = statuses.filter((s) => !s.code);
const reorder = (from: number, to: number) =>
act.mutate({ path: `/pipelines/${pipeline}/order`, method: 'POST', body: { ids: move(middle, from, to).map((s) => s.id) } });
const reorderReasons = (from: number, to: number) =>
act.mutate({ path: `/pipelines/${pipeline}/reasons-order`, method: 'POST', body: { ids: move(reasons, from, to).map((r) => r.id) } });
const remove = (s: Status) => {
if (s.count > 0) {
setMoveTo('');
setRemoving(s);
} else if (confirm(x.confirmStatus(s.name))) act.mutate({ path: `/pipeline-statuses/${s.id}`, method: 'DELETE' });
};
// Карточки удаляемого статуса переходят в другой рабочий статус.
const targets = statuses.filter((s) => s.id !== removing?.id && (!s.code || s.code === 'taken'));
return (
<section className="card p-5 sm:p-6">
<h2 className="text-lg font-medium tracking-tight">{x.title[pipeline]}</h2>
<p className="mb-5 mt-1 text-sm text-muted">{x.hint}</p>
<ErrorNote error={info.error ?? (removing ? null : act.error)} />
<h3 className="label">{x.statuses}</h3>
<ul className="space-y-2">
{statuses.map((s) => {
const i = middle.findIndex((m) => m.id === s.id);
const system = Boolean(s.code);
return (
<li key={s.id} className="flex flex-wrap items-center gap-1">
<div className="flex">
<IconButton label={x.up} disabled={system || i <= 0} onClick={() => reorder(i, i - 1)}><ArrowUp size={16} /></IconButton>
<IconButton label={x.down} disabled={system || i === middle.length - 1} onClick={() => reorder(i, i + 1)}><ArrowDown size={16} /></IconButton>
</div>
<NameField value={s.name} label={x.statusName} onSave={(name) => act.mutate({ path: `/pipeline-statuses/${s.id}`, method: 'PATCH', body: { name } })} />
{pipeline === 'object' && !['new', 'deal', 'rejected'].includes(s.code ?? '') && (
<label className="flex min-h-9 cursor-pointer items-center gap-2 px-2 text-sm text-muted" title={x.poolHint}>
<input
type="checkbox"
className="size-4 accent-(--capture)"
checked={s.poolReturn}
onChange={(e) => act.mutate({ path: `/pipeline-statuses/${s.id}`, method: 'PATCH', body: { poolReturn: e.target.checked } })}
/>
{x.pool}
</label>
)}
{system ? (
<span className="flex size-9 shrink-0 items-center justify-center text-muted" title={x.system} aria-label={x.system}><Lock size={15} /></span>
) : (
<IconButton label={t.common.delete} danger onClick={() => remove(s)}><Trash2 size={16} /></IconButton>
)}
</li>
);
})}
</ul>
<AddRow
placeholder={x.newStatus}
pending={act.isPending}
onAdd={(name, done) => act.mutate({ path: `/pipelines/${pipeline}/statuses`, method: 'POST', body: { name } }, { onSuccess: done })}
/>
<h3 className="label mt-7">{x.reasons}</h3>
<ul className="space-y-2">
{reasons.map((r, i) => (
<li key={r.id} className="flex items-center gap-1">
<div className="flex">
<IconButton label={x.up} disabled={i === 0} onClick={() => reorderReasons(i, i - 1)}><ArrowUp size={16} /></IconButton>
<IconButton label={x.down} disabled={i === reasons.length - 1} onClick={() => reorderReasons(i, i + 1)}><ArrowDown size={16} /></IconButton>
</div>
<NameField value={r.name} label={x.reasonName} onSave={(name) => act.mutate({ path: `/reject-reasons/${r.id}`, method: 'PATCH', body: { name } })} />
{r.code ? (
<span className="flex size-9 shrink-0 items-center justify-center text-muted" title={x.systemReason} aria-label={x.systemReason}><Lock size={15} /></span>
) : (
<IconButton label={t.common.delete} danger onClick={() => confirm(x.confirmReason(r.name)) && act.mutate({ path: `/reject-reasons/${r.id}`, method: 'DELETE' })}>
<Trash2 size={16} />
</IconButton>
)}
</li>
))}
</ul>
<AddRow
placeholder={x.newReason}
pending={act.isPending}
onAdd={(name, done) => act.mutate({ path: `/pipelines/${pipeline}/reasons`, method: 'POST', body: { name } }, { onSuccess: done })}
/>
{removing && (
<Dialog title={x.removeTitle(removing.name)} onClose={() => { setRemoving(null); act.reset(); }}>
<form
className="space-y-4"
onSubmit={(e) => {
e.preventDefault();
act.mutate({ path: `/pipeline-statuses/${removing.id}?moveTo=${moveTo}`, method: 'DELETE' }, { onSuccess: () => setRemoving(null) });
}}
>
<p className="text-sm text-muted">{x.removeHint(removing.count)}</p>
<select className="field" required aria-label={x.moveTo} value={moveTo} onChange={(e) => setMoveTo(e.target.value)}>
<option value="">{x.moveTo}</option>
{targets.map((s) => <option key={s.id} value={s.id}>{s.name}</option>)}
</select>
<ErrorNote error={act.error} />
<div className="flex justify-end gap-2">
<button type="button" className="btn btn-ghost" onClick={() => { setRemoving(null); act.reset(); }}>{t.common.cancel}</button>
<button className="btn btn-danger" disabled={act.isPending}>{t.common.delete}</button>
</div>
</form>
</Dialog>
)}
</section>
);
}
+185
View File
@@ -0,0 +1,185 @@
import type { ClientKind, ClientRequest, FilterParams, Household, ListingFeatures, Pipeline, RentTerm } from '@lr/shared';
import { useQuery } from '@tanstack/react-query';
import { api, type Member } from '../api.ts';
import type { CommissionInfo } from './commission.tsx';
export interface Lead {
id: number;
source: 'listam' | 'manual';
url: string | null;
title: string;
description: string | null;
dealType: 'sale' | 'rent' | null;
propertyType: string | null;
region: string | null;
district: string | null;
address: string | null;
price: number | null;
currency: string | null;
rooms: number | null;
area: number | null;
floor: number | null;
floorsTotal: number | null;
photos: string[];
authorName: string | null;
phone: string | null;
phoneHidden: boolean;
features: ListingFeatures | null;
assigneeId: number | null;
assigneeName: string | null;
statusId: number;
rejectReasonId: number | null;
nextActionAt: string | null;
nextActionNote: string | null;
createdAt: string;
/** Объявление подняли на list.am — лид встал наверх. */
bumpedAt: string | null;
statusChangedAt: string;
/** В избранном у текущего пользователя. */
favorite: boolean;
/** Текущий пользователь уже открывал карточку (в карточке не приходит). */
seen?: boolean;
departmentName: string | null;
}
export interface LeadEvent {
id: number;
type: string;
at: string;
userName: string | null;
oldValue: string | null;
newValue: string | null;
text: string | null;
}
export interface DealInfo {
id: number;
commission: number;
/** Часть с собственника — агенту объекта; часть с клиента — агенту клиента. */
ownerCommission: number;
clientCommission: number;
ownerAgentName: string | null;
clientAgentName: string | null;
currency: string;
date: string;
leadId?: number | null;
leadTitle?: string | null;
clientId?: number | null;
clientName?: string | null;
}
export interface LeadCard extends Lead {
/** ready — все фото загружены; pending — догружаются; none — загрузить нечего. */
gallery: 'ready' | 'pending' | 'none';
/** Текст для клиента: карточка по шаблону, без адреса. */
clientText: string;
commission: CommissionInfo;
/** Варианты номера из общей базы: сколько компаний указали каждый и насколько ему можно доверять. */
phoneVariants: { phone: string; votes: number; confidence: number | null }[];
/** Объявление снято с List.am. */
removedAt: string | null;
/** Формулировка в объявлении: посредник или собственник против агентов. */
agentPhrase: { kind: 'broker' | 'noAgents'; phrase: string } | null;
/** Прошлая карточка того же объекта; id пуст, если она агенту недоступна. */
previous: { id: number | null; title: string; assigneeName: string | null; status: string; removedAt: string | null } | null;
/** Клиенты, которым объект предложен или показан. */
clients: { id: number; name: string; kind: ClientKind; statusId: number; state: 'offered' | 'shown'; assigneeName: string | null }[];
deal: DealInfo | null;
events: LeadEvent[];
}
export interface Client {
id: number;
name: string;
phone: string | null;
/** @ник в Telegram. */
telegram: string | null;
phoneHidden: boolean;
kind: ClientKind;
rentTerm: RentTerm | null;
request: Partial<ClientRequest>;
household: Household | null;
source: string | null;
assigneeId: number;
assigneeName: string | null;
statusId: number;
rejectReasonId: number | null;
nextActionAt: string | null;
nextActionNote: string | null;
createdAt: string;
leadsCount?: number;
departmentName?: string | null;
}
/** Объект в карточке клиента: прикреплённый (есть state) или подходящий под запрос. */
export interface ClientLead extends Pick<Lead, 'id' | 'title' | 'price' | 'currency' | 'dealType' | 'district' | 'region' | 'address' | 'statusId' | 'assigneeId' | 'assigneeName'> {
photo: string | null;
state?: 'offered' | 'shown';
shownAt?: string | null;
}
export interface ClientCard extends Client {
photos: { id: number; createdAt: string }[];
pageUrl: string | null;
commission: CommissionInfo;
viewings: { leadId: number; day: string; note: string | null; title: string }[];
leads: ClientLead[];
deal: DealInfo | null;
events: LeadEvent[];
}
export interface Status {
id: number;
code: string | null;
name: string;
poolReturn: boolean;
/** Сколько карточек в статусе; считается только для руководителя. */
count: number;
}
export interface PipelineInfo {
statuses: Status[];
reasons: { id: number; code: string | null; name: string }[];
}
export interface Filter {
id: number;
name: string;
enabled: boolean;
params: FilterParams;
scope: 'company' | 'department' | 'personal';
ownerUserId: number | null;
ownerName: string | null;
departmentId: number | null;
departmentName: string | null;
editable: boolean;
}
export interface Department {
id: number;
name: string;
members: number;
filters: number;
leads: number;
clients: number;
}
export const usePipeline = (pipeline: Pipeline = 'object') =>
useQuery({ queryKey: ['pipeline', pipeline], queryFn: () => api<PipelineInfo>(`/pipelines/${pipeline}`), staleTime: 5 * 60_000 });
export const useMembers = () => useQuery({ queryKey: ['members'], queryFn: () => api<Member[]>('/members') });
export const useDepartments = () => useQuery({ queryKey: ['departments'], queryFn: () => api<Department[]>('/departments') });
/** Курсы валют: сколько единиц в одном долларе. */
export const useRates = () => useQuery({ queryKey: ['rates'], queryFn: () => api<Record<string, number>>('/rates'), staleTime: 60 * 60_000 });
export const useFilters = () => useQuery({ queryKey: ['filters'], queryFn: () => api<Filter[]>('/filters') });
/** Строка запроса из непустых полей. */
export const qs = (params: Record<string, string | number | undefined | null>) => {
const p = new URLSearchParams();
for (const [k, v] of Object.entries(params)) if (v !== undefined && v !== null && v !== '') p.set(k, String(v));
const s = p.toString();
return s ? `?${s}` : '';
};
+122
View File
@@ -0,0 +1,122 @@
import { CURRENCIES, type CommissionRule, type Currency } from '@lr/shared';
import { useState } from 'react';
import { api } from '../api.ts';
import { ErrorNote } from '../components/ui.tsx';
import { useAction } from '../hooks.ts';
import { useCrm } from './i18n.ts';
export interface CommissionInfo {
rule: CommissionRule | null;
/** Условие задано в этой карточке, а не взято из настроек компании. */
custom: boolean;
expected: { amount: number; currency: Currency } | null;
}
const num = (v: number | undefined) => (v !== undefined && Number.isFinite(v) ? v : '');
const parse = (v: string) => (v === '' ? Number.NaN : Number(v));
/** Поля одного условия: не задано, процент, фиксированная сумма или фикс + процент. */
export function CommissionFields({ id, label, value, onChange }: { id: string; label: string; value: CommissionRule | null; onChange: (v: CommissionRule | null) => void }) {
const { c } = useCrm();
const m = c.cm;
const mode = value?.mode ?? '';
return (
<fieldset>
<legend className="label">{label}</legend>
<div className="flex flex-wrap gap-2">
<select
id={`${id}-mode`}
aria-label={`${label}: ${m.mode}`}
className="field w-auto"
value={mode}
onChange={(e) => onChange(e.target.value ? { mode: e.target.value as CommissionRule['mode'], value: value?.value ?? 0, currency: value?.currency ?? 'USD', fixed: value?.fixed ?? 0 } : null)}
>
<option value="">{m.none}</option>
<option value="percent">{m.percent}</option>
<option value="fixed">{m.fixed}</option>
<option value="mixed">{m.mixed}</option>
</select>
{/* Фиксированная часть: в режиме «фикс» это вся сумма, в «фикс + %» — отдельное поле. */}
{value && value.mode !== 'percent' && (
<>
<input
type="number" min={0} step="any" required aria-label={`${label}: ${m.amount}`}
className="field w-28 tabular-nums"
value={num(value.mode === 'fixed' ? value.value : value.fixed)}
onChange={(e) => onChange(value.mode === 'fixed' ? { ...value, value: parse(e.target.value) } : { ...value, fixed: parse(e.target.value) })}
/>
<select aria-label={`${label}: ${c.dlg.currency}`} className="field w-auto" value={value.currency} onChange={(e) => onChange({ ...value, currency: e.target.value as Currency })}>
{CURRENCIES.map((v) => <option key={v}>{v}</option>)}
</select>
</>
)}
{value?.mode === 'mixed' && <span className="self-center text-muted">+</span>}
{value && value.mode !== 'fixed' && (
<>
<input
type="number" min={0} step="any" required aria-label={`${label}: %`}
className="field w-24 tabular-nums"
value={num(value.value)}
onChange={(e) => onChange({ ...value, value: parse(e.target.value) })}
/>
<span className="self-center text-muted">%</span>
</>
)}
</div>
</fieldset>
);
}
/** «3 % от цены» или «$1 000». */
export function useRuleText() {
const { c, price } = useCrm();
return (rule: CommissionRule | null, rent: boolean) =>
!rule
? c.cm.notSet
: rule.mode === 'fixed'
? price(rule.value, rule.currency)
: `${rule.mode === 'mixed' ? `${price(rule.fixed ?? 0, rule.currency)} + ` : ''}${rent ? c.cm.ofRent(rule.value) : c.cm.ofPrice(rule.value)}`;
}
/** Блок «Комиссия агентства» в карточке объекта или клиента: условие, ожидаемая сумма, правка агентом. */
export function CommissionBlock({ kind, id, info, rent, canEdit }: { kind: 'leads' | 'clients'; id: number; info: CommissionInfo; rent: boolean; canEdit: boolean }) {
const { c, t, price } = useCrm();
const m = c.cm;
const ruleText = useRuleText();
const [draft, setDraft] = useState<CommissionRule | null | undefined>(undefined);
const save = useAction(
(rule: CommissionRule | null) => api(`/${kind}/${id}/commission`, { method: 'POST', body: { rule } }),
[kind === 'leads' ? 'lead' : 'client', id],
);
const editing = draft !== undefined;
return (
<section>
<h3 className="label">{m.title}</h3>
{!editing ? (
<div className="flex flex-wrap items-center justify-between gap-3">
<p>
<span className="font-medium">{ruleText(info.rule, rent)}</span>
{info.expected && info.rule && info.rule.mode !== 'fixed' && <span className="text-muted"> · ≈ {price(info.expected.amount, info.expected.currency)}</span>}
<span className="block text-xs text-muted">{info.custom ? m.custom : info.rule ? m.fromSettings : kind === 'clients' ? m.hintClient : m.hintLead}</span>
</p>
{canEdit && <button type="button" className="btn btn-ghost btn-sm" onClick={() => setDraft(info.rule ?? { mode: 'percent', value: 0, currency: 'USD' })}>{c.dlg.edit}</button>}
</div>
) : (
<form
className="space-y-3"
onSubmit={(e) => { e.preventDefault(); save.mutate(draft ?? null, { onSuccess: () => setDraft(undefined) }); }}
>
<CommissionFields id={`cm-${kind}-${id}`} label={m.forThis} value={draft ?? null} onChange={setDraft} />
<ErrorNote error={save.error} />
<div className="flex flex-wrap gap-2">
<button className="btn btn-primary btn-sm" disabled={save.isPending}>{t.common.save}</button>
{info.custom && (
<button type="button" className="btn btn-ghost btn-sm" disabled={save.isPending} onClick={() => save.mutate(null, { onSuccess: () => setDraft(undefined) })}>{m.reset}</button>
)}
<button type="button" className="btn btn-ghost btn-sm" onClick={() => setDraft(undefined)}>{t.common.cancel}</button>
</div>
</form>
)}
</section>
);
}
+109
View File
@@ -0,0 +1,109 @@
import { Check, Pencil, Plus, Trash2, X } from 'lucide-react';
import { useState } from 'react';
import { api, type Member } from '../api.ts';
import { ErrorNote } from '../components/ui.tsx';
import { useAction } from '../hooks.ts';
import { useDepartments, type Department } from './api.ts';
import { useCrm } from './i18n.ts';
/** Строка отдела: название, сколько в нём сотрудников, фильтров, объектов и клиентов; переименование и удаление. */
function DepartmentRow({ d, readOnly }: { d: Department; readOnly: boolean }) {
const { c, t } = useCrm();
const [name, setName] = useState<string | null>(null);
const rename = useAction((value: string) => api(`/departments/${d.id}`, { method: 'PATCH', body: { name: value } }));
const remove = useAction(() => api(`/departments/${d.id}`, { method: 'DELETE' }));
return (
<li className="px-5 py-3.5">
<div className="flex flex-wrap items-center gap-x-4 gap-y-2">
{name === null ? (
<div className="min-w-0 flex-1 basis-56">
<p className="truncate font-medium">{d.name}</p>
<p className="text-sm text-muted">{c.dp.stats(d.members, d.filters, d.leads, d.clients)}</p>
</div>
) : (
<form
className="flex min-w-0 flex-1 basis-56 items-center gap-2"
onSubmit={(e) => { e.preventDefault(); rename.mutate(name, { onSuccess: () => setName(null) }); }}
>
<label className="sr-only" htmlFor={`dept-${d.id}`}>{c.dp.namePh}</label>
<input id={`dept-${d.id}`} className="field" required maxLength={80} autoFocus value={name} onChange={(e) => setName(e.target.value)} />
<button className="btn btn-primary btn-sm" disabled={rename.isPending} aria-label={t.common.save}><Check size={14} aria-hidden="true" /></button>
<button type="button" className="btn btn-ghost btn-sm" onClick={() => setName(null)} aria-label={t.common.cancel}><X size={14} aria-hidden="true" /></button>
</form>
)}
{!readOnly && name === null && (
<div className="flex items-center gap-2">
<button className="btn btn-ghost btn-sm" onClick={() => setName(d.name)} aria-label={`${c.dp.rename}: ${d.name}`} title={c.dp.rename}>
<Pencil size={14} aria-hidden="true" />
</button>
<button
className="btn btn-danger btn-sm" aria-label={`${c.dp.del}: ${d.name}`} title={c.dp.del} disabled={remove.isPending}
onClick={() => confirm(c.dp.delConfirm) && remove.mutate()}
>
<Trash2 size={14} aria-hidden="true" />
</button>
</div>
)}
</div>
<div className="mt-2 empty:hidden">
<ErrorNote error={rename.error ?? remove.error} />
</div>
</li>
);
}
/** Отделы компании — в разделе «Команда» у руководителя. */
export function DepartmentsSection({ readOnly }: { readOnly: boolean }) {
const { c } = useCrm();
const departments = useDepartments();
const [name, setName] = useState('');
const create = useAction(() => api('/departments', { method: 'POST', body: { name } }));
return (
<section className="mt-8">
<h2 className="mb-3 text-lg font-medium tracking-tight">{c.dp.departments}</h2>
<p className="mb-4 max-w-2xl text-sm text-muted">{c.dp.hint}</p>
{departments.data?.length ? (
<ul className="card mb-4 divide-y divide-line">
{departments.data.map((d) => <DepartmentRow key={d.id} d={d} readOnly={readOnly} />)}
</ul>
) : (
departments.data && <p className="mb-4 text-sm text-muted">{c.dp.none}</p>
)}
{!readOnly && (
<form className="flex max-w-md items-center gap-2" onSubmit={(e) => { e.preventDefault(); create.mutate(undefined, { onSuccess: () => setName('') }); }}>
<label className="sr-only" htmlFor="dept-new">{c.dp.namePh}</label>
<input id="dept-new" className="field" required maxLength={80} placeholder={c.dp.namePh} value={name} onChange={(e) => setName(e.target.value)} />
<button className="btn btn-primary shrink-0" disabled={create.isPending}>
<Plus size={16} aria-hidden="true" />
{c.dp.add}
</button>
</form>
)}
<ErrorNote error={create.error ?? departments.error} />
</section>
);
}
/** Отдел сотрудника в списке команды: руководитель назначает и снимает. */
export function MemberDepartment({ member, readOnly }: { member: Member; readOnly: boolean }) {
const { c } = useCrm();
const departments = useDepartments();
const move = useAction((departmentId: number | null) =>
api<{ ok: true; kept: number }>(`/members/${member.id}/department`, { method: 'PUT', body: { departmentId } }),
);
if (!departments.data?.length) return null;
return (
<span className="flex flex-col">
<label className="sr-only" htmlFor={`md-${member.id}`}>{`${c.dp.department}: ${member.name}`}</label>
<select
id={`md-${member.id}`} className="field min-h-9 py-1 text-sm" disabled={readOnly || move.isPending}
value={member.departmentId ?? ''} onChange={(e) => move.mutate(e.target.value ? Number(e.target.value) : null)}
>
<option value="">{c.dp.noDepartment}</option>
{departments.data.map((d) => <option key={d.id} value={d.id}>{d.name}</option>)}
</select>
{Boolean(move.data?.kept) && <span className="mt-1 max-w-56 text-xs text-warn" role="status">{c.dp.kept(move.data!.kept)}</span>}
<ErrorNote error={move.error} />
</span>
);
}
+234
View File
@@ -0,0 +1,234 @@
import { HOUSEHOLD_WHO, type Household, type HouseholdWho, type ListingFeatures } from '@lr/shared';
import { ImagePlus, X } from 'lucide-react';
import { useRef } from 'react';
import { ApiError } from '../api.ts';
import { ErrorNote, Toggle } from '../components/ui.tsx';
import { useAction } from '../hooks.ts';
import { useCrm } from './i18n.ts';
/** Состояние формы «Кто будет жить»: числа и возраст держим строками, пока человек печатает. */
export interface HouseholdForm {
who: HouseholdWho | '';
children: boolean;
childrenCount: string;
childrenAge: string;
pets: boolean;
petsCount: string;
petsAge: string;
}
export const householdForm = (h: Household | null | undefined): HouseholdForm => ({
who: h?.who ?? '',
children: Boolean(h?.children),
childrenCount: String(h?.children?.count ?? 1),
childrenAge: h?.children?.age ?? '',
pets: Boolean(h?.pets),
petsCount: String(h?.pets?.count ?? 1),
petsAge: h?.pets?.age ?? '',
});
const count = (v: string) => Math.max(1, Math.min(20, Math.round(Number(v)) || 1));
/** Что уйдёт на сервер; null — блок не заполнен. */
export function householdValue(f: HouseholdForm): Household | null {
const value: Household = {
...(f.who ? { who: f.who } : {}),
...(f.children ? { children: { count: count(f.childrenCount), age: f.childrenAge.trim() } } : {}),
...(f.pets ? { pets: { count: count(f.petsCount), age: f.petsAge.trim() } } : {}),
};
return Object.keys(value).length ? value : null;
}
function Group({ id, on, onToggle, label, cnt, onCount, age, onAge, hint }: {
id: string; on: boolean; onToggle: (v: boolean) => void; label: string;
cnt: string; onCount: (v: string) => void; age: string; onAge: (v: string) => void; hint: string;
}) {
const { c } = useCrm();
return (
<div>
<Toggle checked={on} onChange={onToggle} label={label} />
{/* Количество и возраст появляются, только когда дети или животные отмечены. */}
{on && (
<div className="mt-3 grid grid-cols-[6rem_1fr] gap-3 pl-[3.25rem]">
<div>
<label className="label" htmlFor={`${id}-n`}>{c.hh.count}</label>
<input id={`${id}-n`} type="number" min={1} max={20} className="field" value={cnt} onChange={(e) => onCount(e.target.value)} />
</div>
<div>
<label className="label" htmlFor={`${id}-a`}>{c.hh.age}</label>
<input id={`${id}-a`} className="field" maxLength={100} placeholder={hint} value={age} onChange={(e) => onAge(e.target.value)} />
</div>
</div>
)}
</div>
);
}
export function HouseholdFields({ value, onChange }: { value: HouseholdForm; onChange: (v: HouseholdForm) => void }) {
const { c } = useCrm();
const h = c.hh;
const set = (patch: Partial<HouseholdForm>) => onChange({ ...value, ...patch });
return (
<div className="space-y-4">
<div role="group" aria-label={h.title} className="flex flex-wrap gap-2">
{HOUSEHOLD_WHO.map((w) => (
<button
key={w}
type="button"
aria-pressed={value.who === w}
onClick={() => set({ who: value.who === w ? '' : w })}
className={`min-h-10 cursor-pointer rounded-lg border px-3 text-sm transition-colors ${
value.who === w ? 'border-capture/60 bg-capture/15 text-text' : 'border-line-strong text-muted hover:text-text'
}`}
>
{h.who[w]}
</button>
))}
</div>
<Group id="hh-ch" on={value.children} onToggle={(v) => set({ children: v })} label={h.children}
cnt={value.childrenCount} onCount={(v) => set({ childrenCount: v })} age={value.childrenAge} onAge={(v) => set({ childrenAge: v })} hint={h.ageHintChildren} />
<Group id="hh-pt" on={value.pets} onToggle={(v) => set({ pets: v })} label={h.pets}
cnt={value.petsCount} onCount={(v) => set({ petsCount: v })} age={value.petsAge} onAge={(v) => set({ petsAge: v })} hint={h.ageHintPets} />
</div>
);
}
/** «Кто будет жить» одной строкой — для карточки клиента. */
export function useHouseholdLine() {
const { c } = useCrm();
return (h: Household | null | undefined) =>
[
h?.who ? c.hh.who[h.who] : null,
h?.children ? c.hh.childrenLine(h.children.count, h.children.age) : null,
h?.pets ? c.hh.petsLine(h.pets.count, h.pets.age) : null,
]
.filter(Boolean)
.join(' · ');
}
const MAX_PHOTO = 5 * 1024 * 1024;
const PHOTO_TYPES = ['image/jpeg', 'image/png', 'image/webp', 'image/heic', 'image/heif'];
/** Фото к карточке клиента: жильцы, животные, документы. */
export function ClientPhotos({ clientId, photos, canEdit }: { clientId: number; photos: { id: number }[]; canEdit: boolean }) {
const { c } = useCrm();
const h = c.hh;
const picker = useRef<HTMLInputElement>(null);
const upload = useAction(async (files: File[]) => {
for (const file of files) {
if (!PHOTO_TYPES.includes(file.type)) throw new ApiError(400, 'PHOTO_TYPE');
if (file.size > MAX_PHOTO) throw new ApiError(400, 'FILE_TOO_LARGE');
const res = await fetch(`/api/clients/${clientId}/photos?type=${encodeURIComponent(file.type)}`, {
method: 'POST',
credentials: 'same-origin',
headers: { 'content-type': 'application/octet-stream' },
body: file,
});
if (!res.ok) throw new ApiError(res.status, ((await res.json().catch(() => ({}))) as { code?: string }).code ?? 'INTERNAL');
}
}, ['client', clientId]);
const remove = useAction(
(photoId: number) => fetch(`/api/clients/${clientId}/photos/${photoId}`, { method: 'DELETE', credentials: 'same-origin' }),
['client', clientId],
);
if (!photos.length && !canEdit) return null;
return (
<div>
<h3 className="label">{h.photos}</h3>
<ul className="flex flex-wrap gap-2">
{photos.map((p) => {
const src = `/api/clients/${clientId}/photos/${p.id}`;
return (
<li key={p.id} className="relative">
<a href={src} target="_blank" rel="noopener noreferrer">
<img src={src} alt={h.photos} loading="lazy" className="size-24 rounded-xl object-cover" />
</a>
{canEdit && (
<button
type="button"
aria-label={h.removePhoto}
title={h.removePhoto}
disabled={remove.isPending}
onClick={() => confirm(h.confirmRemove) && remove.mutate(p.id)}
className="absolute right-1 top-1 flex size-7 cursor-pointer items-center justify-center rounded-lg bg-ink/80 text-text hover:bg-ink"
>
<X size={14} />
</button>
)}
</li>
);
})}
{canEdit && photos.length < 10 && (
<li>
<input
ref={picker}
type="file"
accept="image/*"
multiple
className="sr-only"
tabIndex={-1}
aria-hidden="true"
onChange={(e) => { const files = [...(e.target.files ?? [])]; e.target.value = ''; if (files.length) upload.mutate(files); }}
/>
<button
type="button"
disabled={upload.isPending}
onClick={() => picker.current?.click()}
className="flex size-24 cursor-pointer flex-col items-center justify-center gap-1 rounded-xl border border-dashed border-line-strong text-xs text-muted hover:text-text"
>
<ImagePlus size={20} aria-hidden="true" />
{h.addPhoto}
</button>
</li>
)}
</ul>
{canEdit && <p className="mt-2 text-xs text-muted">{h.photosHint}</p>}
<div className="mt-2"><ErrorNote error={upload.error ?? remove.error} /></div>
</div>
);
}
const ALLOW_TONE = { yes: 'bg-capture/15 text-capture', no: 'bg-danger/15 text-danger', negotiable: 'bg-warn/15 text-warn' } as const;
/** Характеристики объекта из объявления: правила проживания, поля и удобства. Подписи — как на List.am. */
export function Features({ features }: { features: ListingFeatures | null }) {
const { c } = useCrm();
const h = c.hh;
if (!features) return null;
const rules = (['children', 'pets'] as const).filter((k) => features[k]);
// Правила уже показаны отметками сверху — в общем списке их не повторяем.
const attrs = features.attrs.filter((a) => !/^Можно с (детьми|животными)$/.test(a.label));
if (!rules.length && !attrs.length && !features.amenities.length) return null;
return (
<section>
<h3 className="label">{h.about}</h3>
{rules.length > 0 && (
<p className="mb-3 flex flex-wrap gap-2">
{rules.map((k) => (
<span key={k} className={`chip ${ALLOW_TONE[features[k]!]}`}>{h.rules[k]}: {h.allow[features[k]!]}</span>
))}
</p>
)}
{attrs.length > 0 && (
<dl className="grid grid-cols-2 gap-x-4 gap-y-2 text-sm">
{attrs.map((a) => (
<div key={a.label} className="min-w-0">
<dt className="truncate text-xs text-muted">{a.label}</dt>
<dd className="break-words">{a.value}</dd>
</div>
))}
</dl>
)}
{features.amenities.map((g) => (
<div key={g.group} className="mt-3">
<p className="mb-1.5 text-xs text-muted">{g.group || h.amenities}</p>
<ul className="flex flex-wrap gap-1.5">
{g.items.map((item) => (
<li key={item} className="rounded-md bg-raised px-2 py-1 text-xs">{item}</li>
))}
</ul>
</div>
))}
</section>
);
}
+726
View File
@@ -0,0 +1,726 @@
import { useLang, type Lang } from '../i18n.tsx';
const ru = {
tabs: { pool: 'Пул', mine: 'Мои', all: 'Все' },
view: { table: 'Таблица', kanban: 'Канбан' },
search: 'Телефон, адрес, ID объявления',
add: 'Добавить объект',
take: 'Беру',
bumped: 'Поднято на List.am',
taken: 'Взят',
empty: { pool: 'В пуле пусто. Новые объекты появятся здесь, как только сработает фильтр.', mine: 'У вас пока нет объектов в работе.', all: 'Объектов пока нет.' },
noFilters: 'Чтобы объекты начали приходить, создайте первый фильтр.',
toFilters: 'Создать фильтр',
col: { object: 'Объект', price: 'Цена', place: 'Район', status: 'Статус', agent: 'Ответственный', created: 'Создан' },
f: { status: 'Статус', agent: 'Агент', district: 'Район', priceFrom: 'Цена от', priceTo: 'до', from: 'Создан с', to: 'по', any: 'Любой', reset: 'Сбросить', more: 'Фильтры' },
pool: 'в пуле',
card: {
onListAm: 'Открыть на List.am', contact: 'Контакт', call: 'Позвонить', phoneHidden: 'Телефон откроется после «Беру»',
noPhone: 'Телефон — в объявлении на List.am', assignee: 'Ответственный', status: 'Статус', nextAction: 'Следующее действие',
nextNote: 'Что сделать', remind: 'Напомнить в боте', clear: 'Убрать', comment: 'Комментарий', addComment: 'Добавить',
history: 'История', isAgent: 'Это агент', toPool: 'Вернуть в пул', manual: 'Вручную', agency: 'Агентство',
clients: 'Клиенты, которым предложен объект', attach: 'Прикрепить к клиенту…',
previous: 'Уже был в работе', confirmAgent: 'Отметить автора как агента? Объект уйдёт в «Отказ».',
confirmPool: 'Вернуть объект в пул? Его сможет взять любой агент.',
gone: 'Объявления больше нет', confirmGone: 'Отметить, что объявление снято с List.am? Отметка появится у всех, кто ведёт этот объект.',
editTitle: 'Данные объекта',
},
ev: {
created: 'Объект создан', createdListam: 'Пришёл с List.am', createdManual: 'Создан вручную', taken: 'Взял в работу',
assigned: (a: string | null, b: string | null) => (a ? `Переназначен: ${a} → ${b}` : `Назначен: ${b}`),
status: (a: string | null, b: string | null) => `Статус: ${a ?? '—'} → ${b}`,
deal: (text: string | null) => `Сделка${text ? `, комиссия ${text}` : ''}`,
poolReturn: (who: string | null) => (who ? `Возврат в пул (был у: ${who})` : 'Возврат в пул'),
clientCreated: 'Клиент создан', edited: 'Данные изменены', dealEdited: (text: string | null) => `Сделка исправлена${text ? `: комиссия ${text}` : ''}`,
offered: (v: string | null) => `Предложен объект: ${v ?? '—'}`, shown: (v: string | null) => `Показан объект: ${v ?? '—'}`,
unlinked: (v: string | null) => `Объект откреплён${v ? `: ${v}` : ''}`,
clientOffered: (v: string | null) => `Предложен клиенту: ${v ?? '—'}`, clientShown: (v: string | null) => `Показан клиенту: ${v ?? '—'}`,
blacklisted: 'Отмечен как агент', system: 'Система', listingRemoved: 'Отмечено: объявления больше нет',
},
dlg: {
reject: 'Причина отказа', deal: 'Закрытие сделки', commission: 'Комиссия агентства', currency: 'Валюта', date: 'Дата сделки',
ownerPart: 'С собственника', clientPart: 'С клиента', splitHint: 'Часть с собственника получает агент объекта, часть с клиента — агент клиента. Если стороны нет — поставьте 0.',
edit: 'Изменить', dealEdit: 'Правка сделки', pairClient: 'Сделка с клиентом из CRM', pairLead: 'Сделка с объектом из CRM',
noPair: 'Без пары — вторая сторона вне CRM', pairHint: 'Сделка закроется один раз: оба перейдут в «Сделка», комиссия не задвоится.',
pairEmptyClient: 'Чтобы закрыть сделку вместе с клиентом, сначала прикрепите объект к клиенту.',
pairEmptyLead: 'Чтобы закрыть сделку вместе с объектом, сначала прикрепите к клиенту объект, взятый в работу.',
title: 'Название', description: 'Описание', address: 'Адрес', price: 'Цена', rooms: 'Комнат', area: 'Площадь, м²',
floor: 'Этаж', floors: 'Этажей', author: 'Имя собственника', phone: 'Телефон', link: 'Ссылка на объявление',
},
deal: { sale: 'Продажа', rent: 'Аренда' },
ptype: { apartment: 'Квартира', house: 'Дом', commercial: 'Коммерческая', land: 'Участок', garage: 'Гараж' },
unit: { rooms: 'комн.', floor: 'этаж', perMonth: '/мес' },
filters: {
title: 'Фильтры', sub: 'Объявление становится лидом, если подошло под общий фильтр компании или под личный фильтр агента.',
company: 'Общие фильтры компании', personal: 'Мои личные фильтры', others: 'Личные фильтры агентов', add: 'Новый фильтр',
none: 'Фильтров нет.', name: 'Название', scope: 'Для кого', scopeCompany: 'Вся компания', scopePersonal: 'Только я',
dealType: 'Тип сделки', types: 'Тип объекта', region: 'Регион', allArmenia: 'Вся Армения', districts: 'Районы',
localities: 'Населённые пункты, через запятую', price: 'Цена', area: 'Площадь, м²', rooms: 'Комнат',
author: 'Автор объявления', ownersOnly: 'Только собственники', everyone: 'Все объявления', enabled: 'Включён',
any: 'любой', anyType: 'любой тип', from: 'от', to: 'до', confirmDelete: 'Удалить фильтр?', fromBase: (n: number) => `Из базы за последние 3 дня в пул добавлено объектов: ${n}`,
},
cl: {
sub: 'Покупатели и арендаторы. Клиент закреплён за агентом; переназначает руководитель.',
add: 'Добавить клиента', one: 'Клиент', name: 'Имя', kindLabel: 'Тип клиента', kind: { buyer: 'Покупатель', tenant: 'Арендатор' },
anyKind: 'Все типы', source: 'Источник', sourceHint: 'Instagram, рекомендация…', me: 'Я', request: 'Запрос', budget: 'Бюджет',
noRequest: 'Запрос не заполнен. Заполните его — и здесь появятся подходящие объекты.', search: 'Имя или телефон',
empty: 'Клиентов пока нет. Добавьте первого покупателя или арендатора.', notFound: 'Ничего не найдено.',
objectsCol: 'Объекты', objects: (n: number) => `объектов: ${n}`, noObjects: 'Объекты пока не прикреплены.',
suggested: 'Подходят под запрос', attach: 'Прикрепить', detach: 'Открепить', state: { offered: 'Предложен', shown: 'Показан' },
phoneHidden: 'Телефон видит ответственный агент', noPhone: 'Телефон не указан', dealLead: 'Объект',
},
ar: {
sub: 'Объекты и клиенты, закрытые сделкой или отказом, и объекты, снятые с List.am. Попадают сюда автоматически.',
tabs: { leads: 'Объекты', contacts: 'Клиенты' }, resultLabel: 'Итог', anyResult: 'Любой итог', result: { deal: 'Сделка', rejected: 'Отказ' },
closed: 'Закрыт', contact: 'Клиент', objects: 'Объекты', empty: 'В базе пока пусто: сюда попадут объекты и клиенты после сделки или отказа.',
removed: (d: string) => `Снято с List.am ${d}`,
},
an: {
pipeline: { object: 'Объекты', client: 'Клиенты' }, period: { today: 'Сегодня', week: 'Неделя', month: 'Месяц', custom: 'Период' },
came: 'Пришло лидов', takenTotal: 'Взято в работу', inPool: 'Сейчас в пуле', inWork: 'Сейчас в работе', deals: 'Сделки',
funnel: 'Воронка целиком', funnelHintObject: 'Лиды, пришедшие за период: сколько из них дошли до каждого этапа.',
funnelHintClient: 'Клиенты, созданные за период: сколько из них дошли до каждого этапа.', noData: 'За этот период данных нет.',
byAgent: 'По сотрудникам', tableHintObject: 'Проценты — доля лидов, взятых агентом за период, которые дошли до этапа при нём.',
tableHintClient: 'Проценты — доля клиентов агента, созданных за период, которые дошли до этапа.',
agent: 'Агент', taken: 'Взято лидов', newRequests: 'Новые запросы', reaction: 'Скорость реакции', poolReturns: 'Возвраты в пул', showings: 'Показы',
commission: 'Сумма комиссий', total: 'Итого', reasons: 'Причины отказа', reason: 'Причина', noRejects: 'За период отказов нет.',
sec: 'с', min: 'мин', hour: 'ч',
},
ls: {
sub: 'Общая база сервиса: всё, что парсер собрал с List.am. Отсюда объявления расходятся клиентам по фильтрам.',
search: 'Номер объявления, название, адрес, телефон', total: (n: number) => `Найдено: ${n}`, anyDeal: 'Любая сделка', anyRegion: 'Все регионы',
state: { any: 'На сайте и снятые', active: 'На сайте', removed: 'Сняты' }, leads: { any: 'С лидами и без', yes: 'Есть лиды', no: 'Без лидов' },
author: { any: 'Любой автор', owner: 'Собственник', agency: 'Агентство' },
col: { listing: 'Объявление', price: 'Цена', place: 'Район', published: 'Размещено', found: 'Найдено', leads: 'Лиды' },
empty: 'Объявлений нет.', prev: 'Назад', next: 'Дальше', page: (a: number, b: number) => `Страница ${a} из ${b}`,
number: 'Номер', sentTo: 'Кому ушло лидом', noLeads: 'Ни под один фильтр не подошло.', pool: 'в пуле',
},
ps: {
title: 'Настройки парсера', hint: 'Применяются со следующего прохода, перезапуск не нужен.',
enabled: 'Парсер включён: сам обходит List.am по кругу', freshDays: 'Свежесть, дней', freshHint: 'Для блока «Топ» и первого прохода: объявления, размещённые не раньше. Поднятые в обычном списке идут в базу всегда',
pauseSec: 'Пауза между проходами, секунд', maxPages: 'Страниц категории за проход', pagesHint: 'Читаются, пока на них есть незнакомые объявления',
categories: 'Категории List.am', keyTitle: 'Ключ ридера Jina',
keyHint: 'С ключом проход быстрее и известно точное время размещения, но ключ платный. Без ключа парсер работает медленнее. Если баланс ключа кончился, парсер сам переходит на работу без ключа.',
useKey: 'Использовать ключ', key: 'Текущий ключ', keyNone: 'не задан', keyEnv: 'из настроек сервера', keySet: 'задан здесь',
keyNew: 'Вставьте новый ключ, чтобы заменить', keyClear: 'Убрать ключ', keyConfirm: 'Убрать ключ, заданный в админке?',
removalTitle: 'Отметка «Снято с List.am»', removalCheck: 'Проверять, не сняты ли объявления, по которым есть лиды',
recheckHours: 'Перепроверять объявление раз в, часов', removalBatch: 'Объявлений за заход (раз в 10 минут)', batchHint: (n: number) => `До ${n} проверок в сутки`,
},
pl: {
title: { object: 'Воронка объекта', client: 'Воронка клиента' },
hint: 'Названия сохраняются сразу. Статусы с замком системные: их можно переименовать, но нельзя удалить или передвинуть.',
statuses: 'Статусы', reasons: 'Причины отказа', statusName: 'Название статуса', reasonName: 'Причина отказа',
newStatus: 'Новый статус', newReason: 'Новая причина', add: 'Добавить', up: 'Выше', down: 'Ниже',
pool: 'возврат в пул', poolHint: 'Взятый объект в этом статусе вернётся в пул, если статус долго не меняется',
system: 'Системный статус', systemReason: 'Системная причина: её ставит кнопка «Это агент»',
confirmStatus: (n: string) => `Удалить статус «${n}»?`, confirmReason: (n: string) => `Удалить причину «${n}»?`,
removeTitle: (n: string) => `Удалить статус «${n}»`, removeHint: (n: number) => `В этом статусе карточек: ${n}. Выберите, куда их перевести.`,
moveTo: 'Перевести в статус…',
},
pay: {
title: 'Условия оплаты', notSet: 'Не заданы', modes: { fixed: 'Оклад', percent: 'Процент с его сделок', mixed: 'Оклад + процент с его сделок' },
salary: (v: string) => `оклад ${v}`, share: (v: number) => `${v}% со сделок`, shareSplit: (sale: number, rent: number) => `${sale}% с продажи, ${rent}% с аренды`,
fixedLabel: 'Оклад в месяц', percentLabel: 'Процент с продажи', percentRentLabel: 'Процент с аренды', payoutCurrency: 'Валюта выплаты',
hint: 'Процент считается от части комиссии, которая приходится на агента: с собственника — агенту объекта, с клиента — агенту клиента.', edit: 'Условия оплаты',
payroll: 'Кому сколько платить', payrollHint: 'Оклад за месяц и процент от части комиссии агента по сделкам этого месяца. Нажмите на сотрудника, чтобы увидеть его сделки.', month: 'Месяц',
col: { agent: 'Сотрудник', terms: 'Условия', deals: 'Сделок', commission: 'Его часть комиссии', share: 'Процент', fixed: 'Оклад', total: 'К выплате', sum: 'Всего к выплате' },
mine: 'Моя оплата', thisMonth: 'Начислено за месяц',
dealsOf: (name: string) => `Сделки: ${name}`, myDeals: 'Мои сделки за месяц', noDeals: 'Сделок за этот месяц нет.',
dcol: { date: 'Дата', deal: 'Объект и клиент', kind: 'Вид', side: 'Сторона', part: 'Часть комиссии', percent: 'Процент', accrued: 'Начислено' },
side: { owner: 'объект', client: 'клиент', both: 'объект и клиент' }, withPartner: (name: string) => `вторая сторона: ${name}`, ofDeal: (v: string) => `из ${v}`,
planTitle: 'План на месяц', planDeals: 'Сделок', planCommission: 'Комиссия агента', planHint: 'Пустое поле — показатель не задан. План выполнен, когда достигнуты все заданные показатели.',
bonusTitle: 'Бонус за выполненный план', bonusModes: { none: 'Нет', fixed: 'Сумма', percent: 'Надбавка к проценту' }, bonusValue: 'Размер', bonusHint: 'Надбавка считается от всей комиссии агента за месяц.',
planOf: (done: string, plan: string) => `${done} из ${plan}`, planDone: 'План выполнен', planNot: 'План не выполнен',
other: 'Бонус и прочее', status: 'Выплата', paid: (d: string) => `Выплачено ${d}`, notPaid: 'Не выплачено',
lines: 'Начисления', lineFixed: 'Оклад', lineShare: 'Процент со сделок', lineBonus: 'Бонус за план', lineRecalc: (m: string) => `Перерасчёт за ${m}`, lineTotal: 'Итого к выплате',
addLine: 'Добавить запись', lineAmount: 'Сумма (с минусом — удержание)', lineNote: 'За что', lineNotePh: 'Премия, штраф, аванс…', add: 'Добавить', remove: 'Удалить запись',
payTitle: 'Отметить выплату', payAmount: 'Выплачено', payDate: 'Дата выплаты', payNote: 'Комментарий', markPaid: 'Выплачено', cancelPaid: 'Отменить отметку',
paidInfo: (v: string, d: string) => `Выплачено ${v} · ${d}`, debt: (v: string) => `Остаток ${v} перейдёт в следующий месяц без выплаты.`, overpaid: (v: string) => `Переплата ${v} будет удержана в следующем месяце без выплаты.`,
closedHint: 'Месяц закрыт: считается по условиям и курсам на день выплаты. Изменения его сделок уходят перерасчётом в следующий месяц.',
history: 'История выплат', accruedOf: (v: string) => `начислено ${v}`,
trend: 'Динамика по месяцам', trendHint: 'Сделки и часть комиссии каждого сотрудника. Комиссия — в долларах по текущему курсу.', trendMine: 'Моя динамика',
metricDeals: 'Сделки', metricCommission: 'Комиссия', company: 'Вся компания', vsPrev: (d: string) => `${d} к прошлому месяцу`, same: 'как в прошлом месяце',
asTable: 'Таблицей', asChart: 'Графиком', metric: 'Показатель',
rating: 'Рейтинг месяца', ratingHint: 'Место — по числу сделок; при равенстве выше тот, у кого комиссия больше. Суммы в рейтинге не показываются.',
place: 'Место', plan: 'План', noPlan: 'нет плана', you: 'вы',
export: 'Скачать для Excel', paidCol: 'Выплачено', currency: 'Валюта', client: 'Клиент', object: 'Объект',
reportsTitle: 'Отчёты в Telegram', reportsHint: 'Приходят руководителю в бот Lead Radar в 9 утра по Еревану.', reportWeekly: 'Итоги недели — по понедельникам', reportMonthly: 'Итоги месяца с ведомостью — первого числа',
},
cm: {
title: 'Комиссия агентства', mode: 'условие', none: 'Не задано', percent: 'Процент', fixed: 'Фикс', mixed: 'Фикс + процент', amount: 'сумма', notSet: 'Не задана',
ofPrice: (v: number) => `${v}% от цены`, ofRent: (v: number) => `${v}% от месячной платы`,
fromSettings: 'как в настройках компании', custom: 'изменено в этой карточке', reset: 'Как в настройках', forThis: 'Условие для этой карточки',
hintLead: 'Условие не задано в настройках — укажите своё.', hintClient: 'Условие не задано в настройках — укажите своё. Процент считается от бюджета клиента.',
settings: 'Комиссия агентства', settingsHint: 'Условия по умолчанию. Процент считается от цены объекта, при аренде — от месячной платы; у клиента — от его бюджета. Агент может поменять условие в карточке.',
objectSale: 'Объекты: продажа (с собственника)', objectRent: 'Объекты: аренда (с собственника)', clientBuyer: 'Клиенты: покупатель', clientTenant: 'Клиенты: арендатор',
prefill: 'Сумма подставлена по условию комиссии — поправьте, если договорились иначе.',
evChanged: (from: string, to: string) => `Комиссия: ${from} → ${to}`, asSettings: 'как в настройках', points: (v: string) => `${v} п.п.`, sumDelta: (v: string) => `сумма ${v}%`,
},
sh: {
page: 'Страница для клиента', pageHint: 'Клиент открывает её без входа: видит прикреплённые объекты и записывается на показ. Адрес и ссылку на List.am страница не показывает.',
create: 'Создать страницу', createMatch: 'Подобрать объекты и создать', added: (n: number) => (n ? `Прикреплено подходящих объектов: ${n}.` : 'Подходящих объектов сейчас нет — страница создана с уже прикреплёнными.'),
close: 'Закрыть страницу', confirmClose: 'Закрыть страницу? Ссылка у клиента перестанет открываться.', open: 'Открыть',
viewings: 'Записи на показ', viewingLine: (day: string, title: string) => `${day} — ${title}`,
intake: 'Ссылка на анкету', intakeTitle: 'Анкета для клиента',
intakeText: 'Отправьте ссылку клиенту или разместите в объявлении. Он ответит на вопросы в Telegram — и появится здесь как ваш клиент. Вам в бот придёт анкета с вопросом, показать ли ему подходящие объекты.',
intakeOff: 'Анкета пока не подключена: клиентский бот не настроен.',
evViewing: (title: string | null, day: string | null) => `Клиент записался на показ ${day ?? ''}: ${title ?? '—'}`,
evClientViewing: (name: string | null, day: string | null) => `Запись на показ ${day ?? ''}: ${name ?? '—'}`,
ownerPhone: 'Телефон собственника', ownerPhoneHint: 'Вставьте номер из объявления на List.am — он сохранится в карточке.', savePhone: 'Сохранить номер', changePhone: 'Изменить номер',
clientText: 'Описание для клиента', copyText: 'Копировать текст', original: 'Объявление на List.am: исходный текст и характеристики', photosLoading: 'Догружаем остальные фото…',
evPhone: (v: string | null) => (v ? `Внесён телефон собственника: ${v}` : 'Телефон собственника убран'),
variants: 'Номера из общей базы', variantsHint: 'Их внесли агенты, работавшие с этим объявлением.', useVariant: 'Взять этот номер',
confidence: (pct: number, votes: number) => `точность ${pct}% · совпал у ${votes}`, unconfirmed: 'не подтверждён: указал один агент',
slug: 'Адрес страниц для клиентов', slugHint: 'Латинские буквы, цифры и дефис. Если поменять, старые ссылки клиентов перестанут открываться.',
},
hh: {
title: 'Кто будет жить', who: { alone: 'Один', friend: 'С другом', couple: 'С партнёром (пара)' }, notSet: 'Не указано',
children: 'Дети', pets: 'Животные', count: 'Сколько', age: 'Возраст', ageHintChildren: '3 и 7 лет', ageHintPets: 'кот, 2 года',
childrenLine: (n: number, age: string) => `детей: ${n}${age ? ` (${age})` : ''}`, petsLine: (n: number, age: string) => `животных: ${n}${age ? ` (${age})` : ''}`,
photos: 'Фото', addPhoto: 'Добавить фото', removePhoto: 'Удалить фото', photosHint: 'Жильцы, животные, документы. До 10 фото, каждое до 5 МБ.',
confirmRemove: 'Удалить фото?', empty: 'Не заполнено.',
about: 'Об объекте', amenities: 'Удобства', rules: { children: 'С детьми', pets: 'С животными' },
allow: { yes: 'можно', no: 'нельзя', negotiable: 'по договорённости' },
},
x: {
byUrl: 'По ссылке', byUrlTitle: 'Объект по ссылке на List.am', byUrlLabel: 'Ссылка на объявление',
byUrlHint: 'Если объект уже есть в CRM и свободен, он закрепится за вами. Если его нет, скачаем с List.am и заведём на вас.',
byUrlGo: 'Проверить и закрепить', byUrlLoading: 'Ищем объект, при необходимости скачиваем с List.am — это может занять до минуты…',
taken: 'Объект был свободен — теперь он закреплён за вами.', created: 'Объекта не было в CRM — скачали с List.am и закрепили за вами.',
mine: 'Этот объект уже закреплён за вами.', busy: (name: string | null) => (name ? `Объект уже закреплён за агентом ${name}.` : 'Объект уже в работе у другого агента.'),
open: 'Открыть карточку', districts: 'Районы',
sendTg: 'Отправить в Telegram', sendingTg: 'Отправляем…', sentTg: 'Отправлено в группу', botNotSet: 'Бот не настроен',
del: 'Удалить карточку', delConfirm: 'Удалить карточку вместе с историей? Отменить это нельзя.', delDeal: 'У карточки есть сделка: удалить её может только суперадмин — сделка пропадёт из аналитики и ведомости.',
phrase: { broker: 'В объявлении похоже на посредника', noAgents: 'Собственник пишет, что не работает с агентами' },
telegram: 'Telegram', telegramPh: '@ник или ссылка t.me', rentTerm: 'Срок аренды', term: { short: 'До 6 месяцев', mid: '6–12 месяцев', long: 'От года' },
},
tg: { link: 'Привязать Telegram', open: 'Откройте бота и нажмите «Start»:', hint: 'После привязки сюда начнут приходить новые объекты.' },
parser: {
title: 'Парсер', off: 'Источник объявлений не настроен.', lastOk: 'Последний успешный проход', day: 'Добавлено в базу за сутки',
total: 'Всего в базе', cycle: 'Полный круг парсера', delay: 'Задержка «размещено → лид», медиана за сутки', delayN: (n: number) => `по ${n} лидам`, day3: 'В базе: свежие, не старше 3 дней', run: 'Запустить сейчас', runs: 'Последние запуски', started: 'Начало', took: 'Длительность',
found: 'Новых', status: 'Статус', errors: 'Ошибки', ok: 'успешно', failed: 'сбой', running: 'идёт', sec: 'с',
},
dp: {
favorite: 'Избранное', unseen: 'Не просмотренные', addFav: 'В избранное', removeFav: 'Убрать из избранного', unseenMark: 'Не просмотрен',
currency: 'Валюта', cur: { AMD: 'АМД', USD: 'ДОЛ' } as Record<string, string>, priceHint: 'Цены в другой валюте пересчитываются по курсу',
sendBot: 'Отправить в бот', sendingBot: 'Отправляем…', sentBot: 'Отправлено вам в бот',
linkTitle: 'Telegram не привязан к боту',
linkText: 'Объект приходит в бот тому, кто нажал кнопку, — в Telegram, привязанный к вашему аккаунту. Нажмите «Привязать Telegram», откройте бота, нажмите «Start» и отправьте объект ещё раз. Привязка есть и в «Настройках».',
blockedText: 'Бот не может вам написать: откройте бота в Telegram, нажмите «Start» (или разблокируйте его) и попробуйте ещё раз.',
linkBtn: 'Привязать Telegram', openBot: 'Открыть бота',
departments: 'Отделы', department: 'Отдел', noDepartment: 'Без отдела', allDepartments: 'Все отделы', add: 'Новый отдел', namePh: 'Название отдела',
rename: 'Переименовать', del: 'Удалить отдел', delConfirm: 'Удалить отдел? Его фильтры и объекты из пула, которые никто не брал, удалятся.',
hint: 'У каждого отдела свои фильтры, объекты и клиенты. Сотрудник видит только данные своего отдела, руководитель — все отделы. При смене отдела объекты и клиенты, которые ведёт сотрудник, переходят вместе с ним.',
stats: (m: number, f: number, l: number, c: number) => `сотрудников: ${m} · фильтров: ${f} · объектов: ${l} · клиентов: ${c}`,
kept: (n: number) => `Объектов осталось в прежнем отделе: ${n} — в новом уже есть карточки тех же объявлений.`,
none: 'Отделов пока нет — вся компания работает с общими объектами и клиентами.',
scope: 'Отдел', filtersOf: (name: string) => `Фильтры отдела «${name}»`, mine: (name: string) => `Ваш отдел: ${name}`,
removed: 'Сняты с List.am',
},
errors: {
ALREADY_TAKEN: 'Этот объект уже взял другой агент', LEAD_NOT_FOUND: 'Объект не найден', NOT_YOUR_LEAD: 'Объект ведёт другой агент',
TAKE_FIRST: 'Сначала возьмите объект в работу', REJECT_REASON_REQUIRED: 'Укажите причину отказа',
COMMISSION_REQUIRED: 'Укажите сумму комиссии', USE_RETURN_TO_POOL: 'Чтобы вернуть объект в пул, используйте кнопку «Вернуть в пул»',
MODULE_NOT_AVAILABLE: 'Раздел доступен в тарифах Team и Agency',
CLIENT_NOT_FOUND: 'Клиент не найден', NOT_YOUR_CLIENT: 'Клиента ведёт другой агент', BAD_ASSIGNEE: 'Такого участника нет в компании',
PAIR_NOT_LINKED: 'Сначала прикрепите объект к клиенту', DEAL_HAS_PAIR: 'По этой сделке уже указана другая пара. Сначала отмените ту сделку',
BAD_STATUS: 'Такого статуса нет в воронке', SYSTEM_STATUS: 'Системный статус удалить нельзя', STATUS_IN_USE: 'В статусе есть карточки: выберите, куда их перевести',
SYSTEM_REASON: 'Системную причину удалить нельзя', LAST_REASON: 'Нужна хотя бы одна причина отказа',
DEAL_CLOSED: 'Сделка закрыта: статус меняет руководитель', TELEGRAM_DISABLED: 'Бот не настроен', PARSER_DISABLED: 'Парсер не настроен', BAD_LISTAM_URL: 'Это не ссылка на объявление List.am', LISTAM_NOT_FOUND: 'Такого объявления на List.am нет — возможно, его сняли', LISTAM_UNAVAILABLE: 'Не удалось прочитать объявление с List.am, попробуйте ещё раз', HAS_DEAL: 'У карточки есть сделка: удалить её может только суперадмин', GROUP_BOT_NOT_SET: 'Бот не настроен', GROUP_SEND_FAILED: 'Не удалось отправить в Telegram, попробуйте ещё раз', TELEGRAM_NOT_LINKED: 'Telegram не привязан к боту', BOT_CHAT_UNAVAILABLE: 'Бот не может вам написать: откройте бота и нажмите «Start»', BOT_SEND_FAILED: 'Не удалось отправить в бот, попробуйте ещё раз', DEPARTMENT_NOT_EMPTY: 'В отделе есть сотрудники, клиенты или объекты в работе — сначала переведите их', BAD_DEPARTMENT: 'Такого отдела нет', DEPARTMENT_HAS_LEAD: 'В отделе этого сотрудника уже есть карточка по этому объявлению',
MONTH_PAID: 'Месяц уже закрыт выплатой. Чтобы изменить его, отмените отметку', PAYOUT_NOT_LAST: 'Отменить можно только последнюю выплату сотрудника',
} as Record<string, string>,
};
type Dict = typeof ru;
const hy: Dict = {
tabs: { pool: 'Ընդհանուր', mine: 'Իմը', all: 'Բոլորը' },
view: { table: 'Աղյուսակ', kanban: 'Կանբան' },
search: 'Հեռախոս, հասցե, հայտարարության ID',
add: 'Ավելացնել օբյեկտ',
take: 'Վերցնում եմ',
bumped: 'Բարձրացվել է List.am-ում',
taken: 'Վերցված է',
empty: { pool: 'Ընդհանուր ցուցակը դատարկ է։ Նոր օբյեկտները կհայտնվեն այստեղ, երբ ֆիլտրը աշխատի։', mine: 'Դուք դեռ ընթացիկ օբյեկտներ չունեք։', all: 'Օբյեկտներ դեռ չկան։' },
noFilters: 'Որպեսզի օբյեկտները սկսեն գալ, ստեղծեք առաջին ֆիլտրը։',
toFilters: 'Ստեղծել ֆիլտր',
col: { object: 'Օբյեկտ', price: 'Գին', place: 'Շրջան', status: 'Կարգավիճակ', agent: 'Պատասխանատու', created: 'Ստեղծվել է' },
f: { status: 'Կարգավիճակ', agent: 'Գործակալ', district: 'Շրջան', priceFrom: 'Գինը՝ սկսած', priceTo: 'մինչև', from: 'Ստեղծվել է՝ սկսած', to: 'մինչև', any: 'Ցանկացած', reset: 'Մաքրել', more: 'Ֆիլտրեր' },
pool: 'ընդհանուր ցուցակում',
card: {
onListAm: 'Բացել List.am-ում', contact: 'Կոնտակտ', call: 'Զանգահարել', phoneHidden: 'Հեռախոսը կբացվի «Վերցնում եմ»-ից հետո',
noPhone: 'Հեռախոսը՝ List.am-ի հայտարարության մեջ', assignee: 'Պատասխանատու', status: 'Կարգավիճակ', nextAction: 'Հաջորդ քայլը',
nextNote: 'Ինչ անել', remind: 'Հիշեցնել բոտում', clear: 'Հեռացնել', comment: 'Մեկնաբանություն', addComment: 'Ավելացնել',
history: 'Պատմություն', isAgent: 'Սա գործակալ է', toPool: 'Վերադարձնել ընդհանուր ցուցակ', manual: 'Ձեռքով', agency: 'Գործակալություն',
clients: 'Հաճախորդներ, որոնց առաջարկվել է օբյեկտը', attach: 'Կցել հաճախորդին…',
previous: 'Արդեն եղել է աշխատանքում', confirmAgent: 'Նշե՞լ հեղինակին որպես գործակալ։ Օբյեկտը կտեղափոխվի «Մերժում»։',
confirmPool: 'Վերադարձնե՞լ օբյեկտը ընդհանուր ցուցակ։ Այն կկարողանա վերցնել ցանկացած գործակալ։',
gone: 'Հայտարարությունն այլևս չկա', confirmGone: 'Նշե՞լ, որ հայտարարությունը հանվել է List.am-ից։ Նշումը կերևա բոլորին, ովքեր վարում են այս օբյեկտը։',
editTitle: 'Օբյեկտի տվյալներ',
},
ev: {
created: 'Օբյեկտը ստեղծվել է', createdListam: 'Եկել է List.am-ից', createdManual: 'Ստեղծվել է ձեռքով', taken: 'Վերցրել է աշխատանքի',
assigned: (a, b) => (a ? `Վերանշանակվել է՝ ${a} → ${b}` : `Նշանակվել է՝ ${b}`),
status: (a, b) => `Կարգավիճակ՝ ${a ?? '—'} → ${b}`,
deal: (text) => `Գործարք${text ? `, միջնորդավճար՝ ${text}` : ''}`,
poolReturn: (who) => (who ? `Վերադարձ ընդհանուր ցուցակ (եղել է՝ ${who})` : 'Վերադարձ ընդհանուր ցուցակ'),
clientCreated: 'Հաճախորդը ստեղծվել է', edited: 'Տվյալները փոխվել են', dealEdited: (text) => `Գործարքը ուղղվել է${text ? `. միջնորդավճար՝ ${text}` : ''}`,
offered: (v) => `Առաջարկվել է օբյեկտ՝ ${v ?? '—'}`, shown: (v) => `Ցուցադրվել է օբյեկտ՝ ${v ?? '—'}`,
unlinked: (v) => `Օբյեկտը անջատվել է${v ? `՝ ${v}` : ''}`,
clientOffered: (v) => `Առաջարկվել է հաճախորդին՝ ${v ?? '—'}`, clientShown: (v) => `Ցուցադրվել է հաճախորդին՝ ${v ?? '—'}`,
blacklisted: 'Նշվել է որպես գործակալ', system: 'Համակարգ', listingRemoved: 'Նշվել է. հայտարարությունն այլևս չկա',
},
dlg: {
reject: 'Մերժման պատճառ', deal: 'Գործարքի փակում', commission: 'Գործակալության միջնորդավճար', currency: 'Արժույթ', date: 'Գործարքի ամսաթիվ',
ownerPart: 'Սեփականատիրոջից', clientPart: 'Հաճախորդից', splitHint: 'Սեփականատիրոջից ստացված մասը ստանում է օբյեկտի գործակալը, հաճախորդից ստացվածը՝ հաճախորդի գործակալը։ Եթե կողմ չկա՝ նշեք 0։',
edit: 'Փոխել', dealEdit: 'Գործարքի ուղղում', pairClient: 'Գործարք CRM-ի հաճախորդի հետ', pairLead: 'Գործարք CRM-ի օբյեկտի հետ',
noPair: 'Առանց զույգի. երկրորդ կողմը CRM-ից դուրս է', pairHint: 'Գործարքը կփակվի մեկ անգամ. երկուսն էլ կանցնեն «Գործարք», միջնորդավճարը չի կրկնապատկվի։',
pairEmptyClient: 'Հաճախորդի հետ միասին փակելու համար նախ կցեք օբյեկտը հաճախորդին։',
pairEmptyLead: 'Օբյեկտի հետ միասին փակելու համար նախ հաճախորդին կցեք աշխատանքի վերցված օբյեկտ։',
title: 'Անվանում', description: 'Նկարագրություն', address: 'Հասցե', price: 'Գին', rooms: 'Սենյակներ', area: 'Մակերես, մ²',
floor: 'Հարկ', floors: 'Հարկայնություն', author: 'Սեփականատիրոջ անունը', phone: 'Հեռախոս', link: 'Հայտարարության հղում',
},
deal: { sale: 'Վաճառք', rent: 'Վարձակալություն' },
ptype: { apartment: 'Բնակարան', house: 'Տուն', commercial: 'Կոմերցիոն', land: 'Հողամաս', garage: 'Ավտոտնակ' },
unit: { rooms: 'սեն.', floor: 'հարկ', perMonth: '/ամիս' },
filters: {
title: 'Ֆիլտրեր', sub: 'Հայտարարությունը դառնում է լիդ, եթե համապատասխանում է ընկերության ընդհանուր ֆիլտրին կամ գործակալի անձնական ֆիլտրին։',
company: 'Ընկերության ընդհանուր ֆիլտրեր', personal: 'Իմ անձնական ֆիլտրերը', others: 'Գործակալների անձնական ֆիլտրեր', add: 'Նոր ֆիլտր',
none: 'Ֆիլտրեր չկան։', name: 'Անվանում', scope: 'Ում համար', scopeCompany: 'Ամբողջ ընկերությունը', scopePersonal: 'Միայն ես',
dealType: 'Գործարքի տեսակ', types: 'Օբյեկտի տեսակ', region: 'Մարզ', allArmenia: 'Ամբողջ Հայաստանը', districts: 'Շրջաններ',
localities: 'Բնակավայրեր՝ ստորակետով', price: 'Գին', area: 'Մակերես, մ²', rooms: 'Սենյակներ',
author: 'Հայտարարության հեղինակ', ownersOnly: 'Միայն սեփականատերեր', everyone: 'Բոլոր հայտարարությունները', enabled: 'Միացված է',
any: 'ցանկացած', anyType: 'ցանկացած տեսակ', from: 'սկսած', to: 'մինչև', confirmDelete: 'Ջնջե՞լ ֆիլտրը', fromBase: (n) => `Վերջին 3 օրվա բազայից ընդհանուր ցուցակ ավելացված օբյեկտներ՝ ${n}`,
},
cl: {
sub: 'Գնորդներ և վարձակալներ։ Հաճախորդը ամրագրված է գործակալին. վերանշանակում է ղեկավարը։',
add: 'Ավելացնել հաճախորդ', one: 'Հաճախորդ', name: 'Անուն', kindLabel: 'Հաճախորդի տեսակ', kind: { buyer: 'Գնորդ', tenant: 'Վարձակալ' },
anyKind: 'Բոլոր տեսակները', source: 'Աղբյուր', sourceHint: 'Instagram, խորհուրդ…', me: 'Ես', request: 'Հարցում', budget: 'Բյուջե',
noRequest: 'Հարցումը լրացված չէ։ Լրացրեք այն, և այստեղ կհայտնվեն համապատասխան օբյեկտները։', search: 'Անուն կամ հեռախոս',
empty: 'Հաճախորդներ դեռ չկան։ Ավելացրեք առաջին գնորդին կամ վարձակալին։', notFound: 'Ոչինչ չի գտնվել։',
objectsCol: 'Օբյեկտներ', objects: (n) => `օբյեկտներ՝ ${n}`, noObjects: 'Օբյեկտներ դեռ կցված չեն։',
suggested: 'Համապատասխանում են հարցմանը', attach: 'Կցել', detach: 'Անջատել', state: { offered: 'Առաջարկված', shown: 'Ցուցադրված' },
phoneHidden: 'Հեռախոսը տեսնում է պատասխանատու գործակալը', noPhone: 'Հեռախոսը նշված չէ', dealLead: 'Օբյեկտ',
},
ar: {
sub: 'Գործարքով կամ մերժումով փակված օբյեկտներ և հաճախորդներ, ինչպես նաև List.am-ից հանված օբյեկտներ։ Այստեղ են հայտնվում ինքնաշխատ։',
tabs: { leads: 'Օբյեկտներ', contacts: 'Հաճախորդներ' }, resultLabel: 'Արդյունք', anyResult: 'Ցանկացած արդյունք', result: { deal: 'Գործարք', rejected: 'Մերժում' },
closed: 'Փակվել է', contact: 'Հաճախորդ', objects: 'Օբյեկտներ', empty: 'Բազան դեռ դատարկ է. այստեղ կհայտնվեն օբյեկտներն ու հաճախորդները գործարքից կամ մերժումից հետո։',
removed: (d) => `Հանվել է List.am-ից ${d}`,
},
an: {
pipeline: { object: 'Օբյեկտներ', client: 'Հաճախորդներ' }, period: { today: 'Այսօր', week: 'Շաբաթ', month: 'Ամիս', custom: 'Ժամանակահատված' },
came: 'Եկած լիդեր', takenTotal: 'Վերցված աշխատանքի', inPool: 'Հիմա ընդհանուր ցուցակում', inWork: 'Հիմա աշխատանքում', deals: 'Գործարքներ',
funnel: 'Ամբողջ ձագարը', funnelHintObject: 'Ժամանակահատվածում եկած լիդերը. քանիսն են հասել յուրաքանչյուր փուլին։',
funnelHintClient: 'Ժամանակահատվածում ստեղծված հաճախորդները. քանիսն են հասել յուրաքանչյուր փուլին։', noData: 'Այս ժամանակահատվածի համար տվյալներ չկան։',
byAgent: 'Ըստ աշխատակիցների', tableHintObject: 'Տոկոսները՝ գործակալի վերցրած լիդերի մասը, որոնք նրա մոտ հասել են փուլին։',
tableHintClient: 'Տոկոսները՝ գործակալի՝ ժամանակահատվածում ստեղծված հաճախորդների մասը, որոնք հասել են փուլին։',
agent: 'Գործակալ', taken: 'Վերցված լիդեր', newRequests: 'Նոր հարցումներ', reaction: 'Արձագանքի արագություն', poolReturns: 'Վերադարձներ ընդհանուր ցուցակ', showings: 'Ցուցադրումներ',
commission: 'Միջնորդավճարների գումար', total: 'Ընդամենը', reasons: 'Մերժման պատճառներ', reason: 'Պատճառ', noRejects: 'Ժամանակահատվածում մերժումներ չկան։',
sec: 'վ', min: 'ր', hour: 'ժ',
},
ls: {
sub: 'Ծառայության ընդհանուր բազան՝ այն ամենը, ինչ պարսերը հավաքել է List.am-ից։ Այստեղից հայտարարությունները բաշխվում են հաճախորդներին ըստ ֆիլտրերի։',
search: 'Հայտարարության համար, անվանում, հասցե, հեռախոս', total: (n) => `Գտնվել է՝ ${n}`, anyDeal: 'Ցանկացած գործարք', anyRegion: 'Բոլոր մարզերը',
state: { any: 'Կայքում և հանված', active: 'Կայքում', removed: 'Հանված' }, leads: { any: 'Լիդերով և առանց', yes: 'Կան լիդեր', no: 'Առանց լիդերի' },
author: { any: 'Ցանկացած հեղինակ', owner: 'Սեփականատեր', agency: 'Գործակալություն' },
col: { listing: 'Հայտարարություն', price: 'Գին', place: 'Շրջան', published: 'Տեղադրվել է', found: 'Գտնվել է', leads: 'Լիդեր' },
empty: 'Հայտարարություններ չկան։', prev: 'Հետ', next: 'Առաջ', page: (a, b) => `Էջ ${a}՝ ${b}-ից`,
number: 'Համար', sentTo: 'Ում է գնացել որպես լիդ', noLeads: 'Ոչ մի ֆիլտրի չի համապատասխանել։', pool: 'ընդհանուր ցուցակում',
},
ps: {
title: 'Պարսերի կարգավորումներ', hint: 'Կիրառվում են հաջորդ անցումից, վերագործարկում պետք չէ։',
enabled: 'Պարսերը միացված է. ինքն է շրջանցում List.am-ը', freshDays: 'Թարմություն, օր', freshHint: '«Թոփ» բլոկի և առաջին անցման համար՝ ոչ ավելի վաղ տեղադրված հայտարարությունները։ Սովորական ցուցակում բարձրացվածները միշտ գնում են բազա',
pauseSec: 'Դադար անցումների միջև, վայրկյան', maxPages: 'Կատեգորիայի էջեր մեկ անցման համար', pagesHint: 'Կարդացվում են, քանի դեռ կան անծանոթ հայտարարություններ',
categories: 'List.am-ի կատեգորիաներ', keyTitle: 'Jina ընթերցիչի բանալի',
keyHint: 'Բանալիով անցումն ավելի արագ է, և հայտնի է տեղադրման ճշգրիտ ժամը, բայց բանալին վճարովի է։ Առանց բանալու պարսերն ավելի դանդաղ է։ Եթե բանալու մնացորդը սպառվի, պարսերն ինքը կանցնի առանց բանալու աշխատանքի։',
useKey: 'Օգտագործել բանալին', key: 'Ընթացիկ բանալի', keyNone: 'նշված չէ', keyEnv: 'սերվերի կարգավորումներից', keySet: 'նշված է այստեղ',
keyNew: 'Տեղադրեք նոր բանալի՝ փոխարինելու համար', keyClear: 'Հեռացնել բանալին', keyConfirm: 'Հեռացնե՞լ ադմինկայում նշված բանալին',
removalTitle: '«Հանվել է List.am-ից» նշում', removalCheck: 'Ստուգել՝ արդյոք հանված են լիդ ունեցող հայտարարությունները',
recheckHours: 'Վերստուգել հայտարարությունը, ժամը մեկ', removalBatch: 'Հայտարարություններ մեկ մոտեցմամբ (10 րոպեն մեկ)', batchHint: (n) => `Օրական մինչև ${n} ստուգում`,
},
pl: {
title: { object: 'Օբյեկտի ձագար', client: 'Հաճախորդի ձագար' },
hint: 'Անվանումները պահպանվում են անմիջապես։ Կողպեքով կարգավիճակները համակարգային են. կարելի է վերանվանել, բայց ոչ ջնջել կամ տեղափոխել։',
statuses: 'Կարգավիճակներ', reasons: 'Մերժման պատճառներ', statusName: 'Կարգավիճակի անվանում', reasonName: 'Մերժման պատճառ',
newStatus: 'Նոր կարգավիճակ', newReason: 'Նոր պատճառ', add: 'Ավելացնել', up: 'Վերև', down: 'Ներքև',
pool: 'վերադարձ ընդհանուր ցուցակ', poolHint: 'Այս կարգավիճակում վերցված օբյեկտը կվերադառնա ընդհանուր ցուցակ, եթե կարգավիճակը երկար չփոխվի',
system: 'Համակարգային կարգավիճակ', systemReason: 'Համակարգային պատճառ. այն դնում է «Սա գործակալ է» կոճակը',
confirmStatus: (n) => `Ջնջե՞լ «${n}» կարգավիճակը`, confirmReason: (n) => `Ջնջե՞լ «${n}» պատճառը`,
removeTitle: (n) => `Ջնջել «${n}» կարգավիճակը`, removeHint: (n) => `Այս կարգավիճակում քարտեր՝ ${n}։ Ընտրեք, թե ուր տեղափոխել դրանք։`,
moveTo: 'Տեղափոխել կարգավիճակ…',
},
pay: {
title: 'Վճարման պայմաններ', notSet: 'Նշված չեն', modes: { fixed: 'Աշխատավարձ', percent: 'Տոկոս իր գործարքներից', mixed: 'Աշխատավարձ + տոկոս իր գործարքներից' },
salary: (v) => `աշխատավարձ ${v}`, share: (v) => `գործարքների ${v}%-ը`, shareSplit: (sale, rent) => `վաճառքից ${sale}%, վարձակալությունից ${rent}%`,
fixedLabel: 'Ամսական աշխատավարձ', percentLabel: 'Տոկոս վաճառքից', percentRentLabel: 'Տոկոս վարձակալությունից', payoutCurrency: 'Վճարման արժույթ',
hint: 'Տոկոսը հաշվվում է միջնորդավճարի այն մասից, որը բաժին է հասնում գործակալին. սեփականատիրոջից՝ օբյեկտի գործակալին, հաճախորդից՝ հաճախորդի գործակալին։', edit: 'Վճարման պայմաններ',
payroll: 'Ում որքան վճարել', payrollHint: 'Ամսվա աշխատավարձը և տոկոսը՝ այս ամսվա գործարքներում գործակալի միջնորդավճարի մասից։ Սեղմեք աշխատակցի վրա՝ նրա գործարքները տեսնելու համար։', month: 'Ամիս',
col: { agent: 'Աշխատակից', terms: 'Պայմաններ', deals: 'Գործարքներ', commission: 'Միջնորդավճարի նրա մասը', share: 'Տոկոս', fixed: 'Աշխատավարձ', total: 'Վճարման ենթակա', sum: 'Ընդամենը վճարման ենթակա' },
mine: 'Իմ վճարումը', thisMonth: 'Հաշվարկված ամսվա համար',
dealsOf: (name) => `Գործարքներ՝ ${name}`, myDeals: 'Իմ գործարքները այս ամիս', noDeals: 'Այս ամիս գործարքներ չկան։',
dcol: { date: 'Ամսաթիվ', deal: 'Օբյեկտ և հաճախորդ', kind: 'Տեսակ', side: 'Կողմ', part: 'Միջնորդավճարի մաս', percent: 'Տոկոս', accrued: 'Հաշվարկված' },
side: { owner: 'օբյեկտ', client: 'հաճախորդ', both: 'օբյեկտ և հաճախորդ' }, withPartner: (name) => `երկրորդ կողմը՝ ${name}`, ofDeal: (v) => `${v}-ից`,
planTitle: 'Ամսական պլան', planDeals: 'Գործարքներ', planCommission: 'Գործակալի միջնորդավճար', planHint: 'Դատարկ դաշտ՝ ցուցանիշը նշված չէ։ Պլանը կատարված է, երբ բոլոր նշված ցուցանիշները հասած են։',
bonusTitle: 'Բոնուս կատարված պլանի համար', bonusModes: { none: 'Չկա', fixed: 'Գումար', percent: 'Հավելում տոկոսին' }, bonusValue: 'Չափ', bonusHint: 'Հավելումը հաշվվում է գործակալի ամսվա ամբողջ միջնորդավճարից։',
planOf: (done, plan) => `${done}՝ ${plan}-ից`, planDone: 'Պլանը կատարված է', planNot: 'Պլանը կատարված չէ',
other: 'Բոնուս և այլ', status: 'Վճարում', paid: (d) => `Վճարված է ${d}`, notPaid: 'Վճարված չէ',
lines: 'Հաշվարկներ', lineFixed: 'Աշխատավարձ', lineShare: 'Տոկոս գործարքներից', lineBonus: 'Բոնուս պլանի համար', lineRecalc: (m) => `Վերահաշվարկ՝ ${m}`, lineTotal: 'Ընդամենը վճարման ենթակա',
addLine: 'Ավելացնել գրառում', lineAmount: 'Գումար (մինուսով՝ պահում)', lineNote: 'Ինչի համար', lineNotePh: 'Պարգև, տուգանք, կանխավճար…', add: 'Ավելացնել', remove: 'Ջնջել գրառումը',
payTitle: 'Նշել վճարումը', payAmount: 'Վճարված է', payDate: 'Վճարման ամսաթիվ', payNote: 'Մեկնաբանություն', markPaid: 'Վճարված է', cancelPaid: 'Չեղարկել նշումը',
paidInfo: (v, d) => `Վճարված է ${v} · ${d}`, debt: (v) => `Մնացորդը՝ ${v}, կտեղափոխվի հաջորդ չվճարված ամիս։`, overpaid: (v) => `Գերավճարը՝ ${v}, կպահվի հաջորդ չվճարված ամսում։`,
closedHint: 'Ամիսը փակված է. հաշվվում է վճարման օրվա պայմաններով և փոխարժեքներով։ Նրա գործարքների փոփոխությունները վերահաշվարկով անցնում են հաջորդ ամիս։',
history: 'Վճարումների պատմություն', accruedOf: (v) => `հաշվարկված՝ ${v}`,
trend: 'Դինամիկան ըստ ամիսների', trendHint: 'Յուրաքանչյուր աշխատակցի գործարքները և միջնորդավճարի մասը։ Միջնորդավճարը՝ դոլարով, ընթացիկ փոխարժեքով։', trendMine: 'Իմ դինամիկան',
metricDeals: 'Գործարքներ', metricCommission: 'Միջնորդավճար', company: 'Ամբողջ ընկերությունը', vsPrev: (d) => `${d}՝ նախորդ ամսվա համեմատ`, same: 'ինչպես նախորդ ամիս',
asTable: 'Աղյուսակով', asChart: 'Գրաֆիկով', metric: 'Ցուցանիշ',
rating: 'Ամսվա վարկանիշ', ratingHint: 'Տեղը՝ ըստ գործարքների թվի. հավասարության դեպքում բարձր է նա, ում միջնորդավճարն ավելի մեծ է։ Գումարները վարկանիշում չեն ցուցադրվում։',
place: 'Տեղ', plan: 'Պլան', noPlan: 'պլան չկա', you: 'դուք',
export: 'Ներբեռնել Excel-ի համար', paidCol: 'Վճարված է', currency: 'Արժույթ', client: 'Հաճախորդ', object: 'Օբյեկտ',
reportsTitle: 'Հաշվետվություններ Telegram-ում', reportsHint: 'Գալիս են ղեկավարին Lead Radar բոտում՝ առավոտյան 9-ին Երևանի ժամանակով։', reportWeekly: 'Շաբաթվա արդյունքները՝ երկուշաբթի օրերին', reportMonthly: 'Ամսվա արդյունքները տեղեկագրով՝ ամսի մեկին',
},
cm: {
title: 'Գործակալության միջնորդավճար', mode: 'պայման', none: 'Նշված չէ', percent: 'Տոկոս', fixed: 'Ֆիքսված', mixed: 'Ֆիքսված + տոկոս', amount: 'գումար', notSet: 'Նշված չէ',
ofPrice: (v) => `գնի ${v}%-ը`, ofRent: (v) => `ամսական վարձի ${v}%-ը`,
fromSettings: 'ինչպես ընկերության կարգավորումներում', custom: 'փոխված է այս քարտում', reset: 'Ինչպես կարգավորումներում', forThis: 'Պայման այս քարտի համար',
hintLead: 'Պայմանը կարգավորումներում նշված չէ. նշեք Ձերը։', hintClient: 'Պայմանը կարգավորումներում նշված չէ. նշեք Ձերը։ Տոկոսը հաշվվում է հաճախորդի բյուջեից։',
settings: 'Գործակալության միջնորդավճար', settingsHint: 'Լռելյայն պայմաններ։ Տոկոսը հաշվվում է օբյեկտի գնից, վարձակալության դեպքում՝ ամսական վարձից, հաճախորդի դեպքում՝ նրա բյուջեից։ Գործակալը կարող է փոխել պայմանը քարտում։',
objectSale: 'Օբյեկտներ՝ վաճառք (սեփականատիրոջից)', objectRent: 'Օբյեկտներ՝ վարձակալություն (սեփականատիրոջից)', clientBuyer: 'Հաճախորդներ՝ գնորդ', clientTenant: 'Հաճախորդներ՝ վարձակալ',
prefill: 'Գումարը լրացվել է ըստ միջնորդավճարի պայմանի. ուղղեք, եթե այլ կերպ եք պայմանավորվել։',
evChanged: (from, to) => `Միջնորդավճար՝ ${from} → ${to}`, asSettings: 'ինչպես կարգավորումներում', points: (v) => `${v} տոկոսային կետ`, sumDelta: (v) => `գումարը ${v}%`,
},
sh: {
page: 'Էջ հաճախորդի համար', pageHint: 'Հաճախորդը բացում է այն առանց մուտքի. տեսնում է կցված օբյեկտները և գրանցվում ցուցադրության։ Էջը չի ցուցադրում հասցեն և List.am-ի հղումը։',
create: 'Ստեղծել էջ', createMatch: 'Ընտրել օբյեկտներ և ստեղծել', added: (n) => (n ? `Կցված համապատասխան օբյեկտներ՝ ${n}։` : 'Համապատասխան օբյեկտներ հիմա չկան. էջը ստեղծվել է արդեն կցվածներով։'),
close: 'Փակել էջը', confirmClose: 'Փակե՞լ էջը։ Հաճախորդի հղումն այլևս չի բացվի։', open: 'Բացել',
viewings: 'Ցուցադրության գրանցումներ', viewingLine: (day, title) => `${day} — ${title}`,
intake: 'Հարցաթերթիկի հղում', intakeTitle: 'Հարցաթերթիկ հաճախորդի համար',
intakeText: 'Ուղարկեք հղումը հաճախորդին կամ տեղադրեք հայտարարության մեջ։ Նա կպատասխանի հարցերին Telegram-ում և կհայտնվի այստեղ որպես Ձեր հաճախորդ։ Բոտում կստանաք հարցաթերթիկը և հարց՝ ցույց տա՞լ նրան համապատասխան օբյեկտները։',
intakeOff: 'Հարցաթերթիկը դեռ միացված չէ. հաճախորդների բոտը կարգավորված չէ։',
evViewing: (title, day) => `Հաճախորդը գրանցվել է ցուցադրության ${day ?? ''}՝ ${title ?? '—'}`,
evClientViewing: (name, day) => `Ցուցադրության գրանցում ${day ?? ''}՝ ${name ?? '—'}`,
ownerPhone: 'Սեփականատիրոջ հեռախոսը', ownerPhoneHint: 'Տեղադրեք համարը List.am-ի հայտարարությունից. այն կպահպանվի քարտում։', savePhone: 'Պահպանել համարը', changePhone: 'Փոխել համարը',
clientText: 'Նկարագրություն հաճախորդի համար', copyText: 'Պատճենել տեքստը', original: 'Հայտարարությունը List.am-ում՝ սկզբնական տեքստ և բնութագրեր', photosLoading: 'Բեռնում ենք մնացած լուսանկարները…',
evPhone: (v) => (v ? `Ավելացվել է սեփականատիրոջ հեռախոսը՝ ${v}` : 'Սեփականատիրոջ հեռախոսը հեռացվել է'),
variants: 'Համարներ ընդհանուր բազայից', variantsHint: 'Դրանք ավելացրել են այս հայտարարության հետ աշխատած գործակալները։', useVariant: 'Վերցնել այս համարը',
confidence: (pct, votes) => `ճշգրտություն ${pct}% · համընկել է ${votes}-ի մոտ`, unconfirmed: 'հաստատված չէ. նշել է մեկ գործակալ',
slug: 'Հաճախորդների էջերի հասցե', slugHint: 'Լատինատառեր, թվեր և գծիկ։ Փոխելու դեպքում հաճախորդների հին հղումները չեն բացվի։',
},
hh: {
title: 'Ով է ապրելու', who: { alone: 'Մենակ', friend: 'Ընկերոջ հետ', couple: 'Զուգընկերոջ հետ (զույգ)' }, notSet: 'Նշված չէ',
children: 'Երեխաներ', pets: 'Կենդանիներ', count: 'Քանիսը', age: 'Տարիք', ageHintChildren: '3 և 7 տարեկան', ageHintPets: 'կատու, 2 տարեկան',
childrenLine: (n, age) => `երեխաներ՝ ${n}${age ? ` (${age})` : ''}`, petsLine: (n, age) => `կենդանիներ՝ ${n}${age ? ` (${age})` : ''}`,
photos: 'Լուսանկարներ', addPhoto: 'Ավելացնել լուսանկար', removePhoto: 'Ջնջել լուսանկարը', photosHint: 'Բնակիչներ, կենդանիներ, փաստաթղթեր։ Մինչև 10 լուսանկար, յուրաքանչյուրը՝ մինչև 5 ՄԲ։',
confirmRemove: 'Ջնջե՞լ լուսանկարը', empty: 'Լրացված չէ։',
about: 'Օբյեկտի մասին', amenities: 'Հարմարություններ', rules: { children: 'Երեխաներով', pets: 'Կենդանիներով' },
allow: { yes: 'կարելի է', no: 'չի կարելի', negotiable: 'պայմանավորվածությամբ' },
},
x: {
byUrl: 'Հղումով', byUrlTitle: 'Օբյեկտ List.am-ի հղումով', byUrlLabel: 'Հայտարարության հղումը',
byUrlHint: 'Եթե օբյեկտն արդեն CRM-ում է և ազատ է, այն կամրագրվի ձեզ։ Եթե չկա, կներբեռնենք List.am-ից և կգրանցենք ձեզ վրա։',
byUrlGo: 'Ստուգել և ամրագրել', byUrlLoading: 'Փնտրում ենք օբյեկտը, անհրաժեշտության դեպքում ներբեռնում List.am-ից — կարող է տևել մինչև մեկ րոպե…',
taken: 'Օբյեկտը ազատ էր — այժմ այն ամրագրված է ձեզ։', created: 'Օբյեկտը CRM-ում չկար — ներբեռնեցինք List.am-ից և ամրագրեցինք ձեզ։',
mine: 'Այս օբյեկտն արդեն ամրագրված է ձեզ։', busy: (name: string | null) => (name ? `Օբյեկտն արդեն ամրագրված է ${name} գործակալին։` : 'Օբյեկտն արդեն մեկ այլ գործակալի մոտ է։'),
open: 'Բացել քարտը', districts: 'Շրջաններ',
sendTg: 'Ուղարկել Telegram', sendingTg: 'Ուղարկում ենք…', sentTg: 'Ուղարկված է խմբին', botNotSet: 'Բոտը կարգավորված չէ',
del: 'Ջնջել քարտը', delConfirm: 'Ջնջե՞լ քարտը պատմության հետ միասին։ Հնարավոր չէ չեղարկել։', delDeal: 'Քարտն ունի գործարք․ այն կարող է ջնջել միայն սուպերադմինը — գործարքը կհեռացվի վերլուծությունից և վճարացուցակից։',
phrase: { broker: 'Հայտարարությունը կարծես միջնորդից է', noAgents: 'Սեփականատերը գրում է, որ չի աշխատում գործակալների հետ' },
telegram: 'Telegram', telegramPh: '@անուն կամ t.me հղում', rentTerm: 'Վարձակալության ժամկետ', term: { short: 'Մինչև 6 ամիս', mid: '6–12 ամիս', long: 'Մեկ տարուց ավելի' },
},
tg: { link: 'Կապել Telegram-ը', open: 'Բացեք բոտը և սեղմեք «Start».', hint: 'Կապելուց հետո նոր օբյեկտները կգան այստեղ։' },
parser: {
title: 'Պարսեր', off: 'Հայտարարությունների աղբյուրը կարգավորված չէ։', lastOk: 'Վերջին հաջող անցումը', day: 'Հայտարարություններ՝ վերջին օրվա ընթացքում',
total: 'Ընդամենը հայտարարություններ', cycle: 'Պարսերի լրիվ շրջանը', delay: '«Տեղադրվել է → լիդ» ուշացում, օրվա մեդիան', delayN: (n) => `${n} լիդի հիման վրա`, day3: 'Բազայում՝ թարմ, 3 օրից ոչ հին', run: 'Գործարկել հիմա', runs: 'Վերջին գործարկումները', started: 'Սկիզբ', took: 'Տևողություն',
found: 'Նոր', status: 'Կարգավիճակ', errors: 'Սխալներ', ok: 'հաջող', failed: 'ձախողում', running: 'ընթացքում', sec: 'վ',
},
dp: {
favorite: 'Ընտրված', unseen: 'Չդիտված', addFav: 'Ավելացնել ընտրվածներին', removeFav: 'Հանել ընտրվածներից', unseenMark: 'Չդիտված',
currency: 'Արժույթ', cur: { AMD: 'ՀՀԴ', USD: 'ԴՈԼ' } as Record<string, string>, priceHint: 'Այլ արժույթով գները վերահաշվարկվում են փոխարժեքով',
sendBot: 'Ուղարկել բոտին', sendingBot: 'Ուղարկում ենք…', sentBot: 'Ուղարկվեց ձեզ բոտում',
linkTitle: 'Telegram-ը կապված չէ բոտին',
linkText: 'Օբյեկտը բոտում ստանում է նա, ով սեղմել է կոճակը՝ ձեր հաշվին կապված Telegram-ում։ Սեղմեք «Կապել Telegram-ը», բացեք բոտը, սեղմեք «Start» և նորից ուղարկեք օբյեկտը։ Կապելը հասանելի է նաև «Կարգավորումներում»։',
blockedText: 'Բոտը չի կարող գրել ձեզ․ բացեք բոտը Telegram-ում, սեղմեք «Start» (կամ ապաարգելափակեք այն) և փորձեք կրկին։',
linkBtn: 'Կապել Telegram-ը', openBot: 'Բացել բոտը',
departments: 'Բաժիններ', department: 'Բաժին', noDepartment: 'Առանց բաժնի', allDepartments: 'Բոլոր բաժինները', add: 'Նոր բաժին', namePh: 'Բաժնի անվանումը',
rename: 'Վերանվանել', del: 'Ջնջել բաժինը', delConfirm: 'Ջնջե՞լ բաժինը։ Կջնջվեն նրա ֆիլտրերը և պուլի այն օբյեկտները, որոնք ոչ ոք չի վերցրել։',
hint: 'Յուրաքանչյուր բաժին ունի իր ֆիլտրերը, օբյեկտները և հաճախորդները։ Աշխատակիցը տեսնում է միայն իր բաժնի տվյալները, ղեկավարը՝ բոլոր բաժինները։ Բաժինը փոխելիս աշխատակցի վարած օբյեկտներն ու հաճախորդները տեղափոխվում են նրա հետ։',
stats: (m: number, f: number, l: number, c: number) => `աշխատակից՝ ${m} · ֆիլտր՝ ${f} · օբյեկտ՝ ${l} · հաճախորդ՝ ${c}`,
kept: (n: number) => `Նախկին բաժնում մնաց ${n} օբյեկտ․ նորում արդեն կան նույն հայտարարությունների քարտերը։`,
none: 'Բաժիններ դեռ չկան․ ամբողջ ընկերությունն աշխատում է ընդհանուր օբյեկտներով և հաճախորդներով։',
scope: 'Բաժին', filtersOf: (name: string) => `«${name}» բաժնի ֆիլտրերը`, mine: (name: string) => `Ձեր բաժինը՝ ${name}`,
removed: 'Հանված են List.am-ից',
},
errors: {
MONTH_PAID: 'Ամիսն արդեն փակված է վճարումով։ Փոխելու համար չեղարկեք նշումը', PAYOUT_NOT_LAST: 'Կարելի է չեղարկել միայն աշխատակցի վերջին վճարումը',
ALREADY_TAKEN: 'Այս օբյեկտն արդեն վերցրել է այլ գործակալ', LEAD_NOT_FOUND: 'Օբյեկտը չի գտնվել', NOT_YOUR_LEAD: 'Օբյեկտը վարում է այլ գործակալ',
TAKE_FIRST: 'Նախ վերցրեք օբյեկտը աշխատանքի', REJECT_REASON_REQUIRED: 'Նշեք մերժման պատճառը',
COMMISSION_REQUIRED: 'Նշեք միջնորդավճարի գումարը', USE_RETURN_TO_POOL: 'Օբյեկտը վերադարձնելու համար օգտագործեք «Վերադարձնել ընդհանուր ցուցակ» կոճակը',
MODULE_NOT_AVAILABLE: 'Բաժինը հասանելի է Team և Agency սակագներում',
CLIENT_NOT_FOUND: 'Հաճախորդը չի գտնվել', NOT_YOUR_CLIENT: 'Հաճախորդին վարում է այլ գործակալ', BAD_ASSIGNEE: 'Ընկերությունում նման մասնակից չկա',
PAIR_NOT_LINKED: 'Նախ կցեք օբյեկտը հաճախորդին', DEAL_HAS_PAIR: 'Այս գործարքում արդեն նշված է այլ զույգ։ Նախ չեղարկեք այն',
BAD_STATUS: 'Ձագարում նման կարգավիճակ չկա', SYSTEM_STATUS: 'Համակարգային կարգավիճակը հնարավոր չէ ջնջել', STATUS_IN_USE: 'Կարգավիճակում քարտեր կան. ընտրեք, թե ուր տեղափոխել',
SYSTEM_REASON: 'Համակարգային պատճառը հնարավոր չէ ջնջել', LAST_REASON: 'Անհրաժեշտ է առնվազն մեկ մերժման պատճառ',
DEAL_CLOSED: 'Գործարքը փակված է. կարգավիճակը փոխում է ղեկավարը', TELEGRAM_DISABLED: 'Բոտը կարգավորված չէ', PARSER_DISABLED: 'Պարսերը կարգավորված չէ', BAD_LISTAM_URL: 'Սա List.am-ի հայտարարության հղում չէ', LISTAM_NOT_FOUND: 'Նման հայտարարություն List.am-ում չկա — հնարավոր է՝ հանվել է', LISTAM_UNAVAILABLE: 'Չհաջողվեց կարդալ հայտարարությունը List.am-ից, փորձեք կրկին', HAS_DEAL: 'Քարտն ունի գործարք․ այն կարող է ջնջել միայն սուպերադմինը', GROUP_BOT_NOT_SET: 'Բոտը կարգավորված չէ', GROUP_SEND_FAILED: 'Չհաջողվեց ուղարկել Telegram, փորձեք կրկին', TELEGRAM_NOT_LINKED: 'Telegram-ը կապված չէ բոտին', BOT_CHAT_UNAVAILABLE: 'Բոտը չի կարող գրել ձեզ․ բացեք բոտը և սեղմեք «Start»', BOT_SEND_FAILED: 'Չհաջողվեց ուղարկել բոտին, փորձեք կրկին', DEPARTMENT_NOT_EMPTY: 'Բաժնում կան աշխատակիցներ, հաճախորդներ կամ աշխատանքի մեջ գտնվող օբյեկտներ․ նախ տեղափոխեք դրանք', BAD_DEPARTMENT: 'Նման բաժին չկա', DEPARTMENT_HAS_LEAD: 'Այս աշխատակցի բաժնում արդեն կա այս հայտարարության քարտը',
},
};
const en: Dict = {
tabs: { pool: 'Pool', mine: 'Mine', all: 'All' },
view: { table: 'Table', kanban: 'Kanban' },
search: 'Phone, address, listing ID',
add: 'Add property',
take: 'Take',
bumped: 'Bumped on List.am',
taken: 'Taken',
empty: { pool: 'The pool is empty. New properties appear here as soon as a filter matches.', mine: 'You have no properties in progress yet.', all: 'No properties yet.' },
noFilters: 'Create your first filter to start receiving properties.',
toFilters: 'Create filter',
col: { object: 'Property', price: 'Price', place: 'District', status: 'Status', agent: 'Assignee', created: 'Created' },
f: { status: 'Status', agent: 'Agent', district: 'District', priceFrom: 'Price from', priceTo: 'to', from: 'Created from', to: 'to', any: 'Any', reset: 'Reset', more: 'Filters' },
pool: 'in pool',
card: {
onListAm: 'Open on List.am', contact: 'Contact', call: 'Call', phoneHidden: 'The phone opens after “Take”',
noPhone: 'The phone is in the List.am listing', assignee: 'Assignee', status: 'Status', nextAction: 'Next action',
nextNote: 'What to do', remind: 'Remind in the bot', clear: 'Clear', comment: 'Comment', addComment: 'Add',
history: 'History', isAgent: 'It is an agent', toPool: 'Return to pool', manual: 'Manual', agency: 'Agency',
clients: 'Clients this property was offered to', attach: 'Attach to a client…',
previous: 'Was in progress before', confirmAgent: 'Mark the author as an agent? The property moves to “Rejected”.',
confirmPool: 'Return the property to the pool? Any agent will be able to take it.',
gone: 'Listing is gone', confirmGone: 'Mark the listing as removed from List.am? Everyone handling this property will see the mark.',
editTitle: 'Property details',
},
ev: {
created: 'Property created', createdListam: 'Came from List.am', createdManual: 'Created manually', taken: 'Took it',
assigned: (a, b) => (a ? `Reassigned: ${a} → ${b}` : `Assigned: ${b}`),
status: (a, b) => `Status: ${a ?? '—'} → ${b}`,
deal: (text) => `Deal${text ? `, commission ${text}` : ''}`,
poolReturn: (who) => (who ? `Returned to pool (was with: ${who})` : 'Returned to pool'),
clientCreated: 'Client created', edited: 'Details changed', dealEdited: (text) => `Deal corrected${text ? `: commission ${text}` : ''}`,
offered: (v) => `Property offered: ${v ?? '—'}`, shown: (v) => `Property shown: ${v ?? '—'}`,
unlinked: (v) => `Property detached${v ? `: ${v}` : ''}`,
clientOffered: (v) => `Offered to client: ${v ?? '—'}`, clientShown: (v) => `Shown to client: ${v ?? '—'}`,
blacklisted: 'Marked as an agent', system: 'System', listingRemoved: 'Marked: listing is gone',
},
dlg: {
reject: 'Reject reason', deal: 'Close the deal', commission: 'Agency commission', currency: 'Currency', date: 'Deal date',
ownerPart: 'From the owner', clientPart: 'From the client', splitHint: 'The owner’s part goes to the property’s agent, the client’s part to the client’s agent. Enter 0 if there is no such side.',
edit: 'Edit', dealEdit: 'Correct the deal', pairClient: 'Deal with a CRM client', pairLead: 'Deal with a CRM property',
noPair: 'No pair — the other side is outside the CRM', pairHint: 'The deal closes once: both move to “Deal” and the commission is not counted twice.',
pairEmptyClient: 'To close the deal together with a client, attach the property to the client first.',
pairEmptyLead: 'To close the deal together with a property, first attach a property that is in progress.',
title: 'Title', description: 'Description', address: 'Address', price: 'Price', rooms: 'Rooms', area: 'Area, m²',
floor: 'Floor', floors: 'Floors', author: 'Owner name', phone: 'Phone', link: 'Listing link',
},
deal: { sale: 'Sale', rent: 'Rent' },
ptype: { apartment: 'Apartment', house: 'House', commercial: 'Commercial', land: 'Land', garage: 'Garage' },
unit: { rooms: 'rooms', floor: 'floor', perMonth: '/mo' },
filters: {
title: 'Filters', sub: 'A listing becomes a lead when it matches a company filter or an agent’s personal filter.',
company: 'Company filters', personal: 'My personal filters', others: 'Agents’ personal filters', add: 'New filter',
none: 'No filters.', name: 'Name', scope: 'For whom', scopeCompany: 'Whole company', scopePersonal: 'Only me',
dealType: 'Deal type', types: 'Property type', region: 'Region', allArmenia: 'All Armenia', districts: 'Districts',
localities: 'Localities, comma-separated', price: 'Price', area: 'Area, m²', rooms: 'Rooms',
author: 'Listing author', ownersOnly: 'Owners only', everyone: 'All listings', enabled: 'Enabled',
any: 'any', anyType: 'any type', from: 'from', to: 'to', confirmDelete: 'Delete the filter?', fromBase: (n) => `Properties added to the pool from the last 3 days of the base: ${n}`,
},
cl: {
sub: 'Buyers and tenants. A client belongs to an agent; the manager reassigns.',
add: 'Add client', one: 'Client', name: 'Name', kindLabel: 'Client type', kind: { buyer: 'Buyer', tenant: 'Tenant' },
anyKind: 'All types', source: 'Source', sourceHint: 'Instagram, referral…', me: 'Me', request: 'Request', budget: 'Budget',
noRequest: 'The request is empty. Fill it in and matching properties will appear here.', search: 'Name or phone',
empty: 'No clients yet. Add your first buyer or tenant.', notFound: 'Nothing found.',
objectsCol: 'Properties', objects: (n) => `properties: ${n}`, noObjects: 'No properties attached yet.',
suggested: 'Match the request', attach: 'Attach', detach: 'Detach', state: { offered: 'Offered', shown: 'Shown' },
phoneHidden: 'The phone is visible to the assigned agent', noPhone: 'No phone', dealLead: 'Property',
},
ar: {
sub: 'Properties and clients closed with a deal or a rejection, and properties removed from List.am. They appear here automatically.',
tabs: { leads: 'Properties', contacts: 'Clients' }, resultLabel: 'Result', anyResult: 'Any result', result: { deal: 'Deal', rejected: 'Rejected' },
closed: 'Closed', contact: 'Client', objects: 'Properties', empty: 'The base is empty: properties and clients appear here after a deal or a rejection.',
removed: (d) => `Removed from List.am ${d}`,
},
an: {
pipeline: { object: 'Properties', client: 'Clients' }, period: { today: 'Today', week: 'Week', month: 'Month', custom: 'Range' },
came: 'Leads received', takenTotal: 'Taken', inPool: 'In the pool now', inWork: 'In progress now', deals: 'Deals',
funnel: 'Whole funnel', funnelHintObject: 'Leads received in the period: how many reached each stage.',
funnelHintClient: 'Clients created in the period: how many reached each stage.', noData: 'No data for this period.',
byAgent: 'By agent', tableHintObject: 'Percentages: share of the leads the agent took in the period that reached the stage with them.',
tableHintClient: 'Percentages: share of the agent’s clients created in the period that reached the stage.',
agent: 'Agent', taken: 'Leads taken', newRequests: 'New requests', reaction: 'Reaction time', poolReturns: 'Pool returns', showings: 'Showings',
commission: 'Commission total', total: 'Total', reasons: 'Reject reasons', reason: 'Reason', noRejects: 'No rejections in the period.',
sec: 's', min: 'min', hour: 'h',
},
ls: {
sub: 'The shared base: everything the parser collected from List.am. From here listings go to clients by their filters.',
search: 'Listing number, title, address, phone', total: (n) => `Found: ${n}`, anyDeal: 'Any deal', anyRegion: 'All regions',
state: { any: 'Live and removed', active: 'Live', removed: 'Removed' }, leads: { any: 'With and without leads', yes: 'Has leads', no: 'No leads' },
author: { any: 'Any author', owner: 'Owner', agency: 'Agency' },
col: { listing: 'Listing', price: 'Price', place: 'District', published: 'Posted', found: 'Found', leads: 'Leads' },
empty: 'No listings.', prev: 'Back', next: 'Next', page: (a, b) => `Page ${a} of ${b}`,
number: 'Number', sentTo: 'Sent as a lead to', noLeads: 'It matched no filter.', pool: 'in pool',
},
ps: {
title: 'Parser settings', hint: 'Applied from the next run; no restart needed.',
enabled: 'Parser is on: it crawls List.am in a loop', freshDays: 'Freshness, days', freshHint: 'For the Top block and the first pass: listings posted no earlier than this. Bumped listings in the regular list always go to the base',
pauseSec: 'Pause between runs, seconds', maxPages: 'Category pages per run', pagesHint: 'Read while they contain unseen listings',
categories: 'List.am categories', keyTitle: 'Jina reader key',
keyHint: 'With a key a run is faster and the exact posting time is known, but the key is paid. Without it the parser is slower. If the key balance runs out, the parser switches to keyless mode by itself.',
useKey: 'Use the key', key: 'Current key', keyNone: 'not set', keyEnv: 'from server settings', keySet: 'set here',
keyNew: 'Paste a new key to replace', keyClear: 'Remove key', keyConfirm: 'Remove the key set in the admin panel?',
removalTitle: '“Removed from List.am” mark', removalCheck: 'Check whether listings with leads were removed',
recheckHours: 'Recheck a listing every, hours', removalBatch: 'Listings per batch (every 10 minutes)', batchHint: (n) => `Up to ${n} checks a day`,
},
pl: {
title: { object: 'Property pipeline', client: 'Client pipeline' },
hint: 'Names are saved immediately. Statuses with a lock are system ones: they can be renamed but not deleted or moved.',
statuses: 'Statuses', reasons: 'Reject reasons', statusName: 'Status name', reasonName: 'Reject reason',
newStatus: 'New status', newReason: 'New reason', add: 'Add', up: 'Up', down: 'Down',
pool: 'return to pool', poolHint: 'A taken property in this status returns to the pool if the status stays unchanged for too long',
system: 'System status', systemReason: 'System reason: set by the “It is an agent” button',
confirmStatus: (n) => `Delete the status “${n}”?`, confirmReason: (n) => `Delete the reason “${n}”?`,
removeTitle: (n) => `Delete the status “${n}”`, removeHint: (n) => `Cards in this status: ${n}. Choose where to move them.`,
moveTo: 'Move to status…',
},
pay: {
title: 'Pay terms', notSet: 'Not set', modes: { fixed: 'Salary', percent: 'Percent of their deals', mixed: 'Salary + percent of their deals' },
salary: (v) => `salary ${v}`, share: (v) => `${v}% of deals`, shareSplit: (sale, rent) => `${sale}% of sales, ${rent}% of rentals`,
fixedLabel: 'Monthly salary', percentLabel: 'Percent of sales', percentRentLabel: 'Percent of rentals', payoutCurrency: 'Payout currency',
hint: 'The percentage is taken from the agent’s part of the commission: the owner’s part goes to the property’s agent, the client’s part to the client’s agent.', edit: 'Pay terms',
payroll: 'Who gets paid how much', payrollHint: 'Monthly salary plus a percentage of the agent’s part of the commission on this month’s deals. Click an employee to see their deals.', month: 'Month',
col: { agent: 'Employee', terms: 'Terms', deals: 'Deals', commission: 'Their part of commission', share: 'Percent', fixed: 'Salary', total: 'To pay', sum: 'Total to pay' },
mine: 'My pay', thisMonth: 'Accrued this month',
dealsOf: (name) => `Deals: ${name}`, myDeals: 'My deals this month', noDeals: 'No deals this month.',
dcol: { date: 'Date', deal: 'Property and client', kind: 'Type', side: 'Side', part: 'Commission part', percent: 'Percent', accrued: 'Accrued' },
side: { owner: 'property', client: 'client', both: 'property and client' }, withPartner: (name) => `other side: ${name}`, ofDeal: (v) => `of ${v}`,
planTitle: 'Monthly plan', planDeals: 'Deals', planCommission: 'Agent’s commission', planHint: 'An empty field means no target. The plan is met when every target set is reached.',
bonusTitle: 'Bonus for meeting the plan', bonusModes: { none: 'None', fixed: 'Amount', percent: 'Extra percent' }, bonusValue: 'Size', bonusHint: 'The extra percent applies to the agent’s whole commission for the month.',
planOf: (done, plan) => `${done} of ${plan}`, planDone: 'Plan met', planNot: 'Plan not met',
other: 'Bonus and other', status: 'Payout', paid: (d) => `Paid ${d}`, notPaid: 'Not paid',
lines: 'Accruals', lineFixed: 'Salary', lineShare: 'Percent of deals', lineBonus: 'Plan bonus', lineRecalc: (m) => `Recalculation for ${m}`, lineTotal: 'Total to pay',
addLine: 'Add an entry', lineAmount: 'Amount (negative to deduct)', lineNote: 'What for', lineNotePh: 'Bonus, penalty, advance…', add: 'Add', remove: 'Delete entry',
payTitle: 'Mark as paid', payAmount: 'Paid', payDate: 'Payment date', payNote: 'Comment', markPaid: 'Paid', cancelPaid: 'Undo',
paidInfo: (v, d) => `Paid ${v} · ${d}`, debt: (v) => `The remaining ${v} moves to the next unpaid month.`, overpaid: (v) => `The overpayment of ${v} will be deducted in the next unpaid month.`,
closedHint: 'The month is closed: it is calculated with the terms and rates of the payment day. Changes to its deals go to the next month as a recalculation.',
history: 'Payout history', accruedOf: (v) => `accrued ${v}`,
trend: 'Monthly trend', trendHint: 'Deals and each employee’s part of the commission. Commission is in dollars at the current rate.', trendMine: 'My trend',
metricDeals: 'Deals', metricCommission: 'Commission', company: 'Whole company', vsPrev: (d) => `${d} vs last month`, same: 'same as last month',
asTable: 'As table', asChart: 'As chart', metric: 'Metric',
rating: 'Rating of the month', ratingHint: 'Ranked by number of deals; on a tie the higher commission wins. No amounts are shown in the rating.',
place: 'Place', plan: 'Plan', noPlan: 'no plan', you: 'you',
export: 'Download for Excel', paidCol: 'Paid', currency: 'Currency', client: 'Client', object: 'Property',
reportsTitle: 'Telegram reports', reportsHint: 'Sent to the manager in the Lead Radar bot at 9 am Yerevan time.', reportWeekly: 'Week summary — on Mondays', reportMonthly: 'Month summary with payroll — on the 1st',
},
cm: {
title: 'Agency commission', mode: 'terms', none: 'Not set', percent: 'Percent', fixed: 'Fixed', mixed: 'Fixed + percent', amount: 'amount', notSet: 'Not set',
ofPrice: (v) => `${v}% of the price`, ofRent: (v) => `${v}% of the monthly rent`,
fromSettings: 'as in company settings', custom: 'changed in this card', reset: 'As in settings', forThis: 'Terms for this card',
hintLead: 'No terms in settings — set your own.', hintClient: 'No terms in settings — set your own. The percentage is taken from the client’s budget.',
settings: 'Agency commission', settingsHint: 'Default terms. The percentage is taken from the property price, for rent — from the monthly rent; for a client — from their budget. An agent can change the terms in a card.',
objectSale: 'Properties: sale (from the owner)', objectRent: 'Properties: rent (from the owner)', clientBuyer: 'Clients: buyer', clientTenant: 'Clients: tenant',
prefill: 'The amount is filled in from the commission terms — adjust it if you agreed otherwise.',
evChanged: (from, to) => `Commission: ${from} → ${to}`, asSettings: 'as in settings', points: (v) => `${v} pp`, sumDelta: (v) => `amount ${v}%`,
},
sh: {
page: 'Page for the client', pageHint: 'The client opens it without logging in: sees the attached properties and books a viewing. The page does not show the address or the List.am link.',
create: 'Create page', createMatch: 'Pick properties and create', added: (n) => (n ? `Matching properties attached: ${n}.` : 'No matching properties right now — the page was created with those already attached.'),
close: 'Close page', confirmClose: 'Close the page? The client’s link will stop opening.', open: 'Open',
viewings: 'Viewing bookings', viewingLine: (day, title) => `${day} — ${title}`,
intake: 'Questionnaire link', intakeTitle: 'Questionnaire for a client',
intakeText: 'Send the link to a client or put it in your ad. They answer the questions in Telegram and appear here as your client. You get the questionnaire in the bot with a question whether to show them matching properties.',
intakeOff: 'The questionnaire is not connected yet: the client bot is not configured.',
evViewing: (title, day) => `The client booked a viewing ${day ?? ''}: ${title ?? '—'}`,
evClientViewing: (name, day) => `Viewing booked ${day ?? ''}: ${name ?? '—'}`,
ownerPhone: 'Owner’s phone', ownerPhoneHint: 'Paste the number from the List.am listing — it will be saved in the card.', savePhone: 'Save number', changePhone: 'Change number',
clientText: 'Description for the client', copyText: 'Copy text', original: 'List.am listing: original text and details', photosLoading: 'Loading the rest of the photos…',
evPhone: (v) => (v ? `Owner’s phone added: ${v}` : 'Owner’s phone removed'),
variants: 'Numbers from the shared base', variantsHint: 'Added by agents who worked with this listing.', useVariant: 'Use this number',
confidence: (pct, votes) => `accuracy ${pct}% · matched by ${votes}`, unconfirmed: 'unconfirmed: given by one agent',
slug: 'Address of client pages', slugHint: 'Latin letters, digits and hyphen. If you change it, clients’ old links stop opening.',
},
hh: {
title: 'Who will live there', who: { alone: 'Alone', friend: 'With a friend', couple: 'With a partner (couple)' }, notSet: 'Not set',
children: 'Children', pets: 'Pets', count: 'How many', age: 'Age', ageHintChildren: '3 and 7 years', ageHintPets: 'cat, 2 years',
childrenLine: (n, age) => `children: ${n}${age ? ` (${age})` : ''}`, petsLine: (n, age) => `pets: ${n}${age ? ` (${age})` : ''}`,
photos: 'Photos', addPhoto: 'Add a photo', removePhoto: 'Remove the photo', photosHint: 'Tenants, pets, documents. Up to 10 photos, 5 MB each.',
confirmRemove: 'Remove the photo?', empty: 'Not filled in.',
about: 'About the property', amenities: 'Amenities', rules: { children: 'Children', pets: 'Pets' },
allow: { yes: 'allowed', no: 'not allowed', negotiable: 'negotiable' },
},
x: {
byUrl: 'By link', byUrlTitle: 'Property by List.am link', byUrlLabel: 'Listing link',
byUrlHint: 'If the property is already in the CRM and free, it will be assigned to you. If not, we will download it from List.am and assign it to you.',
byUrlGo: 'Check and assign', byUrlLoading: 'Looking for the property and downloading it from List.am if needed — this can take up to a minute…',
taken: 'The property was free — it is now assigned to you.', created: 'The property was not in the CRM — downloaded from List.am and assigned to you.',
mine: 'This property is already assigned to you.', busy: (name: string | null) => (name ? `The property is already assigned to ${name}.` : 'Another agent is already working on this property.'),
open: 'Open card', districts: 'Districts',
sendTg: 'Send to Telegram', sendingTg: 'Sending…', sentTg: 'Sent to the group', botNotSet: 'Bot is not set up',
del: 'Delete card', delConfirm: 'Delete the card together with its history? This cannot be undone.', delDeal: 'The card has a deal: only the superadmin can delete it — the deal will disappear from analytics and payroll.',
phrase: { broker: 'The listing looks like it is from an intermediary', noAgents: 'The owner says they do not work with agents' },
telegram: 'Telegram', telegramPh: '@username or t.me link', rentTerm: 'Rental term', term: { short: 'Up to 6 months', mid: '6–12 months', long: 'A year or more' },
},
tg: { link: 'Link Telegram', open: 'Open the bot and press “Start”:', hint: 'Once linked, new properties will arrive there.' },
parser: {
title: 'Parser', off: 'The listing source is not configured.', lastOk: 'Last successful run', day: 'Listings in 24 hours',
total: 'Listings total', cycle: 'Full parser cycle', delay: '“Posted → lead” delay, 24h median', delayN: (n) => `over ${n} leads`, day3: 'In the base: fresh, up to 3 days old', run: 'Run now', runs: 'Recent runs', started: 'Started', took: 'Duration',
found: 'New', status: 'Status', errors: 'Errors', ok: 'ok', failed: 'failed', running: 'running', sec: 's',
},
dp: {
favorite: 'Favourites', unseen: 'Not viewed', addFav: 'Add to favourites', removeFav: 'Remove from favourites', unseenMark: 'Not viewed',
currency: 'Currency', cur: { AMD: 'AMD', USD: 'USD' } as Record<string, string>, priceHint: 'Prices in another currency are converted at the current rate',
sendBot: 'Send to bot', sendingBot: 'Sending…', sentBot: 'Sent to you in the bot',
linkTitle: 'Telegram is not linked to the bot',
linkText: 'The property goes to whoever pressed the button — to the Telegram linked to your account. Press “Link Telegram”, open the bot, press “Start” and send the property again. Linking is also available in Settings.',
blockedText: 'The bot cannot message you: open the bot in Telegram, press “Start” (or unblock it) and try again.',
linkBtn: 'Link Telegram', openBot: 'Open the bot',
departments: 'Departments', department: 'Department', noDepartment: 'No department', allDepartments: 'All departments', add: 'New department', namePh: 'Department name',
rename: 'Rename', del: 'Delete department', delConfirm: 'Delete the department? Its filters and the pool properties nobody took will be deleted.',
hint: 'Each department has its own filters, properties and clients. An employee sees only their department’s data; the manager sees all departments. When an employee changes department, the properties and clients they handle move with them.',
stats: (m: number, f: number, l: number, c: number) => `employees: ${m} · filters: ${f} · properties: ${l} · clients: ${c}`,
kept: (n: number) => `Properties left in the previous department: ${n} — the new one already has cards for the same listings.`,
none: 'No departments yet — the whole company works with shared properties and clients.',
scope: 'Department', filtersOf: (name: string) => `“${name}” department filters`, mine: (name: string) => `Your department: ${name}`,
removed: 'Removed from List.am',
},
errors: {
MONTH_PAID: 'The month is already closed by a payout. Undo the payout to change it', PAYOUT_NOT_LAST: 'Only the employee’s latest payout can be undone',
ALREADY_TAKEN: 'Another agent has already taken this property', LEAD_NOT_FOUND: 'Property not found', NOT_YOUR_LEAD: 'Another agent handles this property',
TAKE_FIRST: 'Take the property first', REJECT_REASON_REQUIRED: 'Choose a reject reason',
COMMISSION_REQUIRED: 'Enter the commission amount', USE_RETURN_TO_POOL: 'Use “Return to pool” to send the property back',
MODULE_NOT_AVAILABLE: 'This section is available on the Team and Agency plans',
CLIENT_NOT_FOUND: 'Client not found', NOT_YOUR_CLIENT: 'Another agent handles this client', BAD_ASSIGNEE: 'No such member in the company',
PAIR_NOT_LINKED: 'Attach the property to the client first', DEAL_HAS_PAIR: 'This deal already has another pair. Cancel that deal first',
BAD_STATUS: 'No such status in the pipeline', SYSTEM_STATUS: 'A system status cannot be deleted', STATUS_IN_USE: 'The status has cards: choose where to move them',
SYSTEM_REASON: 'A system reason cannot be deleted', LAST_REASON: 'At least one reject reason is required',
DEAL_CLOSED: 'The deal is closed: only the manager changes the status', TELEGRAM_DISABLED: 'The bot is not configured', PARSER_DISABLED: 'The parser is not configured', BAD_LISTAM_URL: 'This is not a List.am listing link', LISTAM_NOT_FOUND: 'There is no such listing on List.am — it may have been removed', LISTAM_UNAVAILABLE: 'Could not read the listing from List.am, please try again', HAS_DEAL: 'The card has a deal: only the superadmin can delete it', GROUP_BOT_NOT_SET: 'Bot is not set up', GROUP_SEND_FAILED: 'Could not send to Telegram, please try again', TELEGRAM_NOT_LINKED: 'Telegram is not linked to the bot', BOT_CHAT_UNAVAILABLE: 'The bot cannot message you: open the bot and press “Start”', BOT_SEND_FAILED: 'Could not send to the bot, please try again', DEPARTMENT_NOT_EMPTY: 'The department has employees, clients or properties in work — move them first', BAD_DEPARTMENT: 'No such department', DEPARTMENT_HAS_LEAD: 'This employee’s department already has a card for this listing',
},
};
const dict: Record<Lang, Dict> = { ru, hy, en };
const LOCALES: Record<Lang, string> = { ru: 'ru-RU', hy: 'hy-AM', en: 'en-GB' };
const SYMBOL: Record<string, string> = { USD: '$', EUR: '€', AMD: '֏', RUB: '₽' };
/** Тексты CRM и форматирование на языке пользователя. Ошибки ищутся сначала здесь, потом в общем словаре. */
export function useCrm() {
const base = useLang();
const c = dict[base.lang];
return {
...base,
c,
err: (e: unknown) => c.errors[(e as { code?: string } | null)?.code ?? ''] ?? base.err(e),
price: (price: number | null, currency: string | null, dealType?: string | null) => {
if (price == null) return '—';
const n = Math.round(price).toLocaleString('ru-RU');
const sym = SYMBOL[currency ?? 'USD'] ?? '';
const text = currency === 'AMD' || currency === 'RUB' ? `${n} ${sym}` : `${sym}${n}`;
return dealType === 'rent' ? `${text}${c.unit.perMonth}` : text;
},
/** «окт» из «2026-10» — подпись под столбцом. */
monthShort: (ym: string) => new Date(`${ym}-01T00:00:00`).toLocaleDateString(LOCALES[base.lang], { month: 'short' }).replace('.', ''),
/** «октябрь 2026» из «2026-10». */
monthName: (ym: string) => new Date(`${ym}-01T00:00:00`).toLocaleDateString(LOCALES[base.lang], { month: 'long', year: 'numeric' }),
dateTime: (iso: string | null | undefined) =>
iso
? new Date(iso).toLocaleString(LOCALES[base.lang], { day: 'numeric', month: 'short', hour: '2-digit', minute: '2-digit' })
: '—',
};
}
+450
View File
@@ -0,0 +1,450 @@
import { CURRENCIES } from '@lr/shared';
import { useQuery } from '@tanstack/react-query';
import { X } from 'lucide-react';
import { useEffect, useState, type ReactNode } from 'react';
import { api } from '../api.ts';
import { ErrorNote } from '../components/ui.tsx';
import { useAction } from '../hooks.ts';
import type { ClientCard, DealInfo, Lead, LeadCard, LeadEvent, PipelineInfo, Status } from './api.ts';
import { useCrm } from './i18n.ts';
function useEscape(onClose: () => void) {
useEffect(() => {
const onKey = (e: KeyboardEvent) => e.key === 'Escape' && onClose();
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [onClose]);
}
export function Dialog({ title, onClose, children, wide }: { title: string; onClose: () => void; children: ReactNode; wide?: boolean }) {
const { t } = useCrm();
useEscape(onClose);
return (
<div className="fixed inset-0 z-[60] flex items-end justify-center overflow-y-auto bg-black/60 p-0 sm:items-center sm:p-4" role="dialog" aria-modal="true" aria-label={title}>
<div className={`card max-h-dvh w-full overflow-y-auto rounded-b-none p-5 sm:rounded-2xl sm:p-6 ${wide ? 'max-w-2xl' : 'max-w-md'}`}>
<div className="mb-4 flex items-start justify-between gap-3">
<h2 className="text-xl font-medium tracking-tight">{title}</h2>
<button type="button" onClick={onClose} aria-label={t.common.cancel} className="-m-2 flex size-10 cursor-pointer items-center justify-center rounded-lg text-muted hover:text-text">
<X size={18} />
</button>
</div>
{children}
</div>
</div>
);
}
/** Боковая карточка лида или клиента. */
export function Drawer({ label, header, onClose, children }: { label: string; header: ReactNode; onClose: () => void; children: ReactNode }) {
const { t } = useCrm();
useEscape(onClose);
return (
<div className="fixed inset-0 z-50 flex justify-end bg-black/50" onClick={onClose}>
<aside
className="flex h-dvh w-full max-w-xl flex-col border-l border-line bg-surface"
role="dialog" aria-modal="true" aria-label={label}
onClick={(e) => e.stopPropagation()}
>
<header className="flex items-center justify-between gap-3 border-b border-line px-5 py-3">
<div className="flex min-w-0 items-center gap-2">{header}</div>
<button type="button" onClick={onClose} aria-label={t.common.cancel} className="flex size-10 cursor-pointer items-center justify-center rounded-lg text-muted hover:text-text">
<X size={18} />
</button>
</header>
{children}
</aside>
</div>
);
}
const TONE: Record<string, string> = {
new: 'bg-signal/15 text-signal',
deal: 'bg-capture/15 text-capture',
rejected: 'bg-danger/15 text-danger',
};
export function StatusChip({ status }: { status: Status | undefined }) {
if (!status) return null;
return <span className={`chip whitespace-nowrap ${TONE[status.code ?? ''] ?? 'bg-raised text-text'}`}>{status.name}</span>;
}
/** Строка характеристик: «3 комн. · 80 м² · этаж 4/9». */
export function useSpecs() {
const { c } = useCrm();
return (l: Pick<Lead, 'rooms' | 'area' | 'floor' | 'floorsTotal'>) =>
[
l.rooms != null ? `${l.rooms} ${c.unit.rooms}` : null,
l.area != null ? `${l.area} м²` : null,
l.floor != null ? `${c.unit.floor} ${l.floor}${l.floorsTotal ? `/${l.floorsTotal}` : ''}` : null,
]
.filter(Boolean)
.join(' · ');
}
/**
* Канбан по статусам: карточку можно перетащить в другой статус (на телефоне — сменить в карточке).
* `locked` — код статуса, в который перетаскивать нельзя (пул у объектов).
*/
export function Kanban<T extends { id: number; statusId: number }>({
items, statuses, onOpen, onMove, canDrag, locked, children,
}: {
items: T[];
statuses: Status[];
onOpen: (id: number) => void;
onMove: (id: number, statusId: number) => void;
canDrag?: (item: T) => boolean;
locked?: string;
children: (item: T) => ReactNode;
}) {
const [over, setOver] = useState<number | null>(null);
return (
<div className="-mx-4 flex gap-3 overflow-x-auto px-4 pb-4 sm:-mx-6 sm:px-6">
{statuses.map((s) => {
const column = items.filter((l) => l.statusId === s.id);
return (
<section
key={s.id}
className={`flex w-64 shrink-0 flex-col rounded-2xl border p-2 transition-colors ${over === s.id ? 'border-signal/60 bg-signal/5' : 'border-line bg-surface'}`}
onDragOver={(e) => { if (!locked || s.code !== locked) { e.preventDefault(); setOver(s.id); } }}
onDragLeave={() => setOver(null)}
onDrop={(e) => {
e.preventDefault();
setOver(null);
const id = Number(e.dataTransfer.getData('text/card'));
if (id && items.find((l) => l.id === id)?.statusId !== s.id) onMove(id, s.id);
}}
>
<h3 className="flex items-center justify-between px-2 py-1.5 text-sm text-muted">
<span className="truncate">{s.name}</span>
<span className="tabular-nums">{column.length}</span>
</h3>
<ul className="flex min-h-16 flex-col gap-2">
{column.map((l) => (
<li key={l.id}>
<button
type="button"
draggable={canDrag ? canDrag(l) : true}
onDragStart={(e) => e.dataTransfer.setData('text/card', String(l.id))}
onClick={() => onOpen(l.id)}
className="w-full cursor-pointer rounded-xl bg-raised p-3 text-left transition-colors hover:bg-raised/70"
>
{children(l)}
</button>
</li>
))}
</ul>
</section>
);
})}
</div>
);
}
/** Сделка в форме: часть комиссии с собственника и часть с клиента. */
interface DealForm { owner: string; client: string; currency: string; date: string }
const dealSum = (d: DealForm) => Math.round(((Number(d.owner) || 0) + (Number(d.client) || 0)) * 100) / 100;
const dealBody = (d: DealForm) => ({ ownerCommission: Number(d.owner) || 0, clientCommission: Number(d.client) || 0, currency: d.currency, date: d.date });
function DealFields({ deal, setDeal, note }: { deal: DealForm; setDeal: (v: DealForm) => void; note?: ReactNode }) {
const { c, price } = useCrm();
return (
<>
<div className="grid grid-cols-2 gap-3 sm:grid-cols-[1fr_1fr_7rem]">
<div>
<label className="label" htmlFor="downer">{c.dlg.ownerPart}</label>
<input id="downer" type="number" min="0" step="0.01" autoFocus className="field tabular-nums" value={deal.owner} onChange={(e) => setDeal({ ...deal, owner: e.target.value })} />
</div>
<div>
<label className="label" htmlFor="dclient">{c.dlg.clientPart}</label>
<input id="dclient" type="number" min="0" step="0.01" className="field tabular-nums" value={deal.client} onChange={(e) => setDeal({ ...deal, client: e.target.value })} />
</div>
<div className="col-span-2 sm:col-span-1">
<label className="label" htmlFor="dcur">{c.dlg.currency}</label>
<select id="dcur" className="field" value={deal.currency} onChange={(e) => setDeal({ ...deal, currency: e.target.value })}>
{CURRENCIES.map((x) => <option key={x}>{x}</option>)}
</select>
</div>
</div>
<p className="-mt-1 text-sm">
{c.dlg.commission}: <span className="font-medium tabular-nums">{price(dealSum(deal), deal.currency)}</span>
<span className="mt-1 block text-xs text-muted">{c.dlg.splitHint}</span>
{note && <span className="mt-1 block text-xs text-muted">{note}</span>}
</p>
<div>
<label className="label" htmlFor="ddate">{c.dlg.date}</label>
<input id="ddate" type="date" required className="field" value={deal.date} onChange={(e) => setDeal({ ...deal, date: e.target.value })} />
</div>
</>
);
}
const today = () => new Date().toISOString().slice(0, 10);
/**
* Смена статуса лида (`leads`) или клиента (`clients`). «Отказ» требует причину, «Сделка» — комиссию
* и дату: для них открывается диалог. В диалоге сделки можно выбрать пару из CRM — прикреплённого
* клиента или объект: тогда сделка закрывается один раз для обоих.
*/
export function useStatusChange(pipeline: PipelineInfo | undefined, kind: 'leads' | 'clients' = 'leads') {
const { c, t } = useCrm();
const [pending, setPending] = useState<{ id: number; status: Status } | null>(null);
const [reasonId, setReasonId] = useState('');
const [pairId, setPairId] = useState('');
const [deal, setDeal] = useState<DealForm>({ owner: '', client: '', currency: 'USD', date: today() });
// Своя сторона сделки: у объекта — часть с собственника, у клиента — часть с клиента.
const own = kind === 'leads' ? 'owner' : 'client';
const change = useAction((v: { id: number; body: object }) => api(`/${kind}/${v.id}/status`, { method: 'POST', body: v.body }));
const isDeal = pending?.status.code === 'deal';
// Кандидаты в пару берутся из карточки: клиенты, которым объект показан, либо объекты клиента.
const card = useQuery({
queryKey: [kind === 'leads' ? 'lead' : 'client', pending?.id],
queryFn: () => api<LeadCard | ClientCard>(`/${kind}/${pending!.id}`),
enabled: isDeal,
});
// Сумму комиссии подставляем по условию из карточки — пока агент сам ничего не ввёл.
const expected = card.data?.commission?.expected ?? null;
const [prefilled, setPrefilled] = useState(false);
useEffect(() => {
if (!isDeal || !expected || prefilled) return;
setPrefilled(true);
setDeal((d) => (d.owner || d.client ? d : { ...d, [own]: String(expected.amount), currency: expected.currency }));
}, [isDeal, expected, prefilled, own]);
const pairs: { id: number; label: string }[] = !card.data
? []
: kind === 'leads'
? (card.data as LeadCard).clients.map((x) => ({ id: x.id, label: x.name }))
: (card.data as ClientCard).leads.filter((x) => x.assigneeId !== null).map((x) => ({ id: x.id, label: x.title }));
const close = () => {
setPending(null);
change.reset();
};
const request = (id: number, statusId: number) => {
const status = pipeline?.statuses.find((s) => s.id === statusId);
if (!status) return;
if (status.code === 'rejected' || status.code === 'deal') {
setReasonId('');
setPairId('');
setPrefilled(false);
setDeal({ owner: '', client: '', currency: 'USD', date: today() });
setPending({ id, status });
} else change.mutate({ id, body: { statusId } });
};
const dialog = pending && (
<Dialog title={isDeal ? c.dlg.deal : c.dlg.reject} onClose={close}>
<form
className="space-y-4"
onSubmit={(e) => {
e.preventDefault();
const pair = pairId ? { [kind === 'leads' ? 'clientId' : 'leadId']: Number(pairId) } : {};
const body = isDeal
? { statusId: pending.status.id, deal: { ...dealBody(deal), ...pair } }
: { statusId: pending.status.id, rejectReasonId: Number(reasonId) };
change.mutate({ id: pending.id, body }, { onSuccess: close });
}}
>
{isDeal ? (
<>
<DealFields deal={deal} setDeal={setDeal} note={prefilled && expected && deal[own] === String(expected.amount) ? c.cm.prefill : null} />
<div>
<label className="label" htmlFor="dpair">{kind === 'leads' ? c.dlg.pairClient : c.dlg.pairLead}</label>
<select id="dpair" className="field" value={pairId} onChange={(e) => setPairId(e.target.value)} disabled={!pairs.length}>
<option value="">{c.dlg.noPair}</option>
{pairs.map((x) => <option key={x.id} value={x.id}>{x.label}</option>)}
</select>
<p className="mt-1.5 text-xs text-muted">{pairs.length ? c.dlg.pairHint : kind === 'leads' ? c.dlg.pairEmptyClient : c.dlg.pairEmptyLead}</p>
</div>
</>
) : (
<fieldset className="space-y-1">
<legend className="sr-only">{c.dlg.reject}</legend>
{pipeline?.reasons.map((r) => (
<label key={r.id} className="flex min-h-11 cursor-pointer items-center gap-3 rounded-xl px-3 hover:bg-raised">
<input type="radio" name="reason" required className="accent-(--capture)" value={r.id} checked={reasonId === String(r.id)} onChange={(e) => setReasonId(e.target.value)} />
{r.name}
</label>
))}
</fieldset>
)}
<ErrorNote error={change.error} />
<div className="flex justify-end gap-2">
<button type="button" className="btn btn-ghost" onClick={close}>{t.common.cancel}</button>
<button className="btn btn-primary" disabled={change.isPending || (isDeal && !(dealSum(deal) > 0))}>{t.common.save}</button>
</div>
</form>
</Dialog>
);
return { request, dialog, error: pending ? null : change.error, isPending: change.isPending };
}
/** Блок «Сделка» в карточке: комиссия по частям, кому они идут, дата, пара. Суммы и дату исправляет руководитель. */
export function DealNote({ deal, pair, canEdit }: { deal: DealInfo; pair: ReactNode; canEdit: boolean }) {
const { c, t, price, date } = useCrm();
const [editing, setEditing] = useState(false);
const [f, setF] = useState<DealForm>({ owner: String(deal.ownerCommission || ''), client: String(deal.clientCommission || ''), currency: deal.currency, date: deal.date });
const parts = [
{ label: c.dlg.ownerPart, amount: deal.ownerCommission, agent: deal.ownerAgentName },
{ label: c.dlg.clientPart, amount: deal.clientCommission, agent: deal.clientAgentName },
].filter((x) => x.amount > 0);
const save = useAction(() =>
api(`/deals/${deal.id}`, { method: 'PATCH', body: dealBody(f) }),
);
return (
<section className="rounded-xl border border-capture/30 bg-capture/10 px-3.5 py-3">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<p className="font-medium">{c.dlg.commission}: <span className="tabular-nums">{price(deal.commission, deal.currency)}</span></p>
{parts.map((x) => (
<p key={x.label} className="mt-0.5 text-sm">
{x.label}: <span className="tabular-nums">{price(x.amount, deal.currency)}</span>
{x.agent && <span className="text-muted"> · {x.agent}</span>}
</p>
))}
<p className="mt-0.5 text-sm text-muted">{c.dlg.date}: {date(deal.date)}</p>
{pair && <p className="mt-0.5 truncate text-sm text-muted">{pair}</p>}
</div>
{canEdit && <button type="button" className="btn btn-ghost btn-sm" onClick={() => setEditing(true)}>{c.dlg.edit}</button>}
</div>
{editing && (
<Dialog title={c.dlg.dealEdit} onClose={() => setEditing(false)}>
<form className="space-y-4" onSubmit={(e) => { e.preventDefault(); save.mutate(undefined, { onSuccess: () => setEditing(false) }); }}>
<DealFields deal={f} setDeal={setF} />
<ErrorNote error={save.error} />
<div className="flex justify-end gap-2">
<button type="button" className="btn btn-ghost" onClick={() => setEditing(false)}>{t.common.cancel}</button>
<button className="btn btn-primary" disabled={save.isPending || !(dealSum(f) > 0)}>{t.common.save}</button>
</div>
</form>
</Dialog>
)}
</section>
);
}
const toLocalInput = (iso: string | null) => {
if (!iso) return '';
const d = new Date(iso);
return new Date(d.getTime() - d.getTimezoneOffset() * 60_000).toISOString().slice(0, 16);
};
/** Следующее действие с напоминанием в боте. */
export function NextAction({ at, note, onSave, pending }: {
at: string | null;
note: string | null;
onSave: (v: { at: string | null; note: string | null }) => void;
pending: boolean;
}) {
const { c } = useCrm();
const [nextAt, setNextAt] = useState('');
const [nextNote, setNextNote] = useState('');
useEffect(() => {
setNextAt(toLocalInput(at));
setNextNote(note ?? '');
}, [at, note]);
return (
<form
className="space-y-3"
onSubmit={(e) => {
e.preventDefault();
onSave({ at: nextAt ? new Date(nextAt).toISOString() : null, note: nextNote || null });
}}
>
<h3 className="label">{c.card.nextAction}</h3>
<div className="grid gap-3 sm:grid-cols-[13rem_1fr]">
<input type="datetime-local" className="field" aria-label={c.card.nextAction} value={nextAt} onChange={(e) => setNextAt(e.target.value)} />
<input className="field" maxLength={500} placeholder={c.card.nextNote} aria-label={c.card.nextNote} value={nextNote} onChange={(e) => setNextNote(e.target.value)} />
</div>
<div className="flex gap-2">
<button className="btn btn-ghost btn-sm" disabled={pending || !nextAt}>{c.card.remind}</button>
{at && <button type="button" className="btn btn-ghost btn-sm" onClick={() => onSave({ at: null, note: null })}>{c.card.clear}</button>}
</div>
</form>
);
}
export function CommentBox({ onSave, pending }: { onSave: (text: string, done: () => void) => void; pending: boolean }) {
const { c } = useCrm();
const [text, setText] = useState('');
return (
<form
className="space-y-3"
onSubmit={(e) => {
e.preventDefault();
if (text.trim()) onSave(text.trim(), () => setText(''));
}}
>
<label className="label" htmlFor="card-comment">{c.card.comment}</label>
<textarea id="card-comment" rows={3} maxLength={5000} className="field py-2.5" value={text} onChange={(e) => setText(e.target.value)} />
<button className="btn btn-ghost btn-sm" disabled={pending || !text.trim()}>{c.card.addComment}</button>
</form>
);
}
/** История карточки. Не редактируется. */
export function History({ events }: { events: LeadEvent[] }) {
const { c, dateTime, date, price } = useCrm();
// Условие комиссии в истории хранится кодом: «percent:2.5», «fixed:1000:USD».
const rule = (code: string | null): string => {
if (!code) return c.cm.notSet.toLowerCase();
const [mode, value, currency, percent] = code.split(':');
if (mode === 'percent') return `${value}%`;
return `${price(Number(value), currency ?? 'USD')}${mode === 'mixed' ? ` + ${percent}%` : ''}`;
};
const line = (e: LeadEvent): string | null => {
switch (e.type) {
case 'created': return e.newValue === 'manual' ? c.ev.createdManual : e.newValue === 'listam' ? c.ev.createdListam : c.ev.clientCreated;
case 'taken': return c.ev.taken;
case 'assigned': return c.ev.assigned(e.oldValue, e.newValue);
case 'status': return c.ev.status(e.oldValue, e.newValue);
case 'deal': return c.ev.deal(e.text);
case 'deal_edited': return c.ev.dealEdited(e.text);
case 'pool_return': return c.ev.poolReturn(e.oldValue);
case 'blacklisted': return c.ev.blacklisted;
case 'listing_removed': return c.ev.listingRemoved;
case 'edited': return c.ev.edited;
case 'offered': return c.ev.offered(e.newValue);
case 'shown': return c.ev.shown(e.newValue);
case 'unlinked': return c.ev.unlinked(e.newValue);
case 'client_offered': return c.ev.clientOffered(e.newValue);
case 'client_shown': return c.ev.clientShown(e.newValue);
case 'phone': return c.sh.evPhone(e.newValue);
case 'commission': {
// text: «<условие компании при сбросе>|<изменение ожидаемой суммы, %>».
const [settings = '', delta = ''] = (e.text ?? '').split('|');
const to = e.newValue ? rule(e.newValue) : `${c.cm.asSettings}${settings ? ` (${rule(settings)})` : ''}`;
// Процент есть у условий «процент» и «фикс + процент».
const pct = (code: string | null) => /^percent:(.+)$/.exec(code ?? '')?.[1] ?? /^mixed:[^:]*:[^:]*:(.+)$/.exec(code ?? '')?.[1];
const oldPct = pct(e.oldValue);
const newPct = pct(e.newValue ?? settings);
const signed = (n: number) => `${n > 0 ? '+' : n < 0 ? '−' : ''}${Math.abs(n).toLocaleString(undefined, { maximumFractionDigits: 1 })}`;
const notes = [
oldPct && newPct ? c.cm.points(signed(Math.round((Number(newPct) - Number(oldPct)) * 100) / 100)) : null,
delta !== '' ? c.cm.sumDelta(signed(Number(delta))) : null,
].filter(Boolean);
return `${c.cm.evChanged(rule(e.oldValue), to)}${notes.length ? ` (${notes.join(', ')})` : ''}`;
}
case 'viewing': return c.sh.evViewing(e.newValue, e.text ? date(e.text) : null);
case 'client_viewing': return c.sh.evClientViewing(e.newValue, e.text ? date(e.text) : null);
case 'comment': return e.text;
default: return e.type;
}
};
return (
<section>
<h3 className="label">{c.card.history}</h3>
<ul className="space-y-3 text-sm">
{events.map((e) => (
<li key={e.id} className="flex gap-3">
<span className={`mt-2 size-1.5 shrink-0 rounded-full ${e.type === 'comment' ? 'bg-capture' : 'bg-line-strong'}`} aria-hidden="true" />
<div className="min-w-0">
<p className={`break-words ${e.type === 'comment' ? 'whitespace-pre-wrap' : 'text-muted'}`}>{line(e)}</p>
<p className="text-xs text-muted">{e.userName ?? c.ev.system} · {dateTime(e.at)}</p>
</div>
</li>
))}
</ul>
</section>
);
}
+572
View File
@@ -0,0 +1,572 @@
import { CURRENCIES, type AgentPay, type Currency } from '@lr/shared';
import { useQuery } from '@tanstack/react-query';
import { Fragment, useState } from 'react';
import { Link } from 'react-router';
import { api, type Member } from '../api.ts';
import { ErrorNote } from '../components/ui.tsx';
import { useAction } from '../hooks.ts';
import { saveCsv } from './AnalyticsPage.tsx';
import { useCrm } from './i18n.ts';
import { Dialog } from './parts.tsx';
interface PayRow {
userId: number;
name: string;
role: string;
pay: AgentPay | null;
deals: number;
/** Часть комиссии агента по его сделкам — в валюте его условий. */
commission: number;
byCurrency: Record<string, number>;
currency: Currency;
fixed: number;
share: number;
/** null — плана нет. */
planDone: boolean | null;
bonus: number;
/** Ручные записи месяца: премии, удержания, авансы. */
adjustments: { id: number; amount: number; currency: string; note: string }[];
adjustmentsTotal: number;
/** Остатки закрытых месяцев, перешедшие в этот. */
recalc: { month: string; amount: number }[];
recalcTotal: number;
total: number;
payout: { id: number; amount: number; currency: string; paidAt: string; note: string | null; last: boolean } | null;
debt: number | null;
}
interface DealRow {
id: number;
date: string;
kind: 'sale' | 'rent';
leadId: number | null;
leadTitle: string | null;
clientId: number | null;
clientName: string | null;
side: 'owner' | 'client' | 'both';
partner: string | null;
dealCommission: number;
part: number;
dealCurrency: string;
percent: number;
share: number;
}
/** «Оклад $300 + 30% со сделок»; если процент с аренды свой — «30% с продажи, 50% с аренды». */
export function usePayText() {
const { c, price } = useCrm();
return (pay: AgentPay | null | undefined) =>
!pay
? c.pay.notSet
: [pay.mode !== 'percent' ? c.pay.salary(price(pay.fixed, pay.currency)) : null, pay.mode === 'fixed' ? null : pay.percentRent != null && pay.percentRent !== pay.percent ? c.pay.shareSplit(pay.percent, pay.percentRent) : c.pay.share(pay.percent)].filter(Boolean).join(' + ');
}
/** Условия оплаты одного агента: оклад, процент с его сделок (отдельно продажа и аренда) или оклад + процент. */
export function PayDialog({ member, onClose }: { member: Member; onClose: () => void }) {
const { c, t } = useCrm();
const p = c.pay;
const [mode, setMode] = useState<AgentPay['mode'] | ''>(member.pay?.mode ?? 'mixed');
const [fixed, setFixed] = useState(String(member.pay?.fixed ?? ''));
const [percent, setPercent] = useState(String(member.pay?.percent ?? ''));
const [percentRent, setPercentRent] = useState(String(member.pay?.percentRent ?? member.pay?.percent ?? ''));
const [currency, setCurrency] = useState<Currency>(member.pay?.currency ?? 'USD');
const [planDeals, setPlanDeals] = useState(String(member.pay?.planDeals ?? ''));
const [planCommission, setPlanCommission] = useState(String(member.pay?.planCommission ?? ''));
const [bonusMode, setBonusMode] = useState<AgentPay['bonusMode']>(member.pay?.bonusMode ?? 'none');
const [bonusValue, setBonusValue] = useState(String(member.pay?.bonusValue || ''));
const plan = {
planDeals: planDeals ? Number(planDeals) : null,
planCommission: planCommission ? Number(planCommission) : null,
bonusMode,
bonusValue: bonusMode === 'none' ? 0 : Number(bonusValue),
};
const save = useAction(() =>
api(`/members/${member.id}/pay`, {
method: 'POST',
body: { pay: mode ? { mode, fixed: mode === 'percent' ? 0 : Number(fixed), percent: mode === 'fixed' ? 0 : Number(percent), percentRent: mode === 'fixed' ? null : Number(percentRent), currency, ...plan } : null },
}),
);
return (
<Dialog title={`${p.title}: ${member.name}`} onClose={onClose}>
<form className="space-y-4" onSubmit={(e) => { e.preventDefault(); save.mutate(undefined, { onSuccess: onClose }); }}>
<fieldset className="space-y-1">
<legend className="sr-only">{p.title}</legend>
{(['fixed', 'percent', 'mixed', ''] as const).map((m) => (
<label key={m || 'none'} className="flex min-h-11 cursor-pointer items-center gap-3 rounded-xl px-3 hover:bg-raised">
<input type="radio" name="pay-mode" className="accent-(--capture)" checked={mode === m} onChange={() => setMode(m)} />
{m ? p.modes[m] : p.notSet}
</label>
))}
</fieldset>
{mode && (
<div className="grid gap-4 sm:grid-cols-2">
{mode !== 'percent' && (
<div>
<label className="label" htmlFor="pay-fixed">{p.fixedLabel}</label>
<div className="flex gap-2">
<input id="pay-fixed" type="number" min={0} step="any" required className="field tabular-nums" value={fixed} onChange={(e) => setFixed(e.target.value)} />
<select aria-label={c.dlg.currency} className="field w-auto" value={currency} onChange={(e) => setCurrency(e.target.value as Currency)}>
{CURRENCIES.map((v) => <option key={v}>{v}</option>)}
</select>
</div>
</div>
)}
{mode !== 'fixed' && (
<div>
<label className="label" htmlFor="pay-percent">{p.percentLabel}</label>
<input id="pay-percent" type="number" min={0} max={100} step="any" required className="field tabular-nums" value={percent} onChange={(e) => setPercent(e.target.value)} />
</div>
)}
{mode !== 'fixed' && (
<div>
<label className="label" htmlFor="pay-percent-rent">{p.percentRentLabel}</label>
<input id="pay-percent-rent" type="number" min={0} max={100} step="any" required className="field tabular-nums" value={percentRent} onChange={(e) => setPercentRent(e.target.value)} />
</div>
)}
{mode === 'percent' && (
<div>
<label className="label" htmlFor="pay-cur">{p.payoutCurrency}</label>
<select id="pay-cur" className="field" value={currency} onChange={(e) => setCurrency(e.target.value as Currency)}>
{CURRENCIES.map((v) => <option key={v}>{v}</option>)}
</select>
</div>
)}
</div>
)}
<p className="text-sm text-muted">{p.hint}</p>
{mode && (
<>
<fieldset className="border-t border-line pt-4">
<legend className="sr-only">{p.planTitle}</legend>
<p className="mb-3 font-medium" aria-hidden>{p.planTitle}</p>
<div className="grid gap-4 sm:grid-cols-2">
<div>
<label className="label" htmlFor="plan-deals">{p.planDeals}</label>
<input id="plan-deals" type="number" min={1} step={1} className="field tabular-nums" value={planDeals} onChange={(e) => setPlanDeals(e.target.value)} />
</div>
<div>
<label className="label" htmlFor="plan-commission">{p.planCommission}, {currency}</label>
<input id="plan-commission" type="number" min={0} step="any" className="field tabular-nums" value={planCommission} onChange={(e) => setPlanCommission(e.target.value)} />
</div>
</div>
<p className="mt-2 text-xs text-muted">{p.planHint}</p>
</fieldset>
<fieldset>
<legend className="sr-only">{p.bonusTitle}</legend>
<p className="mb-3 font-medium" aria-hidden>{p.bonusTitle}</p>
<div className="grid gap-4 sm:grid-cols-2">
<div>
<label className="label" htmlFor="bonus-mode">{p.bonusTitle}</label>
<select id="bonus-mode" className="field" value={bonusMode} onChange={(e) => setBonusMode(e.target.value as AgentPay['bonusMode'])}>
{(['none', 'fixed', 'percent'] as const).map((m) => <option key={m} value={m}>{p.bonusModes[m]}</option>)}
</select>
</div>
{bonusMode !== 'none' && (
<div>
<label className="label" htmlFor="bonus-value">{p.bonusValue}, {bonusMode === 'percent' ? '%' : currency}</label>
<input id="bonus-value" type="number" min={0} max={bonusMode === 'percent' ? 100 : undefined} step="any" required className="field tabular-nums" value={bonusValue} onChange={(e) => setBonusValue(e.target.value)} />
</div>
)}
</div>
{bonusMode === 'percent' && <p className="mt-2 text-xs text-muted">{p.bonusHint}</p>}
</fieldset>
</>
)}
<ErrorNote error={save.error} />
<div className="flex justify-end gap-2">
<button type="button" className="btn btn-ghost" onClick={onClose}>{t.common.cancel}</button>
<button className="btn btn-primary" disabled={save.isPending}>{t.common.save}</button>
</div>
</form>
</Dialog>
);
}
const thisMonth = () => new Date(Date.now() + 4 * 3_600_000).toISOString().slice(0, 7);
const monthRange = (ym: string) => {
const [y, m] = ym.split('-').map(Number) as [number, number];
return { from: `${ym}-01`, to: `${ym}-${String(new Date(Date.UTC(y, m, 0)).getUTCDate()).padStart(2, '0')}` };
};
/** Сделки агента за период: его часть комиссии, процент и начисление. `userId` не задан — свои. */
function AgentDeals({ userId, from, to, currency }: { userId?: number; from: string; to: string; currency: Currency }) {
const { c, price, date } = useCrm();
const p = c.pay;
const list = useQuery({
queryKey: ['payroll-deals', userId ?? 'me', from],
queryFn: () => api<{ deals: DealRow[] }>(`/payroll/deals?from=${from}&to=${to}${userId ? `&userId=${userId}` : ''}`),
});
if (list.error) return <ErrorNote error={list.error} />;
if (!list.data) return null;
if (!list.data.deals.length) return <p className="text-sm text-muted">{p.noDeals}</p>;
return (
<div className="overflow-x-auto">
<table className="w-full min-w-[40rem] text-left text-sm">
<thead className="text-muted">
<tr>
{[p.dcol.date, p.dcol.deal, p.dcol.kind, p.dcol.side, p.dcol.part, p.dcol.percent, p.dcol.accrued].map((h, i) => (
<th key={h} className={`px-3 py-2 font-normal ${i > 3 ? 'text-right' : ''}`}>{h}</th>
))}
</tr>
</thead>
<tbody className="divide-y divide-line">
{list.data.deals.map((d) => (
<tr key={d.id} className="align-top">
<td className="whitespace-nowrap px-3 py-2 tabular-nums">{date(d.date)}</td>
<td className="px-3 py-2">
{d.leadId && <Link to={`/leads/${d.leadId}`} className="block text-signal hover:underline">{d.leadTitle}</Link>}
{d.clientId && <Link to={`/clients/${d.clientId}`} className="block text-signal hover:underline">{d.clientName}</Link>}
</td>
<td className="px-3 py-2">{c.deal[d.kind]}</td>
<td className="px-3 py-2">
{p.side[d.side]}
{d.partner && <span className="block text-xs text-muted">{p.withPartner(d.partner)}</span>}
</td>
<td className="px-3 py-2 text-right tabular-nums">
{price(d.part, d.dealCurrency)}
{d.part !== d.dealCommission && <span className="block text-xs text-muted">{p.ofDeal(price(d.dealCommission, d.dealCurrency))}</span>}
</td>
<td className="px-3 py-2 text-right tabular-nums">{d.percent ? `${d.percent}%` : '—'}</td>
<td className="px-3 py-2 text-right font-medium tabular-nums">{d.share ? price(d.share, currency) : '—'}</td>
</tr>
))}
</tbody>
</table>
</div>
);
}
/** Сумма со знаком: «+$100», «−$50». */
function useSigned() {
const { price } = useCrm();
return (n: number, currency: string) => `${n < 0 ? '−' : '+'}${price(Math.abs(n), currency)}`;
}
const todayIso = () => new Date(Date.now() + 4 * 3_600_000).toISOString().slice(0, 10);
/** Из чего складывается выплата за месяц. Руководитель добавляет и убирает ручные записи, пока месяц не закрыт. */
function PayLines({ row, month, canEdit }: { row: PayRow; month: string; canEdit: boolean }) {
const { c, price, monthName } = useCrm();
const signed = useSigned();
const p = c.pay;
const [amount, setAmount] = useState('');
const [note, setNote] = useState('');
const add = useAction(() => api('/payroll/adjustments', { method: 'POST', body: { userId: row.userId, month, amount: Number(amount), note } }));
const remove = useAction((id: number) => api(`/payroll/adjustments/${id}`, { method: 'DELETE' }));
const open = canEdit && !row.payout;
const lines: { key: string; label: string; amount: number; currency: string; id?: number }[] = [
...(row.fixed ? [{ key: 'fixed', label: p.lineFixed, amount: row.fixed, currency: row.currency }] : []),
...(row.share ? [{ key: 'share', label: p.lineShare, amount: row.share, currency: row.currency }] : []),
...(row.bonus ? [{ key: 'bonus', label: p.lineBonus, amount: row.bonus, currency: row.currency }] : []),
...row.adjustments.map((a) => ({ key: `a${a.id}`, label: a.note, amount: a.amount, currency: a.currency, id: a.id })),
...row.recalc.map((r) => ({ key: `r${r.month}`, label: p.lineRecalc(monthName(r.month)), amount: r.amount, currency: row.currency })),
];
return (
<div>
<h3 className="mb-2 font-medium">{p.lines}</h3>
<ul className="divide-y divide-line text-sm">
{lines.map((l) => (
<li key={l.key} className="flex items-center justify-between gap-3 py-1.5">
<span className="min-w-0 break-words">{l.label}</span>
<span className="flex shrink-0 items-center gap-2">
<span className={`tabular-nums ${l.amount < 0 ? 'text-danger' : ''}`}>{l.id || l.key.startsWith('r') ? signed(l.amount, l.currency) : price(l.amount, l.currency)}</span>
{open && l.id && (
<button type="button" className="btn btn-ghost btn-sm" aria-label={`${p.remove}: ${l.label}`} disabled={remove.isPending} onClick={() => remove.mutate(l.id!)}>×</button>
)}
</span>
</li>
))}
<li className="flex items-center justify-between gap-3 py-2 font-semibold">
<span>{p.lineTotal}</span>
<span className="tabular-nums text-capture">{price(row.total, row.currency)}</span>
</li>
</ul>
{open && (
<form className="mt-3 flex flex-wrap items-end gap-2" onSubmit={(e) => { e.preventDefault(); add.mutate(undefined, { onSuccess: () => { setAmount(''); setNote(''); } }); }}>
<div className="w-44">
<label className="label" htmlFor={`adj-amount-${row.userId}`}>{p.lineAmount}, {row.currency}</label>
<input id={`adj-amount-${row.userId}`} type="number" step="any" required className="field tabular-nums" value={amount} onChange={(e) => setAmount(e.target.value)} />
</div>
<div className="min-w-40 flex-1">
<label className="label" htmlFor={`adj-note-${row.userId}`}>{p.lineNote}</label>
<input id={`adj-note-${row.userId}`} required maxLength={200} placeholder={p.lineNotePh} className="field" value={note} onChange={(e) => setNote(e.target.value)} />
</div>
<button className="btn btn-ghost" disabled={add.isPending || !Number(amount)}>{p.add}</button>
</form>
)}
<ErrorNote error={add.error ?? remove.error} />
</div>
);
}
/** Отметка «выплачено» за месяц: сумма, дата, комментарий. После неё месяц закрыт. */
function PayoutBox({ row, month, canEdit }: { row: PayRow; month: string; canEdit: boolean }) {
const { c, price, date } = useCrm();
const p = c.pay;
const [amount, setAmount] = useState('');
const [paidAt, setPaidAt] = useState(todayIso);
const [note, setNote] = useState('');
const pay = useAction(() =>
api('/payroll/payouts', { method: 'POST', body: { userId: row.userId, month, paidAt, ...(amount ? { amount: Number(amount) } : {}), ...(note ? { note } : {}) } }),
);
const cancel = useAction(() => api(`/payroll/payouts/${row.payout!.id}`, { method: 'DELETE' }));
if (row.payout) {
const debt = row.debt ?? 0;
return (
<div className="rounded-xl border border-capture/30 bg-capture/10 px-3.5 py-3 text-sm">
<div className="flex flex-wrap items-start justify-between gap-3">
<div>
<p className="font-medium">{p.paidInfo(price(row.payout.amount, row.payout.currency), date(row.payout.paidAt))}</p>
{row.payout.note && <p className="mt-0.5 text-muted">{row.payout.note}</p>}
{debt > 0 && <p className="mt-1">{p.debt(price(debt, row.payout.currency))}</p>}
{debt < 0 && <p className="mt-1">{p.overpaid(price(-debt, row.payout.currency))}</p>}
<p className="mt-1 text-xs text-muted">{p.closedHint}</p>
</div>
{canEdit && row.payout.last && <button type="button" className="btn btn-ghost btn-sm" disabled={cancel.isPending} onClick={() => cancel.mutate()}>{p.cancelPaid}</button>}
</div>
<ErrorNote error={cancel.error} />
</div>
);
}
if (!canEdit) return <p className="text-sm text-muted">{p.notPaid}</p>;
return (
<form className="flex flex-wrap items-end gap-2" onSubmit={(e) => { e.preventDefault(); pay.mutate(); }}>
<div className="w-40">
<label className="label" htmlFor={`pay-amount-${row.userId}`}>{p.payAmount}, {row.currency}</label>
<input id={`pay-amount-${row.userId}`} type="number" min={0} step="any" className="field tabular-nums" placeholder={String(Math.max(0, row.total))} value={amount} onChange={(e) => setAmount(e.target.value)} />
</div>
<div>
<label className="label" htmlFor={`pay-date-${row.userId}`}>{p.payDate}</label>
<input id={`pay-date-${row.userId}`} type="date" required className="field w-auto" value={paidAt} onChange={(e) => setPaidAt(e.target.value)} />
</div>
<div className="min-w-40 flex-1">
<label className="label" htmlFor={`pay-note-${row.userId}`}>{p.payNote}</label>
<input id={`pay-note-${row.userId}`} maxLength={200} className="field" value={note} onChange={(e) => setNote(e.target.value)} />
</div>
<button className="btn btn-primary" disabled={pay.isPending}>{p.markPaid}</button>
<div className="basis-full"><ErrorNote error={pay.error} /></div>
</form>
);
}
/** Прошлые выплаты агента: месяц, сколько выплачено и сколько было начислено. `userId` не задан — свои. */
function PayoutHistory({ userId }: { userId?: number }) {
const { c, price, date, monthName } = useCrm();
const p = c.pay;
const list = useQuery({
queryKey: ['payouts', userId ?? 'me'],
queryFn: () => api<{ id: number; month: string; amount: number; currency: string; paidAt: string; note: string | null; accrued: number }[]>(`/payroll/payouts${userId ? `?userId=${userId}` : ''}`),
});
if (!list.data?.length) return null;
return (
<div>
<h3 className="mb-2 font-medium">{p.history}</h3>
<ul className="divide-y divide-line text-sm">
{list.data.map((x) => (
<li key={x.id} className="flex flex-wrap items-baseline justify-between gap-x-3 py-1.5">
<span>{monthName(x.month)} <span className="text-muted">· {date(x.paidAt)}{x.note ? ` · ${x.note}` : ''}</span></span>
<span className="tabular-nums">
{price(x.amount, x.currency)}
{x.accrued !== x.amount && <span className="text-muted"> · {p.accruedOf(price(x.accrued, x.currency))}</span>}
</span>
</li>
))}
</ul>
</div>
);
}
/** Выполнение плана: «2 из 3» и «$3 500 из $5 000». */
function PlanProgress({ row }: { row: PayRow }) {
const { c, price } = useCrm();
const p = c.pay;
if (row.planDone === null || !row.pay) return null;
return (
<p className={`text-sm ${row.planDone ? 'text-capture' : 'text-muted'}`}>
{row.planDone ? p.planDone : p.planNot}
{': '}
{[
row.pay.planDeals != null ? `${p.planOf(String(row.deals), String(row.pay.planDeals))} (${p.col.deals.toLowerCase()})` : null,
row.pay.planCommission != null ? p.planOf(price(row.commission, row.currency), price(row.pay.planCommission, row.currency)) : null,
].filter(Boolean).join(' · ')}
</p>
);
}
/** Кому сколько платить за месяц: сделки агента, его часть комиссии, процент, оклад, бонус и прочее, итог, отметка о выплате. Строка раскрывает подробности. */
export function PayrollTable() {
const { c, price, date } = useCrm();
const signed = useSigned();
const p = c.pay;
const payText = usePayText();
const [month, setMonth] = useState(thisMonth);
const [open, setOpen] = useState<number | null>(null);
const range = monthRange(month || thisMonth());
const data = useQuery({
queryKey: ['payroll', range.from],
queryFn: () => api<{ months: number; agents: PayRow[] }>(`/payroll?from=${range.from}&to=${range.to}`),
});
// Файл для бухгалтерии: ведомость за месяц и под ней — сделки каждого сотрудника.
const exportCsv = useAction(async () => {
const agents = data.data?.agents ?? [];
const lists = await Promise.all(
agents.filter((r) => r.deals).map(async (r) => ({ r, deals: (await api<{ deals: DealRow[] }>(`/payroll/deals?from=${range.from}&to=${range.to}&userId=${r.userId}`)).deals })),
);
saveCsv(`payroll-${range.from.slice(0, 7)}.csv`, [
[p.col.agent, p.col.terms, p.col.deals, p.col.commission, p.col.share, p.col.fixed, p.other, p.col.total, p.currency, p.paidCol, p.payDate],
...agents.map((r) => [
r.name, payText(r.pay), r.deals, Object.entries(r.byCurrency).map(([cur, n]) => `${n} ${cur}`).join(' + '),
r.share, r.fixed, r.bonus + r.adjustmentsTotal + r.recalcTotal, r.total, r.currency, r.payout?.amount ?? '', r.payout?.paidAt ?? '',
]),
[],
[p.col.agent, p.dcol.date, p.object, p.client, p.dcol.kind, p.dcol.side, p.dcol.part, p.currency, p.dcol.percent, p.dcol.accrued, p.currency],
...lists.flatMap(({ r, deals }) =>
deals.map((d) => [r.name, d.date, d.leadTitle ?? '', d.clientName ?? '', c.deal[d.kind], p.side[d.side], d.part, d.dealCurrency, d.percent, d.share, r.currency]),
),
]);
}, []);
// Итог складывается по валютам выплат: агентам могут платить в разных.
const totals: Record<string, number> = {};
for (const r of data.data?.agents ?? []) if (r.total) totals[r.currency] = (totals[r.currency] ?? 0) + r.total;
return (
<section className="mt-10">
<div className="mb-3 flex flex-wrap items-end justify-between gap-3">
<div>
<h2 className="text-lg font-medium tracking-tight">{p.payroll}</h2>
<p className="mt-1 max-w-2xl text-sm text-muted">{p.payrollHint}</p>
</div>
<div className="flex flex-wrap items-end gap-2">
<div>
<label className="label" htmlFor="pay-month">{p.month}</label>
<input id="pay-month" type="month" className="field w-auto" max={thisMonth()} value={month} onChange={(e) => setMonth(e.target.value)} />
</div>
<button type="button" className="btn btn-ghost" disabled={!data.data || exportCsv.isPending} onClick={() => exportCsv.mutate()}>{p.export}</button>
</div>
</div>
<ErrorNote error={data.error} />
{data.data && (
<div className="card overflow-x-auto">
<table className="w-full min-w-[60rem] text-left text-sm">
<thead className="text-muted">
<tr className="border-b border-line">
{[p.col.agent, p.col.terms, p.col.deals, p.col.commission, p.col.share, p.col.fixed, p.other, p.col.total, p.status].map((h, i) => (
<th key={h} className={`px-4 py-3 font-normal ${i > 1 ? 'text-right' : ''}`}>{h}</th>
))}
</tr>
</thead>
<tbody className="divide-y divide-line">
{data.data.agents.map((r) => (
<Fragment key={r.userId}>
<tr>
<td className="px-4 py-3 font-medium">
<button type="button" className="text-left text-signal hover:underline" aria-expanded={open === r.userId} onClick={() => setOpen(open === r.userId ? null : r.userId)}>
{r.name}
</button>
</td>
<td className={`px-4 py-3 ${r.pay ? '' : 'text-muted'}`}>{payText(r.pay)}</td>
<td className="px-4 py-3 text-right tabular-nums">{r.pay?.planDeals != null ? p.planOf(String(r.deals), String(r.pay.planDeals)) : r.deals || '—'}</td>
<td className="px-4 py-3 text-right tabular-nums">
{Object.entries(r.byCurrency).map(([cur, n]) => price(n, cur)).join(' + ') || '—'}
</td>
<td className="px-4 py-3 text-right tabular-nums">{r.share ? price(r.share, r.currency) : '—'}</td>
<td className="px-4 py-3 text-right tabular-nums">{r.fixed ? price(r.fixed, r.currency) : '—'}</td>
<td className="px-4 py-3 text-right tabular-nums">{r.bonus + r.adjustmentsTotal + r.recalcTotal ? signed(r.bonus + r.adjustmentsTotal + r.recalcTotal, r.currency) : '—'}</td>
<td className="px-4 py-3 text-right text-base font-semibold tabular-nums text-capture">{r.pay || r.total ? price(r.total, r.currency) : '—'}</td>
<td className={`whitespace-nowrap px-4 py-3 text-right ${r.payout ? 'text-capture' : 'text-muted'}`}>{r.payout ? p.paid(date(r.payout.paidAt)) : r.pay || r.total ? p.notPaid : '—'}</td>
</tr>
{open === r.userId && (
<tr>
<td colSpan={9} className="space-y-5 bg-raised px-4 py-4">
<PlanProgress row={r} />
<div>
<h3 className="mb-2 font-medium">{p.dealsOf(r.name)}</h3>
<AgentDeals userId={r.userId} from={range.from} to={range.to} currency={r.currency} />
</div>
<div className="grid gap-5 lg:grid-cols-2">
<PayLines row={r} month={month || thisMonth()} canEdit />
<div className="space-y-5">
<div>
<h3 className="mb-2 font-medium">{r.payout ? p.status : p.payTitle}</h3>
<PayoutBox row={r} month={month || thisMonth()} canEdit />
</div>
<PayoutHistory userId={r.userId} />
</div>
</div>
</td>
</tr>
)}
</Fragment>
))}
</tbody>
<tfoot>
<tr className="border-t border-line-strong">
<td className="px-4 py-3 font-medium" colSpan={7}>{p.col.sum}</td>
<td className="px-4 py-3 text-right text-base font-semibold tabular-nums">
{Object.entries(totals).map(([cur, n]) => price(n, cur)).join(' + ') || '—'}
</td>
<td />
</tr>
</tfoot>
</table>
</div>
)}
</section>
);
}
/** Агенту: его условия, сделки, начисления и выплаты за выбранный месяц. */
export function MyPay() {
const { c, price } = useCrm();
const p = c.pay;
const payText = usePayText();
const [picked, setPicked] = useState(thisMonth);
const month = picked || thisMonth();
const mine = useQuery({ queryKey: ['my-pay', month], queryFn: () => api<PayRow | { pay: null }>(`/my-pay?month=${month}`), retry: false, placeholderData: (prev) => prev });
const row = mine.data;
const range = monthRange(month);
if (!row?.pay) return null;
const r = row as PayRow;
return (
<section className="card p-5 sm:p-6">
<div className="flex flex-wrap items-start justify-between gap-3">
<div>
<h2 className="text-lg font-medium tracking-tight">{p.mine}</h2>
<p className="mt-1 text-muted">{payText(r.pay)}</p>
</div>
<div>
<label className="label" htmlFor="my-pay-month">{p.month}</label>
<input id="my-pay-month" type="month" className="field w-auto" max={thisMonth()} value={picked} onChange={(e) => setPicked(e.target.value)} />
</div>
</div>
<dl className="mt-4 grid grid-cols-3 gap-4">
<div>
<dt className="text-sm text-muted">{p.col.deals}</dt>
<dd className="mt-1 text-2xl font-semibold tabular-nums tracking-tight">{r.deals}</dd>
</div>
<div>
<dt className="text-sm text-muted">{p.col.share}</dt>
<dd className="mt-1 text-2xl font-semibold tabular-nums tracking-tight">{price(r.share, r.currency)}</dd>
</div>
<div>
<dt className="text-sm text-muted">{p.thisMonth}</dt>
<dd className="mt-1 text-2xl font-semibold tabular-nums tracking-tight text-capture">{price(r.total, r.currency)}</dd>
</div>
</dl>
<div className="mt-4"><PlanProgress row={r} /></div>
{r.deals > 0 && (
<div className="mt-5 border-t border-line pt-4">
<h3 className="mb-2 font-medium">{p.myDeals}</h3>
<AgentDeals from={range.from} to={range.to} currency={r.currency} />
</div>
)}
<div className="mt-5 grid gap-5 border-t border-line pt-4 lg:grid-cols-2">
<PayLines row={r} month={month} canEdit={false} />
<div className="space-y-5">
<PayoutBox row={r} month={month} canEdit={false} />
<PayoutHistory />
</div>
</div>
</section>
);
}
+84
View File
@@ -0,0 +1,84 @@
import { useQuery } from '@tanstack/react-query';
import { ExternalLink } from 'lucide-react';
import { api } from '../api.ts';
import { ErrorNote } from '../components/ui.tsx';
import { useAction } from '../hooks.ts';
import { CopyButton } from '../pages/Team.tsx';
import { useCrm } from './i18n.ts';
import { Dialog } from './parts.tsx';
/** Ссылка агента на анкету в клиентском боте. */
export function IntakeDialog({ onClose }: { onClose: () => void }) {
const { c, t } = useCrm();
const link = useQuery({ queryKey: ['intake-link'], queryFn: () => api<{ url: string | null }>('/intake-link') });
return (
<Dialog title={c.sh.intakeTitle} onClose={onClose}>
<ErrorNote error={link.error} />
{link.isPending && <p className="text-muted">{t.common.loading}</p>}
{link.data && !link.data.url && <p className="text-muted">{c.sh.intakeOff}</p>}
{link.data?.url && (
<div className="space-y-4">
<p className="text-sm leading-relaxed text-muted">{c.sh.intakeText}</p>
<code className="block break-all rounded-xl bg-ink p-3 text-sm">{link.data.url}</code>
<div className="flex justify-end"><CopyButton text={link.data.url} /></div>
</div>
)}
</Dialog>
);
}
/** Страница подборки для клиента и его записи на показ — блок в карточке клиента. */
export function ClientPage({ clientId, url, viewings, canEdit }: {
clientId: number; url: string | null; viewings: { leadId: number; day: string; title: string; note: string | null }[]; canEdit: boolean;
}) {
const { c, date } = useCrm();
const s = c.sh;
const create = useAction((match: boolean) => api<{ url: string; added: number }>(`/clients/${clientId}/page`, { method: 'POST', body: { match } }), ['client', clientId]);
const close = useAction(() => api(`/clients/${clientId}/page`, { method: 'DELETE' }), ['client', clientId]);
if (!url && !canEdit && !viewings.length) return null;
return (
<section className="space-y-3">
<h3 className="label">{s.page}</h3>
{url ? (
<>
<code className="block break-all rounded-xl bg-ink p-3 text-sm">{url}</code>
<div className="flex flex-wrap gap-2">
<CopyButton text={url} />
<a className="btn btn-ghost btn-sm" href={url} target="_blank" rel="noopener noreferrer"><ExternalLink size={14} aria-hidden="true" />{s.open}</a>
{canEdit && (
<>
<button type="button" className="btn btn-ghost btn-sm" disabled={create.isPending} onClick={() => create.mutate(true)}>{s.createMatch}</button>
<button type="button" className="btn btn-danger btn-sm" disabled={close.isPending} onClick={() => confirm(s.confirmClose) && close.mutate()}>{s.close}</button>
</>
)}
</div>
</>
) : (
canEdit && (
<>
<p className="text-sm text-muted">{s.pageHint}</p>
<div className="flex flex-wrap gap-2">
<button type="button" className="btn btn-primary btn-sm" disabled={create.isPending} onClick={() => create.mutate(true)}>{s.createMatch}</button>
<button type="button" className="btn btn-ghost btn-sm" disabled={create.isPending} onClick={() => create.mutate(false)}>{s.create}</button>
</div>
</>
)
)}
{create.data && create.variables && <p className="text-sm text-muted" role="status">{s.added(create.data.added)}</p>}
<ErrorNote error={create.error ?? close.error} />
{viewings.length > 0 && (
<div>
<h4 className="mb-1.5 text-xs text-muted">{s.viewings}</h4>
<ul className="space-y-1.5 text-sm">
{viewings.map((v) => (
<li key={v.leadId} className="rounded-lg bg-raised px-3 py-2">
<span className="font-medium tabular-nums">{date(v.day)}</span> — {v.title}
{v.note && <span className="block text-xs text-muted">{v.note}</span>}
</li>
))}
</ul>
</div>
)}
</section>
);
}
+176
View File
@@ -0,0 +1,176 @@
import { useQuery } from '@tanstack/react-query';
import { useState } from 'react';
import { api } from '../api.ts';
import { ErrorNote } from '../components/ui.tsx';
import { useCrm } from './i18n.ts';
interface Point { deals: number; commission: number }
interface Trend { months: string[]; agents: { userId: number; name: string; points: Point[] }[]; totals: Point[] }
type Metric = 'deals' | 'commission';
/** Столбцы одного сотрудника по месяцам. Шкала общая для всех карточек — их можно сравнивать между собой. */
function Bars({ title, months, points, metric, max }: { title: string; months: string[]; points: Point[]; metric: Metric; max: number }) {
const { c, price, monthShort, monthName } = useCrm();
const p = c.pay;
const [hover, setHover] = useState<number | null>(null);
const show = (n: number) => (metric === 'deals' ? String(n) : price(n, 'USD'));
const last = points.length - 1;
const now = points[last]![metric];
const delta = now - (points[last - 1]?.[metric] ?? 0);
// Подпись над столбцом: под курсором, иначе — у текущего месяца.
const labelled = hover ?? last;
return (
<figure className="card p-4">
<figcaption>
<p className="truncate font-medium">{title}</p>
<p className="mt-1 text-2xl font-semibold tabular-nums tracking-tight">{show(now)}</p>
<p className="text-xs text-muted">{delta ? p.vsPrev(`${delta > 0 ? '+' : '−'}${show(Math.abs(delta))}`) : p.same}</p>
</figcaption>
<div className="mt-3 flex h-28 items-end gap-0.5 border-b border-line" onMouseLeave={() => setHover(null)}>
{points.map((pt, i) => {
const v = pt[metric];
return (
<div
key={months[i]}
className="relative flex h-full flex-1 items-end justify-center"
tabIndex={0}
aria-label={`${monthName(months[i]!)}: ${show(v)}`}
onMouseEnter={() => setHover(i)}
onFocus={() => setHover(i)}
onBlur={() => setHover(null)}
>
{labelled === i && (
<span className="pointer-events-none absolute whitespace-nowrap text-xs tabular-nums" style={{ bottom: `calc(${max ? (v / max) * 82 : 0}% + 4px)` }}>
{show(v)}
</span>
)}
<span
className={`w-full max-w-6 rounded-t ${hover === i ? 'bg-capture' : 'bg-capture/75'}`}
style={{ height: v ? `max(2px, ${max ? (v / max) * 82 : 0}%)` : 0 }}
/>
</div>
);
})}
</div>
<div className="mt-1 flex gap-0.5 text-center text-[0.6875rem] text-muted" aria-hidden>
{months.map((m, i) => <span key={m} className={`flex-1 truncate ${labelled === i ? 'text-text' : ''}`}>{monthShort(m)}</span>)}
</div>
</figure>
);
}
/**
* Динамика по месяцам. Руководитель видит компанию и каждого сотрудника, агент — только себя.
* Сделки и комиссия — разные шкалы, поэтому показатель переключается, а не рисуется второй осью.
*/
export function PayTrend() {
const { c, price, monthName } = useCrm();
const p = c.pay;
const [metric, setMetric] = useState<Metric>('deals');
const [table, setTable] = useState(false);
const data = useQuery({ queryKey: ['pay-trend'], queryFn: () => api<Trend>('/payroll/trend?months=6') });
const d = data.data;
const solo = d?.agents.length === 1;
const series = d ? [...(solo ? [] : [{ userId: 0, name: p.company, points: d.totals }]), ...d.agents] : [];
// Общая шкала — по сотрудникам; итог компании крупнее всех и рисуется в своей.
const max = d ? Math.max(0, ...d.agents.flatMap((a) => a.points.map((x) => x[metric]))) : 0;
const totalMax = d ? Math.max(0, ...d.totals.map((x) => x[metric])) : 0;
const show = (n: number) => (metric === 'deals' ? String(n) : price(n, 'USD'));
return (
<section className="mt-10">
<div className="mb-3 flex flex-wrap items-end justify-between gap-3">
<div>
<h2 className="text-lg font-medium tracking-tight">{solo ? p.trendMine : p.trend}</h2>
<p className="mt-1 max-w-2xl text-sm text-muted">{p.trendHint}</p>
</div>
<div className="flex flex-wrap gap-2">
<div role="group" aria-label={p.metric} className="flex rounded-xl border border-line p-0.5">
{(['deals', 'commission'] as const).map((m) => (
<button key={m} type="button" aria-pressed={metric === m} className={`rounded-lg px-3 py-1.5 text-sm ${metric === m ? 'bg-raised font-medium' : 'text-muted'}`} onClick={() => setMetric(m)}>
{m === 'deals' ? p.metricDeals : p.metricCommission}
</button>
))}
</div>
<button type="button" className="btn btn-ghost btn-sm" aria-pressed={table} onClick={() => setTable(!table)}>{table ? p.asChart : p.asTable}</button>
</div>
</div>
<ErrorNote error={data.error} />
{d && !table && (
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
{series.map((a) => <Bars key={a.userId} title={a.name} months={d.months} points={a.points} metric={metric} max={a.userId ? max : totalMax} />)}
</div>
)}
{d && table && (
<div className="card overflow-x-auto">
<table className="w-full min-w-[36rem] text-left text-sm">
<thead className="text-muted">
<tr className="border-b border-line">
<th className="px-4 py-3 font-normal">{p.col.agent}</th>
{d.months.map((m) => <th key={m} className="px-4 py-3 text-right font-normal">{monthName(m)}</th>)}
</tr>
</thead>
<tbody className="divide-y divide-line">
{series.map((a) => (
<tr key={a.userId} className={a.userId ? '' : 'font-medium'}>
<td className="px-4 py-3">{a.name}</td>
{a.points.map((x, i) => <td key={d.months[i]} className="px-4 py-3 text-right tabular-nums">{x[metric] ? show(x[metric]) : '—'}</td>)}
</tr>
))}
</tbody>
</table>
</div>
)}
</section>
);
}
/** Рейтинг месяца — виден всем: место, сделки, выполнение плана. Денег в нём нет. */
export function Rating({ meId }: { meId: number }) {
const { c, monthName } = useCrm();
const p = c.pay;
const data = useQuery({
queryKey: ['rating'],
queryFn: () => api<{ month: string; agents: { place: number; userId: number; name: string; deals: number; planPercent: number | null }[] }>('/rating'),
});
const d = data.data;
if (!d || d.agents.length < 2) return <ErrorNote error={data.error} />;
return (
<section className="mt-10">
<h2 className="text-lg font-medium tracking-tight">{p.rating}: {monthName(d.month)}</h2>
<p className="mb-3 mt-1 max-w-2xl text-sm text-muted">{p.ratingHint}</p>
<div className="card overflow-x-auto">
<table className="w-full text-left text-sm">
<thead className="text-muted">
<tr className="border-b border-line">
<th className="w-16 px-4 py-3 font-normal">{p.place}</th>
<th className="px-4 py-3 font-normal">{p.col.agent}</th>
<th className="px-4 py-3 text-right font-normal">{p.col.deals}</th>
<th className="w-2/5 px-4 py-3 font-normal">{p.plan}</th>
</tr>
</thead>
<tbody className="divide-y divide-line">
{d.agents.map((a) => (
<tr key={a.userId} className={a.userId === meId ? 'bg-raised' : ''}>
<td className="px-4 py-3 tabular-nums">{a.place}</td>
<td className="px-4 py-3 font-medium">{a.name}{a.userId === meId && <span className="font-normal text-muted"> · {p.you}</span>}</td>
<td className="px-4 py-3 text-right tabular-nums">{a.deals}</td>
<td className="px-4 py-3">
{a.planPercent === null ? (
<span className="text-muted">{p.noPlan}</span>
) : (
<span className="flex items-center gap-3">
<span className="h-2 flex-1 overflow-hidden rounded-full bg-raised" role="meter" aria-valuemin={0} aria-valuemax={100} aria-valuenow={Math.min(100, a.planPercent)} aria-label={`${p.plan}: ${a.name}`}>
<span className="block h-full rounded-full bg-capture" style={{ width: `${Math.min(100, a.planPercent)}%` }} />
</span>
<span className="w-12 text-right tabular-nums">{a.planPercent}%</span>
</span>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
</section>
);
}
+44
View File
@@ -0,0 +1,44 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useEffect, useState } from 'react';
import { api, ApiError, type Me } from './api.ts';
export const ME_KEY = ['me'] as const;
/** Текущий пользователь; null — не вошёл. */
export function useMe() {
return useQuery({
queryKey: ME_KEY,
queryFn: async (): Promise<Me | null> => {
try {
return await api<Me>('/me');
} catch (e) {
if (e instanceof ApiError && e.status === 401) return null;
throw e;
}
},
});
}
/** Мутация, после которой перечитываются указанные запросы (по умолчанию — все). */
export function useAction<TArgs = void, TResult = unknown>(
fn: (args: TArgs) => Promise<TResult>,
invalidate?: readonly unknown[],
) {
const qc = useQueryClient();
return useMutation({
mutationFn: fn,
onSuccess: () => qc.invalidateQueries(invalidate ? { queryKey: invalidate } : undefined),
});
}
export type Theme = 'dark' | 'light';
export function useTheme(): [Theme, () => void] {
const [theme, setTheme] = useState<Theme>(() => (localStorage.getItem('lr_theme') === 'light' ? 'light' : 'dark'));
useEffect(() => {
document.documentElement.dataset.theme = theme;
localStorage.setItem('lr_theme', theme);
document.querySelector('meta[name="theme-color"]')?.setAttribute('content', theme === 'light' ? '#f6f8fb' : '#020617');
}, [theme]);
return [theme, () => setTheme((v) => (v === 'dark' ? 'light' : 'dark'))];
}
+348
View File
@@ -0,0 +1,348 @@
import { createContext, useContext, useEffect, useMemo, useState, type ReactNode } from 'react';
export type Lang = 'ru' | 'hy' | 'en';
export const LANGS: { code: Lang; label: string; name: string }[] = [
{ code: 'ru', label: 'RU', name: 'Русский' },
{ code: 'hy', label: 'AM', name: 'Հայերեն' },
{ code: 'en', label: 'EN', name: 'English' },
];
const ru = {
brand: 'Lead Radar',
tagline: 'Новые объекты с List.am — первыми',
common: {
save: 'Сохранить', cancel: 'Отмена', create: 'Создать', delete: 'Удалить', copy: 'Копировать',
copied: 'Скопировано', loading: 'Загрузка…', soon: 'Раздел в разработке', back: 'Назад', yes: 'Да', no: 'Нет',
theme: 'Тема', lang: 'Язык', logout: 'Выйти', menu: 'Меню', never: 'никогда', of: 'из', more: 'Ещё',
},
auth: {
login: 'Вход', register: 'Регистрация', name: 'Имя', email: 'Email', password: 'Пароль',
passwordHint: 'Не короче 8 символов', signIn: 'Войти', signUp: 'Зарегистрироваться',
noAccount: 'Нет аккаунта?', haveAccount: 'Уже есть аккаунт?', forgot: 'Забыли пароль?',
domainFor: 'Вход для сотрудников', domainOther: 'Другая компания? Входите на',
telegram: 'Войти через Telegram', or: 'или', resetTitle: 'Сброс пароля',
resetSend: 'Отправить ссылку', resetSent: 'Если такой email есть, мы отправили на него ссылку.',
newPassword: 'Новый пароль', resetDone: 'Пароль изменён. Теперь можно войти.',
verifyDone: 'Email подтверждён.', verifyFail: 'Ссылка недействительна или устарела.',
},
onboarding: {
title: 'Подключение компании', sub: 'Выберите тариф. Доступ откроется после оплаты.',
companyName: 'Название компании или ваше имя', model: 'Модель оплаты',
fixed: 'Фикс в месяц', percent: '% от комиссии по сделкам', submit: 'Создать компанию',
members1: '1 участник', membersUpTo: (n: number) => `до ${n} участников`,
setup: (n: number) => (n ? `Установка $${n}` : 'Без платы за установку'),
perMonth: (n: number) => `$${n} в месяц`, orPercent: (n: number) => `или ${n}% комиссии`,
soloUi: 'Telegram-бот и Mini App', teamUi: 'Веб-кабинет, бот, Mini App',
features: { notify: 'Уведомления в Telegram', crm: 'CRM по лидам', analytics: 'Аналитика по сотрудникам', archive: 'База бывших клиентов и объектов', training: 'Обучение команды' },
},
invite: {
title: (c: string) => `Вас пригласили в «${c}»`, accept: 'Присоединиться', needLogin: 'Войдите или зарегистрируйтесь, чтобы принять приглашение.',
invalid: 'Приглашение недействительно или отозвано.',
},
nav: {
leads: 'Объекты', clients: 'Клиенты', archive: 'База', analytics: 'Аналитика', filters: 'Фильтры',
team: 'Команда', billing: 'Подписка', settings: 'Настройки', admin: 'Компании', parser: 'Парсер', listings: 'Объявления',
invoices: 'Счета', messages: 'Сообщения', telegram: 'Telegram', support: 'Поддержка', help: 'Поддержка',
},
roles: { superadmin: 'Суперадмин', manager: 'Руководитель', agent: 'Агент' },
plans: { solo: 'Solo', team: 'Team', agency: 'Agency' },
access: { active: 'Активна', overdue: 'Просрочена', restricted: 'Доступ ограничен', disabled: 'Не включена' },
banner: {
disabled: 'Доступ пока закрыт: подписка не оплачена. После оплаты или включения доступ откроется сразу.',
overdue: (d: number) => `Подписка просрочена. Сервис работает ещё ${d} дн., затем уведомления остановятся, а CRM станет доступна только на чтение.`,
restricted: 'Доступ ограничен: уведомления о новых лидах не приходят, CRM работает только на чтение. Оплатите подписку, чтобы вернуть доступ.',
impersonating: (c: string) => `Вы в кабинете компании «${c}» как суперадмин.`, exit: 'Вернуться в админку',
},
team: {
title: 'Команда', count: (n: number, max: number) => `${n} из ${max} участников`, invites: 'Ссылки-приглашения',
newInvite: 'Создать ссылку', revoke: 'Отозвать', noInvites: 'Активных ссылок нет.',
inviteHint: 'Агент переходит по ссылке, входит через Telegram или email и попадает в компанию.',
limit: 'Достигнут лимит участников тарифа.', remove: 'Удалить', removeTitle: (n: string) => `Удалить агента ${n}?`,
removeHint: 'Что сделать с его лидами:', toPool: 'Вернуть в пул', noTelegram: 'Telegram не привязан',
soloOnly: 'В тарифе Solo один участник. Чтобы пригласить агентов, перейдите на Team или Agency.',
},
billing: {
title: 'Подписка', plan: 'Тариф', model: 'Модель оплаты', status: 'Статус', until: 'Действует до',
members: 'Участников', planned: (p: string, d: string) => `Запланировано с ${d}: ${p}`,
},
settings: {
title: 'Настройки', profile: 'Профиль', company: 'Компания', companyName: 'Название',
agentsSeeAll: 'Агенты видят все лиды компании', autoRejectRemoved: 'Объявление сняли с List.am — лид сам уходит в «Отказ»', poolHours: 'Возврат лида в пул, часов без смены статуса',
agentRules: 'Кого считать агентом', useBadge: 'Учитывать метку List.am «Агентство»',
threshold: 'Номер агентский, если активных объявлений больше', notifications: 'Уведомления',
paused: 'Поставить уведомления о новых лидах на паузу', quiet: 'Тихие часы', from: 'с', to: 'до',
telegram: 'Telegram', linked: 'привязан', notLinked: 'не привязан — уведомления не придут', saved: 'Сохранено',
},
admin: {
title: 'Компании', new: 'Новая компания', name: 'Название', plan: 'Тариф', model: 'Модель', members: 'Участники',
status: 'Подписка', until: 'До', activity: 'Активность', endsAt: 'Дата окончания подписки',
grace: 'Отсрочка, дней', enabled: 'Доступ включён', enter: 'Войти в кабинет', edit: 'Изменить',
created: 'Компания создана. Отправьте руководителю ссылку для входа:', empty: 'Компаний пока нет.',
},
errors: {
BAD_CREDENTIALS: 'Неверный email или пароль', EMAIL_TAKEN: 'Этот email уже зарегистрирован',
VALIDATION: 'Проверьте заполнение полей', TOKEN_INVALID: 'Ссылка недействительна или устарела',
INVITE_NOT_FOUND: 'Приглашение недействительно или отозвано', MEMBER_LIMIT_REACHED: 'Достигнут лимит участников тарифа',
ALREADY_IN_COMPANY: 'Вы уже состоите в компании', SUBSCRIPTION_REQUIRED: 'Доступ ограничен: оплатите подписку',
BILLING_MODEL_NOT_ALLOWED: 'Эта модель оплаты недоступна на выбранном тарифе', FORBIDDEN: 'Недостаточно прав',
WRONG_DOMAIN: 'Этот адрес только для сотрудников компании. Войдите на основном адресе ниже',
TELEGRAM_AUTH_FAILED: 'Не удалось войти через Telegram', TELEGRAM_TAKEN: 'Этот Telegram уже привязан к другому аккаунту',
PLAN_TOO_SMALL: 'В компании больше участников, чем вмещает этот тариф. Сначала удалите лишних в разделе «Команда»',
PAYMENTS_NOT_CONFIGURED: 'Онлайн-оплата пока не подключена', INVOICE_PAID: 'Счёт уже оплачен', INVOICE_CANCELED: 'Счёт отменён',
INVOICE_NOT_PENDING: 'Счёт уже закрыт', NO_RECIPIENTS: 'Получателей не найдено',
WEB_NOT_AVAILABLE: 'На вашем тарифе этот раздел открывается в Telegram',
PHOTO_TYPE: 'Подходят только изображения: JPG, PNG, WebP, HEIC', PHOTO_LIMIT: 'К клиенту можно приложить до 10 фото',
BAD_SLUG: 'Адрес: 2–40 латинских букв, цифр и дефисов; служебные слова заняты', SLUG_TAKEN: 'Этот адрес уже занят другой компанией',
PAGE_NOT_FOUND: 'Страница не найдена или закрыта', BAD_DAY: 'Выберите день в ближайшие три месяца', OBJECT_NOT_FOUND: 'Объект больше недоступен',
BAD_PHONE: 'Не похоже на номер телефона. Пример: 091 123 456',
TICKET_CLOSED: 'Обращение уже закрыто', FILE_TOO_LARGE: 'Файл больше 20 МБ',
LINK_TELEGRAM_FIRST: 'Сначала привяжите свой Telegram в настройках: бот пришлёт файл вам', TOO_MANY_FILES: 'Можно добавить не больше 10 файлов',
FILE_SEND_FAILED: 'Telegram не принял файл. Попробуйте другой формат или размер',
INTERNAL: 'Что-то пошло не так. Попробуйте ещё раз',
} as Record<string, string>,
};
type Dict = typeof ru;
const hy: Dict = {
brand: 'Lead Radar',
tagline: 'List.am-ի նոր օբյեկտները՝ առաջինը',
common: {
save: 'Պահպանել', cancel: 'Չեղարկել', create: 'Ստեղծել', delete: 'Ջնջել', copy: 'Պատճենել',
copied: 'Պատճենված է', loading: 'Բեռնվում է…', soon: 'Բաժինը մշակման փուլում է', back: 'Հետ', yes: 'Այո', no: 'Ոչ',
theme: 'Թեմա', lang: 'Լեզու', logout: 'Ելք', menu: 'Մենյու', never: 'երբեք', of: '/', more: 'Ավելին',
},
auth: {
login: 'Մուտք', register: 'Գրանցում', name: 'Անուն', email: 'Էլ. փոստ', password: 'Գաղտնաբառ',
passwordHint: 'Առնվազն 8 նիշ', signIn: 'Մուտք գործել', signUp: 'Գրանցվել',
noAccount: 'Հաշիվ չունե՞ք', haveAccount: 'Արդեն ունե՞ք հաշիվ', forgot: 'Մոռացե՞լ եք գաղտնաբառը',
domainFor: 'Մուտք աշխատակիցների համար՝', domainOther: 'Այլ ընկերությո՞ւն։ Մուտք գործեք',
telegram: 'Մուտք Telegram-ով', or: 'կամ', resetTitle: 'Գաղտնաբառի վերականգնում',
resetSend: 'Ուղարկել հղումը', resetSent: 'Եթե այդ հասցեն գրանցված է, հղումն ուղարկված է։',
newPassword: 'Նոր գաղտնաբառ', resetDone: 'Գաղտնաբառը փոխված է։ Կարող եք մուտք գործել։',
verifyDone: 'Էլ. փոստը հաստատված է։', verifyFail: 'Հղումն անվավեր է կամ ժամկետանց։',
},
onboarding: {
title: 'Ընկերության միացում', sub: 'Ընտրեք սակագինը։ Մուտքը կբացվի վճարումից հետո։',
companyName: 'Ընկերության անվանումը կամ ձեր անունը', model: 'Վճարման մոդել',
fixed: 'Ֆիքսված՝ ամսական', percent: 'Գործարքների միջնորդավճարի %', submit: 'Ստեղծել ընկերություն',
members1: '1 մասնակից', membersUpTo: (n) => `մինչև ${n} մասնակից`,
setup: (n) => (n ? `Տեղադրում՝ $${n}` : 'Առանց տեղադրման վճարի'),
perMonth: (n) => `$${n} ամսական`, orPercent: (n) => `կամ միջնորդավճարի ${n}%`,
soloUi: 'Telegram-բոտ և Mini App', teamUi: 'Վեբ-կաբինետ, բոտ, Mini App',
features: { notify: 'Ծանուցումներ Telegram-ում', crm: 'CRM լիդերի համար', analytics: 'Վերլուծություն ըստ աշխատակիցների', archive: 'Նախկին հաճախորդների և օբյեկտների բազա', training: 'Թիմի ուսուցում' },
},
invite: {
title: (c) => `Ձեզ հրավիրել են «${c}»`, accept: 'Միանալ', needLogin: 'Հրավերն ընդունելու համար մուտք գործեք կամ գրանցվեք։',
invalid: 'Հրավերն անվավեր է կամ չեղարկված։',
},
nav: {
leads: 'Օբյեկտներ', clients: 'Հաճախորդներ', archive: 'Բազա', analytics: 'Վերլուծություն', filters: 'Ֆիլտրեր',
team: 'Թիմ', billing: 'Բաժանորդագրություն', settings: 'Կարգավորումներ', admin: 'Ընկերություններ', parser: 'Պարսեր', listings: 'Հայտարարություններ',
invoices: 'Հաշիվներ', messages: 'Հաղորդագրություններ', telegram: 'Telegram', support: 'Աջակցություն', help: 'Աջակցություն',
},
roles: { superadmin: 'Սուպերադմին', manager: 'Ղեկավար', agent: 'Գործակալ' },
plans: { solo: 'Solo', team: 'Team', agency: 'Agency' },
access: { active: 'Ակտիվ', overdue: 'Ժամկետանց', restricted: 'Մուտքը սահմանափակված է', disabled: 'Միացված չէ' },
banner: {
disabled: 'Մուտքը դեռ փակ է. բաժանորդագրությունը վճարված չէ։ Վճարումից կամ միացումից հետո մուտքը կբացվի անմիջապես։',
overdue: (d) => `Բաժանորդագրությունը ժամկետանց է։ Ծառայությունը կաշխատի ևս ${d} օր, ապա ծանուցումները կդադարեն, իսկ CRM-ը կլինի միայն ընթերցման համար։`,
restricted: 'Մուտքը սահմանափակված է. նոր լիդերի ծանուցումներ չեն գալիս, CRM-ը միայն ընթերցման համար է։ Վճարեք բաժանորդագրությունը՝ մուտքը վերականգնելու համար։',
impersonating: (c) => `Դուք «${c}» ընկերության կաբինետում եք որպես սուպերադմին։`, exit: 'Վերադառնալ ադմին-վահանակ',
},
team: {
title: 'Թիմ', count: (n, max) => `${n} / ${max} մասնակից`, invites: 'Հրավերի հղումներ',
newInvite: 'Ստեղծել հղում', revoke: 'Չեղարկել', noInvites: 'Ակտիվ հղումներ չկան։',
inviteHint: 'Գործակալն անցնում է հղումով, մուտք գործում Telegram-ով կամ էլ. փոստով և միանում ընկերությանը։',
limit: 'Սակագնի մասնակիցների սահմանաչափը լրացել է։', remove: 'Հեռացնել', removeTitle: (n) => `Հեռացնե՞լ գործակալ ${n}-ին`,
removeHint: 'Ի՞նչ անել նրա լիդերի հետ.', toPool: 'Վերադարձնել ընդհանուր ցուցակ', noTelegram: 'Telegram-ը կապված չէ',
soloOnly: 'Solo սակագնում մեկ մասնակից է։ Գործակալներ հրավիրելու համար անցեք Team կամ Agency։',
},
billing: {
title: 'Բաժանորդագրություն', plan: 'Սակագին', model: 'Վճարման մոդել', status: 'Կարգավիճակ', until: 'Գործում է մինչև',
members: 'Մասնակիցներ', planned: (p, d) => `Պլանավորված է ${d}-ից՝ ${p}`,
},
settings: {
title: 'Կարգավորումներ', profile: 'Պրոֆիլ', company: 'Ընկերություն', companyName: 'Անվանում',
agentsSeeAll: 'Գործակալները տեսնում են ընկերության բոլոր լիդերը', autoRejectRemoved: 'Հայտարարությունը հանվել է List.am-ից — լիդն ինքնաբերաբար անցնում է «Մերժում»', poolHours: 'Լիդի վերադարձ ընդհանուր ցուցակ՝ ժամ առանց կարգավիճակի փոփոխության',
agentRules: 'Ում համարել գործակալ', useBadge: 'Հաշվի առնել List.am-ի «Գործակալություն» նշումը',
threshold: 'Համարը գործակալի է, եթե ակտիվ հայտարարությունները ավելի են, քան', notifications: 'Ծանուցումներ',
paused: 'Դադարեցնել նոր լիդերի ծանուցումները', quiet: 'Լուռ ժամեր', from: 'սկսած', to: 'մինչև',
telegram: 'Telegram', linked: 'կապված է', notLinked: 'կապված չէ — ծանուցումներ չեն գա', saved: 'Պահպանված է',
},
admin: {
title: 'Ընկերություններ', new: 'Նոր ընկերություն', name: 'Անվանում', plan: 'Սակագին', model: 'Մոդել', members: 'Մասնակիցներ',
status: 'Բաժանորդագրություն', until: 'Մինչև', activity: 'Ակտիվություն', endsAt: 'Բաժանորդագրության ավարտի ամսաթիվ',
grace: 'Հետաձգում, օր', enabled: 'Մուտքը միացված է', enter: 'Մտնել կաբինետ', edit: 'Փոխել',
created: 'Ընկերությունը ստեղծված է։ Ուղարկեք ղեկավարին մուտքի հղումը.', empty: 'Ընկերություններ դեռ չկան։',
},
errors: {
BAD_CREDENTIALS: 'Սխալ էլ. փոստ կամ գաղտնաբառ', EMAIL_TAKEN: 'Այս էլ. փոստն արդեն գրանցված է',
VALIDATION: 'Ստուգեք դաշտերի լրացումը', TOKEN_INVALID: 'Հղումն անվավեր է կամ ժամկետանց',
INVITE_NOT_FOUND: 'Հրավերն անվավեր է կամ չեղարկված', MEMBER_LIMIT_REACHED: 'Սակագնի մասնակիցների սահմանաչափը լրացել է',
ALREADY_IN_COMPANY: 'Դուք արդեն ընկերության անդամ եք', SUBSCRIPTION_REQUIRED: 'Մուտքը սահմանափակված է. վճարեք բաժանորդագրությունը',
BILLING_MODEL_NOT_ALLOWED: 'Վճարման այս մոդելը հասանելի չէ ընտրված սակագնում', FORBIDDEN: 'Իրավունքները բավարար չեն',
WRONG_DOMAIN: 'Այս հասցեն միայն ընկերության աշխատակիցների համար է։ Մուտք գործեք ներքևի հիմնական հասցեով',
TELEGRAM_AUTH_FAILED: 'Չհաջողվեց մուտք գործել Telegram-ով', TELEGRAM_TAKEN: 'Այս Telegram-ն արդեն կապված է այլ հաշվի',
PLAN_TOO_SMALL: 'Ընկերությունում ավելի շատ մասնակից կա, քան թույլ է տալիս այս սակագինը։ Նախ հեռացրեք ավելորդներին «Թիմ» բաժնում',
PAYMENTS_NOT_CONFIGURED: 'Առցանց վճարումը դեռ միացված չէ', INVOICE_PAID: 'Հաշիվն արդեն վճարված է', INVOICE_CANCELED: 'Հաշիվը չեղարկված է',
INVOICE_NOT_PENDING: 'Հաշիվն արդեն փակված է', NO_RECIPIENTS: 'Ստացողներ չեն գտնվել',
WEB_NOT_AVAILABLE: 'Ձեր սակագնում այս բաժինը բացվում է Telegram-ում',
PHOTO_TYPE: 'Թույլատրվում են միայն պատկերներ՝ JPG, PNG, WebP, HEIC', PHOTO_LIMIT: 'Հաճախորդին կարելի է կցել մինչև 10 լուսանկար',
BAD_SLUG: 'Հասցե՝ 2–40 լատինատառ, թիվ և գծիկ. ծառայողական բառերը զբաղված են', SLUG_TAKEN: 'Այս հասցեն արդեն զբաղված է այլ ընկերության կողմից',
PAGE_NOT_FOUND: 'Էջը չի գտնվել կամ փակված է', BAD_DAY: 'Ընտրեք օր առաջիկա երեք ամսում', OBJECT_NOT_FOUND: 'Օբյեկտն այլևս հասանելի չէ',
BAD_PHONE: 'Հեռախոսահամար չի հիշեցնում։ Օրինակ՝ 091 123 456',
TICKET_CLOSED: 'Դիմումն արդեն փակված է', FILE_TOO_LARGE: 'Ֆայլը 20 ՄԲ-ից մեծ է',
LINK_TELEGRAM_FIRST: 'Նախ կապեք ձեր Telegram-ը կարգավորումներում. բոտը ֆայլը կուղարկի ձեզ', TOO_MANY_FILES: 'Կարելի է ավելացնել առավելագույնը 10 ֆայլ',
FILE_SEND_FAILED: 'Telegram-ը չընդունեց ֆայլը։ Փորձեք այլ ձևաչափ կամ չափ',
INTERNAL: 'Ինչ-որ բան սխալ գնաց։ Փորձեք կրկին',
},
};
const en: Dict = {
brand: 'Lead Radar',
tagline: 'New List.am properties — before anyone else',
common: {
save: 'Save', cancel: 'Cancel', create: 'Create', delete: 'Delete', copy: 'Copy',
copied: 'Copied', loading: 'Loading…', soon: 'This section is in development', back: 'Back', yes: 'Yes', no: 'No',
theme: 'Theme', lang: 'Language', logout: 'Log out', menu: 'Menu', never: 'never', of: 'of', more: 'More',
},
auth: {
login: 'Log in', register: 'Sign up', name: 'Name', email: 'Email', password: 'Password',
passwordHint: 'At least 8 characters', signIn: 'Log in', signUp: 'Sign up',
noAccount: 'No account yet?', haveAccount: 'Already have an account?', forgot: 'Forgot password?',
domainFor: 'Sign-in for employees of', domainOther: 'Another company? Sign in at',
telegram: 'Log in with Telegram', or: 'or', resetTitle: 'Reset password',
resetSend: 'Send link', resetSent: 'If that email is registered, we have sent a link to it.',
newPassword: 'New password', resetDone: 'Password changed. You can log in now.',
verifyDone: 'Email confirmed.', verifyFail: 'The link is invalid or expired.',
},
onboarding: {
title: 'Connect your company', sub: 'Choose a plan. Access opens after payment.',
companyName: 'Company name or your name', model: 'Billing model',
fixed: 'Fixed monthly fee', percent: '% of deal commission', submit: 'Create company',
members1: '1 member', membersUpTo: (n) => `up to ${n} members`,
setup: (n) => (n ? `Setup $${n}` : 'No setup fee'),
perMonth: (n) => `$${n} per month`, orPercent: (n) => `or ${n}% of commission`,
soloUi: 'Telegram bot and Mini App', teamUi: 'Web app, bot, Mini App',
features: { notify: 'Telegram notifications', crm: 'Lead CRM', analytics: 'Team analytics', archive: 'Past clients and properties', training: 'Team training' },
},
invite: {
title: (c) => `You are invited to “${c}”`, accept: 'Join', needLogin: 'Log in or sign up to accept the invitation.',
invalid: 'The invitation is invalid or revoked.',
},
nav: {
leads: 'Properties', clients: 'Clients', archive: 'Archive', analytics: 'Analytics', filters: 'Filters',
team: 'Team', billing: 'Subscription', settings: 'Settings', admin: 'Companies', parser: 'Parser', listings: 'Listings',
invoices: 'Invoices', messages: 'Messages', telegram: 'Telegram', support: 'Support', help: 'Support',
},
roles: { superadmin: 'Superadmin', manager: 'Manager', agent: 'Agent' },
plans: { solo: 'Solo', team: 'Team', agency: 'Agency' },
access: { active: 'Active', overdue: 'Overdue', restricted: 'Access restricted', disabled: 'Not enabled' },
banner: {
disabled: 'Access is closed: the subscription is not paid yet. It opens right after payment or activation.',
overdue: (d) => `The subscription is overdue. The service keeps working for ${d} more days; after that notifications stop and the CRM becomes read-only.`,
restricted: 'Access is restricted: no new-lead notifications, the CRM is read-only. Pay the subscription to restore access.',
impersonating: (c) => `You are in “${c}” as superadmin.`, exit: 'Back to admin',
},
team: {
title: 'Team', count: (n, max) => `${n} of ${max} members`, invites: 'Invite links',
newInvite: 'Create link', revoke: 'Revoke', noInvites: 'No active links.',
inviteHint: 'An agent opens the link, logs in with Telegram or email, and joins the company.',
limit: 'The plan member limit is reached.', remove: 'Remove', removeTitle: (n) => `Remove agent ${n}?`,
removeHint: 'What to do with their leads:', toPool: 'Return to pool', noTelegram: 'Telegram not linked',
soloOnly: 'Solo has one member. Switch to Team or Agency to invite agents.',
},
billing: {
title: 'Subscription', plan: 'Plan', model: 'Billing model', status: 'Status', until: 'Valid until',
members: 'Members', planned: (p, d) => `Scheduled from ${d}: ${p}`,
},
settings: {
title: 'Settings', profile: 'Profile', company: 'Company', companyName: 'Name',
agentsSeeAll: 'Agents see all company leads', autoRejectRemoved: 'Listing removed from List.am — the lead moves to «Rejected» automatically', poolHours: 'Return a lead to the pool after hours without a status change',
agentRules: 'Who counts as an agent', useBadge: 'Use the List.am “Agency” badge',
threshold: 'A number is an agent’s if it has more active listings than', notifications: 'Notifications',
paused: 'Pause new-lead notifications', quiet: 'Quiet hours', from: 'from', to: 'to',
telegram: 'Telegram', linked: 'linked', notLinked: 'not linked — notifications will not arrive', saved: 'Saved',
},
admin: {
title: 'Companies', new: 'New company', name: 'Name', plan: 'Plan', model: 'Model', members: 'Members',
status: 'Subscription', until: 'Until', activity: 'Activity', endsAt: 'Subscription end date',
grace: 'Grace period, days', enabled: 'Access enabled', enter: 'Open workspace', edit: 'Edit',
created: 'Company created. Send the manager this login link:', empty: 'No companies yet.',
},
errors: {
BAD_CREDENTIALS: 'Wrong email or password', EMAIL_TAKEN: 'This email is already registered',
VALIDATION: 'Check the form fields', TOKEN_INVALID: 'The link is invalid or expired',
INVITE_NOT_FOUND: 'The invitation is invalid or revoked', MEMBER_LIMIT_REACHED: 'The plan member limit is reached',
ALREADY_IN_COMPANY: 'You already belong to a company', SUBSCRIPTION_REQUIRED: 'Access is restricted: pay the subscription',
BILLING_MODEL_NOT_ALLOWED: 'This billing model is not available on the selected plan', FORBIDDEN: 'Not enough permissions',
WRONG_DOMAIN: 'This address is only for employees of the company. Sign in at the main address below',
TELEGRAM_AUTH_FAILED: 'Telegram login failed', TELEGRAM_TAKEN: 'This Telegram is already linked to another account',
PLAN_TOO_SMALL: 'The company has more members than this plan allows. Remove the extra ones in “Team” first',
PAYMENTS_NOT_CONFIGURED: 'Online payment is not connected yet', INVOICE_PAID: 'The invoice is already paid', INVOICE_CANCELED: 'The invoice is canceled',
INVOICE_NOT_PENDING: 'The invoice is already closed', NO_RECIPIENTS: 'No recipients found',
WEB_NOT_AVAILABLE: 'On your plan this section opens in Telegram',
PHOTO_TYPE: 'Only images are accepted: JPG, PNG, WebP, HEIC', PHOTO_LIMIT: 'Up to 10 photos per client',
BAD_SLUG: 'Address: 2–40 Latin letters, digits and hyphens; service words are taken', SLUG_TAKEN: 'This address is already taken by another company',
PAGE_NOT_FOUND: 'The page was not found or is closed', BAD_DAY: 'Choose a day within the next three months', OBJECT_NOT_FOUND: 'The property is no longer available',
BAD_PHONE: 'That does not look like a phone number. Example: 091 123 456',
TICKET_CLOSED: 'The request is already closed', FILE_TOO_LARGE: 'The file is larger than 20 MB',
LINK_TELEGRAM_FIRST: 'Link your Telegram in settings first: the bot will send the file to you', TOO_MANY_FILES: 'You can add up to 10 files',
FILE_SEND_FAILED: 'Telegram rejected the file. Try another format or size',
INTERNAL: 'Something went wrong. Please try again',
},
};
const dict: Record<Lang, Dict> = { ru, hy, en };
const LOCALES: Record<Lang, string> = { ru: 'ru-RU', hy: 'hy-AM', en: 'en-GB' };
interface LangCtx {
lang: Lang;
t: Dict;
setLang: (l: Lang) => void;
err: (e: unknown) => string;
date: (iso: string | null | undefined) => string;
}
const Ctx = createContext<LangCtx | null>(null);
const initialLang = (): Lang => {
// ?lang=hy в адресе важнее сохранённого выбора: так клиенту приходит страница на его языке.
const fromUrl = new URLSearchParams(window.location.search).get('lang');
if (fromUrl === 'ru' || fromUrl === 'hy' || fromUrl === 'en') return fromUrl;
const saved = localStorage.getItem('lr_lang');
if (saved === 'ru' || saved === 'hy' || saved === 'en') return saved;
const nav = navigator.language.slice(0, 2);
return nav === 'hy' ? 'hy' : nav === 'en' ? 'en' : 'ru';
};
export function LangProvider({ children }: { children: ReactNode }) {
const [lang, setLangState] = useState<Lang>(initialLang);
useEffect(() => {
document.documentElement.lang = lang;
}, [lang]);
const value = useMemo<LangCtx>(() => {
const t = dict[lang];
return {
lang,
t,
setLang: (l) => {
localStorage.setItem('lr_lang', l);
setLangState(l);
},
err: (e) => {
const code = (e as { code?: string } | null)?.code ?? 'INTERNAL';
return t.errors[code] ?? t.errors.INTERNAL!;
},
date: (iso) =>
iso ? new Date(iso).toLocaleDateString(LOCALES[lang], { day: 'numeric', month: 'short', year: 'numeric' }) : '—',
};
}, [lang]);
return <Ctx.Provider value={value}>{children}</Ctx.Provider>;
}
export function useLang() {
const ctx = useContext(Ctx);
if (!ctx) throw new Error('LangProvider не подключён');
return ctx;
}
+128
View File
@@ -0,0 +1,128 @@
@import "tailwindcss";
/*
Цвета и шрифты — те же, что на yandpconsulting.com.
signal (cyan) — входящий сигнал: новый лид, то, что ещё не обработано
capture (emerald) — перехвачено: лид взят, основное действие
*/
:root {
--ink: #020617;
--surface: #0a1224;
--raised: #111b31;
--line: rgb(148 163 184 / 0.14);
--line-strong: rgb(148 163 184 / 0.26);
--text: #e2e8f0;
--muted: #94a3b8;
--signal: #22d3ee;
--capture: #34d399;
--capture-hover: #6ee7b7;
--on-capture: #020617;
--danger: #f87171;
--warn: #fbbf24;
color-scheme: dark;
}
:root[data-theme="light"] {
--ink: #f6f8fb;
--surface: #ffffff;
--raised: #eef2f7;
--line: rgb(15 23 42 / 0.1);
--line-strong: rgb(15 23 42 / 0.2);
--text: #0f172a;
--muted: #55627a;
--signal: #0891b2;
--capture: #059669;
--capture-hover: #047857;
--on-capture: #ffffff;
--danger: #dc2626;
--warn: #b45309;
color-scheme: light;
}
@theme inline {
--color-ink: var(--ink);
--color-surface: var(--surface);
--color-raised: var(--raised);
--color-line: var(--line);
--color-line-strong: var(--line-strong);
--color-text: var(--text);
--color-muted: var(--muted);
--color-signal: var(--signal);
--color-capture: var(--capture);
--color-danger: var(--danger);
--color-warn: var(--warn);
--font-sans: "Geologica", "Noto Sans Armenian", ui-sans-serif, system-ui, sans-serif;
}
@layer base {
html { -webkit-text-size-adjust: 100%; }
body {
background: var(--ink);
color: var(--text);
font-family: var(--font-sans);
-webkit-font-smoothing: antialiased;
}
h1, h2, h3 { text-wrap: balance; }
:focus-visible {
outline: 2px solid var(--signal);
outline-offset: 2px;
border-radius: 6px;
}
}
@layer components {
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
min-height: 44px;
padding: 0 1.125rem;
border-radius: 0.75rem;
font-weight: 500;
transition: background-color 180ms, border-color 180ms, color 180ms, opacity 180ms;
cursor: pointer;
white-space: nowrap;
}
.btn:disabled { opacity: 0.55; cursor: default; }
.btn-sm { min-height: 36px; padding: 0 0.75rem; font-size: 0.875rem; border-radius: 0.625rem; }
.btn-primary { background: var(--capture); color: var(--on-capture); }
.btn-primary:hover:not(:disabled) { background: var(--capture-hover); }
.btn-ghost { border: 1px solid var(--line-strong); color: var(--text); }
.btn-ghost:hover:not(:disabled) { background: rgb(148 163 184 / 0.08); }
.btn-danger { border: 1px solid color-mix(in srgb, var(--danger) 45%, transparent); color: var(--danger); }
.btn-danger:hover:not(:disabled) { background: color-mix(in srgb, var(--danger) 10%, transparent); }
.field {
width: 100%;
min-height: 44px;
padding: 0 0.875rem;
border-radius: 0.75rem;
border: 1px solid var(--line-strong);
background: var(--ink);
color: var(--text);
}
.field::placeholder { color: var(--muted); opacity: 0.7; }
.field:focus { outline: 2px solid var(--signal); outline-offset: 0; border-color: transparent; }
.label { display: block; margin-bottom: 0.375rem; font-size: 0.875rem; color: var(--muted); }
.card {
border: 1px solid var(--line);
background: var(--surface);
border-radius: 1rem;
}
.chip {
display: inline-flex;
align-items: center;
gap: 0.375rem;
padding: 0.125rem 0.5rem;
border-radius: 0.375rem;
font-size: 0.75rem;
font-weight: 500;
}
}
/* Радарные кольца за формой входа */
.radar-rings {
background:
radial-gradient(circle at 50% 30%, transparent 0 139px, color-mix(in srgb, var(--signal) 9%, transparent) 140px 141px, transparent 142px 279px, color-mix(in srgb, var(--signal) 7%, transparent) 280px 281px, transparent 282px 439px, color-mix(in srgb, var(--signal) 5%, transparent) 440px 441px, transparent 442px);
}
+36
View File
@@ -0,0 +1,36 @@
import './index.css';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import { BrowserRouter } from 'react-router';
import { ApiError } from './api.ts';
import { App } from './App.tsx';
import { LangProvider } from './i18n.tsx';
import { telegramSignIn } from './telegram.ts';
// Тема применяется до первого кадра, чтобы светлая не мигала тёмной.
if (localStorage.getItem('lr_theme') === 'light') document.documentElement.dataset.theme = 'light';
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 30_000,
refetchOnWindowFocus: false,
retry: (count, error) => !(error instanceof ApiError && error.status < 500) && count < 1,
},
},
});
await telegramSignIn();
createRoot(document.getElementById('root')!).render(
<StrictMode>
<QueryClientProvider client={queryClient}>
<LangProvider>
<BrowserRouter>
<App />
</BrowserRouter>
</LangProvider>
</QueryClientProvider>
</StrictMode>,
);
+179
View File
@@ -0,0 +1,179 @@
import { PLAN_SPECS, PLANS, type BillingModel, type Plan } from '@lr/shared';
import { useQuery } from '@tanstack/react-query';
import { LogIn, Pencil, Plus } from 'lucide-react';
import { useState } from 'react';
import { useNavigate } from 'react-router';
import { api, type AdminCompany } from '../api.ts';
import { AccessChip, ErrorNote, PageTitle, Toggle } from '../components/ui.tsx';
import { useAction } from '../hooks.ts';
import { useLang } from '../i18n.tsx';
import { AdminSummary } from '../account/InvoicesPage.tsx';
const toDateInput = (iso: string | null) => (iso ? iso.slice(0, 10) : '');
const inDays = (n: number) => new Date(Date.now() + n * 86_400_000).toISOString().slice(0, 10);
/** Подписка действует до конца выбранного дня по времени Еревана. */
const endOfDay = (d: string) => new Date(`${d}T23:59:59+04:00`).toISOString();
function CompanyDialog({ company, onClose }: { company: AdminCompany | null; onClose: () => void }) {
const { t } = useLang();
const a = t.admin;
const [f, setF] = useState({
name: company?.name ?? '',
plan: (company?.plan ?? 'team') as Plan,
billingModel: (company?.billingModel ?? 'fixed') as BillingModel,
endsAt: toDateInput(company?.subscriptionEndsAt ?? null) || inDays(30),
graceDays: company?.graceDays ?? 15,
accessEnabled: company?.accessEnabled ?? true,
});
const [managerUrl, setManagerUrl] = useState<string | null>(null);
const percentAllowed = PLAN_SPECS[f.plan].commissionPercent !== null;
const billingModel = percentAllowed ? f.billingModel : 'fixed';
const save = useAction(() =>
company
? api(`/admin/companies/${company.id}`, {
method: 'PATCH',
body: { plan: f.plan, billingModel, subscriptionEndsAt: endOfDay(f.endsAt), graceDays: f.graceDays, accessEnabled: f.accessEnabled },
})
: api<{ managerUrl: string }>('/admin/companies', {
method: 'POST',
body: { name: f.name, plan: f.plan, billingModel, subscriptionEndsAt: endOfDay(f.endsAt) },
}),
);
return (
<div className="fixed inset-0 z-50 flex items-end justify-center overflow-y-auto bg-black/60 p-4 sm:items-center" role="dialog" aria-modal="true">
<div className="card w-full max-w-lg p-6">
<h2 className="text-xl font-medium tracking-tight">{company ? company.name : a.new}</h2>
{managerUrl ? (
<div className="mt-4 space-y-4">
<p className="text-muted">{a.created}</p>
<code className="block break-all rounded-xl bg-ink p-3 text-sm">{managerUrl}</code>
<div className="flex justify-end gap-2">
<button className="btn btn-ghost" onClick={() => navigator.clipboard.writeText(managerUrl)}>{t.common.copy}</button>
<button className="btn btn-primary" onClick={onClose}>OK</button>
</div>
</div>
) : (
<form
className="mt-4 space-y-4"
onSubmit={(e) => {
e.preventDefault();
save.mutate(undefined, {
onSuccess: (res) => {
const url = (res as { managerUrl?: string }).managerUrl;
if (url) setManagerUrl(url);
else onClose();
},
});
}}
>
{!company && (
<div>
<label className="label" htmlFor="a-name">{a.name}</label>
<input id="a-name" className="field" required maxLength={100} value={f.name} onChange={(e) => setF({ ...f, name: e.target.value })} />
</div>
)}
<div className="grid gap-4 sm:grid-cols-2">
<div>
<label className="label" htmlFor="a-plan">{a.plan}</label>
<select id="a-plan" className="field" value={f.plan} onChange={(e) => setF({ ...f, plan: e.target.value as Plan })}>
{PLANS.map((p) => <option key={p} value={p}>{t.plans[p]}</option>)}
</select>
</div>
<div>
<label className="label" htmlFor="a-model">{a.model}</label>
<select id="a-model" className="field" value={billingModel} disabled={!percentAllowed} onChange={(e) => setF({ ...f, billingModel: e.target.value as BillingModel })}>
<option value="fixed">{t.onboarding.fixed}</option>
{percentAllowed && <option value="percent">{t.onboarding.percent}</option>}
</select>
</div>
<div>
<label className="label" htmlFor="a-ends">{a.endsAt}</label>
<input id="a-ends" type="date" className="field" required value={f.endsAt} onChange={(e) => setF({ ...f, endsAt: e.target.value })} />
</div>
{company && (
<div>
<label className="label" htmlFor="a-grace">{a.grace}</label>
<input id="a-grace" type="number" min={0} max={365} className="field" value={f.graceDays} onChange={(e) => setF({ ...f, graceDays: Number(e.target.value) })} />
</div>
)}
</div>
{company && <Toggle checked={f.accessEnabled} onChange={(v) => setF({ ...f, accessEnabled: v })} label={a.enabled} />}
<ErrorNote error={save.error} />
<div className="flex justify-end gap-2 pt-2">
<button type="button" className="btn btn-ghost" onClick={onClose}>{t.common.cancel}</button>
<button className="btn btn-primary" disabled={save.isPending}>{company ? t.common.save : t.common.create}</button>
</div>
</form>
)}
</div>
</div>
);
}
export function AdminPage() {
const { t, date } = useLang();
const a = t.admin;
const navigate = useNavigate();
const list = useQuery({ queryKey: ['admin', 'companies'], queryFn: () => api<AdminCompany[]>('/admin/companies') });
const enter = useAction((companyId: number) => api('/admin/impersonate', { method: 'POST', body: { companyId } }));
const [dialog, setDialog] = useState<AdminCompany | 'new' | null>(null);
return (
<>
<PageTitle title={a.title}>
<button className="btn btn-primary" onClick={() => setDialog('new')}>
<Plus size={17} aria-hidden="true" />
{a.new}
</button>
</PageTitle>
<AdminSummary />
<ErrorNote error={list.error ?? enter.error} />
{list.data?.length === 0 && <p className="text-muted">{a.empty}</p>}
{Boolean(list.data?.length) && (
<div className="card overflow-x-auto">
<table className="w-full min-w-[46rem] text-left text-sm">
<thead className="text-muted">
<tr className="border-b border-line">
{[a.name, a.plan, a.members, a.status, a.until, a.activity, ''].map((h, i) => (
<th key={i} className="px-4 py-3 font-normal">{h}</th>
))}
</tr>
</thead>
<tbody className="divide-y divide-line">
{list.data!.map((c) => (
<tr key={c.id}>
<td className="px-4 py-3 font-medium">{c.name}</td>
<td className="px-4 py-3">
{t.plans[c.plan]}
<span className="text-muted"> · {c.billingModel === 'fixed' ? '$' : '%'}</span>
</td>
<td className="px-4 py-3 tabular-nums">{c.members} / {c.maxMembers}</td>
<td className="px-4 py-3"><AccessChip access={c.access} /></td>
<td className="px-4 py-3 tabular-nums">{date(c.subscriptionEndsAt)}</td>
<td className="px-4 py-3 tabular-nums text-muted">{date(c.lastActivityAt)}</td>
<td className="px-4 py-3">
<div className="flex justify-end gap-2">
<button className="btn btn-ghost btn-sm" aria-label={`${a.edit}: ${c.name}`} title={a.edit} onClick={() => setDialog(c)}>
<Pencil size={14} aria-hidden="true" />
</button>
<button
className="btn btn-ghost btn-sm"
aria-label={`${a.enter}: ${c.name}`}
title={a.enter}
onClick={() => enter.mutate(c.id, { onSuccess: () => navigate('/leads') })}
>
<LogIn size={14} aria-hidden="true" />
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{dialog && <CompanyDialog company={dialog === 'new' ? null : dialog} onClose={() => setDialog(null)} />}
</>
);
}
+232
View File
@@ -0,0 +1,232 @@
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { Send } from 'lucide-react';
import { useEffect, useRef, useState, type FormEvent, type ReactNode } from 'react';
import { Link, useSearchParams } from 'react-router';
import { api } from '../api.ts';
import { ErrorNote, LangSwitcher, Logo, ThemeToggle } from '../components/ui.tsx';
import { ME_KEY, useAction } from '../hooks.ts';
import { useLang } from '../i18n.tsx';
export function AuthLayout({ title, children }: { title: string; children: ReactNode }) {
const { t } = useLang();
return (
<div className="radar-rings flex min-h-dvh flex-col">
<header className="flex items-center justify-between px-4 py-4 sm:px-6">
<Logo />
<div className="flex items-center gap-2">
<LangSwitcher />
<ThemeToggle />
</div>
</header>
<main className="flex flex-1 items-center justify-center px-4 pb-16 pt-6">
<div className="w-full max-w-sm">
<p className="mb-5 flex items-center gap-2 text-sm text-muted">
<span className="size-1.5 rounded-full bg-signal" aria-hidden="true" />
{t.tagline}
</p>
<div className="card p-6 sm:p-7">
<h1 className="mb-5 text-2xl font-semibold tracking-tight">{title}</h1>
{children}
</div>
</div>
</main>
</div>
);
}
/** Куда вернуться после входа: приглашение сохраняется, пока пользователь регистрируется. */
export const nextPath = (params: URLSearchParams) => {
const next = params.get('next');
return next?.startsWith('/') && !next.startsWith('//') ? next : '/';
};
interface PublicConfig {
telegramBotUsername: string | null;
/** Свой домен компании: вход только для её сотрудников. */
domain: { company: string; appUrl: string } | null;
}
const usePublicConfig = () =>
useQuery({ queryKey: ['config'], queryFn: () => api<PublicConfig>('/config'), staleTime: Infinity }).data;
/** Кнопка Telegram Login. Показывается, только если на сервере настроен бот. */
function TelegramLogin({ onDone }: { onDone: () => void }) {
const { t } = useLang();
const box = useRef<HTMLDivElement>(null);
const config = usePublicConfig();
const login = useAction((user: unknown) => api('/auth/telegram', { method: 'POST', body: user }));
// Виджет Telegram работает только на основном домене (/setdomain у бота).
const bot = config?.domain ? null : config?.telegramBotUsername;
useEffect(() => {
if (!bot || !box.current) return;
(window as unknown as Record<string, unknown>).onTelegramAuth = (user: unknown) =>
login.mutate(user, { onSuccess: onDone });
const script = document.createElement('script');
script.src = 'https://telegram.org/js/telegram-widget.js?22';
script.async = true;
script.dataset.telegramLogin = bot;
script.dataset.size = 'large';
script.dataset.radius = '12';
script.dataset.onauth = 'onTelegramAuth(user)';
script.dataset.requestAccess = 'write';
box.current.replaceChildren(script);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [bot]);
if (!bot) return null;
return (
<>
<div className="my-5 flex items-center gap-3 text-xs text-muted">
<span className="h-px flex-1 bg-line" />
{t.auth.or}
<span className="h-px flex-1 bg-line" />
</div>
<div ref={box} className="flex min-h-10 justify-center [color-scheme:light]" aria-label={t.auth.telegram}>
<Send size={16} className="text-muted" aria-hidden="true" />
</div>
<div className="mt-3">
<ErrorNote error={login.error} />
</div>
</>
);
}
export function LoginPage({ mode }: { mode: 'login' | 'register' }) {
const { t, lang } = useLang();
const [params] = useSearchParams();
const qc = useQueryClient();
const [form, setForm] = useState({ name: '', email: '', password: '' });
const submit = useAction(() =>
api(`/auth/${mode}`, {
method: 'POST',
body: mode === 'register' ? { ...form, lang } : { email: form.email, password: form.password },
}),
);
// После входа App сам уводит со страницы входа по параметру next: форма к этому моменту
// уже размонтирована, и её собственные обработчики не сработали бы.
const done = () => qc.invalidateQueries({ queryKey: ME_KEY });
const onSubmit = (e: FormEvent) => {
e.preventDefault();
submit.mutate(undefined, { onSuccess: done });
};
const set = (k: keyof typeof form) => (e: { target: { value: string } }) => setForm({ ...form, [k]: e.target.value });
const other = `${mode === 'login' ? '/register' : '/login'}${params.size ? `?${params}` : ''}`;
const domain = usePublicConfig()?.domain;
return (
<AuthLayout title={mode === 'login' ? t.auth.login : t.auth.register}>
{domain && <p className="-mt-2 mb-5 text-sm text-muted">{t.auth.domainFor} {domain.company}</p>}
<form onSubmit={onSubmit} className="space-y-4">
{mode === 'register' && (
<div>
<label className="label" htmlFor="name">{t.auth.name}</label>
<input id="name" className="field" required maxLength={100} autoComplete="name" value={form.name} onChange={set('name')} />
</div>
)}
<div>
<label className="label" htmlFor="email">{t.auth.email}</label>
<input id="email" type="email" className="field" required autoComplete="email" autoCapitalize="none" value={form.email} onChange={set('email')} />
</div>
<div>
<label className="label" htmlFor="password">{t.auth.password}</label>
<input
id="password" type="password" className="field" required minLength={mode === 'register' ? 8 : 1}
autoComplete={mode === 'register' ? 'new-password' : 'current-password'} value={form.password} onChange={set('password')}
/>
{mode === 'register' && <p className="mt-1.5 text-xs text-muted">{t.auth.passwordHint}</p>}
</div>
<ErrorNote error={submit.error} />
<button className="btn btn-primary w-full" disabled={submit.isPending}>
{mode === 'login' ? t.auth.signIn : t.auth.signUp}
</button>
</form>
<TelegramLogin onDone={done} />
<div className="mt-6 flex flex-wrap justify-between gap-2 text-sm text-muted">
{domain ? (
<span>
{t.auth.domainOther}{' '}
<a href={domain.appUrl} className="text-signal hover:underline">{new URL(domain.appUrl).host}</a>
</span>
) : (
<span>
{mode === 'login' ? t.auth.noAccount : t.auth.haveAccount}{' '}
<Link to={other} className="text-signal hover:underline">
{mode === 'login' ? t.auth.register : t.auth.login}
</Link>
</span>
)}
{mode === 'login' && <Link to="/forgot-password" className="hover:text-text">{t.auth.forgot}</Link>}
</div>
</AuthLayout>
);
}
export function ForgotPasswordPage() {
const { t } = useLang();
const [email, setEmail] = useState('');
const send = useAction(() => api('/auth/forgot-password', { method: 'POST', body: { email } }));
return (
<AuthLayout title={t.auth.resetTitle}>
{send.isSuccess ? (
<p className="text-muted">{t.auth.resetSent}</p>
) : (
<form onSubmit={(e) => { e.preventDefault(); send.mutate(); }} className="space-y-4">
<div>
<label className="label" htmlFor="email">{t.auth.email}</label>
<input id="email" type="email" className="field" required value={email} onChange={(e) => setEmail(e.target.value)} />
</div>
<ErrorNote error={send.error} />
<button className="btn btn-primary w-full" disabled={send.isPending}>{t.auth.resetSend}</button>
</form>
)}
<Link to="/login" className="mt-6 inline-block text-sm text-muted hover:text-text">← {t.auth.login}</Link>
</AuthLayout>
);
}
export function ResetPasswordPage() {
const { t } = useLang();
const [params] = useSearchParams();
const [password, setPassword] = useState('');
const reset = useAction(() =>
api('/auth/reset-password', { method: 'POST', body: { token: params.get('token'), password } }),
);
return (
<AuthLayout title={t.auth.resetTitle}>
{reset.isSuccess ? (
<p className="text-muted">{t.auth.resetDone}</p>
) : (
<form onSubmit={(e) => { e.preventDefault(); reset.mutate(); }} className="space-y-4">
<div>
<label className="label" htmlFor="password">{t.auth.newPassword}</label>
<input id="password" type="password" className="field" required minLength={8} autoComplete="new-password" value={password} onChange={(e) => setPassword(e.target.value)} />
<p className="mt-1.5 text-xs text-muted">{t.auth.passwordHint}</p>
</div>
<ErrorNote error={reset.error} />
<button className="btn btn-primary w-full" disabled={reset.isPending}>{t.common.save}</button>
</form>
)}
<Link to="/login" className="mt-6 inline-block text-sm text-muted hover:text-text">← {t.auth.login}</Link>
</AuthLayout>
);
}
export function VerifyEmailPage() {
const { t } = useLang();
const [params] = useSearchParams();
const token = params.get('token');
const verify = useQuery({
queryKey: ['verify-email', token],
queryFn: () => api('/auth/verify-email', { method: 'POST', body: { token } }),
retry: false,
staleTime: Infinity,
});
return (
<AuthLayout title={t.auth.email}>
<p className="text-muted">
{verify.isPending ? t.common.loading : verify.isSuccess ? t.auth.verifyDone : t.auth.verifyFail}
</p>
<Link to="/" className="btn btn-ghost mt-6 w-full">{t.brand}</Link>
</AuthLayout>
);
}
+148
View File
@@ -0,0 +1,148 @@
import { PLAN_SPECS, PLANS, type BillingModel, type Plan } from '@lr/shared';
import { useQuery } from '@tanstack/react-query';
import { Check } from 'lucide-react';
import { useState } from 'react';
import { Link, Navigate, useParams } from 'react-router';
import { api } from '../api.ts';
import { ErrorNote } from '../components/ui.tsx';
import { useAction, useMe } from '../hooks.ts';
import { useLang } from '../i18n.tsx';
import { AuthLayout } from './AuthPages.tsx';
/** Пользователь вошёл, но компании ещё нет: выбор тарифа и модели оплаты. */
export function OnboardingPage() {
const { t } = useLang();
const o = t.onboarding;
const [name, setName] = useState('');
const [plan, setPlan] = useState<Plan>('team');
const [model, setModel] = useState<BillingModel>('fixed');
const create = useAction(() =>
api('/company', { method: 'POST', body: { name, plan, billingModel: plan === 'solo' ? 'fixed' : model } }),
);
const logout = useAction(() => api('/auth/logout', { method: 'POST' }));
const spec = PLAN_SPECS[plan];
return (
<div className="radar-rings min-h-dvh px-4 py-10 sm:px-6">
<form
className="mx-auto max-w-4xl"
onSubmit={(e) => { e.preventDefault(); create.mutate(); }}
>
<h1 className="text-3xl font-semibold tracking-tight">{o.title}</h1>
<p className="mt-2 text-muted">{o.sub}</p>
<fieldset className="mt-8 grid gap-4 md:grid-cols-3">
<legend className="sr-only">{t.billing.plan}</legend>
{PLANS.map((p) => {
const s = PLAN_SPECS[p];
const features = [o.features.notify, o.features.crm];
if (s.modules.includes('analytics')) features.push(o.features.analytics, o.features.archive);
if (p === 'agency') features.push(o.features.training);
const active = plan === p;
return (
<label
key={p}
className={`card flex cursor-pointer flex-col p-5 transition-colors has-focus-visible:outline-2 has-focus-visible:outline-signal ${
active ? 'border-capture/50 bg-raised' : 'hover:border-line-strong'
}`}
>
<input type="radio" name="plan" className="sr-only" checked={active} onChange={() => setPlan(p)} />
<span className="flex items-center justify-between">
<span className="text-xl font-medium tracking-tight">{t.plans[p]}</span>
<span className={`size-4 rounded-full border ${active ? 'border-capture bg-capture' : 'border-line-strong'}`} aria-hidden="true" />
</span>
<span className="mt-1 text-sm text-muted">
{s.maxMembers === 1 ? o.members1 : o.membersUpTo(s.maxMembers)}
</span>
<span className="mt-4 text-2xl font-semibold tabular-nums tracking-tight">{o.perMonth(s.monthlyUsd)}</span>
<span className="text-sm text-muted">
{s.commissionPercent ? `${o.orPercent(s.commissionPercent)} · ` : ''}
{o.setup(s.setupFeeUsd)}
</span>
<ul className="mt-5 space-y-2 border-t border-line pt-4 text-sm">
<li className="text-muted">{p === 'solo' ? o.soloUi : o.teamUi}</li>
{features.map((f) => (
<li key={f} className="flex gap-2">
<Check size={16} className="mt-0.5 shrink-0 text-capture" aria-hidden="true" />
{f}
</li>
))}
</ul>
</label>
);
})}
</fieldset>
<div className="card mt-4 grid gap-5 p-5 md:grid-cols-2">
<div>
<label className="label" htmlFor="company">{o.companyName}</label>
<input id="company" className="field" required maxLength={100} value={name} onChange={(e) => setName(e.target.value)} />
</div>
<fieldset>
<legend className="label">{o.model}</legend>
<div className="flex flex-col gap-2">
{(['fixed', 'percent'] as const).map((m) => {
const disabled = m === 'percent' && spec.commissionPercent === null;
const checked = disabled ? false : plan === 'solo' ? m === 'fixed' : model === m;
return (
<label key={m} className={`flex items-center gap-2.5 ${disabled ? 'opacity-45' : 'cursor-pointer'}`}>
<input type="radio" name="model" className="accent-(--capture)" disabled={disabled} checked={checked} onChange={() => setModel(m)} />
{m === 'fixed' ? `${o.fixed} — $${spec.monthlyUsd}` : `${o.percent}${spec.commissionPercent ? ` — ${spec.commissionPercent}%` : ''}`}
</label>
);
})}
</div>
</fieldset>
</div>
<div className="mt-4"><ErrorNote error={create.error} /></div>
<div className="mt-6 flex flex-wrap items-center gap-3">
<button className="btn btn-primary" disabled={create.isPending}>{o.submit}</button>
<button type="button" className="btn btn-ghost" onClick={() => logout.mutate()}>{t.common.logout}</button>
</div>
</form>
</div>
);
}
/** Страница приглашения: до входа показывает компанию, после — кнопку «Присоединиться». */
export function InvitePage() {
const { t } = useLang();
const { token = '' } = useParams();
const me = useMe();
const invite = useQuery({
queryKey: ['invite', token],
queryFn: () => api<{ companyName: string }>(`/invites/${token}`),
retry: false,
});
const accept = useAction(() => api(`/invites/${token}/accept`, { method: 'POST' }));
const next = encodeURIComponent(`/invite/${token}`);
// Уже в компании (в том числе сразу после принятия приглашения) — в кабинет.
if (me.data?.company) return <Navigate to="/" replace />;
if (invite.isPending || me.isPending) return <AuthLayout title={t.brand}><p className="text-muted">{t.common.loading}</p></AuthLayout>;
if (invite.isError) return <AuthLayout title={t.brand}><p className="text-muted">{t.invite.invalid}</p></AuthLayout>;
return (
<AuthLayout title={t.invite.title(invite.data.companyName)}>
{me.data ? (
<div className="space-y-4">
<ErrorNote error={accept.error} />
<button
className="btn btn-primary w-full"
disabled={accept.isPending}
onClick={() => accept.mutate()}
>
{t.invite.accept}
</button>
</div>
) : (
<div className="space-y-4">
<p className="text-muted">{t.invite.needLogin}</p>
<Link to={`/register?next=${next}`} className="btn btn-primary w-full">{t.auth.register}</Link>
<Link to={`/login?next=${next}`} className="btn btn-ghost w-full">{t.auth.login}</Link>
</div>
)}
</AuthLayout>
);
}
+219
View File
@@ -0,0 +1,219 @@
import { PayTrend, Rating } from '../crm/trend.tsx';
import { Send } from 'lucide-react';
import { useState, type ReactNode } from 'react';
import { api, type Me } from '../api.ts';
import { ErrorNote, PageTitle, Toggle } from '../components/ui.tsx';
import { useAction } from '../hooks.ts';
import { COMMISSION_KEYS, type CommissionSettings } from '@lr/shared';
import { CommissionFields } from '../crm/commission.tsx';
import { MyPay } from '../crm/payroll.tsx';
import { useCrm } from '../crm/i18n.ts';
import { PipelineSettings } from '../crm/PipelineSettings.tsx';
import { useLang } from '../i18n.tsx';
function Block({ title, children }: { title: string; children: ReactNode }) {
return (
<section className="card p-5 sm:p-6">
<h2 className="mb-5 text-lg font-medium tracking-tight">{title}</h2>
<div className="space-y-5">{children}</div>
</section>
);
}
function SaveRow({ pending, saved, error }: { pending: boolean; saved: boolean; error: unknown }) {
const { t } = useLang();
return (
<div className="space-y-3">
<ErrorNote error={error} />
<div className="flex items-center gap-3">
<button className="btn btn-primary" disabled={pending}>{t.common.save}</button>
{saved && <span className="text-sm text-capture" role="status">{t.settings.saved}</span>}
</div>
</div>
);
}
/** Привязка Telegram: ссылка ведёт в бота, тот запоминает чат пользователя. */
export function TelegramLink({ linked }: { linked: boolean }) {
const { t } = useLang();
const { c } = useCrm();
const s = t.settings;
const link = useAction(() => api<{ url: string }>('/me/telegram-link', { method: 'POST' }), ['none']);
return (
<div className="space-y-3">
<p className="text-sm">
<span className="text-muted">{s.telegram}: </span>
<span className={linked ? 'text-capture' : 'text-warn'}>{linked ? s.linked : s.notLinked}</span>
</p>
{!linked && !link.data && (
<button type="button" className="btn btn-ghost btn-sm" disabled={link.isPending} onClick={() => link.mutate()}>
<Send size={15} aria-hidden="true" />
{c.tg.link}
</button>
)}
{link.data && !linked && (
<p className="text-sm">
{c.tg.open}{' '}
<a href={link.data.url} target="_blank" rel="noopener noreferrer" className="break-all text-signal hover:underline">{link.data.url}</a>
<span className="mt-1 block text-muted">{c.tg.hint}</span>
</p>
)}
<ErrorNote error={link.error} />
</div>
);
}
function Profile({ me }: { me: Me }) {
const { t, setLang } = useLang();
const s = t.settings;
const [f, setF] = useState({
name: me.user.name,
lang: me.user.lang,
notificationsPaused: me.user.notificationsPaused,
quietFrom: me.user.quietFrom ?? '',
quietTo: me.user.quietTo ?? '',
});
const save = useAction(() =>
api('/me', {
method: 'PATCH',
body: { ...f, quietFrom: f.quietFrom || null, quietTo: f.quietTo || null },
}),
);
return (
<form onSubmit={(e) => { e.preventDefault(); save.mutate(undefined, { onSuccess: () => setLang(f.lang) }); }} className="space-y-4">
<Block title={s.profile}>
<div className="grid gap-4 sm:grid-cols-2">
<div>
<label className="label" htmlFor="name">{t.auth.name}</label>
<input id="name" className="field" required maxLength={100} value={f.name} onChange={(e) => setF({ ...f, name: e.target.value })} />
</div>
<div>
<label className="label" htmlFor="lang">{t.common.lang}</label>
<select id="lang" className="field" value={f.lang} onChange={(e) => setF({ ...f, lang: e.target.value as Me['user']['lang'] })}>
<option value="ru">Русский</option>
<option value="hy">Հայերեն</option>
<option value="en">English</option>
</select>
</div>
</div>
<TelegramLink linked={me.user.telegramLinked} />
</Block>
<Block title={s.notifications}>
<Toggle checked={f.notificationsPaused} onChange={(v) => setF({ ...f, notificationsPaused: v })} label={s.paused} />
<fieldset>
<legend className="label">{s.quiet}</legend>
<div className="flex items-center gap-2 text-sm text-muted">
{s.from}
<input type="time" className="field w-32" aria-label={`${s.quiet} ${s.from}`} value={f.quietFrom} onChange={(e) => setF({ ...f, quietFrom: e.target.value })} />
{s.to}
<input type="time" className="field w-32" aria-label={`${s.quiet} ${s.to}`} value={f.quietTo} onChange={(e) => setF({ ...f, quietTo: e.target.value })} />
</div>
</fieldset>
</Block>
<SaveRow pending={save.isPending} saved={save.isSuccess} error={save.error} />
</form>
);
}
function Company({ me }: { me: Me }) {
const { t } = useLang();
const { c: crm } = useCrm();
const s = t.settings;
const c = me.company!;
const [f, setF] = useState({
name: c.name,
slug: c.slug ?? '',
commission: { objectSale: null, objectRent: null, clientBuyer: null, clientTenant: null, ...c.settings.commission } as CommissionSettings,
agentsSeeAll: c.settings.agentsSeeAll,
autoRejectRemoved: c.settings.autoRejectRemoved ?? false,
poolReturnHours: c.settings.poolReturnHours,
useAgencyBadge: c.settings.agentRules.useAgencyBadge,
phoneThreshold: c.settings.agentRules.phoneThreshold,
reportWeekly: c.settings.reports?.weekly ?? false,
reportMonthly: c.settings.reports?.monthly ?? false,
});
const save = useAction(() => api('/company', { method: 'PATCH', body: f }));
return (
<form onSubmit={(e) => { e.preventDefault(); save.mutate(); }} className="space-y-4">
<Block title={s.company}>
<div className="grid gap-4 sm:grid-cols-2">
<div>
<label className="label" htmlFor="cname">{s.companyName}</label>
<input id="cname" className="field" required maxLength={100} value={f.name} onChange={(e) => setF({ ...f, name: e.target.value })} />
</div>
<div>
<label className="label" htmlFor="pool">{s.poolHours}</label>
<input id="pool" type="number" min={1} max={720} className="field" value={f.poolReturnHours} onChange={(e) => setF({ ...f, poolReturnHours: Number(e.target.value) })} />
</div>
</div>
<div>
<label className="label" htmlFor="cslug">{crm.sh.slug}</label>
<div className="flex items-center gap-2">
<span className="hidden shrink-0 text-sm text-muted sm:inline">{window.location.host}/</span>
<input id="cslug" className="field" required minLength={2} maxLength={40} pattern="[a-zA-Z0-9][a-zA-Z0-9\-]+" value={f.slug} onChange={(e) => setF({ ...f, slug: e.target.value.toLowerCase() })} />
</div>
<p className="mt-1.5 text-xs text-muted">{crm.sh.slugHint}</p>
</div>
{c.maxMembers > 1 && <Toggle checked={f.agentsSeeAll} onChange={(v) => setF({ ...f, agentsSeeAll: v })} label={s.agentsSeeAll} />}
<Toggle checked={f.autoRejectRemoved} onChange={(v) => setF({ ...f, autoRejectRemoved: v })} label={s.autoRejectRemoved} />
</Block>
<Block title={crm.pay.reportsTitle}>
<p className="-mt-2 text-sm text-muted">{crm.pay.reportsHint}</p>
<Toggle checked={f.reportWeekly} onChange={(v) => setF({ ...f, reportWeekly: v })} label={crm.pay.reportWeekly} />
<Toggle checked={f.reportMonthly} onChange={(v) => setF({ ...f, reportMonthly: v })} label={crm.pay.reportMonthly} />
</Block>
<Block title={crm.cm.settings}>
<p className="-mt-2 text-sm text-muted">{crm.cm.settingsHint}</p>
<div className="grid gap-5 sm:grid-cols-2">
{COMMISSION_KEYS.map((key) => (
<CommissionFields key={key} id={`cm-${key}`} label={crm.cm[key]} value={f.commission[key]} onChange={(v) => setF({ ...f, commission: { ...f.commission, [key]: v } })} />
))}
</div>
</Block>
<Block title={s.agentRules}>
<Toggle checked={f.useAgencyBadge} onChange={(v) => setF({ ...f, useAgencyBadge: v })} label={s.useBadge} />
<div className="max-w-xs">
<label className="label" htmlFor="threshold">{s.threshold}</label>
<input id="threshold" type="number" min={1} max={100} className="field" value={f.phoneThreshold} onChange={(e) => setF({ ...f, phoneThreshold: Number(e.target.value) })} />
</div>
</Block>
<SaveRow pending={save.isPending} saved={save.isSuccess} error={save.error} />
</form>
);
}
export function SettingsPage({ me }: { me: Me }) {
const { t } = useLang();
return (
<>
<PageTitle title={t.settings.title} />
<div className="max-w-3xl space-y-8">
<Profile me={me} />
{me.company && !me.webBlocked && <MyPay />}
{me.company && !me.webBlocked && me.user.role !== 'manager' && (
<div className="[&>section]:mt-0 space-y-8">
<PayTrend />
<Rating meId={me.user.id} />
</div>
)}
{me.company && me.user.role === 'manager' && !me.webBlocked && (
<>
<Company me={me} />
<PipelineSettings pipeline="object" />
<PipelineSettings pipeline="client" />
</>
)}
</div>
</>
);
}
export function Placeholder({ title }: { title: string }) {
const { t } = useLang();
return (
<>
<PageTitle title={title} />
<div className="card flex min-h-60 items-center justify-center p-8 text-muted">{t.common.soon}</div>
</>
);
}
+159
View File
@@ -0,0 +1,159 @@
import { PayTrend, Rating } from '../crm/trend.tsx';
import { useQuery } from '@tanstack/react-query';
import { Copy, Link2, Send, Trash2, Wallet } from 'lucide-react';
import { useState } from 'react';
import { api, type Me, type Member } from '../api.ts';
import { ErrorNote, PageTitle } from '../components/ui.tsx';
import { useAction } from '../hooks.ts';
import { useLang } from '../i18n.tsx';
import { useCrm } from '../crm/i18n.ts';
import { PayDialog, PayrollTable, usePayText } from '../crm/payroll.tsx';
import { DepartmentsSection, MemberDepartment } from '../crm/departments.tsx';
interface Invite {
token: string;
url: string;
createdAt: string;
}
export function CopyButton({ text }: { text: string }) {
const { t } = useLang();
const [copied, setCopied] = useState(false);
return (
<button
type="button"
className="btn btn-ghost btn-sm"
onClick={async () => {
await navigator.clipboard.writeText(text);
setCopied(true);
setTimeout(() => setCopied(false), 1500);
}}
>
<Copy size={14} aria-hidden="true" />
{copied ? t.common.copied : t.common.copy}
</button>
);
}
function RemoveDialog({ member, others, onClose }: { member: Member; others: Member[]; onClose: () => void }) {
const { t } = useLang();
const [heir, setHeir] = useState('');
const remove = useAction(() =>
api(`/members/${member.id}${heir ? `?reassignTo=${heir}` : ''}`, { method: 'DELETE' }),
);
return (
<div className="fixed inset-0 z-50 flex items-end justify-center bg-black/60 p-4 sm:items-center" role="dialog" aria-modal="true">
<div className="card w-full max-w-md p-6">
<h2 className="text-xl font-medium tracking-tight">{t.team.removeTitle(member.name)}</h2>
<label className="label mt-4" htmlFor="heir">{t.team.removeHint}</label>
<select id="heir" className="field" value={heir} onChange={(e) => setHeir(e.target.value)}>
<option value="">{t.team.toPool}</option>
{others.map((m) => <option key={m.id} value={m.id}>{m.name}</option>)}
</select>
<div className="mt-4"><ErrorNote error={remove.error} /></div>
<div className="mt-6 flex justify-end gap-2">
<button className="btn btn-ghost" onClick={onClose}>{t.common.cancel}</button>
<button className="btn btn-danger" disabled={remove.isPending} onClick={() => remove.mutate(undefined, { onSuccess: onClose })}>
{t.team.remove}
</button>
</div>
</div>
</div>
);
}
export function TeamPage({ me }: { me: Me }) {
const { t, date } = useLang();
const company = me.company!;
const members = useQuery({ queryKey: ['members'], queryFn: () => api<Member[]>('/members') });
const invites = useQuery({ queryKey: ['invites'], queryFn: () => api<Invite[]>('/invites') });
const create = useAction(() => api('/invites', { method: 'POST' }));
const revoke = useAction((token: string) => api(`/invites/${token}`, { method: 'DELETE' }));
const [removing, setRemoving] = useState<Member | null>(null);
const [paying, setPaying] = useState<Member | null>(null);
const { c } = useCrm();
const payText = usePayText();
const full = company.members >= company.maxMembers;
const readOnly = company.access === 'restricted' || company.access === 'disabled';
return (
<>
<PageTitle title={t.team.title} sub={t.team.count(company.members, company.maxMembers)} />
<ul className="card divide-y divide-line">
{members.data?.map((m) => (
<li key={m.id} className="flex flex-wrap items-center gap-x-4 gap-y-1 px-5 py-3.5">
<div className="min-w-0 flex-1">
<p className="truncate font-medium">{m.name}</p>
<p className="truncate text-sm text-muted">
{[m.email, m.telegramUsername && `@${m.telegramUsername}`].filter(Boolean).join(' · ') || '—'}
</p>
</div>
{m.telegramLinked ? (
<Send size={15} className="text-signal" aria-label="Telegram" />
) : (
<span className="chip bg-warn/15 text-warn">{t.team.noTelegram}</span>
)}
<span className="chip bg-raised text-muted">{t.roles[m.role]}</span>
{company.maxMembers > 1 && <MemberDepartment member={m} readOnly={readOnly} />}
<button className="btn btn-ghost btn-sm" onClick={() => setPaying(m)} title={c.pay.edit}>
<Wallet size={14} aria-hidden="true" />
<span className={m.pay ? '' : 'text-muted'}>{payText(m.pay)}</span>
</button>
{m.role === 'agent' && (
<button className="btn btn-danger btn-sm" onClick={() => setRemoving(m)} aria-label={`${t.team.remove}: ${m.name}`}>
<Trash2 size={14} aria-hidden="true" />
</button>
)}
</li>
))}
</ul>
{company.maxMembers > 1 && <DepartmentsSection readOnly={readOnly} />}
<section className="mt-8">
<div className="mb-3 flex flex-wrap items-center justify-between gap-3">
<h2 className="text-lg font-medium tracking-tight">{t.team.invites}</h2>
{company.maxMembers > 1 && (
<button className="btn btn-primary btn-sm" disabled={full || create.isPending} onClick={() => create.mutate()}>
<Link2 size={15} aria-hidden="true" />
{t.team.newInvite}
</button>
)}
</div>
<p className="mb-4 max-w-2xl text-sm text-muted">
{company.maxMembers === 1 ? t.team.soloOnly : full ? t.team.limit : t.team.inviteHint}
</p>
<ErrorNote error={create.error} />
{company.maxMembers > 1 &&
(invites.data?.length ? (
<ul className="card divide-y divide-line">
{invites.data.map((i) => (
<li key={i.token} className="flex flex-wrap items-center gap-3 px-5 py-3.5">
<code className="min-w-0 flex-1 truncate text-sm text-muted">{i.url}</code>
<span className="text-xs text-muted">{date(i.createdAt)}</span>
<CopyButton text={i.url} />
<button className="btn btn-danger btn-sm" onClick={() => revoke.mutate(i.token)}>{t.team.revoke}</button>
</li>
))}
</ul>
) : (
<p className="text-sm text-muted">{t.team.noInvites}</p>
))}
</section>
<PayrollTable />
<PayTrend />
<Rating meId={me.user.id} />
{paying && <PayDialog member={paying} onClose={() => setPaying(null)} />}
{removing && (
<RemoveDialog
member={removing}
others={(members.data ?? []).filter((m) => m.id !== removing.id)}
onClose={() => setRemoving(null)}
/>
)}
</>
);
}
+166
View File
@@ -0,0 +1,166 @@
import { useQuery } from '@tanstack/react-query';
import { CalendarCheck } from 'lucide-react';
import { useState } from 'react';
import { useParams } from 'react-router';
import { api } from '../api.ts';
import { ErrorNote, LangSwitcher, Logo } from '../components/ui.tsx';
import { useCrm } from '../crm/i18n.ts';
import { useAction } from '../hooks.ts';
// Страница подборки, которую агент отправляет клиенту: объекты на выбор и запись на показ.
// Открывается без входа; точного адреса и ссылки на объявление здесь нет.
interface PageObject {
id: number;
/** Готовый текст объекта: карточка по шаблону, иногда с абзацем от модели. */
text: string;
dealType: 'sale' | 'rent' | null;
region: string | null;
district: string | null;
price: number | null;
currency: string | null;
rooms: number | null;
area: number | null;
floor: number | null;
floorsTotal: number | null;
photos: string[];
viewingDay: string | null;
}
interface PageData {
company: string;
agent: string;
client: string;
objects: PageObject[];
}
const TEXTS = {
ru: {
title: (name: string) => `Подборка для вас, ${name}`, sub: (agent: string, company: string) => `Подобрал агент ${agent}, ${company}. Выберите объект и день — агент подтвердит показ.`,
empty: 'Агент пока не добавил объекты. Загляните позже.', gone: 'Страница не найдена или закрыта. Попросите агента прислать новую ссылку.',
day: 'День показа', note: 'Комментарий (необязательно)', noteHint: 'Например: после 18:00', book: 'Записаться на показ', change: 'Изменить день', save: 'Сохранить',
booked: (d: string) => `Вы записаны на показ ${d}`, wait: 'Агент свяжется с вами и уточнит время.', more: 'Подробнее', less: 'Свернуть',
photo: 'Фото',
},
hy: {
title: (name: string) => `Ընտրանի Ձեզ համար, ${name}`, sub: (agent: string, company: string) => `Ընտրել է գործակալ ${agent}-ը, ${company}։ Ընտրեք օբյեկտը և օրը. գործակալը կհաստատի ցուցադրությունը։`,
empty: 'Գործակալը դեռ օբյեկտներ չի ավելացրել։ Այցելեք ավելի ուշ։', gone: 'Էջը չի գտնվել կամ փակված է։ Խնդրեք գործակալին ուղարկել նոր հղում։',
day: 'Ցուցադրության օրը', note: 'Մեկնաբանություն (ոչ պարտադիր)', noteHint: 'Օրինակ՝ 18:00-ից հետո', book: 'Գրանցվել ցուցադրության', change: 'Փոխել օրը', save: 'Պահպանել',
booked: (d: string) => `Դուք գրանցված եք ցուցադրության՝ ${d}`, wait: 'Գործակալը կկապվի Ձեզ հետ և կճշտի ժամը։', more: 'Մանրամասն', less: 'Փակել',
photo: 'Լուսանկար',
},
en: {
title: (name: string) => `A selection for you, ${name}`, sub: (agent: string, company: string) => `Picked by agent ${agent}, ${company}. Choose a property and a day — the agent will confirm the viewing.`,
empty: 'The agent has not added properties yet. Check back later.', gone: 'The page was not found or is closed. Ask the agent for a new link.',
day: 'Viewing day', note: 'Comment (optional)', noteHint: 'E.g. after 6 pm', book: 'Book a viewing', change: 'Change the day', save: 'Save',
booked: (d: string) => `You are booked for a viewing on ${d}`, wait: 'The agent will contact you to agree on the time.', more: 'More', less: 'Less',
photo: 'Photo',
},
};
const iso = (d: Date) => new Date(d.getTime() - d.getTimezoneOffset() * 60_000).toISOString().slice(0, 10);
function ObjectCard({ o, slug, token }: { o: PageObject; slug: string; token: string }) {
const { lang, price, date } = useCrm();
const t = TEXTS[lang];
const [open, setOpen] = useState(false);
const [editing, setEditing] = useState(false);
const [day, setDay] = useState(o.viewingDay ?? iso(new Date(Date.now() + 86_400_000)));
const [note, setNote] = useState('');
const book = useAction(
() => api(`/public/pages/${slug}/${token}/viewings`, { method: 'POST', body: { leadId: o.id, day, note: note || undefined } }),
['showcase', slug, token],
);
const booked = o.viewingDay && !editing;
return (
<article className="card overflow-hidden">
{o.photos.length > 0 && (
<div className="relative">
{o.photos.length > 1 && <span className="absolute right-3 top-3 z-10 rounded-md bg-ink/75 px-2 py-0.5 text-xs tabular-nums">1 / {o.photos.length} →</span>}
<div className="flex snap-x snap-mandatory gap-1 overflow-x-auto bg-ink">
{o.photos.map((src, i) => (
<img key={src} src={src} alt={`${t.photo} ${i + 1}`} loading={i ? 'lazy' : 'eager'} referrerPolicy="no-referrer" onError={(e) => { e.currentTarget.style.display = 'none'; }} className="h-64 w-full shrink-0 snap-center object-cover sm:h-80" />
))}
</div>
</div>
)}
<div className="space-y-4 p-5">
<p className="text-2xl font-semibold tracking-tight tabular-nums">{price(o.price, o.currency, o.dealType)}</p>
{o.text && (
<div>
<p className={`whitespace-pre-wrap break-words leading-relaxed ${open ? '' : 'line-clamp-[9]'}`}>{o.text}</p>
{o.text.split('\n').length > 9 && (
<button type="button" className="mt-1 cursor-pointer text-sm text-signal hover:underline" aria-expanded={open} onClick={() => setOpen((v) => !v)}>
{open ? t.less : t.more}
</button>
)}
</div>
)}
{booked ? (
<div className="rounded-xl border border-capture/40 bg-capture/10 p-4">
<p className="flex items-center gap-2 font-medium text-capture"><CalendarCheck size={18} aria-hidden="true" />{t.booked(date(o.viewingDay))}</p>
<p className="mt-1 text-sm text-muted">{t.wait}</p>
<button type="button" className="btn btn-ghost btn-sm mt-3" onClick={() => setEditing(true)}>{t.change}</button>
</div>
) : (
<form
className="space-y-3 border-t border-line pt-4"
onSubmit={(e) => { e.preventDefault(); book.mutate(undefined, { onSuccess: () => setEditing(false) }); }}
>
<div className="grid gap-3 sm:grid-cols-[11rem_1fr]">
<div>
<label className="label" htmlFor={`day-${o.id}`}>{t.day}</label>
<input id={`day-${o.id}`} type="date" required className="field" min={iso(new Date())} max={iso(new Date(Date.now() + 89 * 86_400_000))} value={day} onChange={(e) => setDay(e.target.value)} />
</div>
<div>
<label className="label" htmlFor={`note-${o.id}`}>{t.note}</label>
<input id={`note-${o.id}`} className="field" maxLength={300} placeholder={t.noteHint} value={note} onChange={(e) => setNote(e.target.value)} />
</div>
</div>
<ErrorNote error={book.error} />
<button className="btn btn-primary w-full sm:w-auto" disabled={book.isPending}>
<CalendarCheck size={17} aria-hidden="true" />
{editing ? t.save : t.book}
</button>
</form>
)}
</div>
</article>
);
}
export function ShowcasePage() {
const { company = '', token = '' } = useParams();
const { lang, t: base } = useCrm();
const t = TEXTS[lang];
const page = useQuery({
queryKey: ['showcase', company, token],
queryFn: () => api<PageData>(`/public/pages/${company}/${token}`),
retry: false,
});
return (
<div className="min-h-dvh">
<header className="flex items-center justify-between border-b border-line px-4 py-3 sm:px-6">
{page.data ? <span className="truncate text-[15px] font-semibold tracking-tight">{page.data.company}</span> : <Logo />}
<LangSwitcher />
</header>
<main className="mx-auto max-w-2xl px-4 py-8 sm:px-6">
{page.isPending && <p className="text-muted">{base.common.loading}</p>}
{page.isError && <p className="card p-6 text-center text-muted">{t.gone}</p>}
{page.data && (
<>
<h1 className="text-2xl font-semibold tracking-tight sm:text-3xl">{t.title(page.data.client)}</h1>
<p className="mt-2 text-muted">{t.sub(page.data.agent, page.data.company)}</p>
{page.data.objects.length === 0 && <p className="card mt-8 p-6 text-center text-muted">{t.empty}</p>}
<div className="mt-8 space-y-5">
{page.data.objects.map((o) => <ObjectCard key={o.id} o={o} slug={company} token={token} />)}
</div>
</>
)}
</main>
</div>
);
}
+50
View File
@@ -0,0 +1,50 @@
// Telegram Mini App: тот же кабинет, открытый кнопкой в боте. Telegram передаёт подписанные
// данные пользователя (initData) — по ним сервер открывает сессию без пароля.
interface WebApp {
initData: string;
ready(): void;
expand(): void;
setHeaderColor?(color: string): void;
setBackgroundColor?(color: string): void;
}
const webApp = (): WebApp | null => {
const app = (window as unknown as { Telegram?: { WebApp?: WebApp } }).Telegram?.WebApp;
return app?.initData ? app : null;
};
/** Кабинет открыт внутри Telegram. */
export const inTelegram = () => Boolean(webApp());
/** Вход по данным Telegram. Выполняется до первого кадра, чтобы не мелькала страница входа. */
export async function telegramSignIn(): Promise<void> {
// Telegram передаёт параметры запуска в адресе после «#»; свой скрипт он потом хранит их в sessionStorage.
// Вне Telegram скрипт моста не загружается вовсе.
const launched = location.hash.includes('tgWebAppData') || Boolean(sessionStorage.getItem('__telegram__initParams'));
if (!launched) return;
await new Promise<void>((resolve) => {
const script = document.createElement('script');
script.src = 'https://telegram.org/js/telegram-web-app.js';
script.onload = script.onerror = () => resolve();
document.head.append(script);
setTimeout(resolve, 5000);
});
const app = webApp();
if (!app) return;
document.documentElement.dataset.miniapp = '1';
try {
app.ready();
app.expand();
app.setHeaderColor?.('#020617');
app.setBackgroundColor?.('#020617');
await fetch('/api/auth/telegram/webapp', {
method: 'POST',
credentials: 'same-origin',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ initData: app.initData }),
});
} catch {
// Не получилось — пользователь увидит обычную страницу входа.
}
}
+13
View File
@@ -0,0 +1,13 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"module": "ESNext",
"moduleResolution": "Bundler",
"jsx": "react-jsx",
"lib": ["ES2023", "DOM", "DOM.Iterable"],
"noEmit": true,
"allowImportingTsExtensions": true,
"types": ["vite/client"]
},
"include": ["src", "vite.config.ts"]
}
+9
View File
@@ -0,0 +1,9 @@
import tailwindcss from '@tailwindcss/vite';
import react from '@vitejs/plugin-react';
import { defineConfig } from 'vite';
export default defineConfig({
plugins: [react(), tailwindcss()],
server: { port: 5174, proxy: { '/api': 'http://localhost:3100' } },
preview: { port: 5174, proxy: { '/api': 'http://localhost:3100' } },
});