Files
pieced-portal/src/components/admin/billing/pricing-editor.tsx
admin 229bfea263
All checks were successful
Build and Push / build (push) Successful in 1m39s
Phase2.5: Skill SetUp Process
2026-05-24 17:51:09 +02:00

492 lines
17 KiB
TypeScript

"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<string, number>;
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 (
<div className="space-y-6">
<Card>
<CardHeader>{t("platformPricingTitle")}</CardHeader>
<form onSubmit={savePricing} className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<label className="block">
<span className="text-sm text-text-secondary">
{t("monthlyFeeLabel")} (CHF)
</span>
<input
type="number"
step="0.01"
min="0"
value={monthly}
onChange={(e) => setMonthly(e.target.value)}
className="mt-1 w-full px-3 py-2 rounded-md border border-border bg-surface-2 text-sm"
required
/>
</label>
<label className="block">
<span className="text-sm text-text-secondary">
{t("setupFeeLabel")} (CHF)
</span>
<input
type="number"
step="0.01"
min="0"
value={setup}
onChange={(e) => setSetup(e.target.value)}
className="mt-1 w-full px-3 py-2 rounded-md border border-border bg-surface-2 text-sm"
required
/>
</label>
<label className="block">
<span className="text-sm text-text-secondary">
{t("threemaMessageLabel")} (CHF)
</span>
<input
type="number"
step="0.0001"
min="0"
value={threema}
onChange={(e) => setThreema(e.target.value)}
className="mt-1 w-full px-3 py-2 rounded-md border border-border bg-surface-2 text-sm"
required
/>
</label>
<label className="block">
<span className="text-sm text-text-secondary">
{t("vatRateLabel")} (%)
</span>
<input
type="number"
step="0.01"
min="0"
max="100"
value={vat}
onChange={(e) => setVat(e.target.value)}
className="mt-1 w-full px-3 py-2 rounded-md border border-border bg-surface-2 text-sm"
required
/>
</label>
</div>
<div className="flex items-center gap-3">
<button
type="submit"
disabled={savingPricing}
className="px-4 py-2 rounded-md bg-accent text-white text-sm disabled:opacity-50"
>
{savingPricing ? t("saving") : t("save")}
</button>
{pricingSaved && (
<span className="text-sm text-success">{t("savedOk")}</span>
)}
{pricingError && (
<span className="text-sm text-error">{pricingError}</span>
)}
</div>
</form>
</Card>
<Card>
<CardHeader>{t("skillPricingTitle")}</CardHeader>
<p className="text-sm text-text-muted mb-4">{t("skillPricingDesc")}</p>
{initialSkillPricing.length > 0 ? (
<table className="w-full text-sm mb-6">
<thead className="text-xs text-text-muted text-left">
<tr>
<th className="pb-2">{t("skillCol")}</th>
<th className="pb-2 text-right">{t("dailyPriceCol")}</th>
<th className="pb-2 text-right">{t("setupFeeCol")}</th>
<th className="pb-2 text-right">{t("actionsCol")}</th>
</tr>
</thead>
<tbody>
{initialSkillPricing.map((sp) => {
const entry = catalogIndex.get(sp.skillId);
return (
<tr
key={sp.skillId}
className="border-t border-border align-top"
>
<td className="py-2">
<div className="font-mono text-xs">{sp.skillId}</div>
{entry && (
<div className="text-xs text-text-muted flex items-center gap-2">
<span>{entry.name}</span>
<span className="text-[10px] uppercase tracking-wider bg-surface-3 px-1.5 py-0.5 rounded">
{entry.category}
</span>
</div>
)}
</td>
<td className="py-2 text-right">
{/* Inline edits write daily + setup together (full
upsert on the API side). The other field is
held constant from the snapshot here. */}
<InlinePriceEditor
skillId={sp.skillId}
initialPrice={sp.dailyPriceChf}
decimals={4}
onSave={(price) =>
upsertSkillPrice(sp.skillId, price, sp.setupFeeChf)
}
/>
</td>
<td className="py-2 text-right">
<InlinePriceEditor
skillId={`${sp.skillId}-setup`}
initialPrice={sp.setupFeeChf}
decimals={2}
onSave={(fee) =>
upsertSkillPrice(sp.skillId, sp.dailyPriceChf, fee)
}
/>
</td>
<td className="py-2 text-right">
<button
onClick={() => deleteSkill(sp.skillId)}
className="text-xs text-error hover:underline"
>
{t("remove")}
</button>
</td>
</tr>
);
})}
</tbody>
</table>
) : (
<p className="text-sm text-text-muted italic mb-4">{t("noSkillsPriced")}</p>
)}
<form onSubmit={onAddNewSkill} className="flex items-end gap-3">
<label className="flex-grow">
<span className="text-xs text-text-muted">{t("addSkillLabel")}</span>
<select
value={newSkillId}
onChange={(e) => setNewSkillId(e.target.value)}
className="mt-1 w-full px-3 py-2 rounded-md border border-border bg-surface-2 text-sm"
>
{(() => {
// Group available options by category for the picker.
// Already-priced packages are filtered out (admin
// edits those inline above).
const available = skillCatalogOptions.filter(
(c) => !pricedIds.has(c.id)
);
const byCat = new Map<string, typeof available>();
for (const c of available) {
if (!byCat.has(c.category)) byCat.set(c.category, []);
byCat.get(c.category)!.push(c);
}
// Labels for the optgroups. Reuse the existing
// packages.categories.* scope which already has
// translations in all four locales.
const labels: Record<string, string> = {
core: tPackages("categories.core"),
channel: tPackages("categories.channels"),
skill: tPackages("categories.skills"),
};
const order: Array<"core" | "channel" | "skill"> = [
"core",
"channel",
"skill",
];
return order.map((cat) => {
const items = byCat.get(cat);
if (!items || items.length === 0) return null;
return (
<optgroup key={cat} label={labels[cat] ?? cat}>
{items.map((c) => (
<option key={c.id} value={c.id}>
{c.name} ({c.id})
</option>
))}
</optgroup>
);
});
})()}
</select>
</label>
<label className="w-28">
<span className="text-xs text-text-muted">
{t("dailyPriceLabel")}
</span>
<input
type="number"
step="0.01"
min="0"
value={newSkillPrice}
onChange={(e) => setNewSkillPrice(e.target.value)}
className="mt-1 w-full px-3 py-2 rounded-md border border-border bg-surface-2 text-sm"
/>
</label>
<label className="w-28">
<span className="text-xs text-text-muted">
{t("skillSetupFeeLabel")}
</span>
<input
type="number"
step="0.01"
min="0"
value={newSkillSetupFee}
onChange={(e) => setNewSkillSetupFee(e.target.value)}
className="mt-1 w-full px-3 py-2 rounded-md border border-border bg-surface-2 text-sm"
/>
</label>
<button
type="submit"
disabled={addingSkill || !newSkillId}
className="px-4 py-2 rounded-md bg-accent text-white text-sm disabled:opacity-50"
>
{addingSkill ? t("saving") : t("add")}
</button>
</form>
{skillError && (
<p className="text-sm text-error mt-2">{skillError}</p>
)}
</Card>
</div>
);
}
/**
* 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> | 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 (
<button
onClick={() => setEditing(true)}
className="text-sm font-mono hover:underline"
title={t("clickToEdit")}
>
CHF {initialPrice.toFixed(decimals)}
</button>
);
}
return (
<span className="inline-flex items-center gap-1">
<input
type="number"
step={step}
min="0"
value={value}
onChange={(e) => setValue(e.target.value)}
className="w-20 px-2 py-1 text-sm border border-border bg-surface-2 rounded"
autoFocus
/>
<button
onClick={async () => {
setBusy(true);
try {
await onSave(Number(value));
setEditing(false);
} finally {
setBusy(false);
}
}}
disabled={busy}
className="text-xs px-2 py-1 bg-accent text-white rounded"
>
{busy ? "…" : "✓"}
</button>
<button
onClick={() => {
setValue(String(initialPrice));
setEditing(false);
}}
className="text-xs px-2 py-1 border border-border rounded"
>
</button>
</span>
);
}