fixes
This commit is contained in:
@@ -10,6 +10,7 @@
|
||||
PAGE_SIZE,
|
||||
STAFF_ROLE_OPTIONS,
|
||||
assignAdminPlan,
|
||||
cloneAdminCompanyCatalog,
|
||||
companyPlanBadge,
|
||||
isStaffRoleApiUnavailable,
|
||||
listAdminCompanies,
|
||||
@@ -24,6 +25,7 @@
|
||||
type AdminOrgUser,
|
||||
type PlatformStaffRole
|
||||
} from "$lib/admin-orgs";
|
||||
import { authSession } from "$lib/auth-session.svelte";
|
||||
import PageShell from "$lib/components/PageShell.svelte";
|
||||
import Alert from "$lib/components/Alert.svelte";
|
||||
import Spinner from "$lib/components/Spinner.svelte";
|
||||
@@ -52,7 +54,7 @@
|
||||
TabsList,
|
||||
TabsTrigger
|
||||
} from "$lib/components/ui";
|
||||
import { Building2, KeyRound, Search, Shield, UserPlus, Users } from "@lucide/svelte";
|
||||
import { Building2, Copy, KeyRound, Search, Shield, UserPlus, Users } from "@lucide/svelte";
|
||||
|
||||
type TabKey = "users" | "companies";
|
||||
|
||||
@@ -88,11 +90,22 @@
|
||||
let assignCompany = $state<AdminOrgCompany | null>(null);
|
||||
let assignPlanId = $state("");
|
||||
|
||||
let cloneOpen = $state(false);
|
||||
let cloneCompany = $state<AdminOrgCompany | null>(null);
|
||||
|
||||
const usersPage = $derived(Math.floor(usersOffset / PAGE_SIZE) + 1);
|
||||
const usersPages = $derived(Math.max(1, Math.ceil(usersTotal / PAGE_SIZE)));
|
||||
const companiesPage = $derived(Math.floor(companiesOffset / PAGE_SIZE) + 1);
|
||||
const companiesPages = $derived(Math.max(1, Math.ceil(companiesTotal / PAGE_SIZE)));
|
||||
|
||||
const cloneDestLabel = $derived.by(() => {
|
||||
const me = authSession.me;
|
||||
const home = me?.staff_home_company;
|
||||
if (home?.name) return home.name;
|
||||
if (me?.company?.name) return me.company.name;
|
||||
return i18n.t("admin.users.cloneDestFallback");
|
||||
});
|
||||
|
||||
onMount(async () => {
|
||||
const gate = await requirePlatformAdmin();
|
||||
if (!gate.ok) {
|
||||
@@ -342,6 +355,44 @@
|
||||
}
|
||||
}
|
||||
|
||||
function openCloneDialog(company: AdminOrgCompany) {
|
||||
cloneCompany = company;
|
||||
cloneOpen = true;
|
||||
error = "";
|
||||
success = "";
|
||||
}
|
||||
|
||||
async function confirmCloneCatalog() {
|
||||
if (!cloneCompany) return;
|
||||
busy = true;
|
||||
error = "";
|
||||
success = "";
|
||||
try {
|
||||
const me = authSession.me;
|
||||
const destId =
|
||||
me?.staff_home_company_id?.trim() ||
|
||||
me?.staff_home_company?.id?.trim() ||
|
||||
undefined;
|
||||
const res = await cloneAdminCompanyCatalog(cloneCompany.id, {
|
||||
destCompanyId: destId
|
||||
});
|
||||
const products = Number(res.counts?.raw_products ?? 0);
|
||||
const categories = Number(res.counts?.categories ?? 0);
|
||||
success = i18n.t("flash.admin.catalogCloned", {
|
||||
source: cloneCompany.name,
|
||||
dest: cloneDestLabel,
|
||||
products: String(products),
|
||||
categories: String(categories)
|
||||
});
|
||||
cloneOpen = false;
|
||||
cloneCompany = null;
|
||||
} catch (err) {
|
||||
error = failureMessage(err, "Clone catalog failed");
|
||||
} finally {
|
||||
busy = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function sendSetPasswordEmails(userId?: string) {
|
||||
busy = true;
|
||||
error = "";
|
||||
@@ -712,14 +763,28 @@
|
||||
</TableCell>
|
||||
<TableCell class="hidden text-muted-foreground lg:table-cell">{company.language || "—"}</TableCell>
|
||||
<TableCell stickyRight>
|
||||
<Button
|
||||
size="sm"
|
||||
onclick={() => openAssignDialog(company)}
|
||||
aria-label={`Assign plan to ${company.name}`}
|
||||
>
|
||||
<UserPlus class="h-3.5 w-3.5 lg:mr-1" aria-hidden="true" />
|
||||
<span class="hidden lg:inline">{i18n.t("admin.users.assignPlan")}</span>
|
||||
</Button>
|
||||
<div class="flex flex-wrap items-center justify-end gap-1">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onclick={() => openCloneDialog(company)}
|
||||
aria-label={i18n.t("admin.users.cloneCatalogAria", {
|
||||
name: company.name
|
||||
})}
|
||||
data-testid="admin-clone-catalog"
|
||||
>
|
||||
<Copy class="h-3.5 w-3.5 lg:mr-1" aria-hidden="true" />
|
||||
<span class="hidden lg:inline">{i18n.t("admin.users.cloneCatalog")}</span>
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
onclick={() => openAssignDialog(company)}
|
||||
aria-label={`Assign plan to ${company.name}`}
|
||||
>
|
||||
<UserPlus class="h-3.5 w-3.5 lg:mr-1" aria-hidden="true" />
|
||||
<span class="hidden lg:inline">{i18n.t("admin.users.assignPlan")}</span>
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
{/each}
|
||||
@@ -758,8 +823,8 @@
|
||||
|
||||
<Dialog
|
||||
bind:open={passwordOpen}
|
||||
title="Set password"
|
||||
description="Force-set a login password for this user (works for fake/legacy emails that cannot receive invites)."
|
||||
title={i18n.t("admin.users.setPasswordTitle")}
|
||||
description={i18n.t("admin.users.setPasswordDesc")}
|
||||
>
|
||||
<form class="space-y-4" onsubmit={saveForcedPassword}>
|
||||
{#if error}
|
||||
@@ -771,7 +836,7 @@
|
||||
</p>
|
||||
{/if}
|
||||
<div class="space-y-2">
|
||||
<Label for="forced-password">New password</Label>
|
||||
<Label for="forced-password">{i18n.t("admin.users.newPassword")}</Label>
|
||||
<Input
|
||||
id="forced-password"
|
||||
type="password"
|
||||
@@ -781,7 +846,9 @@
|
||||
bind:value={passwordValue}
|
||||
/>
|
||||
</div>
|
||||
<Button type="submit" loading={busyUserId === passwordUser?.id}>Set password</Button>
|
||||
<Button type="submit" loading={busyUserId === passwordUser?.id}
|
||||
>{i18n.t("admin.users.setPasswordSubmit")}</Button
|
||||
>
|
||||
</form>
|
||||
</Dialog>
|
||||
|
||||
@@ -838,3 +905,42 @@
|
||||
<Button type="submit" loading={busy} disabled={!assignPlanId}>{i18n.t("admin.users.assign")}</Button>
|
||||
</form>
|
||||
</Dialog>
|
||||
|
||||
<Dialog
|
||||
bind:open={cloneOpen}
|
||||
title={i18n.t("admin.users.cloneCatalogTitle")}
|
||||
description={i18n.t("admin.users.cloneCatalogDesc")}
|
||||
>
|
||||
<div class="space-y-4">
|
||||
{#if error}
|
||||
<p class="text-sm text-destructive" role="alert">{error}</p>
|
||||
{/if}
|
||||
{#if cloneCompany}
|
||||
<p class="text-sm text-foreground">
|
||||
{i18n.t("admin.users.cloneCatalogFrom", { name: cloneCompany.name })}
|
||||
</p>
|
||||
{/if}
|
||||
<p class="text-sm text-muted-foreground">
|
||||
{i18n.t("admin.users.cloneCatalogInto", { name: cloneDestLabel })}
|
||||
</p>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
{i18n.t("admin.users.cloneCatalogWarning")}
|
||||
</p>
|
||||
<div class="flex flex-wrap justify-end gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
disabled={busy}
|
||||
onclick={() => {
|
||||
cloneOpen = false;
|
||||
cloneCompany = null;
|
||||
}}
|
||||
>
|
||||
{i18n.t("admin.users.cloneCatalogCancel")}
|
||||
</Button>
|
||||
<Button type="button" loading={busy} onclick={() => confirmCloneCatalog()}>
|
||||
{i18n.t("admin.users.cloneCatalogConfirm")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
|
||||
@@ -37,6 +37,7 @@
|
||||
startCheckout,
|
||||
startCreditPackCheckout,
|
||||
redirectToCheckout,
|
||||
canSelfServePurchase,
|
||||
type StripeStatus
|
||||
} from "$lib/stripe-billing";
|
||||
import { CREDIT_PACKS } from "$lib/components/pricing/credit-packs";
|
||||
@@ -255,8 +256,12 @@
|
||||
);
|
||||
const atProductLimit = $derived(Boolean(credits?.at_product_limit) && !enterprise);
|
||||
const nextUpgradePlan = $derived(nextSelfServeUpgradePlan(planName));
|
||||
const purchasesEnabled = $derived(
|
||||
canSelfServePurchase(stripe, { isPlatformAdmin })
|
||||
);
|
||||
const showQuickUpgrade = $derived(
|
||||
canManageBilling &&
|
||||
purchasesEnabled &&
|
||||
!enterprise &&
|
||||
!payg &&
|
||||
!isLegacyPlan(plan) &&
|
||||
@@ -450,6 +455,10 @@
|
||||
<Alert message={error} />
|
||||
<Alert tone="success" message={success} />
|
||||
|
||||
{#if canManageBilling && !purchasesEnabled}
|
||||
<Alert tone="info" message={i18n.t("billing.checkoutUnavailable")} />
|
||||
{/if}
|
||||
|
||||
{#if recovery}
|
||||
<UpgradeBanner
|
||||
tone={recovery.tone}
|
||||
@@ -615,7 +624,7 @@
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
{#if canManageBilling && !enterprise && planAssigned}
|
||||
{#if canManageBilling && purchasesEnabled && !enterprise && planAssigned}
|
||||
<section class="space-y-3 rounded-lg border border-border bg-card px-4 py-4">
|
||||
<div>
|
||||
<h2 class="text-base font-semibold tracking-tight">{i18n.t("billing.buyCreditPacks")}</h2>
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
fetchStripeStatus,
|
||||
redirectToCheckout,
|
||||
startCheckout,
|
||||
canSelfServePurchase,
|
||||
type StripeStatus
|
||||
} from "$lib/stripe-billing";
|
||||
import type { CreditBalance, MeResponse } from "$lib/types";
|
||||
@@ -74,6 +75,7 @@
|
||||
let apiAvailable = $state(true);
|
||||
let checkoutBusy = $state<string | null>(null);
|
||||
let canManageBilling = $state(false);
|
||||
let isPlatformAdmin = $state(false);
|
||||
|
||||
function apiFor(name: string): ApiPlan | undefined {
|
||||
const key = name.trim().toLowerCase();
|
||||
@@ -99,6 +101,7 @@
|
||||
apiPlans = (plansPayload?.plans ?? []).filter((p) => isPublicProductPlan(p.name));
|
||||
stripe = stripeRes;
|
||||
canManageBilling = isCompanyAdmin(me);
|
||||
isPlatformAdmin = Boolean(me.user?.is_platform_admin);
|
||||
|
||||
const bal: CreditBalance | null =
|
||||
me.credits ?? (await api<CreditBalance>("/api/billing/credits").catch(() => null));
|
||||
@@ -124,6 +127,7 @@
|
||||
const currentName = $derived(hasPlan ? planNameOf(currentPlan).toLowerCase() : "");
|
||||
const remainingLabel = $derived(formatCreditsRemaining(remainingCredits, hasPlan ? currentPlan : null));
|
||||
const currentIsPayg = $derived(hasPlan && isPayAsYouGoPlan(currentPlan));
|
||||
const purchasesEnabled = $derived(canSelfServePurchase(stripe, { isPlatformAdmin }));
|
||||
|
||||
const displayPlans = $derived.by(() => {
|
||||
return PRICING_PLANS.map((marketing) => {
|
||||
@@ -152,10 +156,14 @@
|
||||
ctaMode = "link";
|
||||
ctaHref = "/billing";
|
||||
} else if (selfServe) {
|
||||
if (canManageBilling) {
|
||||
if (canManageBilling && purchasesEnabled) {
|
||||
ctaText = i18n.t("plans.cta.upgradeTo", { name });
|
||||
ctaMode = "checkout";
|
||||
ctaHref = "/billing";
|
||||
} else if (canManageBilling) {
|
||||
ctaText = i18n.t("plans.cta.contactSales");
|
||||
ctaMode = "link";
|
||||
ctaHref = `${CONTACT_SALES_HREF}?source=plans`;
|
||||
} else {
|
||||
ctaText = i18n.t("common.askAdmin");
|
||||
ctaMode = "link";
|
||||
@@ -238,6 +246,10 @@
|
||||
<Alert tone="info" message={i18n.t("plans.apiUnavailable")} />
|
||||
{/if}
|
||||
|
||||
{#if canManageBilling && !purchasesEnabled}
|
||||
<Alert tone="info" message={i18n.t("plans.checkoutUnavailable")} />
|
||||
{/if}
|
||||
|
||||
<div class="space-y-4 text-center">
|
||||
<h1 class="text-4xl font-bold tracking-tight">{i18n.t("plans.title")}</h1>
|
||||
<p class="mx-auto max-w-2xl text-xl text-muted-foreground">
|
||||
@@ -260,7 +272,7 @@
|
||||
{i18n.t("plans.sub.none")}
|
||||
{/if}
|
||||
</p>
|
||||
{#if stripe?.configured && !stripe?.mock}
|
||||
{#if purchasesEnabled && stripe?.configured && !stripe?.mock}
|
||||
<p class="text-sm text-muted-foreground">{i18n.t("plans.stripeHint")}</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user