Files
flatrender/src/components/admin/UserActions.tsx
T
soroush.asadi 81912cac66
Build backend images / build content-svc (push) Failing after 14s
Build backend images / build file-svc (push) Failing after 1m28s
Build backend images / build gateway (push) Failing after 1m43s
Build backend images / build identity-svc (push) Failing after 3m0s
Build backend images / build notification-svc (push) Failing after 51s
Build backend images / build render-svc (push) Failing after 1m3s
Build backend images / build studio-svc (push) Failing after 1m1s
feat(render): full-screen render page, one-active-render limit, app-wide progress
Concurrent-render ceiling (a user runs 1 render at a time unless granted more):
- Identity: TokenService emits max_renders claim from User.ParallelRenderingCeiling
- Identity: admin POST /v1/users/{id}/render-slots (AdminService.SetRenderSlotsAsync,
  clamped 1..50) — gamification or admin raises a user's ceiling
- render-svc: middleware reads max_renders (default 1); CreateJob rejects with 409
  active_render_limit when active jobs >= ceiling
- render-svc: db.CountActiveJobs + ListActiveJobs; GET /v1/renders/active returns
  in-flight renders + can_start_new

Full-screen render page (replaces the modal):
- /studio/render/[projectId]: config (resolution/fps) → live preview + progress →
  download; resumes this project's in-flight render on mount; blocks when another
  render is active; reads ?preset=
- StudioTopBar export menu now navigates to the page; RenderModal deleted (dead)

App-wide minimal progress:
- GlobalRenderProgress pill mounted in the locale layout for authed users; polls
  /api/render/active every 4s, shows thumbnail + step + % on every page, click →
  the render page; hidden on the render page and when idle

Admin: UserActions gains a "concurrent render slots" control.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-05 16:48:05 +03:30

