Phase2.5: Skill SetUp Process
All checks were successful
Build and Push / build (push) Successful in 1m39s

This commit is contained in:
2026-05-24 17:51:09 +02:00
parent 49b085e59e
commit 229bfea263
7 changed files with 126 additions and 66 deletions

View File

@@ -34,6 +34,7 @@ export function PricingEditor({
catalog,
}: Props) {
const t = useTranslations("adminBilling");
const tPackages = useTranslations("packages");
const router = useRouter();
// -- Platform pricing form ----------------------------------------------
@@ -78,12 +79,19 @@ export function PricingEditor({
}
};
// -- Skill pricing ------------------------------------------------------
// -- 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().
const [newSkillId, setNewSkillId] = useState(
catalog.find((c) => c.category === "skill")?.id ?? ""
);
//
// 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);
@@ -147,9 +155,16 @@ export function PricingEditor({
}
};
// 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.category === "skill");
// 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));
@@ -260,7 +275,12 @@ export function PricingEditor({
<td className="py-2">
<div className="font-mono text-xs">{sp.skillId}</div>
{entry && (
<div className="text-xs text-text-muted">{entry.name}</div>
<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">
@@ -311,13 +331,45 @@ export function PricingEditor({
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>
))}
{(() => {
// 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">

View File

@@ -70,15 +70,22 @@ export function SkillCostDialog({
</div>
)}
{showDaily && (
/* Display reference monthly cost (daily × 30) plus the
actual daily rate as a sub-note. Billing is always
per UTC day enabled — partial months prorate to that
same daily rate, full months land at roughly the
figure shown (varies ±~3% by month length). */
<div className="flex justify-between items-baseline">
<div>
<div className="text-sm">{t("dailyPriceLabel")}</div>
<div className="text-sm">{t("monthlyPriceLabel")}</div>
<div className="text-xs text-text-muted">
{t("dailyPriceNote")}
{t("monthlyPriceNote", {
daily: dailyPriceChf.toFixed(2),
})}
</div>
</div>
<div className="text-sm font-mono">
CHF {dailyPriceChf.toFixed(2)} / {t("dayUnit")}
CHF {(dailyPriceChf * 30).toFixed(2)} / {t("monthUnit")}
</div>
</div>
)}

View File

@@ -231,6 +231,7 @@ export const PACKAGE_CATALOG: PackageDef[] = [
},
{
id: "gog",
requiresManualSetup: true,
name: "Google Workspace (Gog)",
descriptionKey: "packages.gog.description",
requiresSecrets: true,

View File

@@ -390,7 +390,7 @@
"resumeRequestTooltip": "Reaktivierungsanfrage für einen bestehenden Tenant. Bei Genehmigung wird der Tenant wieder aktiviert; keine Provisionierung läuft.",
"openclawTool": "OpenClaw-Versionen",
"billingTool": "Abrechnung →",
"skillsQueueTool": "Skill-Warteschlange"
"skillsQueueTool": "Aktivierungs-Warteschlange"
},
"channelUsers": {
"title": "Autorisierte Benutzer",
@@ -591,17 +591,17 @@
"save": "Speichern",
"saving": "Speichere…",
"savedOk": "Gespeichert",
"skillPricingTitle": "Skill-Preise",
"skillPricingDesc": "Tagespreis pro Skill. Ein zu beliebigem Zeitpunkt an einem UTC-Tag aktivierter Skill zählt als ein abrechenbarer Tag.",
"skillCol": "Skill",
"skillPricingTitle": "Paket-Preise",
"skillPricingDesc": "Tagespreis und einmalige Einrichtungsgebühr für jedes Paket — Core, Kanal oder Skill. Die Preisgestaltung gilt für jeden Tenant, der das Paket aktiviert.",
"skillCol": "Paket",
"dailyPriceCol": "Tagespreis",
"actionsCol": "",
"remove": "Entfernen",
"noSkillsPriced": "Noch keine Skills bepreist.",
"addSkillLabel": "Skill hinzufügen",
"noSkillsPriced": "Noch keine Pakete bepreist.",
"addSkillLabel": "Paket hinzufügen",
"dailyPriceLabel": "Tagespreis",
"add": "Hinzufügen",
"confirmDeleteSkillPrice": "Preis für {skill} entfernen?",
"confirmDeleteSkillPrice": "Preisgestaltung für {skill} entfernen? Bereits abgerechnete Zeiträume bleiben unberührt.",
"clickToEdit": "Zum Bearbeiten klicken",
"generateFormTitle": "Rechnung erstellen",
"noOrgsToGenerate": "Keine Organisationen mit Tenants gefunden.",
@@ -667,17 +667,17 @@
"intro": "Die Aktivierung von {skill} verursacht folgende Kosten:",
"setupFeeLabel": "Einrichtungsgebühr",
"setupFeeNote": "Einmalig, nur bei erster Aktivierung",
"dailyPriceLabel": "Tagespreis",
"dailyPriceNote": "Pro Kalendertag (UTC) berechnet, an dem der Skill aktiviert ist",
"dayUnit": "Tag",
"monthlyPriceLabel": "Monatspreis",
"monthlyPriceNote": "CHF {daily}/Tag aktiv; Teilmonate werden taggenau berechnet",
"monthUnit": "Monat",
"disclaimer": "Diese Kosten erscheinen auf Ihrer nächsten Monatsrechnung. Mit der Bestätigung stimmen Sie ihnen zu.",
"cancel": "Abbrechen",
"confirm": "Bestätigen & aktivieren",
"confirming": "Aktiviere…"
},
"adminSkills": {
"title": "Skill-Aktivierungs-Warteschlange",
"subtitle": "Kundenanfragen für Skills, die manuelle plattformseitige Einrichtung benötigen. Genehmigen, sobald die Konfiguration steht; ablehnen mit Grund, wenn die Aktivierung nicht möglich ist.",
"title": "Aktivierungs-Warteschlange",
"subtitle": "Kundenanfragen für Pakete, die manuelle plattformseitige Einrichtung benötigen. Genehmigen, sobald die Konfiguration steht; ablehnen mit Grund, wenn die Aktivierung nicht möglich ist.",
"backToAdmin": "Zurück zur Verwaltung",
"emptyQueue": "Keine ausstehenden Skill-Aktivierungsanfragen.",
"requestedAtCol": "Angefragt",

View File

@@ -390,7 +390,7 @@
"resumeRequestTooltip": "Reactivation request for an existing tenant. Approving will un-suspend the tenant; no provisioning runs.",
"openclawTool": "OpenClaw versions",
"billingTool": "Billing →",
"skillsQueueTool": "Skills Queue"
"skillsQueueTool": "Activation Queue"
},
"channelUsers": {
"title": "Authorized Users",
@@ -591,17 +591,17 @@
"save": "Save",
"saving": "Saving…",
"savedOk": "Saved",
"skillPricingTitle": "Skill pricing",
"skillPricingDesc": "Per-skill daily price. A skill enabled at any point during a UTC day counts as one billable day.",
"skillCol": "Skill",
"skillPricingTitle": "Package pricing",
"skillPricingDesc": "Set per-day rate and one-time setup fee for any package — core, channel, or skill. Pricing applies to every tenant that enables the package.",
"skillCol": "Package",
"dailyPriceCol": "Daily price",
"actionsCol": "",
"remove": "Remove",
"noSkillsPriced": "No skills are priced yet.",
"addSkillLabel": "Add skill",
"noSkillsPriced": "No packages priced yet.",
"addSkillLabel": "Add package",
"dailyPriceLabel": "Daily price",
"add": "Add",
"confirmDeleteSkillPrice": "Remove pricing for {skill}?",
"confirmDeleteSkillPrice": "Remove pricing for {skill}? Already-billed periods are unaffected.",
"clickToEdit": "Click to edit",
"generateFormTitle": "Generate invoice",
"noOrgsToGenerate": "No organizations with tenants found.",
@@ -667,17 +667,17 @@
"intro": "Activating {skill} will incur the following charges:",
"setupFeeLabel": "Setup fee",
"setupFeeNote": "One-time, charged on first activation only",
"dailyPriceLabel": "Daily price",
"dailyPriceNote": "Charged for each calendar day (UTC) the skill is enabled",
"dayUnit": "day",
"monthlyPriceLabel": "Monthly price",
"monthlyPriceNote": "CHF {daily}/day enabled; partial months prorated by day",
"monthUnit": "month",
"disclaimer": "These charges appear on your next monthly invoice. By confirming you agree to incur them.",
"cancel": "Cancel",
"confirm": "Confirm & activate",
"confirming": "Activating…"
},
"adminSkills": {
"title": "Skill activation queue",
"subtitle": "Customer requests to activate skills that need manual platform-side setup. Approve once configuration is in place; reject with a reason if the activation can't proceed.",
"title": "Activation queue",
"subtitle": "Customer requests to activate packages that need manual platform-side setup. Approve once configuration is in place; reject with a reason if the activation can't proceed.",
"backToAdmin": "Back to Admin",
"emptyQueue": "No pending skill activation requests.",
"requestedAtCol": "Requested",

View File

@@ -390,7 +390,7 @@
"resumeRequestTooltip": "Demande de réactivation d'un locataire existant. L'approbation le réactivera ; aucun provisionnement ne s'exécute.",
"openclawTool": "Versions OpenClaw",
"billingTool": "Facturation →",
"skillsQueueTool": "File des skills"
"skillsQueueTool": "File d'activation"
},
"channelUsers": {
"title": "Utilisateurs autorisés",
@@ -591,17 +591,17 @@
"save": "Enregistrer",
"saving": "Enregistrement…",
"savedOk": "Enregistré",
"skillPricingTitle": "Tarifs des skills",
"skillPricingDesc": "Prix journalier par skill. Un skill activé à tout moment au cours d'une journée UTC compte comme un jour facturable.",
"skillCol": "Skill",
"skillPricingTitle": "Tarification des paquets",
"skillPricingDesc": "Tarif journalier et frais de configuration uniques pour chaque paquet — core, canal ou skill. La tarification s'applique à chaque tenant activant le paquet.",
"skillCol": "Paquet",
"dailyPriceCol": "Prix/jour",
"actionsCol": "",
"remove": "Retirer",
"noSkillsPriced": "Aucun skill n'a encore de prix.",
"addSkillLabel": "Ajouter un skill",
"noSkillsPriced": "Aucun paquet tarifé.",
"addSkillLabel": "Ajouter un paquet",
"dailyPriceLabel": "Prix/jour",
"add": "Ajouter",
"confirmDeleteSkillPrice": "Retirer le prix pour {skill}?",
"confirmDeleteSkillPrice": "Supprimer la tarification de {skill} ? Les périodes déjà facturées ne sont pas affectées.",
"clickToEdit": "Cliquer pour modifier",
"generateFormTitle": "Générer une facture",
"noOrgsToGenerate": "Aucune organisation avec tenants trouvée.",
@@ -667,17 +667,17 @@
"intro": "L'activation de {skill} entraînera les frais suivants :",
"setupFeeLabel": "Frais de configuration",
"setupFeeNote": "Unique, facturé uniquement à la première activation",
"dailyPriceLabel": "Prix journalier",
"dailyPriceNote": "Facturé pour chaque jour calendaire (UTC) où le skill est activé",
"dayUnit": "jour",
"monthlyPriceLabel": "Prix mensuel",
"monthlyPriceNote": "CHF {daily}/jour actif ; mois partiels prorata journalier",
"monthUnit": "mois",
"disclaimer": "Ces frais figureront sur votre prochaine facture mensuelle. En confirmant, vous acceptez de les engager.",
"cancel": "Annuler",
"confirm": "Confirmer & activer",
"confirming": "Activation…"
},
"adminSkills": {
"title": "File d'activation des skills",
"subtitle": "Demandes clients d'activation de skills nécessitant une configuration manuelle côté plateforme. Approuver une fois la configuration en place ; refuser avec un motif si l'activation est impossible.",
"title": "File d'activation",
"subtitle": "Demandes clients d'activation de paquets nécessitant une configuration manuelle côté plateforme. Approuver une fois la configuration en place ; refuser avec un motif si l'activation est impossible.",
"backToAdmin": "Retour à l'administration",
"emptyQueue": "Aucune demande d'activation en attente.",
"requestedAtCol": "Demandée le",

View File

@@ -390,7 +390,7 @@
"resumeRequestTooltip": "Richiesta di riattivazione di un tenant esistente. L'approvazione lo riattiverà; non viene eseguito alcun provisioning.",
"openclawTool": "Versioni OpenClaw",
"billingTool": "Fatturazione →",
"skillsQueueTool": "Coda skill"
"skillsQueueTool": "Coda di attivazione"
},
"channelUsers": {
"title": "Utenti autorizzati",
@@ -591,17 +591,17 @@
"save": "Salva",
"saving": "Salvataggio…",
"savedOk": "Salvato",
"skillPricingTitle": "Prezzi skill",
"skillPricingDesc": "Prezzo giornaliero per skill. Una skill attiva in qualsiasi momento di un giorno UTC conta come un giorno fatturabile.",
"skillCol": "Skill",
"skillPricingTitle": "Prezzi dei pacchetti",
"skillPricingDesc": "Tariffa giornaliera e spese di attivazione una tantum per qualsiasi pacchetto — core, canale o skill. La tariffazione si applica a ogni tenant che attiva il pacchetto.",
"skillCol": "Pacchetto",
"dailyPriceCol": "Prezzo/giorno",
"actionsCol": "",
"remove": "Rimuovi",
"noSkillsPriced": "Nessuna skill ha ancora un prezzo.",
"addSkillLabel": "Aggiungi skill",
"noSkillsPriced": "Nessun pacchetto con prezzo.",
"addSkillLabel": "Aggiungi pacchetto",
"dailyPriceLabel": "Prezzo/giorno",
"add": "Aggiungi",
"confirmDeleteSkillPrice": "Rimuovere il prezzo per {skill}?",
"confirmDeleteSkillPrice": "Rimuovere la tariffazione per {skill}? I periodi già fatturati non sono influenzati.",
"clickToEdit": "Clicca per modificare",
"generateFormTitle": "Genera fattura",
"noOrgsToGenerate": "Nessuna organizzazione con tenant trovata.",
@@ -667,17 +667,17 @@
"intro": "L'attivazione di {skill} comporterà i seguenti costi:",
"setupFeeLabel": "Spese di attivazione",
"setupFeeNote": "Una tantum, addebitate solo alla prima attivazione",
"dailyPriceLabel": "Prezzo giornaliero",
"dailyPriceNote": "Addebitato per ogni giorno di calendario (UTC) in cui lo skill è attivato",
"dayUnit": "giorno",
"monthlyPriceLabel": "Prezzo mensile",
"monthlyPriceNote": "CHF {daily}/giorno attivo; mesi parziali calcolati al giorno",
"monthUnit": "mese",
"disclaimer": "Questi costi appariranno sulla prossima fattura mensile. Confermando accetti di sostenerli.",
"cancel": "Annulla",
"confirm": "Conferma & attiva",
"confirming": "Attivazione…"
},
"adminSkills": {
"title": "Coda di attivazione skill",
"subtitle": "Richieste dei clienti per attivare skill che richiedono configurazione manuale lato piattaforma. Approva quando la configurazione è pronta; rifiuta con motivazione se l'attivazione non è possibile.",
"title": "Coda di attivazione",
"subtitle": "Richieste dei clienti per attivare pacchetti che richiedono configurazione manuale lato piattaforma. Approva quando la configurazione è pronta; rifiuta con motivazione se l'attivazione non è possibile.",
"backToAdmin": "Torna ad amministrazione",
"emptyQueue": "Nessuna richiesta di attivazione skill in attesa.",
"requestedAtCol": "Richiesta",