feat(ai-credits): add role scoping UI to AI Credit Packages admin

Matches the backend's new applicable_roles field
(nxtgauge-backend-rust apps/payments/src/ai_credits.rs): a role-toggle
chip picker (reusing the same ALL_ROLES/ROLE_LABELS already used by
the TraceCoin packages tab in this file) on both the create form and
the inline edit row, plus a Roles column in the packages table
showing 'All roles' when applicable_roles is empty or up to 3 role
badges (+N overflow) otherwise.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Ashwin Kumar Sivakumar 2026-08-14 01:46:22 +05:30
parent bd9666aabf
commit 066b077941

View file

@ -17,6 +17,7 @@ type AiCreditPackage = {
credits: number;
price_inr: number;
is_active: boolean;
applicable_roles: string[];
created_at: string;
updated_at: string;
};
@ -127,6 +128,7 @@ export default function PricingPage() {
const [aiEditDescription, setAiEditDescription] = createSignal("");
const [aiEditCredits, setAiEditCredits] = createSignal("");
const [aiEditPrice, setAiEditPrice] = createSignal("");
const [aiEditRoles, setAiEditRoles] = createSignal<string[]>([]);
const [aiEditSaving, setAiEditSaving] = createSignal(false);
const [aiEditError, setAiEditError] = createSignal("");
const [aiTogglingId, setAiTogglingId] = createSignal("");
@ -135,10 +137,20 @@ export default function PricingPage() {
const [aiCDescription, setAiCDescription] = createSignal("");
const [aiCCredits, setAiCCredits] = createSignal("");
const [aiCPrice, setAiCPrice] = createSignal("");
const [aiCRoles, setAiCRoles] = createSignal<string[]>([]);
const [aiCSaving, setAiCSaving] = createSignal(false);
const [aiCError, setAiCError] = createSignal("");
const [aiCreateView, setAiCreateView] = createSignal(false);
const toggleAiCRole = (role: string) => {
const current = aiCRoles();
setAiCRoles(current.includes(role) ? current.filter((r) => r !== role) : [...current, role]);
};
const toggleAiEditRole = (role: string) => {
const current = aiEditRoles();
setAiEditRoles(current.includes(role) ? current.filter((r) => r !== role) : [...current, role]);
};
const load = async () => {
setLoading(true);
setLoadError("");
@ -184,11 +196,13 @@ export default function PricingPage() {
setAiEditDescription(pkg.description || "");
setAiEditCredits(String(pkg.credits));
setAiEditPrice(String(pkg.price_inr / 100)); // Convert paise to rupees for display
setAiEditRoles(pkg.applicable_roles ?? []);
setAiEditError("");
};
const cancelAiEdit = () => {
setAiEditingId("");
setAiEditRoles([]);
setAiEditError("");
};
@ -206,6 +220,7 @@ export default function PricingPage() {
description: aiEditDescription() || undefined,
credits: Number(aiEditCredits()),
price_inr: priceInPaise,
applicable_roles: aiEditRoles(),
}),
});
if (!res.ok) throw new Error("Failed to save");
@ -250,6 +265,7 @@ export default function PricingPage() {
description: aiCDescription() || undefined,
credits: Number(aiCCredits()),
price_inr: priceInPaise,
applicable_roles: aiCRoles(),
}),
});
if (!res.ok) throw new Error("Failed to create package");
@ -257,6 +273,7 @@ export default function PricingPage() {
setAiCDescription("");
setAiCCredits("");
setAiCPrice("");
setAiCRoles([]);
setAiCreateView(false);
await loadAiPackages();
} catch (err: any) {
@ -1006,6 +1023,24 @@ export default function PricingPage() {
style="padding:7px 10px;border:1px solid #e2e8f0;border-radius:6px;font-size:13px;width:110px"
/>
</div>
<div style="width:100%">
<label style="display:block;font-size:12px;font-weight:600;margin-bottom:4px">
Applicable Roles (none selected = every role)
</label>
<div style="display:flex;flex-wrap:wrap;gap:6px">
<For each={ALL_ROLES}>
{(role) => (
<button
type="button"
onClick={() => toggleAiCRole(role)}
style={`padding:4px 10px;border-radius:9999px;font-size:11px;font-weight:600;cursor:pointer;border:1px solid ${aiCRoles().includes(role) ? "#FF5E13" : "#D1D5DB"};background:${aiCRoles().includes(role) ? "#FFF1EB" : "#fff"};color:${aiCRoles().includes(role) ? "#FF5E13" : "#4B5563"}`}
>
{ROLE_LABELS[role] || role}
</button>
)}
</For>
</div>
</div>
<div style="display:flex;gap:8px">
<button class="btn-primary" type="submit" disabled={aiCSaving()}>
{aiCSaving() ? "Creating..." : "Create"}
@ -1030,6 +1065,7 @@ export default function PricingPage() {
<th>Name</th>
<th>Credits</th>
<th>Price ()</th>
<th>Roles</th>
<th>Status</th>
<th class="text-right">Actions</th>
</tr>
@ -1037,14 +1073,14 @@ export default function PricingPage() {
<tbody>
<Show when={aiPackagesLoading()}>
<tr>
<td colspan="5" style="text-align:center;padding:32px;color:#64748b">
<td colspan="6" style="text-align:center;padding:32px;color:#64748b">
Loading...
</td>
</tr>
</Show>
<Show when={!aiPackagesLoading() && aiPackages().length === 0}>
<tr>
<td colspan="5" style="text-align:center;padding:32px;color:#94a3b8">
<td colspan="6" style="text-align:center;padding:32px;color:#94a3b8">
No AI credit packages found.
</td>
</tr>
@ -1059,6 +1095,25 @@ export default function PricingPage() {
</td>
<td class="text-slate-700 font-medium">{pkg.credits}</td>
<td class="text-slate-700">{(pkg.price_inr / 100).toLocaleString("en-IN")}</td>
<td>
<Show
when={(pkg.applicable_roles ?? []).length > 0}
fallback={<span style="font-size:11px;color:#94a3b8">All roles</span>}
>
<div style="display:flex;flex-wrap:wrap;gap:4px;max-width:220px">
<For each={(pkg.applicable_roles ?? []).slice(0, 3)}>
{(role) => (
<span style="display:inline-block;padding:1px 6px;border-radius:4px;font-size:10px;background:#f1f5f9;color:#475569">
{ROLE_LABELS[role] || role}
</span>
)}
</For>
{(pkg.applicable_roles ?? []).length > 3 && (
<span style="font-size:10px;color:#64748b">+{pkg.applicable_roles.length - 3}</span>
)}
</div>
</Show>
</td>
<td>
<span
style={`display:inline-flex;align-items:center;border-radius:9999px;border:1px solid ${pkg.is_active ? "#FFD8C2" : "#D1D5DB"};background:${pkg.is_active ? "#FFF1EB" : "#F3F4F6"};color:${pkg.is_active ? "#FF5E13" : "#4B5563"};padding:2px 10px;font-size:12px;font-weight:500`}
@ -1097,7 +1152,7 @@ export default function PricingPage() {
</tr>
<Show when={aiEditingId() === pkg.id}>
<tr>
<td colspan="5" style="background:#f8fafc;padding:16px">
<td colspan="6" style="background:#f8fafc;padding:16px">
<Show when={aiEditError()}>
<div class="mb-3 rounded-lg border border-red-200 bg-red-50 px-4 py-2 text-sm text-red-700">
{aiEditError()}
@ -1140,6 +1195,24 @@ export default function PricingPage() {
style="padding:7px 10px;border:1px solid #e2e8f0;border-radius:6px;font-size:13px;width:110px"
/>
</div>
<div style="width:100%">
<label style="display:block;font-size:12px;font-weight:600;margin-bottom:4px">
Applicable Roles (none selected = every role)
</label>
<div style="display:flex;flex-wrap:wrap;gap:6px">
<For each={ALL_ROLES}>
{(role) => (
<button
type="button"
onClick={() => toggleAiEditRole(role)}
style={`padding:4px 10px;border-radius:9999px;font-size:11px;font-weight:600;cursor:pointer;border:1px solid ${aiEditRoles().includes(role) ? "#FF5E13" : "#D1D5DB"};background:${aiEditRoles().includes(role) ? "#FFF1EB" : "#fff"};color:${aiEditRoles().includes(role) ? "#FF5E13" : "#4B5563"}`}
>
{ROLE_LABELS[role] || role}
</button>
)}
</For>
</div>
</div>
<div style="display:flex;gap:8px">
<button class="btn-primary" disabled={aiEditSaving()} onClick={() => saveAiEdit(pkg.id)}>
{aiEditSaving() ? "Saving..." : "Save"}