Phase2: Invoicecomputation/AdminpricingUI/Ainvoicemgnt
Some checks failed
Build and Push / build (push) Failing after 28s
Some checks failed
Build and Push / build (push) Failing after 28s
This commit is contained in:
399
src/components/admin/billing/pricing-editor.tsx
Normal file
399
src/components/admin/billing/pricing-editor.tsx
Normal file
@@ -0,0 +1,399 @@
|
||||
"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;
|
||||
kind: 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 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);
|
||||
}
|
||||
};
|
||||
|
||||
// -- Skill 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().
|
||||
const [newSkillId, setNewSkillId] = useState(
|
||||
catalog.find((c) => c.kind === "skill")?.id ?? ""
|
||||
);
|
||||
const [newSkillPrice, setNewSkillPrice] = useState("0.10");
|
||||
const [addingSkill, setAddingSkill] = useState(false);
|
||||
const [skillError, setSkillError] = useState("");
|
||||
|
||||
const addOrUpdateSkill = async (
|
||||
e: React.FormEvent,
|
||||
overrideId?: string,
|
||||
overridePrice?: string
|
||||
) => {
|
||||
e.preventDefault();
|
||||
setAddingSkill(true);
|
||||
setSkillError("");
|
||||
try {
|
||||
const res = await fetch("/api/admin/billing/skill-pricing", {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
skillId: overrideId ?? newSkillId,
|
||||
dailyPriceChf: Number(overridePrice ?? newSkillPrice),
|
||||
}),
|
||||
});
|
||||
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 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);
|
||||
}
|
||||
};
|
||||
|
||||
// Catalog filtered to skill-kind entries for the picker, but keeping
|
||||
// existing pricing rows even if they reference non-skill packages.
|
||||
const skillCatalogOptions = catalog.filter((c) => c.kind === "skill");
|
||||
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("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">{entry.name}</div>
|
||||
)}
|
||||
</td>
|
||||
<td className="py-2 text-right">
|
||||
<InlinePriceEditor
|
||||
skillId={sp.skillId}
|
||||
initialPrice={sp.dailyPriceChf}
|
||||
onSave={(price) =>
|
||||
addOrUpdateSkill(
|
||||
new Event("submit") as any,
|
||||
sp.skillId,
|
||||
String(price)
|
||||
)
|
||||
}
|
||||
/>
|
||||
</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={(e) => addOrUpdateSkill(e)}
|
||||
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"
|
||||
>
|
||||
{skillCatalogOptions
|
||||
.filter((c) => !pricedIds.has(c.id))
|
||||
.map((c) => (
|
||||
<option key={c.id} value={c.id}>
|
||||
{c.name} ({c.id})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label className="w-32">
|
||||
<span className="text-xs text-text-muted">
|
||||
{t("dailyPriceLabel")} (CHF)
|
||||
</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>
|
||||
<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 skill's daily price. Mounts in
|
||||
* "view" mode showing the current value as a clickable badge;
|
||||
* clicking turns it into an input + save/cancel buttons.
|
||||
*/
|
||||
function InlinePriceEditor({
|
||||
skillId,
|
||||
initialPrice,
|
||||
onSave,
|
||||
}: {
|
||||
skillId: string;
|
||||
initialPrice: 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);
|
||||
|
||||
if (!editing) {
|
||||
return (
|
||||
<button
|
||||
onClick={() => setEditing(true)}
|
||||
className="text-sm font-mono hover:underline"
|
||||
title={t("clickToEdit")}
|
||||
>
|
||||
CHF {initialPrice.toFixed(2)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<input
|
||||
type="number"
|
||||
step="0.01"
|
||||
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user