Ребренд Мониторинг Орбита -> Железный Егорин; accent green->amber #F59E0B; LoginScreen правая половина = Lightning (WebGL, hue 35 тёплый) под стеклянной формой; левая панель — тёплый градиент. Доступ агентам: репо публичный, push german-токеном. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
166 lines
8.4 KiB
TypeScript
166 lines
8.4 KiB
TypeScript
import { useEffect, useState } from 'react';
|
|
import { Wallet, CreditCard, Building2, Check, Smartphone, Users, UserPlus, Trash2 } from 'lucide-react';
|
|
import { PageHeader } from '@/components/PageHeader';
|
|
import { Card, CardHeader, Badge } from '@/components/ui/primitives';
|
|
import { KpiCard } from '@/components/ui/KpiCard';
|
|
import { cn, fmt } from '@/lib/utils';
|
|
import { apiGet, apiPost, apiDelete } from '@/api/client';
|
|
import { useAuth } from '@/core/store';
|
|
|
|
interface Billing {
|
|
clientId: string;
|
|
balance: number;
|
|
currency: string;
|
|
plan: string;
|
|
devices: number;
|
|
}
|
|
|
|
const AMOUNTS = [300, 500, 1000, 3000];
|
|
|
|
export function CabinetModule() {
|
|
const user = useAuth((s) => s.user);
|
|
const [b, setB] = useState<Billing | null>(null);
|
|
const [amount, setAmount] = useState(500);
|
|
const [busy, setBusy] = useState(false);
|
|
const [links, setLinks] = useState<string[]>([]);
|
|
const [newEmail, setNewEmail] = useState('');
|
|
|
|
useEffect(() => {
|
|
apiGet<Billing>('/billing').then(setB).catch(() => {});
|
|
apiGet<string[]>('/cabinet/links').then(setLinks).catch(() => {});
|
|
}, []);
|
|
|
|
const addLink = async () => {
|
|
const e = newEmail.trim();
|
|
if (!e) return;
|
|
const r = await apiPost<string[]>('/cabinet/links', { email: e });
|
|
setLinks(r || []);
|
|
setNewEmail('');
|
|
};
|
|
const removeLink = async (email: string) => {
|
|
const r = await apiDelete<string[]>(`/cabinet/links/${encodeURIComponent(email)}`);
|
|
setLinks(r || []);
|
|
};
|
|
|
|
const topup = async (method: 'yoomoney' | 'sberbank') => {
|
|
setBusy(true);
|
|
try {
|
|
const r = await apiPost<{ payUrl: string }>('/billing/topup', { amount, method });
|
|
if (r?.payUrl) window.open(r.payUrl, '_blank', 'noopener');
|
|
} finally {
|
|
setBusy(false);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div>
|
|
<PageHeader icon={Wallet} title="Личный кабинет" desc="Баланс · тариф · пополнение доступа" actions={<Badge severity="info">кабинет: {user.clientId}</Badge>} />
|
|
|
|
<div className="grid grid-cols-2 gap-3 lg:grid-cols-4">
|
|
<KpiCard label="Баланс" value={b?.balance ?? 0} unit="₽" digits={0} icon={Wallet} tone="accent" />
|
|
<KpiCard label="Тариф" value={b?.plan ?? '—'} icon={Check} tone="ok" />
|
|
<KpiCard label="Устройств" value={b?.devices ?? 0} digits={0} icon={Smartphone} tone="muted" />
|
|
<KpiCard label="Аккаунт" value={user.name} icon={CreditCard} tone="muted" />
|
|
</div>
|
|
|
|
<div className="mt-4 grid grid-cols-1 gap-4 lg:grid-cols-2">
|
|
<Card>
|
|
<CardHeader title="Пополнить баланс" desc="выберите сумму и способ оплаты" />
|
|
<div className="px-4 pb-4">
|
|
<div className="flex flex-wrap gap-2">
|
|
{AMOUNTS.map((a) => (
|
|
<button
|
|
key={a}
|
|
onClick={() => setAmount(a)}
|
|
className={cn('rounded-lg border px-4 py-2 text-sm font-medium transition-colors', amount === a ? 'border-accent bg-accent/15 text-accent' : 'border-border text-fg hover:bg-muted')}
|
|
>
|
|
{fmt(a, 0)} ₽
|
|
</button>
|
|
))}
|
|
<input
|
|
type="number"
|
|
value={amount}
|
|
onChange={(e) => setAmount(Math.max(0, Number(e.target.value)))}
|
|
className="tnum w-28 rounded-lg border border-border bg-surface-2 px-3 py-2 text-sm text-fg outline-none focus:border-accent"
|
|
/>
|
|
</div>
|
|
|
|
<div className="mt-4 flex flex-col gap-2 sm:flex-row">
|
|
<button
|
|
onClick={() => topup('yoomoney')}
|
|
disabled={busy || amount <= 0}
|
|
className="flex flex-1 items-center justify-center gap-2 rounded-xl bg-[#8B3FFD] px-4 py-3 text-sm font-semibold text-white hover:opacity-90 disabled:opacity-50"
|
|
>
|
|
<CreditCard className="h-4 w-4" /> ЮMoney / Яндекс
|
|
</button>
|
|
<button
|
|
onClick={() => topup('sberbank')}
|
|
disabled={busy || amount <= 0}
|
|
className="flex flex-1 items-center justify-center gap-2 rounded-xl bg-[#21A038] px-4 py-3 text-sm font-semibold text-white hover:opacity-90 disabled:opacity-50"
|
|
>
|
|
<Building2 className="h-4 w-4" /> Сбербанк Онлайн
|
|
</button>
|
|
</div>
|
|
<p className="mt-3 text-xs text-fg-muted">⚠️ Демо-заглушка: реальное списание не происходит, открывается тестовая форма оплаты. Боевая интеграция ЮMoney/Сбербанк — по плану.</p>
|
|
</div>
|
|
</Card>
|
|
|
|
<Card>
|
|
<CardHeader title="Доступ и подписка" desc="что входит в тариф" />
|
|
<div className="space-y-2 px-4 pb-4 text-sm">
|
|
{['GPS-мониторинг своего автопарка', 'Привязка трекеров по IMEI', 'Треки, поездки, геозоны', 'Отчёты CSV/PDF', 'Уведомления и «Рация»'].map((f) => (
|
|
<div key={f} className="flex items-center gap-2 text-fg">
|
|
<Check className="h-4 w-4 shrink-0 text-ok" /> {f}
|
|
</div>
|
|
))}
|
|
<div className="mt-3 rounded-lg bg-bg/40 p-3 text-xs text-fg-muted">
|
|
Тариф <span className="font-medium text-fg">{b?.plan ?? '—'}</span>. Расширенные модули (Энерго / Смарт / Сеть) подключает администратор.
|
|
</div>
|
|
</div>
|
|
</Card>
|
|
</div>
|
|
|
|
{/* Связанные аккаунты — общий кабинет для нескольких Яндекс-входов */}
|
|
<Card className="mt-4">
|
|
<CardHeader
|
|
title={<span className="flex items-center gap-1.5"><Users className="h-4 w-4 text-accent" /> Связанные аккаунты</span>}
|
|
desc="добавьте e-mail коллеги — при входе через Яндекс он попадёт в этот же кабинет (общие объекты и устройства)"
|
|
right={<Badge severity="info">{links.length}</Badge>}
|
|
/>
|
|
<div className="px-4 pb-4">
|
|
<div className="mb-3 flex flex-col gap-2 sm:flex-row">
|
|
<input
|
|
value={newEmail}
|
|
onChange={(e) => setNewEmail(e.target.value)}
|
|
onKeyDown={(e) => e.key === 'Enter' && addLink()}
|
|
type="email"
|
|
placeholder="e-mail Яндекс-аккаунта"
|
|
className="flex-1 rounded-lg border border-border bg-surface-2 px-3 py-2 text-sm text-fg outline-none placeholder:text-fg-muted focus:border-accent"
|
|
/>
|
|
<button onClick={addLink} disabled={!newEmail.trim()} className="inline-flex items-center justify-center gap-1.5 rounded-lg bg-accent px-4 py-2 text-sm font-semibold text-[#06210f] hover:opacity-90 disabled:opacity-40">
|
|
<UserPlus className="h-4 w-4" /> Привязать
|
|
</button>
|
|
</div>
|
|
{links.length === 0 ? (
|
|
<p className="py-3 text-center text-sm text-fg-muted">Связанных аккаунтов нет. Кабинет доступен только вам ({user.name}).</p>
|
|
) : (
|
|
<div className="divide-y divide-border">
|
|
{links.map((e) => (
|
|
<div key={e} className="flex items-center gap-2 py-2 text-sm">
|
|
<span className="grid h-7 w-7 shrink-0 place-items-center rounded-full bg-secondary text-[10px] font-semibold">{e.slice(0, 2).toUpperCase()}</span>
|
|
<span className="min-w-0 flex-1 truncate text-fg">{e}</span>
|
|
<Badge severity="ok">кабинет: {user.clientId}</Badge>
|
|
<button onClick={() => removeLink(e)} className="grid h-7 w-7 place-items-center rounded-md text-fg-muted hover:bg-muted hover:text-crit" title="Отвязать">
|
|
<Trash2 className="h-4 w-4" />
|
|
</button>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
<p className="mt-2 text-[11px] text-fg-muted">Связанный аккаунт видит те же объекты, устройства и данные, но не является администратором. Отвязка — кнопкой корзины.</p>
|
|
</div>
|
|
</Card>
|
|
</div>
|
|
);
|
|
}
|