492 lines
17 KiB
TypeScript
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>
|
|
);
|
|
}
|