Phase2: Invoicecomputation/AdminpricingUI/Ainvoicemgnt
All checks were successful
Build and Push / build (push) Successful in 1m34s
All checks were successful
Build and Push / build (push) Successful in 1m34s
This commit is contained in:
@@ -85,20 +85,27 @@ export function PricingEditor({
|
||||
catalog.find((c) => c.category === "skill")?.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
|
||||
// editor on existing rows. Kept event-free so callers can invoke it
|
||||
// without synthesizing a fake form event.
|
||||
const upsertSkillPrice = async (skillId: string, dailyPriceChf: number) => {
|
||||
// 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 }),
|
||||
body: JSON.stringify({ skillId, dailyPriceChf, setupFeeChf }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const j = await res.json().catch(() => ({}));
|
||||
@@ -115,7 +122,11 @@ export function PricingEditor({
|
||||
const onAddNewSkill = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!newSkillId) return;
|
||||
void upsertSkillPrice(newSkillId, Number(newSkillPrice));
|
||||
void upsertSkillPrice(
|
||||
newSkillId,
|
||||
Number(newSkillPrice),
|
||||
Number(newSkillSetupFee)
|
||||
);
|
||||
};
|
||||
|
||||
const deleteSkill = async (skillId: string) => {
|
||||
@@ -234,6 +245,7 @@ export function PricingEditor({
|
||||
<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>
|
||||
@@ -252,10 +264,26 @@ export function PricingEditor({
|
||||
)}
|
||||
</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}
|
||||
onSave={(price) => upsertSkillPrice(sp.skillId, price)}
|
||||
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">
|
||||
@@ -292,9 +320,9 @@ export function PricingEditor({
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label className="w-32">
|
||||
<label className="w-28">
|
||||
<span className="text-xs text-text-muted">
|
||||
{t("dailyPriceLabel")} (CHF)
|
||||
{t("dailyPriceLabel")}
|
||||
</span>
|
||||
<input
|
||||
type="number"
|
||||
@@ -305,6 +333,19 @@ export function PricingEditor({
|
||||
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}
|
||||
@@ -322,23 +363,30 @@ export function PricingEditor({
|
||||
}
|
||||
|
||||
/**
|
||||
* Tiny inline editor for a single skill's daily price. Mounts in
|
||||
* 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 (
|
||||
@@ -347,7 +395,7 @@ function InlinePriceEditor({
|
||||
className="text-sm font-mono hover:underline"
|
||||
title={t("clickToEdit")}
|
||||
>
|
||||
CHF {initialPrice.toFixed(2)}
|
||||
CHF {initialPrice.toFixed(decimals)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -355,7 +403,7 @@ function InlinePriceEditor({
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<input
|
||||
type="number"
|
||||
step="0.01"
|
||||
step={step}
|
||||
min="0"
|
||||
value={value}
|
||||
onChange={(e) => setValue(e.target.value)}
|
||||
|
||||
Reference in New Issue
Block a user