583682f330
TicketsPage.tsx — список open/closed тикетов + тред сообщений + ответ + закрытие (owner/moderator/support). ObservabilityPage.tsx — вкладки дашбордов Grafana через iframe за auth-гейтом Caddy, URL берётся из GET /api/v1/config (grafana_url), страница просто не показывает дашборды, пока GRAFANA_PUBLIC_URL не задан на сервере. StaffPage.tsx — форма создания support-аккаунта (только Owner). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
278 lines
10 KiB
TypeScript
278 lines
10 KiB
TypeScript
import { useEffect, useState } from "react";
|
||
import { useOutletContext } from "react-router-dom";
|
||
import { UserPlus, RefreshCw } from "lucide-react";
|
||
import {
|
||
createModerator,
|
||
createPartner,
|
||
createSupport,
|
||
fetchStaffUsers,
|
||
setNodePermission,
|
||
type StaffRole,
|
||
type StaffUser,
|
||
} from "./api";
|
||
|
||
export default function StaffPage() {
|
||
const role = useOutletContext<StaffRole>();
|
||
const [staff, setStaff] = useState<StaffUser[]>([]);
|
||
const [loading, setLoading] = useState(true);
|
||
|
||
const [modUsername, setModUsername] = useState("");
|
||
const [modPassword, setModPassword] = useState("");
|
||
const [modCanManageNodes, setModCanManageNodes] = useState(false);
|
||
|
||
const [partnerUsername, setPartnerUsername] = useState("");
|
||
const [partnerPassword, setPartnerPassword] = useState("");
|
||
const [partnerPercent, setPartnerPercent] = useState("10");
|
||
|
||
const [supportUsername, setSupportUsername] = useState("");
|
||
const [supportPassword, setSupportPassword] = useState("");
|
||
|
||
const [error, setError] = useState("");
|
||
|
||
const load = async () => {
|
||
setLoading(true);
|
||
try {
|
||
setStaff(await fetchStaffUsers());
|
||
} catch (e) {
|
||
console.error(e);
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
};
|
||
|
||
useEffect(() => {
|
||
load();
|
||
}, []);
|
||
|
||
const handleCreateModerator = async (e: React.FormEvent) => {
|
||
e.preventDefault();
|
||
setError("");
|
||
try {
|
||
await createModerator(modUsername, modPassword, modCanManageNodes);
|
||
setModUsername("");
|
||
setModPassword("");
|
||
setModCanManageNodes(false);
|
||
await load();
|
||
} catch {
|
||
setError("Не удалось создать модератора (логин уже занят?).");
|
||
}
|
||
};
|
||
|
||
const handleCreatePartner = async (e: React.FormEvent) => {
|
||
e.preventDefault();
|
||
setError("");
|
||
try {
|
||
await createPartner(partnerUsername, partnerPassword, Number(partnerPercent));
|
||
setPartnerUsername("");
|
||
setPartnerPassword("");
|
||
setPartnerPercent("10");
|
||
await load();
|
||
} catch {
|
||
setError("Не удалось создать партнёра (логин уже занят?).");
|
||
}
|
||
};
|
||
|
||
const handleCreateSupport = async (e: React.FormEvent) => {
|
||
e.preventDefault();
|
||
setError("");
|
||
try {
|
||
await createSupport(supportUsername, supportPassword);
|
||
setSupportUsername("");
|
||
setSupportPassword("");
|
||
await load();
|
||
} catch {
|
||
setError("Не удалось создать аккаунт саппорта (логин уже занят?).");
|
||
}
|
||
};
|
||
|
||
const handleTogglePermission = async (userId: string, current: boolean) => {
|
||
try {
|
||
await setNodePermission(userId, !current);
|
||
await load();
|
||
} catch (e) {
|
||
console.error(e);
|
||
}
|
||
};
|
||
|
||
return (
|
||
<div className="p-6 sm:p-10 max-w-5xl mx-auto space-y-8">
|
||
<div className="flex items-center justify-between">
|
||
<h1 className="text-xl font-black tracking-widest text-primary uppercase">
|
||
Staff
|
||
</h1>
|
||
<button
|
||
onClick={load}
|
||
className="p-2 bg-white/5 hover:bg-white/10 rounded-full transition-colors"
|
||
>
|
||
<RefreshCw className={`w-4 h-4 text-secondary ${loading ? "animate-spin" : ""}`} />
|
||
</button>
|
||
</div>
|
||
|
||
{error && <p className="text-xs text-red-500">{error}</p>}
|
||
|
||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||
{role.role === "owner" && (
|
||
<form
|
||
onSubmit={handleCreateModerator}
|
||
className="bg-black/40 border border-white/10 rounded-2xl p-6 space-y-3"
|
||
>
|
||
<h2 className="text-xs font-bold uppercase tracking-widest text-secondary flex items-center gap-2">
|
||
<UserPlus className="w-4 h-4" /> Новый модератор
|
||
</h2>
|
||
<input
|
||
required
|
||
placeholder="Логин"
|
||
value={modUsername}
|
||
onChange={(e) => setModUsername(e.target.value)}
|
||
className="w-full bg-white/5 border border-white/10 rounded-lg p-2.5 text-sm outline-none focus:border-secondary"
|
||
/>
|
||
<input
|
||
required
|
||
type="password"
|
||
placeholder="Пароль"
|
||
value={modPassword}
|
||
onChange={(e) => setModPassword(e.target.value)}
|
||
className="w-full bg-white/5 border border-white/10 rounded-lg p-2.5 text-sm outline-none focus:border-secondary"
|
||
/>
|
||
<label className="flex items-center gap-2 text-xs text-muted-foreground cursor-pointer select-none">
|
||
<input
|
||
type="checkbox"
|
||
checked={modCanManageNodes}
|
||
onChange={(e) => setModCanManageNodes(e.target.checked)}
|
||
className="accent-secondary"
|
||
/>
|
||
Право управлять нодами
|
||
</label>
|
||
<button
|
||
type="submit"
|
||
className="w-full bg-secondary/10 hover:bg-secondary/20 text-secondary border border-secondary/30 font-bold uppercase tracking-widest text-xs py-2.5 rounded-lg transition-all"
|
||
>
|
||
Создать
|
||
</button>
|
||
</form>
|
||
)}
|
||
|
||
{role.role === "owner" && (
|
||
<form
|
||
onSubmit={handleCreateSupport}
|
||
className="bg-black/40 border border-white/10 rounded-2xl p-6 space-y-3"
|
||
>
|
||
<h2 className="text-xs font-bold uppercase tracking-widest text-secondary flex items-center gap-2">
|
||
<UserPlus className="w-4 h-4" /> Новый саппорт
|
||
</h2>
|
||
<input
|
||
required
|
||
placeholder="Логин"
|
||
value={supportUsername}
|
||
onChange={(e) => setSupportUsername(e.target.value)}
|
||
className="w-full bg-white/5 border border-white/10 rounded-lg p-2.5 text-sm outline-none focus:border-secondary"
|
||
/>
|
||
<input
|
||
required
|
||
type="password"
|
||
placeholder="Пароль"
|
||
value={supportPassword}
|
||
onChange={(e) => setSupportPassword(e.target.value)}
|
||
className="w-full bg-white/5 border border-white/10 rounded-lg p-2.5 text-sm outline-none focus:border-secondary"
|
||
/>
|
||
<p className="text-[10px] text-muted-foreground">
|
||
Видит только раздел "Тикеты" — доступа к нодам/staff/выводам нет.
|
||
</p>
|
||
<button
|
||
type="submit"
|
||
className="w-full bg-secondary/10 hover:bg-secondary/20 text-secondary border border-secondary/30 font-bold uppercase tracking-widest text-xs py-2.5 rounded-lg transition-all"
|
||
>
|
||
Создать
|
||
</button>
|
||
</form>
|
||
)}
|
||
|
||
<form
|
||
onSubmit={handleCreatePartner}
|
||
className="bg-black/40 border border-white/10 rounded-2xl p-6 space-y-3"
|
||
>
|
||
<h2 className="text-xs font-bold uppercase tracking-widest text-primary flex items-center gap-2">
|
||
<UserPlus className="w-4 h-4" /> Новый партнёр
|
||
</h2>
|
||
<input
|
||
required
|
||
placeholder="Логин"
|
||
value={partnerUsername}
|
||
onChange={(e) => setPartnerUsername(e.target.value)}
|
||
className="w-full bg-white/5 border border-white/10 rounded-lg p-2.5 text-sm outline-none focus:border-primary"
|
||
/>
|
||
<input
|
||
required
|
||
type="password"
|
||
placeholder="Пароль"
|
||
value={partnerPassword}
|
||
onChange={(e) => setPartnerPassword(e.target.value)}
|
||
className="w-full bg-white/5 border border-white/10 rounded-lg p-2.5 text-sm outline-none focus:border-primary"
|
||
/>
|
||
<div className="flex items-center gap-2">
|
||
<input
|
||
required
|
||
type="number"
|
||
min="0"
|
||
max="100"
|
||
step="0.01"
|
||
value={partnerPercent}
|
||
onChange={(e) => setPartnerPercent(e.target.value)}
|
||
className="w-24 bg-white/5 border border-white/10 rounded-lg p-2.5 text-sm outline-none focus:border-primary"
|
||
/>
|
||
<span className="text-xs text-muted-foreground">% с продаж</span>
|
||
</div>
|
||
<button
|
||
type="submit"
|
||
className="w-full bg-primary/10 hover:bg-primary/20 text-primary border border-primary/30 font-bold uppercase tracking-widest text-xs py-2.5 rounded-lg transition-all"
|
||
>
|
||
Создать
|
||
</button>
|
||
</form>
|
||
</div>
|
||
|
||
<div className="space-y-3">
|
||
<h2 className="text-xs font-bold uppercase tracking-widest text-muted-foreground">
|
||
Список staff
|
||
</h2>
|
||
{staff.map((u) => (
|
||
<div
|
||
key={u.id}
|
||
className="bg-black/60 border border-white/5 rounded-xl p-4 flex items-center justify-between gap-4"
|
||
>
|
||
<div>
|
||
<div className="flex items-center gap-2">
|
||
<span className="font-bold text-sm">{u.username}</span>
|
||
<span className="text-[10px] bg-white/10 px-2 py-0.5 rounded uppercase text-muted-foreground">
|
||
{u.role}
|
||
</span>
|
||
</div>
|
||
{u.role === "partner" && (
|
||
<p className="text-xs text-muted-foreground font-mono mt-1">
|
||
{u.commission_percent}% · код: {u.partner_code}
|
||
</p>
|
||
)}
|
||
</div>
|
||
{role.role === "owner" && u.role === "moderator" && (
|
||
<label className="flex items-center gap-2 text-xs text-muted-foreground cursor-pointer select-none">
|
||
<input
|
||
type="checkbox"
|
||
checked={u.can_manage_nodes}
|
||
onChange={() => handleTogglePermission(u.id, u.can_manage_nodes)}
|
||
className="accent-secondary"
|
||
/>
|
||
Ноды
|
||
</label>
|
||
)}
|
||
</div>
|
||
))}
|
||
{staff.length === 0 && !loading && (
|
||
<p className="text-center py-10 text-muted-foreground text-sm uppercase tracking-widest">
|
||
Пока никого нет
|
||
</p>
|
||
)}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|