Files
egorin/src/modules/cabinet/CabinetModule.tsx
T
GermanandClaude Opus 4.8 b1c52ab748 Железный Егорин — форк Орбиты: тёплый стиль (amber), логин с odyssey WebGL-молнией справа, энергомониторинг+панель
Ребренд Мониторинг Орбита -> Железный Егорин; accent green->amber #F59E0B; LoginScreen правая половина = Lightning (WebGL, hue 35 тёплый) под стеклянной формой; левая панель — тёплый градиент.
Доступ агентам: репо публичный, push german-токеном.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-07 23:57:16 +03:00

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>
);
}