Phase2.5: Skill SetUp Process
All checks were successful
Build and Push / build (push) Successful in 1m39s
All checks were successful
Build and Push / build (push) Successful in 1m39s
This commit is contained in:
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -231,6 +231,7 @@ export const PACKAGE_CATALOG: PackageDef[] = [
|
||||
},
|
||||
{
|
||||
id: "gog",
|
||||
requiresManualSetup: true,
|
||||
name: "Google Workspace (Gog)",
|
||||
descriptionKey: "packages.gog.description",
|
||||
requiresSecrets: true,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user