192 lines
12 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import { useState } from "react";
const inp = "w-full rounded-lg border border-[#262b40] bg-[#0c0e1a] px-3 py-2 text-sm text-gray-100 outline-none focus:border-indigo-500";
const lbl = "mb-1 block text-xs font-medium text-gray-400";
const btn = "rounded-lg bg-indigo-600 px-3 py-1.5 text-xs font-medium text-white hover:bg-indigo-500 disabled:opacity-50";
const card = "rounded-xl border border-[#1e2235] bg-[#0f1120]";
/** Per-user admin power-actions + CRM notes, opened as a modal from the Users table. */
export function UserActions({ row }: { row: Record<string, unknown>; reload?: () => void }) {
const id = String(row.id);
const [open, setOpen] = useState(false);
const [msg, setMsg] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
// form state
const [balance, setBalance] = useState(""); const [balanceAdd, setBalanceAdd] = useState(true);
const [pw, setPw] = useState("");
const [seconds, setSeconds] = useState(""); const [renders, setRenders] = useState("");
const [planDays, setPlanDays] = useState("");
const [slots, setSlots] = useState(String(row.parallel_rendering_ceiling ?? "1"));
const [tags, setTags] = useState(""); const [note, setNote] = useState(""); const [status, setStatus] = useState("new");
// discount / affiliate
const [dcCode, setDcCode] = useState(""); const [dcKind, setDcKind] = useState("Percentage");
const [dcValue, setDcValue] = useState(""); const [dcProfit, setDcProfit] = useState(""); const [dcDays, setDcDays] = useState("30");
// videos
const [vids, setVids] = useState<Array<Record<string, unknown>> | null>(null);
const createDiscount = async () => {
setBusy(true); setMsg(null);
const expires = new Date(Date.now() + (Number(dcDays) || 30) * 864e5).toISOString();
const res = await fetch(`/api/admin/resource/discounts`, {
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({
name: dcCode || `user-${id.slice(0, 8)}`, code: dcCode, kind: dcKind, value: Number(dcValue) || 0,
owner_user_id: id, owner_profit_percentage: Number(dcProfit) || 0, expires_at: expires,
}),
});
const d = await res.json().catch(() => null);
setMsg(res.ok ? (Number(dcProfit) > 0 ? "کد افیلیت ساخته شد ✓" : "کد تخفیف ساخته شد ✓") : (d?.error?.message ?? d?.error ?? "خطا"));
setBusy(false);
};
const loadVideos = async () => {
const r = await fetch(`/api/admin/resource/saved-projects/by-user/${id}?pageSize=50`, { cache: "no-store" })
.then((x) => x.json()).catch(() => null);
setVids(r?.data ?? r?.items ?? (Array.isArray(r) ? r : []));
};
const call = async (path: string, body: object, ok: string) => {
setBusy(true); setMsg(null);
const res = await fetch(`/api/admin/resource/${path}`, {
method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body),
});
const d = await res.json().catch(() => null);
setMsg(res.ok ? ok : (d?.error?.message ?? d?.error ?? "خطا"));
setBusy(false);
};
const loadCrm = async () => {
const r = await fetch(`/api/admin/resource/users/${id}/crm`, { cache: "no-store" }).then((x) => x.json()).catch(() => null);
if (r) { setTags((r.tags ?? []).join(", ")); setNote(r.note ?? ""); setStatus(r.status ?? "new"); }
};
const saveCrm = async () => {
setBusy(true); setMsg(null);
const res = await fetch(`/api/admin/resource/users/${id}/crm`, {
method: "PUT", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ tags: tags.split(",").map((t) => t.trim()).filter(Boolean), note, status }),
});
setMsg(res.ok ? "یادداشت ذخیره شد ✓" : "خطا"); setBusy(false);
};
return (
<>
<button className="rounded-lg border border-[#262b40] px-3 py-1.5 text-xs text-gray-300 hover:bg-[#161a2e]" onClick={() => { setOpen(true); setMsg(null); loadCrm(); }}>مدیریت</button>
{open && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4 text-right" dir="rtl" onClick={() => setOpen(false)}>
<div className={`${card} max-h-[85vh] w-full max-w-lg overflow-y-auto p-5`} onClick={(e) => e.stopPropagation()}>
<h2 className="text-sm font-semibold text-white">مدیریت کاربر: {String(row.email ?? row.full_name ?? id)}</h2>
{msg && <p className="mt-2 rounded-lg bg-[#12152a] px-3 py-2 text-xs text-gray-300">{msg}</p>}
<div className="mt-4 space-y-4">
<div className={`${card} p-3`}>
<label className={lbl}>موجودی (ریال)</label>
<div className="flex gap-2">
<input className={inp} type="number" value={balance} onChange={(e) => setBalance(e.target.value)} />
<label className="flex items-center gap-1 whitespace-nowrap text-xs text-gray-400"><input type="checkbox" checked={balanceAdd} onChange={(e) => setBalanceAdd(e.target.checked)} /> افزودن</label>
<button className={btn} disabled={busy || !balance} onClick={() => call(`users/${id}/balance`, { amount_minor: Number(balance), add: balanceAdd }, "موجودی به‌روزرسانی شد ✓")}>اعمال</button>
</div>
</div>
<div className={`${card} p-3`}>
<label className={lbl}>شارژ رندر</label>
<div className="flex flex-wrap gap-2">
<input className={`${inp} max-w-[120px]`} type="number" placeholder="ثانیه" value={seconds} onChange={(e) => setSeconds(e.target.value)} />
<input className={`${inp} max-w-[120px]`} type="number" placeholder="تعداد رندر" value={renders} onChange={(e) => setRenders(e.target.value)} />
<button className={btn} disabled={busy || (!seconds && !renders)} onClick={() => call(`users/${id}/charge`, { seconds: Number(seconds) || 0, render_count: Number(renders) || 0 }, "شارژ اضافه شد ✓")}>افزودن</button>
</div>
</div>
<div className={`${card} p-3`}>
<label className={lbl}>تمدید پلن (روز)</label>
<div className="flex gap-2">
<input className={inp} type="number" value={planDays} onChange={(e) => setPlanDays(e.target.value)} />
<button className={btn} disabled={busy || !planDays} onClick={() => call(`users/${id}/grant-plan`, { plan_id: "00000000-0000-0000-0000-000000000000", days: Number(planDays) }, "پلن تمدید شد ✓")}>تمدید</button>
</div>
</div>
<div className={`${card} p-3`}>
<label className={lbl}>تغییر رمز عبور</label>
<div className="flex gap-2">
<input className={inp} type="text" value={pw} onChange={(e) => setPw(e.target.value)} placeholder="رمز جدید" />
<button className={btn} disabled={busy || pw.length < 8} onClick={() => call(`users/${id}/password`, { new_password: pw }, "رمز تغییر کرد ✓")}>تغییر</button>
</div>
</div>
<div className={`${card} p-3`}>
<label className={lbl}>تعداد رندر همزمان مجاز</label>
<p className="mb-1.5 text-[11px] text-gray-500">پیشفرض ۱. با افزایش این مقدار، کاربر میتواند چند رندر همزمان اجرا کند (پس از تازهسازی توکن اعمال میشود).</p>
<div className="flex gap-2">
<input className={`${inp} max-w-[120px]`} type="number" min={1} max={50} value={slots} onChange={(e) => setSlots(e.target.value)} />
<button className={btn} disabled={busy || !slots} onClick={() => call(`users/${id}/render-slots`, { ceiling: Number(slots) || 1 }, "ظرفیت رندر هم‌زمان به‌روزرسانی شد ✓")}>اعمال</button>
</div>
</div>
<div className={`${card} flex items-center justify-between p-3`}>
<span className="text-sm text-gray-300">دسترسی مدیر (مدراتور)</span>
<div className="flex gap-2">
<button className={btn} disabled={busy} onClick={() => call(`users/${id}/moderator`, { enabled: true }, "مدیر شد ✓")}>اعطا</button>
<button className="rounded-lg border border-[#262b40] px-3 py-1.5 text-xs text-gray-300 hover:bg-[#161a2e]" disabled={busy} onClick={() => call(`users/${id}/moderator`, { enabled: false }, "لغو شد ✓")}>لغو</button>
</div>
</div>
<div className={`${card} p-3`}>
<label className={lbl}>کد تخفیف / افیلیت (درصد سود &gt; ۰ یعنی افیلیت)</label>
<div className="grid gap-2 sm:grid-cols-2">
<input className={inp} placeholder="کد" value={dcCode} onChange={(e) => setDcCode(e.target.value)} />
<select className={inp} value={dcKind} onChange={(e) => setDcKind(e.target.value)}>
<option value="Percentage">درصدی</option>
<option value="FixedAmount">مبلغ ثابت</option>
<option value="RenderCredits">اعتبار رندر</option>
</select>
<input className={inp} type="number" placeholder="مقدار" value={dcValue} onChange={(e) => setDcValue(e.target.value)} />
<input className={inp} type="number" placeholder="٪ سود افیلیت" value={dcProfit} onChange={(e) => setDcProfit(e.target.value)} />
<input className={inp} type="number" placeholder="اعتبار (روز)" value={dcDays} onChange={(e) => setDcDays(e.target.value)} />
</div>
<button className={`${btn} mt-2`} disabled={busy || !dcCode || !dcValue} onClick={createDiscount}>ساخت کد</button>
</div>
<div className={`${card} p-3`}>
<div className="flex items-center justify-between">
<label className={lbl}>ویدیوهای کاربر</label>
<button className="rounded-lg border border-[#262b40] px-2.5 py-1 text-xs text-gray-300 hover:bg-[#161a2e]" onClick={loadVideos}>بارگذاری</button>
</div>
{vids != null && (
vids.length === 0 ? <p className="text-xs text-gray-500">ویدیویی یافت نشد.</p> : (
<ul className="mt-1 max-h-40 space-y-1 overflow-y-auto text-sm text-gray-300">
{vids.map((v) => (
<li key={String(v.id)} className="flex items-center justify-between rounded bg-[#0c0e1a] px-2 py-1">
<span className="truncate">{String(v.name ?? v.original_project_name ?? "—")}</span>
<span className="text-[11px] text-gray-500">{String(v.type ?? "")} · {String(v.resolution ?? "")}</span>
</li>
))}
</ul>
)
)}
</div>
<div className={`${card} p-3`}>
<label className={lbl}>یادداشت CRM</label>
<div className="grid gap-2">
<input className={inp} placeholder="برچسب‌ها (با کاما)" value={tags} onChange={(e) => setTags(e.target.value)} />
<select className={inp} value={status} onChange={(e) => setStatus(e.target.value)}>
<option value="new">جدید</option><option value="contacted">تماسگرفته</option><option value="customer">مشتری</option><option value="churned">ریزشکرده</option>
</select>
<textarea className={`${inp} min-h-[60px]`} placeholder="یادداشت" value={note} onChange={(e) => setNote(e.target.value)} />
<div><button className={btn} disabled={busy} onClick={saveCrm}>ذخیره یادداشت</button></div>
</div>
</div>
</div>
<div className="mt-4 flex justify-end">
<button className="rounded-lg border border-[#262b40] px-4 py-2 text-sm text-gray-300 hover:bg-[#161a2e]" onClick={() => setOpen(false)}>بستن</button>
</div>
</div>
</div>
)}
</>
);
}