"use client"; import { useState } from "react"; import { useRouter } from "next/navigation"; import { useTranslations } from "next-intl"; import { Card, CardHeader } from "@/components/ui/card"; import type { PlatformPricing, SkillPricing } from "@/types"; interface CatalogEntry { id: string; name: string; category: string; } interface Props { initialPricing: PlatformPricing; initialSkillPricing: SkillPricing[]; catalog: CatalogEntry[]; } /** * Two-card layout: * 1. Platform pricing form (4 inputs, save = PUT to /pricing). * 2. Skill pricing table — list of priced skills, "Add skill" * picker below. * * No optimistic updates — every save round-trips and we * router.refresh() afterwards so the server-side render stays * the source of truth. */ export function PricingEditor({ initialPricing, initialSkillPricing, catalog, }: Props) { const t = useTranslations("adminBilling"); const tPackages = useTranslations("packages"); const router = useRouter(); // -- Platform pricing form ---------------------------------------------- const [monthly, setMonthly] = useState( String(initialPricing.tenantMonthlyFeeChf) ); const [setup, setSetup] = useState(String(initialPricing.tenantSetupFeeChf)); const [threema, setThreema] = useState( String(initialPricing.threemaMessageChf) ); const [vat, setVat] = useState(String(initialPricing.vatRateChli)); const [savingPricing, setSavingPricing] = useState(false); const [pricingError, setPricingError] = useState(""); const [pricingSaved, setPricingSaved] = useState(false); const savePricing = async (e: React.FormEvent) => { e.preventDefault(); setSavingPricing(true); setPricingError(""); setPricingSaved(false); try { const res = await fetch("/api/admin/billing/pricing", { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ tenantMonthlyFeeChf: Number(monthly), tenantSetupFeeChf: Number(setup), threemaMessageChf: Number(threema), vatRateChli: Number(vat), }), }); if (!res.ok) { const j = await res.json().catch(() => ({})); throw new Error(j.error || `HTTP ${res.status}`); } setPricingSaved(true); router.refresh(); } catch (e: any) { setPricingError(e.message); } finally { setSavingPricing(false); } }; // -- Package pricing ---------------------------------------------------- // Server is authoritative — we don't keep an editable local copy of the // table; instead each action posts to the API and we router.refresh(). // // Naming carry-over: the underlying DB table is `skill_pricing` and the // column is `skill_id`, dating from when only skills were priced. The // model now applies to any PackageDef in the catalog regardless of // category — core, channel, or skill. The state variable names below // (newSkill*, addingSkill, etc.) retain the legacy "skill" prefix // because renaming the entire surface for purely cosmetic reasons // would create churn for no functional gain. Treat "skill" here as // shorthand for "priced package". const [newSkillId, setNewSkillId] = useState(catalog[0]?.id ?? ""); const [newSkillPrice, setNewSkillPrice] = useState("0.10"); const [newSkillSetupFee, setNewSkillSetupFee] = useState("0"); const [addingSkill, setAddingSkill] = useState(false); const [skillError, setSkillError] = useState(""); // Core upsert — used by both the "add new skill" form and the inline // editors on existing rows. Kept event-free so callers can invoke it // without synthesizing a fake form event. Both `dailyPriceChf` and // `setupFeeChf` are written together because the API does a full // upsert; partial updates would silently zero the other field. const upsertSkillPrice = async ( skillId: string, dailyPriceChf: number, setupFeeChf: number ) => { setAddingSkill(true); setSkillError(""); try { const res = await fetch("/api/admin/billing/skill-pricing", { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ skillId, dailyPriceChf, setupFeeChf }), }); if (!res.ok) { const j = await res.json().catch(() => ({})); throw new Error(j.error || `HTTP ${res.status}`); } router.refresh(); } catch (e: any) { setSkillError(e.message); } finally { setAddingSkill(false); } }; const onAddNewSkill = (e: React.FormEvent) => { e.preventDefault(); if (!newSkillId) return; void upsertSkillPrice( newSkillId, Number(newSkillPrice), Number(newSkillSetupFee) ); }; const deleteSkill = async (skillId: string) => { if (!confirm(t("confirmDeleteSkillPrice", { skill: skillId }))) return; setSkillError(""); try { const res = await fetch( `/api/admin/billing/skill-pricing/${encodeURIComponent(skillId)}`, { method: "DELETE" } ); if (!res.ok) { const j = await res.json().catch(() => ({})); throw new Error(j.error || `HTTP ${res.status}`); } router.refresh(); } catch (e: any) { setSkillError(e.message); } }; // Pricing applies to any catalog entry regardless of category. Grouped // dropdown sorts options by category for visual scanning — core, // channel, and skill in a single picker. const skillCatalogOptions = [...catalog].sort((a, b) => { const order = { core: 0, channel: 1, skill: 2 } as Record; const ca = order[a.category] ?? 99; const cb = order[b.category] ?? 99; if (ca !== cb) return ca - cb; return a.name.localeCompare(b.name); }); const catalogIndex = new Map(catalog.map((c) => [c.id, c])); const pricedIds = new Set(initialSkillPricing.map((s) => s.skillId)); return (
{t("platformPricingTitle")}
{pricingSaved && ( {t("savedOk")} )} {pricingError && ( {pricingError} )}
{t("skillPricingTitle")}

{t("skillPricingDesc")}

{initialSkillPricing.length > 0 ? ( {initialSkillPricing.map((sp) => { const entry = catalogIndex.get(sp.skillId); return ( ); })}
{t("skillCol")} {t("dailyPriceCol")} {t("setupFeeCol")} {t("actionsCol")}
{sp.skillId}
{entry && (
{entry.name} {entry.category}
)}
{/* Inline edits write daily + setup together (full upsert on the API side). The other field is held constant from the snapshot here. */} upsertSkillPrice(sp.skillId, price, sp.setupFeeChf) } /> upsertSkillPrice(sp.skillId, sp.dailyPriceChf, fee) } />
) : (

{t("noSkillsPriced")}

)}
{skillError && (

{skillError}

)}
); } /** * Tiny inline editor for a single numeric price/fee. Mounts in * "view" mode showing the current value as a clickable badge; * clicking turns it into an input + save/cancel buttons. * * `decimals` controls the display precision in view mode AND the * step granularity of the input (daily prices use 4dp, setup fees * use 2dp). */ function InlinePriceEditor({ skillId, initialPrice, decimals = 2, onSave, }: { skillId: string; initialPrice: number; decimals?: number; onSave: (price: number) => Promise | void; }) { const t = useTranslations("adminBilling"); const [editing, setEditing] = useState(false); const [value, setValue] = useState(String(initialPrice)); const [busy, setBusy] = useState(false); const step = decimals === 4 ? "0.0001" : "0.01"; if (!editing) { return ( ); } return ( setValue(e.target.value)} className="w-20 px-2 py-1 text-sm border border-border bg-surface-2 rounded" autoFocus /> ); }