Compare commits
14 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c1833c1def | |||
| 521398b0fc | |||
| 74d276b656 | |||
| 3110b40cf9 | |||
| 08f28aeb93 | |||
| fb9c0ad25a | |||
| 322cfae824 | |||
| 7fac3c3aa8 | |||
| bff3aad1ca | |||
| f2a9637058 | |||
| bfc2194e24 | |||
| 6f8de14b4a | |||
| a6ed74b1be | |||
| 1741574eb2 |
@@ -98,6 +98,7 @@ export default async function AdminBillingPage() {
|
||||
<div className="animate-in animate-in-delay-3">
|
||||
<h2 className="text-lg font-semibold mb-3">{t("balancesTitle")}</h2>
|
||||
<Card>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="text-xs text-text-muted text-left">
|
||||
<tr>
|
||||
@@ -126,6 +127,7 @@ export default async function AdminBillingPage() {
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -5,6 +5,11 @@ import { listTenants } from "@/lib/k8s";
|
||||
import { countPendingSkillActivationRequests } from "@/lib/db";
|
||||
import { AdminPanel } from "@/components/admin/admin-panel";
|
||||
|
||||
export async function generateMetadata() {
|
||||
const t = await getTranslations("common");
|
||||
return { title: t("admin") };
|
||||
}
|
||||
|
||||
export default async function AdminPage() {
|
||||
const user = await getSessionUser();
|
||||
if (!user) redirect("/login");
|
||||
|
||||
@@ -26,6 +26,11 @@ import { RunningTotalWidget } from "@/components/billing/running-total-widget";
|
||||
* Anyone signed in can view this. The data is org-scoped; even
|
||||
* non-owner team members see the same view.
|
||||
*/
|
||||
export async function generateMetadata() {
|
||||
const t = await getTranslations("common");
|
||||
return { title: t("billing") };
|
||||
}
|
||||
|
||||
export default async function CustomerBillingPage() {
|
||||
const user = await getSessionUser();
|
||||
if (!user) redirect("/login");
|
||||
|
||||
@@ -81,6 +81,7 @@ export default async function NewInstancePage() {
|
||||
hasOrgBilling={hasOrgBilling}
|
||||
existingOrgBilling={orgBilling}
|
||||
setupFeeChf={pricing.tenantSetupFeeChf}
|
||||
monthlyFeeChf={pricing.tenantMonthlyFeeChf}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -22,6 +22,11 @@ import { ProvisioningStatus } from "@/components/onboarding/provisioning-status"
|
||||
import { formatDateTime } from "@/lib/format";
|
||||
import Link from "next/link";
|
||||
|
||||
export async function generateMetadata() {
|
||||
const t = await getTranslations("common");
|
||||
return { title: t("dashboard") };
|
||||
}
|
||||
|
||||
export default async function DashboardPage() {
|
||||
const user = await getSessionUser();
|
||||
if (!user) redirect("/login");
|
||||
@@ -321,6 +326,7 @@ export default async function DashboardPage() {
|
||||
hasOrgBilling={hasOrgBilling}
|
||||
existingOrgBilling={orgBilling}
|
||||
setupFeeChf={platformPricing.tenantSetupFeeChf}
|
||||
monthlyFeeChf={platformPricing.tenantMonthlyFeeChf}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -344,7 +350,7 @@ export default async function DashboardPage() {
|
||||
{canCreate && (
|
||||
<Link
|
||||
href="/dashboard/new"
|
||||
className="shrink-0 inline-flex items-center gap-1.5 py-2 px-4 bg-accent text-white text-xs font-medium rounded-lg hover:bg-accent-dim transition-colors"
|
||||
className="shrink-0 inline-flex items-center gap-1.5 py-2 px-4 bg-accent text-surface-0 text-xs font-medium rounded-lg hover:bg-accent-dim transition-colors"
|
||||
>
|
||||
<span>+</span> {t("createInstance")}
|
||||
</Link>
|
||||
|
||||
72
src/app/[locale]/error.tsx
Normal file
72
src/app/[locale]/error.tsx
Normal file
@@ -0,0 +1,72 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Link } from "@/i18n/navigation";
|
||||
|
||||
/**
|
||||
* Error boundary for the [locale] segment. Catches render/data errors
|
||||
* thrown by any page below the locale layout (which is where K8s, DB,
|
||||
* LiteLLM and Stripe calls happen). Renders inside NextIntlClientProvider,
|
||||
* so translations are available. Root-layout failures fall through to
|
||||
* global-error.tsx instead.
|
||||
*/
|
||||
export default function LocaleError({
|
||||
error,
|
||||
reset,
|
||||
}: {
|
||||
error: Error & { digest?: string };
|
||||
reset: () => void;
|
||||
}) {
|
||||
const t = useTranslations("errors");
|
||||
|
||||
useEffect(() => {
|
||||
// Surface the error for log scraping; the digest correlates with
|
||||
// the server-side stack in production.
|
||||
console.error("Portal error boundary:", error);
|
||||
}, [error]);
|
||||
|
||||
return (
|
||||
<div className="flex min-h-[60vh] items-center justify-center px-5">
|
||||
<div className="w-full max-w-md text-center">
|
||||
<div className="mx-auto mb-5 flex h-14 w-14 items-center justify-center rounded-xl bg-error/10">
|
||||
<svg
|
||||
className="h-7 w-7 text-error"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={1.75}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="M12 9v4M12 17h.01M10.3 3.86l-8.5 14.7A1.5 1.5 0 003.1 21h17.8a1.5 1.5 0 001.3-2.44l-8.5-14.7a1.5 1.5 0 00-2.6 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
<h1 className="font-display text-xl font-semibold text-text-primary mb-2">
|
||||
{t("title")}
|
||||
</h1>
|
||||
<p className="text-sm text-text-secondary mb-6">{t("description")}</p>
|
||||
{error?.digest && (
|
||||
<p className="text-[11px] font-mono text-text-muted mb-6">
|
||||
{error.digest}
|
||||
</p>
|
||||
)}
|
||||
<div className="flex items-center justify-center gap-3">
|
||||
<button
|
||||
onClick={reset}
|
||||
className="py-2 px-4 rounded-lg bg-accent text-surface-0 text-sm font-medium hover:bg-accent-dim transition-colors cursor-pointer"
|
||||
>
|
||||
{t("retry")}
|
||||
</button>
|
||||
<Link
|
||||
href="/dashboard"
|
||||
className="py-2 px-4 rounded-lg border border-border text-sm font-medium text-text-secondary hover:text-text-primary hover:bg-surface-2 transition-colors"
|
||||
>
|
||||
{t("backToDashboard")}
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,13 +1,36 @@
|
||||
import type { Metadata, Viewport } from "next";
|
||||
import { NextIntlClientProvider } from "next-intl";
|
||||
import { getMessages } from "next-intl/server";
|
||||
import { getMessages, getTranslations } from "next-intl/server";
|
||||
import { routing } from "@/i18n/routing";
|
||||
import { notFound } from "next/navigation";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { NavShell } from "@/components/layout/nav-shell";
|
||||
|
||||
export function generateStaticParams() {
|
||||
return routing.locales.map((locale) => ({ locale }));
|
||||
}
|
||||
|
||||
// Metadata API (Next 15) instead of a hand-rolled <head>. The title
|
||||
// template lets each page export a short `title` (e.g. "Dashboard")
|
||||
// that renders as "Dashboard · PieCed". Pages that export no metadata
|
||||
// fall back to the default below.
|
||||
export async function generateMetadata(): Promise<Metadata> {
|
||||
const t = await getTranslations("common");
|
||||
const appName = t("appName");
|
||||
return {
|
||||
title: {
|
||||
default: `${appName} Portal`,
|
||||
template: `%s · ${appName}`,
|
||||
},
|
||||
description: "PieCed IT — Multi-tenant AI assistant platform",
|
||||
};
|
||||
}
|
||||
|
||||
export const viewport: Viewport = {
|
||||
width: "device-width",
|
||||
initialScale: 1,
|
||||
};
|
||||
|
||||
export default async function LocaleLayout({
|
||||
children,
|
||||
params,
|
||||
@@ -22,20 +45,13 @@ export default async function LocaleLayout({
|
||||
}
|
||||
|
||||
const messages = await getMessages();
|
||||
const session = await auth();
|
||||
|
||||
return (
|
||||
<html lang={locale} className="dark">
|
||||
<head>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>PieCed Portal</title>
|
||||
<meta
|
||||
name="description"
|
||||
content="PieCed IT — Multi-tenant AI assistant platform"
|
||||
/>
|
||||
</head>
|
||||
<body className="min-h-screen bg-surface-0 text-text-primary antialiased">
|
||||
<NextIntlClientProvider messages={messages}>
|
||||
<NavShell>{children}</NavShell>
|
||||
<NavShell session={session}>{children}</NavShell>
|
||||
</NextIntlClientProvider>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
25
src/app/[locale]/loading.tsx
Normal file
25
src/app/[locale]/loading.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
/**
|
||||
* Loading skeleton for the [locale] segment. Shown during navigation
|
||||
* while a server component fetches (the dashboard, for instance, does
|
||||
* listTenants() + one K8s GET per provisioning row). Textless on
|
||||
* purpose so it needs no translations and adds no layout shift.
|
||||
*/
|
||||
export default function LocaleLoading() {
|
||||
return (
|
||||
<div className="animate-pulse" aria-hidden="true">
|
||||
<div className="mb-8">
|
||||
<div className="h-7 w-48 rounded-md bg-surface-2" />
|
||||
<div className="mt-4 h-4 w-72 rounded bg-surface-1" />
|
||||
</div>
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{Array.from({ length: 6 }).map((_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="h-28 rounded-xl border border-border bg-surface-1"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<span className="sr-only">Loading…</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,11 +1,12 @@
|
||||
"use client";
|
||||
|
||||
import { signIn } from "next-auth/react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import Link from "next/link";
|
||||
import { useTranslations, useLocale } from "next-intl";
|
||||
import { Link, getPathname } from "@/i18n/navigation";
|
||||
|
||||
export default function LoginPage() {
|
||||
const t = useTranslations("login");
|
||||
const locale = useLocale();
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 flex items-center justify-center bg-surface-0">
|
||||
@@ -39,7 +40,14 @@ export default function LoginPage() {
|
||||
</p>
|
||||
|
||||
<button
|
||||
onClick={() => signIn("zitadel", { callbackUrl: "/dashboard" })}
|
||||
onClick={() =>
|
||||
signIn("zitadel", {
|
||||
// Preserve the active locale across the OIDC round-trip.
|
||||
// A bare "/dashboard" would resolve to the default (de)
|
||||
// locale on return; getPathname prefixes it as needed.
|
||||
callbackUrl: getPathname({ href: "/dashboard", locale }),
|
||||
})
|
||||
}
|
||||
className="
|
||||
w-full py-3 px-4 rounded-lg font-medium text-sm
|
||||
bg-accent text-surface-0 cursor-pointer
|
||||
|
||||
34
src/app/[locale]/not-found.tsx
Normal file
34
src/app/[locale]/not-found.tsx
Normal file
@@ -0,0 +1,34 @@
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { Link } from "@/i18n/navigation";
|
||||
|
||||
/**
|
||||
* 404 for the [locale] segment. Triggered by notFound() calls in pages
|
||||
* below the locale layout. (A notFound() thrown by the locale layout
|
||||
* itself — e.g. an unknown locale — resolves to the framework default,
|
||||
* which is acceptable for that narrow case.)
|
||||
*/
|
||||
export default async function LocaleNotFound() {
|
||||
const t = await getTranslations("errors");
|
||||
|
||||
return (
|
||||
<div className="flex min-h-[60vh] items-center justify-center px-5">
|
||||
<div className="w-full max-w-md text-center">
|
||||
<div className="font-display text-5xl font-semibold text-accent mb-4 tabular-nums">
|
||||
404
|
||||
</div>
|
||||
<h1 className="font-display text-xl font-semibold text-text-primary mb-2">
|
||||
{t("notFoundTitle")}
|
||||
</h1>
|
||||
<p className="text-sm text-text-secondary mb-6">
|
||||
{t("notFoundDescription")}
|
||||
</p>
|
||||
<Link
|
||||
href="/dashboard"
|
||||
className="inline-flex py-2 px-4 rounded-lg bg-accent text-surface-0 text-sm font-medium hover:bg-accent-dim transition-colors"
|
||||
>
|
||||
{t("backToDashboard")}
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,13 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { redirect } from "@/i18n/navigation";
|
||||
|
||||
export default function RootPage() {
|
||||
redirect("/dashboard");
|
||||
export default async function RootPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ locale: string }>;
|
||||
}) {
|
||||
// Locale-aware redirect: a bare next/navigation redirect("/dashboard")
|
||||
// drops the prefix and lands non-default-locale users on the German
|
||||
// dashboard. The i18n redirect prefixes per the active locale.
|
||||
const { locale } = await params;
|
||||
redirect({ href: "/dashboard", locale });
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useState, useRef, forwardRef } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useRouter, Link } from "@/i18n/navigation";
|
||||
import { Card } from "@/components/ui/card";
|
||||
|
||||
type FormState = "idle" | "submitting" | "success" | "error";
|
||||
@@ -50,6 +50,30 @@ export default function RegisterPage() {
|
||||
const [state, setState] = useState<FormState>("idle");
|
||||
const [error, setError] = useState("");
|
||||
|
||||
// Radiogroup keyboard support. `role="radio"` requires roving
|
||||
// tabindex (one tab stop) + arrow-key navigation between options —
|
||||
// native buttons don't move focus on arrows. The selected card is
|
||||
// the tab stop; when nothing is selected yet the first card is
|
||||
// focusable so keyboard users can enter the group.
|
||||
const TYPES: AccountType[] = ["personal", "company"];
|
||||
const cardRefs = useRef<(HTMLButtonElement | null)[]>([]);
|
||||
|
||||
const rovingTabIndex = (type: AccountType, index: number) =>
|
||||
accountType === type || (accountType === null && index === 0) ? 0 : -1;
|
||||
|
||||
const handleCardKeyDown = (e: React.KeyboardEvent, index: number) => {
|
||||
let next: number | null = null;
|
||||
if (e.key === "ArrowRight" || e.key === "ArrowDown") {
|
||||
next = (index + 1) % TYPES.length;
|
||||
} else if (e.key === "ArrowLeft" || e.key === "ArrowUp") {
|
||||
next = (index - 1 + TYPES.length) % TYPES.length;
|
||||
}
|
||||
if (next === null) return;
|
||||
e.preventDefault();
|
||||
setAccountType(TYPES[next]);
|
||||
cardRefs.current[next]?.focus();
|
||||
};
|
||||
|
||||
const isPersonal = accountType === "personal";
|
||||
|
||||
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
@@ -120,7 +144,7 @@ export default function RegisterPage() {
|
||||
</p>
|
||||
<button
|
||||
onClick={() => router.push("/login")}
|
||||
className="w-full py-2.5 px-4 bg-accent text-white text-sm font-medium rounded-lg hover:bg-accent-dim transition-colors"
|
||||
className="w-full py-2.5 px-4 bg-accent text-surface-0 text-sm font-medium rounded-lg hover:bg-accent-dim transition-colors"
|
||||
>
|
||||
{t("goToLogin")}
|
||||
</button>
|
||||
@@ -146,8 +170,13 @@ export default function RegisterPage() {
|
||||
className="grid grid-cols-2 gap-3 mb-6 animate-in animate-in-delay-1"
|
||||
>
|
||||
<AccountTypeCard
|
||||
ref={(el) => {
|
||||
cardRefs.current[0] = el;
|
||||
}}
|
||||
selected={accountType === "personal"}
|
||||
onClick={() => setAccountType("personal")}
|
||||
tabIndex={rovingTabIndex("personal", 0)}
|
||||
onKeyDown={(e) => handleCardKeyDown(e, 0)}
|
||||
label={t("personalCardTitle")}
|
||||
description={t("personalCardDescription")}
|
||||
icon={
|
||||
@@ -168,8 +197,13 @@ export default function RegisterPage() {
|
||||
}
|
||||
/>
|
||||
<AccountTypeCard
|
||||
ref={(el) => {
|
||||
cardRefs.current[1] = el;
|
||||
}}
|
||||
selected={accountType === "company"}
|
||||
onClick={() => setAccountType("company")}
|
||||
tabIndex={rovingTabIndex("company", 1)}
|
||||
onKeyDown={(e) => handleCardKeyDown(e, 1)}
|
||||
label={t("companyCardTitle")}
|
||||
description={t("companyCardDescription")}
|
||||
icon={
|
||||
@@ -270,7 +304,7 @@ export default function RegisterPage() {
|
||||
<button
|
||||
type="submit"
|
||||
disabled={state === "submitting"}
|
||||
className="w-full py-2.5 px-4 bg-accent text-white text-sm font-medium rounded-lg hover:bg-accent-dim transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
className="w-full py-2.5 px-4 bg-accent text-surface-0 text-sm font-medium rounded-lg hover:bg-accent-dim transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{state === "submitting" ? tCommon("loading") : t("submit")}
|
||||
</button>
|
||||
@@ -278,12 +312,12 @@ export default function RegisterPage() {
|
||||
|
||||
<p className="text-xs text-text-muted text-center mt-4">
|
||||
{t("hasAccount")}{" "}
|
||||
<a
|
||||
<Link
|
||||
href="/login"
|
||||
className="text-accent hover:text-accent-dim transition-colors"
|
||||
>
|
||||
{tCommon("login")}
|
||||
</a>
|
||||
</Link>
|
||||
</p>
|
||||
</Card>
|
||||
)}
|
||||
@@ -305,41 +339,42 @@ export default function RegisterPage() {
|
||||
* and text colours intensify when selected to give a clear "this one
|
||||
* is on" signal beyond just the border colour.
|
||||
*/
|
||||
function AccountTypeCard({
|
||||
selected,
|
||||
onClick,
|
||||
label,
|
||||
description,
|
||||
icon,
|
||||
}: {
|
||||
selected: boolean;
|
||||
onClick: () => void;
|
||||
label: string;
|
||||
description: string;
|
||||
icon: React.ReactNode;
|
||||
}) {
|
||||
const AccountTypeCard = forwardRef<
|
||||
HTMLButtonElement,
|
||||
{
|
||||
selected: boolean;
|
||||
onClick: () => void;
|
||||
label: string;
|
||||
description: string;
|
||||
icon: React.ReactNode;
|
||||
tabIndex: number;
|
||||
onKeyDown: (e: React.KeyboardEvent) => void;
|
||||
}
|
||||
>(function AccountTypeCard(
|
||||
{ selected, onClick, label, description, icon, tabIndex, onKeyDown },
|
||||
ref
|
||||
) {
|
||||
return (
|
||||
<button
|
||||
ref={ref}
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={selected}
|
||||
tabIndex={tabIndex}
|
||||
onClick={onClick}
|
||||
onKeyDown={onKeyDown}
|
||||
className={`text-left rounded-xl border p-4 transition-colors cursor-pointer focus:outline-none focus:ring-2 focus:ring-accent/40 ${
|
||||
selected
|
||||
? "border-accent bg-accent/10"
|
||||
: "border-border bg-surface-2 hover:border-accent/40 hover:bg-surface-3/30"
|
||||
}`}
|
||||
>
|
||||
<div
|
||||
className={`mb-2 ${
|
||||
selected ? "text-accent" : "text-text-muted"
|
||||
}`}
|
||||
>
|
||||
<div className={`mb-2 ${selected ? "text-accent" : "text-text-muted"}`}>
|
||||
{icon}
|
||||
</div>
|
||||
<div
|
||||
className={`text-sm font-semibold mb-0.5 ${
|
||||
selected ? "text-text-primary" : "text-text-primary"
|
||||
selected ? "text-text-primary" : "text-text-secondary"
|
||||
}`}
|
||||
>
|
||||
{label}
|
||||
@@ -347,4 +382,4 @@ function AccountTypeCard({
|
||||
<div className="text-xs text-text-muted leading-snug">{description}</div>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -14,6 +14,11 @@ import { Card } from "@/components/ui/card";
|
||||
* Access: any authenticated user (the cards themselves gate further;
|
||||
* non-owner users would not see "Billing" as actionable, etc.).
|
||||
*/
|
||||
export async function generateMetadata() {
|
||||
const t = await getTranslations("common");
|
||||
return { title: t("settings") };
|
||||
}
|
||||
|
||||
export default async function SettingsPage() {
|
||||
const user = await getSessionUser();
|
||||
if (!user) redirect("/login");
|
||||
|
||||
@@ -24,6 +24,11 @@ import { TicketCategoryLabel } from "@/components/support/ticket-category-label"
|
||||
* having recent activity, but we don't sort by status; that's a
|
||||
* filter the admin can add later if the queue grows.
|
||||
*/
|
||||
export async function generateMetadata() {
|
||||
const t = await getTranslations("common");
|
||||
return { title: t("support") };
|
||||
}
|
||||
|
||||
export default async function SupportListPage() {
|
||||
const user = await getSessionUser();
|
||||
if (!user) redirect("/login");
|
||||
@@ -48,7 +53,7 @@ export default async function SupportListPage() {
|
||||
{!user.isPlatform && (
|
||||
<Link
|
||||
href="/support/new"
|
||||
className="text-sm font-medium px-4 py-2 rounded-lg bg-accent text-white hover:bg-accent/90 transition-colors"
|
||||
className="text-sm font-medium px-4 py-2 rounded-lg bg-accent text-surface-0 hover:bg-accent/90 transition-colors"
|
||||
>
|
||||
{t("newTicket")}
|
||||
</Link>
|
||||
|
||||
@@ -6,6 +6,7 @@ import { Card } from "@/components/ui/card";
|
||||
import { BackLink } from "@/components/ui/back-link";
|
||||
import { TeamList } from "@/components/team/team-list";
|
||||
import { InviteForm } from "@/components/team/invite-form";
|
||||
import { AccessOverview } from "@/components/team/access-overview";
|
||||
|
||||
/**
|
||||
* /team — manage org members.
|
||||
@@ -17,6 +18,11 @@ import { InviteForm } from "@/components/team/invite-form";
|
||||
* `<TeamList>` and `<InviteForm>` client components handle live
|
||||
* updates after invites and refreshes.
|
||||
*/
|
||||
export async function generateMetadata() {
|
||||
const t = await getTranslations("common");
|
||||
return { title: t("team") };
|
||||
}
|
||||
|
||||
export default async function TeamPage() {
|
||||
const user = await getSessionUser();
|
||||
if (!user) redirect("/login");
|
||||
@@ -65,6 +71,16 @@ export default async function TeamPage() {
|
||||
canEditRoles={isCustomerOwner(user)}
|
||||
/>
|
||||
</section>
|
||||
|
||||
{/* Access overview — single place to see which member can reach
|
||||
which assistant, instead of checking each tenant page. */}
|
||||
<section className="mt-8 animate-in animate-in-delay-3">
|
||||
<h2 className="text-xs font-semibold uppercase tracking-wider text-text-muted mb-1">
|
||||
{t("accessTitle")}
|
||||
</h2>
|
||||
<p className="text-xs text-text-muted mb-3">{t("accessDescription")}</p>
|
||||
<AccessOverview />
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ import { WorkspaceEditor } from "@/components/packages/workspace-editor";
|
||||
import { ChannelUsers } from "@/components/channel-users/channel-users";
|
||||
import { AssignedUsersPanel } from "@/components/tenants/assigned-users-panel";
|
||||
import { SubscriptionToggle } from "@/components/tenants/subscription-toggle";
|
||||
import { ConnectPanel } from "@/components/tenants/connect-panel";
|
||||
import { formatDateTime, formatRelative } from "@/lib/format";
|
||||
import { CHANNEL_PACKAGE_IDS } from "@/lib/packages";
|
||||
|
||||
@@ -216,6 +217,19 @@ export default async function TenantDetailPage({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Connect: how the customer actually reaches their assistant.
|
||||
The portal manages the assistant; the assistant lives in the
|
||||
customer's messaging app. This bridges that gap right at the
|
||||
top of the page (and calls out the case where no channel is
|
||||
enabled, which would otherwise leave a running assistant
|
||||
unreachable). */}
|
||||
<section className="mb-8 animate-in animate-in-delay-1">
|
||||
<ConnectPanel
|
||||
enabledChannels={enabledChannels}
|
||||
phase={tenant.status?.phase ?? "Pending"}
|
||||
/>
|
||||
</section>
|
||||
|
||||
{/* Usage */}
|
||||
<section className="mb-8 animate-in animate-in-delay-1">
|
||||
<h2 className="text-xs font-semibold uppercase tracking-wider text-text-muted mb-3">
|
||||
|
||||
@@ -4,14 +4,12 @@ import {
|
||||
getTenantRequestById,
|
||||
updateTenantRequestStatus,
|
||||
clearEncryptedSecrets,
|
||||
recordTenantCreated,
|
||||
recordSkillEvents,
|
||||
recordSuspensionEvent,
|
||||
} from "@/lib/db";
|
||||
import { createTenant, patchTenantSpec, setTenantAnnotation } from "@/lib/k8s";
|
||||
import { sendApprovalEmail, sendResumeApprovalEmail } from "@/lib/email";
|
||||
import { decryptSecrets } from "@/lib/crypto";
|
||||
import { writePackageSecrets } from "@/lib/openbao";
|
||||
import { createRoute as createRelayRoute } from "@/lib/threema-relay";
|
||||
import {
|
||||
getDefaultSoulMd,
|
||||
getDefaultAgentsMd,
|
||||
@@ -88,23 +86,6 @@ export async function POST(
|
||||
}
|
||||
try {
|
||||
await patchTenantSpec(tenantRequest.tenantName, { suspend: false });
|
||||
|
||||
// Billing — Phase 1: record the resume so monthly proration
|
||||
// counts the suspended segment correctly. Best-effort; if
|
||||
// logging fails, the approval still succeeds.
|
||||
try {
|
||||
await recordSuspensionEvent(
|
||||
tenantRequest.tenantName,
|
||||
tenantRequest.zitadelOrgId,
|
||||
"resumed"
|
||||
);
|
||||
} catch (e) {
|
||||
console.error(
|
||||
"billing: failed to record resumed suspension event:",
|
||||
e
|
||||
);
|
||||
}
|
||||
|
||||
// Clear the annotation that pauses the operator's 60-day TTL.
|
||||
// Best-effort — annotation cleanup is also done by the operator
|
||||
// when it sees suspend=false on the next reconcile (it clears
|
||||
@@ -197,6 +178,29 @@ export async function POST(
|
||||
? tenantRequest.contactName || "Assistant"
|
||||
: tenantRequest.companyName;
|
||||
|
||||
// Phase 9b: split the customer's initial channel-user ids into
|
||||
// (a) ids the operator needs in spec.channelUsers (telegram,
|
||||
// discord, …) — passed straight into createTenant
|
||||
// (b) Threema ids that ALSO need a relay route registered so
|
||||
// inbound messages reach this tenant. Threema is in (a)
|
||||
// AND (b): spec.channelUsers tells the operator the id is
|
||||
// authorized; the relay's route maps inbound traffic from
|
||||
// that id to this tenant.
|
||||
const initialChannelUsers = tenantRequest.channelUsers ?? {};
|
||||
// Strip channels the customer didn't actually enable (defensive
|
||||
// — the wizard already filters this, but the row could carry
|
||||
// stale data if the customer edited their request post-submit).
|
||||
const filteredChannelUsers: Record<string, string[]> = {};
|
||||
for (const [channel, ids] of Object.entries(initialChannelUsers)) {
|
||||
if (!packages.includes(channel)) continue;
|
||||
const cleaned = (ids ?? [])
|
||||
.map((s) => (s ?? "").trim())
|
||||
.filter((s) => s.length > 0);
|
||||
if (cleaned.length > 0) {
|
||||
filteredChannelUsers[channel] = cleaned;
|
||||
}
|
||||
}
|
||||
|
||||
await createTenant(
|
||||
tenantName,
|
||||
{
|
||||
@@ -204,6 +208,9 @@ export async function POST(
|
||||
agentName: tenantRequest.agentName,
|
||||
packages,
|
||||
workspaceFiles,
|
||||
...(Object.keys(filteredChannelUsers).length > 0
|
||||
? { channelUsers: filteredChannelUsers }
|
||||
: {}),
|
||||
},
|
||||
{
|
||||
"pieced.ch/zitadel-org-id": tenantRequest.zitadelOrgId,
|
||||
@@ -219,33 +226,33 @@ export async function POST(
|
||||
}
|
||||
);
|
||||
|
||||
// Billing — Phase 1: record the tenant's creation and initial
|
||||
// package state. Anchored at "now" rather than the CR's
|
||||
// creationTimestamp because we don't get the timestamp back from
|
||||
// createTenant — the few-millisecond skew vs the CR's actual
|
||||
// creationTimestamp is irrelevant for monthly billing.
|
||||
//
|
||||
// Best-effort: tracking failures must never block provisioning.
|
||||
// The backfill helper can repair any gaps later if needed.
|
||||
const billingAnchor = new Date();
|
||||
try {
|
||||
await recordTenantCreated(
|
||||
tenantName,
|
||||
tenantRequest.zitadelOrgId,
|
||||
billingAnchor
|
||||
);
|
||||
await recordSkillEvents(
|
||||
tenantName,
|
||||
tenantRequest.zitadelOrgId,
|
||||
packages,
|
||||
[],
|
||||
billingAnchor
|
||||
);
|
||||
} catch (e) {
|
||||
console.error(
|
||||
"billing: failed to record tenant creation / initial skill events:",
|
||||
e
|
||||
);
|
||||
// Threema: register relay routes for each id the customer
|
||||
// entered. Best-effort — a route failure doesn't unwind the
|
||||
// tenant creation (admin can retry from the tenant page later).
|
||||
// The Threema package itself isn't enabled on the tenant until
|
||||
// the customer toggles it from the tenant detail page (which
|
||||
// also mints the per-tenant token); the routes here pre-warm
|
||||
// the relay so the first toggle works without re-typing the id.
|
||||
if (
|
||||
packages.includes("threema") &&
|
||||
filteredChannelUsers.threema &&
|
||||
filteredChannelUsers.threema.length > 0
|
||||
) {
|
||||
for (const tid of filteredChannelUsers.threema) {
|
||||
try {
|
||||
const res = await createRelayRoute(tenantName, tid);
|
||||
if (!res.ok) {
|
||||
console.warn(
|
||||
`[approve] Threema route create for tenant=${tenantName} id=${tid} returned not-ok: ${res.message}`
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(
|
||||
`[approve] Threema route create threw for tenant=${tenantName} id=${tid}:`,
|
||||
e
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Step 5: Update request status — clear admin notes on re-approval
|
||||
|
||||
@@ -208,6 +208,7 @@ export async function POST(request: Request) {
|
||||
|
||||
const input: OnboardingInput & {
|
||||
packageSecrets?: Record<string, Record<string, string>>;
|
||||
channelUsers?: Record<string, string[]>;
|
||||
} = parsed.data;
|
||||
|
||||
// Look up an existing approved request for this org to inherit
|
||||
@@ -443,6 +444,7 @@ export async function POST(request: Request) {
|
||||
billingNotes,
|
||||
encryptedSecrets,
|
||||
isPersonal,
|
||||
channelUsers: input.channelUsers ?? {},
|
||||
});
|
||||
try {
|
||||
await sendAdminNotificationEmail(
|
||||
@@ -487,6 +489,7 @@ export async function POST(request: Request) {
|
||||
billingNotes,
|
||||
encryptedSecrets,
|
||||
isPersonal,
|
||||
channelUsers: input.channelUsers ?? {},
|
||||
});
|
||||
|
||||
// Derive the future tenant_name — needed on the invoice line so
|
||||
|
||||
78
src/app/global-error.tsx
Normal file
78
src/app/global-error.tsx
Normal file
@@ -0,0 +1,78 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
|
||||
/**
|
||||
* Last-resort boundary for errors thrown in the root layout itself
|
||||
* (before the locale layout / intl provider mount). It replaces the
|
||||
* entire document, so it must render its own <html>/<body> and cannot
|
||||
* use translations or rely on the app stylesheet being applied — styles
|
||||
* are inlined with the palette's hex values so it renders correctly in
|
||||
* isolation. Everything below the locale layout is handled by
|
||||
* [locale]/error.tsx instead; this should almost never be seen.
|
||||
*/
|
||||
export default function GlobalError({
|
||||
error,
|
||||
reset,
|
||||
}: {
|
||||
error: Error & { digest?: string };
|
||||
reset: () => void;
|
||||
}) {
|
||||
useEffect(() => {
|
||||
console.error("Portal global error:", error);
|
||||
}, [error]);
|
||||
|
||||
return (
|
||||
<html lang="en" className="dark">
|
||||
<body
|
||||
style={{
|
||||
margin: 0,
|
||||
minHeight: "100vh",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
background: "#0a0c10",
|
||||
color: "#e8ecf4",
|
||||
fontFamily: "system-ui, sans-serif",
|
||||
padding: "20px",
|
||||
}}
|
||||
>
|
||||
<div style={{ maxWidth: "28rem", textAlign: "center" }}>
|
||||
<h1 style={{ fontSize: "1.25rem", fontWeight: 600, margin: "0 0 0.5rem" }}>
|
||||
Something went wrong
|
||||
</h1>
|
||||
<p style={{ fontSize: "0.875rem", color: "#8892a4", margin: "0 0 1.5rem" }}>
|
||||
An unexpected error occurred. Please try again.
|
||||
</p>
|
||||
{error?.digest && (
|
||||
<p
|
||||
style={{
|
||||
fontSize: "11px",
|
||||
fontFamily: "monospace",
|
||||
color: "#565e6e",
|
||||
margin: "0 0 1.5rem",
|
||||
}}
|
||||
>
|
||||
{error.digest}
|
||||
</p>
|
||||
)}
|
||||
<button
|
||||
onClick={reset}
|
||||
style={{
|
||||
padding: "0.5rem 1rem",
|
||||
borderRadius: "0.5rem",
|
||||
border: "none",
|
||||
background: "#00d4aa",
|
||||
color: "#0a0c10",
|
||||
fontSize: "0.875rem",
|
||||
fontWeight: 500,
|
||||
cursor: "pointer",
|
||||
}}
|
||||
>
|
||||
Try again
|
||||
</button>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import { useState, useEffect, useCallback } from "react";
|
||||
import { useTranslations, useFormatter } from "next-intl";
|
||||
import type { PiecedTenant, TenantRequest } from "@/types";
|
||||
import { StatusBadge } from "@/components/ui/status-badge";
|
||||
import { Modal } from "@/components/ui/modal";
|
||||
import { formatDateTime, formatRelative } from "@/lib/format";
|
||||
import Link from "next/link";
|
||||
|
||||
@@ -35,6 +36,11 @@ export function AdminPanel({ initialTenants }: AdminPanelProps) {
|
||||
const [actionLoading, setActionLoading] = useState<string | null>(null);
|
||||
const [rejectModal, setRejectModal] = useState<string | null>(null);
|
||||
const [rejectNotes, setRejectNotes] = useState("");
|
||||
// Approve is the highest-consequence request action — it provisions
|
||||
// real infrastructure and triggers the billable setup fee — so it now
|
||||
// goes through a confirmation modal like reject/delete, instead of
|
||||
// firing on a single click.
|
||||
const [approveModal, setApproveModal] = useState<string | null>(null);
|
||||
|
||||
// Tenants state
|
||||
const [tenants, setTenants] = useState<PiecedTenant[]>(initialTenants);
|
||||
@@ -47,6 +53,11 @@ export function AdminPanel({ initialTenants }: AdminPanelProps) {
|
||||
|
||||
// Shared
|
||||
const [error, setError] = useState("");
|
||||
// Action-scoped error — shown inside the active confirmation modal so
|
||||
// a failed approve/reject/delete surfaces next to the action that
|
||||
// caused it (and keeps the modal open), rather than as a detached
|
||||
// panel-level banner that isn't tied to any row.
|
||||
const [actionError, setActionError] = useState("");
|
||||
|
||||
// ─── Requests fetching ───
|
||||
const fetchRequests = useCallback(async () => {
|
||||
@@ -125,18 +136,21 @@ export function AdminPanel({ initialTenants }: AdminPanelProps) {
|
||||
// ─── Request actions ───
|
||||
const handleApprove = async (id: string) => {
|
||||
setActionLoading(id);
|
||||
setError("");
|
||||
setActionError("");
|
||||
try {
|
||||
const res = await fetch(`/api/admin/requests/${id}/approve`, {
|
||||
method: "POST",
|
||||
});
|
||||
if (!res.ok) {
|
||||
const data = await res.json();
|
||||
const data = await res.json().catch(() => ({}));
|
||||
throw new Error(data.error || "Approve failed");
|
||||
}
|
||||
setApproveModal(null);
|
||||
await fetchRequests();
|
||||
} catch (e: any) {
|
||||
setError(e.message);
|
||||
// Keep the modal open so the admin sees why provisioning didn't
|
||||
// start; the error renders inside the dialog next to the action.
|
||||
setActionError(e.message);
|
||||
} finally {
|
||||
setActionLoading(null);
|
||||
}
|
||||
@@ -144,7 +158,7 @@ export function AdminPanel({ initialTenants }: AdminPanelProps) {
|
||||
|
||||
const handleReject = async (id: string) => {
|
||||
setActionLoading(id);
|
||||
setError("");
|
||||
setActionError("");
|
||||
try {
|
||||
const res = await fetch(`/api/admin/requests/${id}/reject`, {
|
||||
method: "POST",
|
||||
@@ -152,14 +166,14 @@ export function AdminPanel({ initialTenants }: AdminPanelProps) {
|
||||
body: JSON.stringify({ adminNotes: rejectNotes || undefined }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const data = await res.json();
|
||||
const data = await res.json().catch(() => ({}));
|
||||
throw new Error(data.error || "Reject failed");
|
||||
}
|
||||
setRejectModal(null);
|
||||
setRejectNotes("");
|
||||
await fetchRequests();
|
||||
} catch (e: any) {
|
||||
setError(e.message);
|
||||
setActionError(e.message);
|
||||
} finally {
|
||||
setActionLoading(null);
|
||||
}
|
||||
@@ -189,7 +203,7 @@ export function AdminPanel({ initialTenants }: AdminPanelProps) {
|
||||
|
||||
const handleDelete = async (name: string) => {
|
||||
setActionLoading(name);
|
||||
setError("");
|
||||
setActionError("");
|
||||
try {
|
||||
const res = await fetch(`/api/admin/tenants/${name}/delete`, {
|
||||
method: "POST",
|
||||
@@ -216,7 +230,7 @@ export function AdminPanel({ initialTenants }: AdminPanelProps) {
|
||||
fetchTenants();
|
||||
setTimeout(() => fetchTenants(), 1500);
|
||||
} catch (e: any) {
|
||||
setError(e.message);
|
||||
setActionError(e.message);
|
||||
} finally {
|
||||
setActionLoading(null);
|
||||
}
|
||||
@@ -246,7 +260,7 @@ export function AdminPanel({ initialTenants }: AdminPanelProps) {
|
||||
>
|
||||
{t("requests")}
|
||||
{pendingCount > 0 && tab !== "requests" && (
|
||||
<span className="ml-1.5 inline-flex items-center justify-center h-4 min-w-[16px] px-1 text-[10px] font-bold bg-accent text-white rounded-full">
|
||||
<span className="ml-1.5 inline-flex items-center justify-center h-4 min-w-[16px] px-1 text-[10px] font-bold bg-accent text-surface-0 rounded-full">
|
||||
{pendingCount}
|
||||
</span>
|
||||
)}
|
||||
@@ -308,7 +322,7 @@ export function AdminPanel({ initialTenants }: AdminPanelProps) {
|
||||
onClick={() => setFilter(f)}
|
||||
className={`px-3 py-1 text-xs rounded-full transition-colors ${
|
||||
filter === f
|
||||
? "bg-accent text-white"
|
||||
? "bg-accent text-surface-0"
|
||||
: "bg-surface-2 text-text-muted hover:text-text-secondary border border-border"
|
||||
}`}
|
||||
>
|
||||
@@ -436,16 +450,20 @@ export function AdminPanel({ initialTenants }: AdminPanelProps) {
|
||||
{req.status === "pending" && (
|
||||
<>
|
||||
<button
|
||||
onClick={() => handleApprove(req.id)}
|
||||
onClick={() => {
|
||||
setActionError("");
|
||||
setApproveModal(req.id);
|
||||
}}
|
||||
disabled={actionLoading === req.id}
|
||||
className="px-2.5 py-1 text-xs font-medium bg-emerald-500/15 text-emerald-400 rounded-md hover:bg-emerald-500/25 transition-colors disabled:opacity-50"
|
||||
>
|
||||
{actionLoading === req.id
|
||||
? "…"
|
||||
: t("approve")}
|
||||
{t("approve")}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setRejectModal(req.id)}
|
||||
onClick={() => {
|
||||
setActionError("");
|
||||
setRejectModal(req.id);
|
||||
}}
|
||||
disabled={actionLoading === req.id}
|
||||
className="px-2.5 py-1 text-xs font-medium bg-red-500/15 text-red-400 rounded-md hover:bg-red-500/25 transition-colors disabled:opacity-50"
|
||||
>
|
||||
@@ -466,7 +484,10 @@ export function AdminPanel({ initialTenants }: AdminPanelProps) {
|
||||
)}
|
||||
{req.status === "rejected" && (
|
||||
<button
|
||||
onClick={() => handleApprove(req.id)}
|
||||
onClick={() => {
|
||||
setActionError("");
|
||||
setApproveModal(req.id);
|
||||
}}
|
||||
disabled={actionLoading === req.id}
|
||||
className="px-2.5 py-1 text-xs font-medium bg-amber-500/15 text-amber-400 rounded-md hover:bg-amber-500/25 transition-colors disabled:opacity-50"
|
||||
>
|
||||
@@ -642,9 +663,10 @@ export function AdminPanel({ initialTenants }: AdminPanelProps) {
|
||||
: t("suspend")}
|
||||
</button>
|
||||
<button
|
||||
onClick={() =>
|
||||
setDeleteModal(tenant.metadata.name)
|
||||
}
|
||||
onClick={() => {
|
||||
setActionError("");
|
||||
setDeleteModal(tenant.metadata.name);
|
||||
}}
|
||||
disabled={actionLoading === tenant.metadata.name}
|
||||
className="px-2.5 py-1 text-xs font-medium bg-red-500/15 text-red-400 rounded-md hover:bg-red-500/25 transition-colors disabled:opacity-50"
|
||||
>
|
||||
@@ -772,10 +794,75 @@ export function AdminPanel({ initialTenants }: AdminPanelProps) {
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* ───── APPROVE MODAL ───── */}
|
||||
<Modal
|
||||
open={!!approveModal}
|
||||
onClose={() => {
|
||||
setApproveModal(null);
|
||||
setActionError("");
|
||||
}}
|
||||
ariaLabel={t("approveTitle")}
|
||||
>
|
||||
{approveModal &&
|
||||
(() => {
|
||||
const req = requests.find((r) => r.id === approveModal);
|
||||
const isReapprove = req?.status === "rejected";
|
||||
return (
|
||||
<>
|
||||
<h3 className="font-display text-lg font-semibold text-text-primary mb-2">
|
||||
{t("approveTitle")}
|
||||
</h3>
|
||||
<p className="text-sm text-text-secondary mb-2">
|
||||
{isReapprove
|
||||
? t("approveReapproveWarning")
|
||||
: t("approveWarning")}
|
||||
</p>
|
||||
{req && (
|
||||
<p className="text-xs font-mono text-accent bg-surface-2 border border-border rounded-lg px-3 py-2 mb-4">
|
||||
{req.companyName}
|
||||
{req.agentName ? ` · ${req.agentName}` : ""}
|
||||
</p>
|
||||
)}
|
||||
{actionError && (
|
||||
<p className="text-xs text-red-400 bg-red-400/10 border border-red-400/20 rounded-lg px-3 py-2 mb-4">
|
||||
{actionError}
|
||||
</p>
|
||||
)}
|
||||
<div className="flex gap-2 justify-end">
|
||||
<button
|
||||
onClick={() => {
|
||||
setApproveModal(null);
|
||||
setActionError("");
|
||||
}}
|
||||
className="px-4 py-2 text-sm text-text-secondary hover:text-text-primary transition-colors"
|
||||
>
|
||||
{t("cancelAction")}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleApprove(approveModal)}
|
||||
disabled={actionLoading === approveModal}
|
||||
className="px-4 py-2 text-sm font-medium bg-emerald-500/15 text-emerald-400 rounded-lg hover:bg-emerald-500/25 transition-colors disabled:opacity-50"
|
||||
>
|
||||
{actionLoading === approveModal ? "…" : t("confirmApprove")}
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
</Modal>
|
||||
|
||||
{/* ───── REJECT MODAL ───── */}
|
||||
{rejectModal && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm">
|
||||
<div className="bg-surface-1 border border-border rounded-xl p-6 max-w-md w-full mx-4 shadow-2xl">
|
||||
<Modal
|
||||
open={!!rejectModal}
|
||||
onClose={() => {
|
||||
setRejectModal(null);
|
||||
setRejectNotes("");
|
||||
setActionError("");
|
||||
}}
|
||||
ariaLabel={t("rejectTitle")}
|
||||
>
|
||||
{rejectModal && (
|
||||
<>
|
||||
<h3 className="font-display text-lg font-semibold text-text-primary mb-4">
|
||||
{t("rejectTitle")}
|
||||
</h3>
|
||||
@@ -789,11 +876,17 @@ export function AdminPanel({ initialTenants }: AdminPanelProps) {
|
||||
rows={3}
|
||||
className="w-full px-3 py-2 bg-surface-2 border border-border rounded-lg text-sm text-text-primary placeholder:text-text-muted focus:outline-none focus:ring-1 focus:ring-accent focus:border-accent transition-colors resize-none mb-4"
|
||||
/>
|
||||
{actionError && (
|
||||
<p className="text-xs text-red-400 bg-red-400/10 border border-red-400/20 rounded-lg px-3 py-2 mb-4">
|
||||
{actionError}
|
||||
</p>
|
||||
)}
|
||||
<div className="flex gap-2 justify-end">
|
||||
<button
|
||||
onClick={() => {
|
||||
setRejectModal(null);
|
||||
setRejectNotes("");
|
||||
setActionError("");
|
||||
}}
|
||||
className="px-4 py-2 text-sm text-text-secondary hover:text-text-primary transition-colors"
|
||||
>
|
||||
@@ -807,14 +900,21 @@ export function AdminPanel({ initialTenants }: AdminPanelProps) {
|
||||
{actionLoading === rejectModal ? "…" : t("confirmReject")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Modal>
|
||||
|
||||
{/* ───── DELETE MODAL ───── */}
|
||||
{deleteModal && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm">
|
||||
<div className="bg-surface-1 border border-border rounded-xl p-6 max-w-md w-full mx-4 shadow-2xl">
|
||||
<Modal
|
||||
open={!!deleteModal}
|
||||
onClose={() => {
|
||||
setDeleteModal(null);
|
||||
setActionError("");
|
||||
}}
|
||||
ariaLabel={t("deleteTitle")}
|
||||
>
|
||||
{deleteModal && (
|
||||
<>
|
||||
<h3 className="font-display text-lg font-semibold text-text-primary mb-2">
|
||||
{t("deleteTitle")}
|
||||
</h3>
|
||||
@@ -824,9 +924,17 @@ export function AdminPanel({ initialTenants }: AdminPanelProps) {
|
||||
<p className="text-xs font-mono text-accent bg-surface-2 border border-border rounded-lg px-3 py-2 mb-4">
|
||||
{deleteModal}
|
||||
</p>
|
||||
{actionError && (
|
||||
<p className="text-xs text-red-400 bg-red-400/10 border border-red-400/20 rounded-lg px-3 py-2 mb-4">
|
||||
{actionError}
|
||||
</p>
|
||||
)}
|
||||
<div className="flex gap-2 justify-end">
|
||||
<button
|
||||
onClick={() => setDeleteModal(null)}
|
||||
onClick={() => {
|
||||
setDeleteModal(null);
|
||||
setActionError("");
|
||||
}}
|
||||
className="px-4 py-2 text-sm text-text-secondary hover:text-text-primary transition-colors"
|
||||
>
|
||||
{t("cancelAction")}
|
||||
@@ -839,9 +947,9 @@ export function AdminPanel({ initialTenants }: AdminPanelProps) {
|
||||
{actionLoading === deleteModal ? "…" : t("confirmDelete")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useMemo, useCallback } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useRouter } from "@/i18n/navigation";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Card, CardHeader } from "@/components/ui/card";
|
||||
import type {
|
||||
@@ -336,6 +336,7 @@ export function CustomInvoiceEditor({ draft, orgBilling }: Props) {
|
||||
<Card>
|
||||
<CardHeader>{t("editorLinesHeading")}</CardHeader>
|
||||
<div className="p-4">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="text-xs text-text-muted text-left">
|
||||
<tr>
|
||||
@@ -420,6 +421,7 @@ export function CustomInvoiceEditor({ draft, orgBilling }: Props) {
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div className="flex gap-2 mt-3">
|
||||
<button
|
||||
onClick={addLine}
|
||||
@@ -525,7 +527,7 @@ export function CustomInvoiceEditor({ draft, orgBilling }: Props) {
|
||||
<button
|
||||
onClick={issue}
|
||||
disabled={busy !== null || !canIssue}
|
||||
className="px-4 py-2 rounded-md bg-accent text-white text-sm disabled:opacity-50"
|
||||
className="px-4 py-2 rounded-md bg-accent text-surface-0 text-sm disabled:opacity-50"
|
||||
type="button"
|
||||
>
|
||||
{busy === "issue" ? t("issuing") : t("editorIssueBtn")}
|
||||
|
||||
@@ -57,7 +57,7 @@ export function DraftList({ drafts, orgNameMap }: Props) {
|
||||
<p className="text-text-secondary mb-4">{t("draftsEmpty")}</p>
|
||||
<Link
|
||||
href="/admin/billing/invoices/new"
|
||||
className="inline-block px-4 py-2 rounded-md bg-accent text-white text-sm"
|
||||
className="inline-block px-4 py-2 rounded-md bg-accent text-surface-0 text-sm"
|
||||
>
|
||||
{t("newInvoiceBtn")}
|
||||
</Link>
|
||||
@@ -71,11 +71,12 @@ export function DraftList({ drafts, orgNameMap }: Props) {
|
||||
<div className="flex justify-end p-3 border-b border-border">
|
||||
<Link
|
||||
href="/admin/billing/invoices/new"
|
||||
className="inline-block px-3 py-1.5 rounded-md bg-accent text-white text-sm"
|
||||
className="inline-block px-3 py-1.5 rounded-md bg-accent text-surface-0 text-sm"
|
||||
>
|
||||
{t("newInvoiceBtn")}
|
||||
</Link>
|
||||
</div>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="text-xs text-text-muted text-left">
|
||||
<tr>
|
||||
@@ -140,6 +141,7 @@ export function DraftList({ drafts, orgNameMap }: Props) {
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -216,7 +216,7 @@ export function GenerateForm({ orgs }: Props) {
|
||||
<button
|
||||
onClick={commit}
|
||||
disabled={busy}
|
||||
className="px-4 py-2 rounded-md bg-accent text-white text-sm disabled:opacity-50"
|
||||
className="px-4 py-2 rounded-md bg-accent text-surface-0 text-sm disabled:opacity-50"
|
||||
>
|
||||
{busy ? t("saving") : t("commitBtn")}
|
||||
</button>
|
||||
@@ -265,6 +265,7 @@ function DraftPreview({ draft }: { draft: InvoiceDraft }) {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="text-xs text-text-muted text-left">
|
||||
<tr>
|
||||
@@ -323,6 +324,7 @@ function DraftPreview({ draft }: { draft: InvoiceDraft }) {
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 pt-3 border-t border-border space-y-1 text-sm">
|
||||
<div className="flex justify-between">
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useState, Fragment } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useRouter } from "@/i18n/navigation";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Card, CardHeader } from "@/components/ui/card";
|
||||
import type { CreditNote, InvoiceDetail, InvoiceStatus } from "@/types";
|
||||
@@ -247,7 +247,7 @@ export function InvoiceDetailView({ detail, creditNotes = [] }: Props) {
|
||||
<button
|
||||
onClick={() => setNoteOpen(true)}
|
||||
disabled={busyAction !== null}
|
||||
className="px-4 py-2 rounded-md bg-accent text-white text-sm disabled:opacity-50"
|
||||
className="px-4 py-2 rounded-md bg-accent text-surface-0 text-sm disabled:opacity-50"
|
||||
>
|
||||
{t("markPaidBtn")}
|
||||
</button>
|
||||
@@ -264,7 +264,7 @@ export function InvoiceDetailView({ detail, creditNotes = [] }: Props) {
|
||||
<button
|
||||
onClick={markPaid}
|
||||
disabled={busyAction !== null}
|
||||
className="px-3 py-1.5 rounded-md bg-accent text-white text-sm disabled:opacity-50"
|
||||
className="px-3 py-1.5 rounded-md bg-accent text-surface-0 text-sm disabled:opacity-50"
|
||||
>
|
||||
{busyAction === "mark-paid" ? t("saving") : t("confirm")}
|
||||
</button>
|
||||
@@ -463,6 +463,7 @@ export function InvoiceDetailView({ detail, creditNotes = [] }: Props) {
|
||||
{creditNotes.length > 0 && (
|
||||
<Card>
|
||||
<CardHeader>{t("creditNotesPanelTitle")}</CardHeader>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="text-xs text-text-muted text-left">
|
||||
<tr>
|
||||
@@ -518,12 +519,14 @@ export function InvoiceDetailView({ detail, creditNotes = [] }: Props) {
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Lines */}
|
||||
<Card>
|
||||
<CardHeader>{t("lineItemsTitle")}</CardHeader>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="text-xs text-text-muted text-left">
|
||||
<tr>
|
||||
@@ -572,6 +575,7 @@ export function InvoiceDetailView({ detail, creditNotes = [] }: Props) {
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div className="mt-4 pt-3 border-t border-border space-y-1 text-sm">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-text-muted">{t("subtotal")}</span>
|
||||
|
||||
@@ -112,7 +112,7 @@ export function InvoicesTable({ initialInvoices }: Props) {
|
||||
</Link>
|
||||
<Link
|
||||
href="/admin/billing/invoices/new"
|
||||
className="px-3 py-1.5 rounded-md bg-accent text-white text-sm"
|
||||
className="px-3 py-1.5 rounded-md bg-accent text-surface-0 text-sm"
|
||||
>
|
||||
+ {t("newInvoiceBtn")}
|
||||
</Link>
|
||||
@@ -126,6 +126,7 @@ export function InvoicesTable({ initialInvoices }: Props) {
|
||||
{t("noInvoicesFound")}
|
||||
</p>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="text-xs text-text-muted text-left">
|
||||
<tr>
|
||||
@@ -178,6 +179,7 @@ export function InvoicesTable({ initialInvoices }: Props) {
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
@@ -155,7 +155,7 @@ export function NewInvoiceForm({ orgs }: Props) {
|
||||
<button
|
||||
onClick={onSubmit}
|
||||
disabled={busy || !orgId || !selected?.hasBillingAddress}
|
||||
className="px-4 py-2 rounded-md bg-accent text-white text-sm disabled:opacity-50"
|
||||
className="px-4 py-2 rounded-md bg-accent text-surface-0 text-sm disabled:opacity-50"
|
||||
>
|
||||
{busy ? t("creating") : t("newInvoiceContinueBtn")}
|
||||
</button>
|
||||
|
||||
@@ -76,6 +76,7 @@ export function OrgPaymentModeList({ orgs }: Props) {
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="text-xs text-text-muted text-left">
|
||||
<tr>
|
||||
@@ -153,6 +154,7 @@ export function OrgPaymentModeList({ orgs }: Props) {
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -236,7 +236,7 @@ export function PricingEditor({
|
||||
<button
|
||||
type="submit"
|
||||
disabled={savingPricing}
|
||||
className="px-4 py-2 rounded-md bg-accent text-white text-sm disabled:opacity-50"
|
||||
className="px-4 py-2 rounded-md bg-accent text-surface-0 text-sm disabled:opacity-50"
|
||||
>
|
||||
{savingPricing ? t("saving") : t("save")}
|
||||
</button>
|
||||
@@ -255,6 +255,7 @@ export function PricingEditor({
|
||||
<p className="text-sm text-text-muted mb-4">{t("skillPricingDesc")}</p>
|
||||
|
||||
{initialSkillPricing.length > 0 ? (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm mb-6">
|
||||
<thead className="text-xs text-text-muted text-left">
|
||||
<tr>
|
||||
@@ -319,6 +320,7 @@ export function PricingEditor({
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-text-muted italic mb-4">{t("noSkillsPriced")}</p>
|
||||
)}
|
||||
@@ -401,7 +403,7 @@ export function PricingEditor({
|
||||
<button
|
||||
type="submit"
|
||||
disabled={addingSkill || !newSkillId}
|
||||
className="px-4 py-2 rounded-md bg-accent text-white text-sm disabled:opacity-50"
|
||||
className="px-4 py-2 rounded-md bg-accent text-surface-0 text-sm disabled:opacity-50"
|
||||
>
|
||||
{addingSkill ? t("saving") : t("add")}
|
||||
</button>
|
||||
@@ -473,7 +475,7 @@ function InlinePriceEditor({
|
||||
}
|
||||
}}
|
||||
disabled={busy}
|
||||
className="text-xs px-2 py-1 bg-accent text-white rounded"
|
||||
className="text-xs px-2 py-1 bg-accent text-surface-0 rounded"
|
||||
>
|
||||
{busy ? "…" : "✓"}
|
||||
</button>
|
||||
|
||||
@@ -147,7 +147,7 @@ export function CronControls({ initialRecent, initialLastSuccess }: Props) {
|
||||
<button
|
||||
onClick={triggerIssue}
|
||||
disabled={busy !== null}
|
||||
className="px-4 py-2 rounded-md bg-accent text-white text-sm font-medium hover:bg-accent-dim transition-colors disabled:opacity-50 cursor-pointer"
|
||||
className="px-4 py-2 rounded-md bg-accent text-surface-0 text-sm font-medium hover:bg-accent-dim transition-colors disabled:opacity-50 cursor-pointer"
|
||||
>
|
||||
{busy === "issue" ? t("running") : t("runIssueNow")}
|
||||
</button>
|
||||
@@ -165,7 +165,7 @@ export function CronControls({ initialRecent, initialLastSuccess }: Props) {
|
||||
<button
|
||||
onClick={triggerReminders}
|
||||
disabled={busy !== null}
|
||||
className="px-4 py-2 rounded-md bg-accent text-white text-sm font-medium hover:bg-accent-dim transition-colors disabled:opacity-50 cursor-pointer"
|
||||
className="px-4 py-2 rounded-md bg-accent text-surface-0 text-sm font-medium hover:bg-accent-dim transition-colors disabled:opacity-50 cursor-pointer"
|
||||
>
|
||||
{busy === "reminders" ? t("running") : t("runRemindersNow")}
|
||||
</button>
|
||||
@@ -194,6 +194,7 @@ export function CronControls({ initialRecent, initialLastSuccess }: Props) {
|
||||
{t("noRunsYet")}
|
||||
</p>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="text-xs text-text-muted text-left">
|
||||
<tr>
|
||||
@@ -241,6 +242,7 @@ export function CronControls({ initialRecent, initialLastSuccess }: Props) {
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
</section>
|
||||
|
||||
@@ -107,7 +107,7 @@ export function OpenClawAdminPanel({ initialDefaults, tenants }: Props) {
|
||||
<button
|
||||
type="submit"
|
||||
disabled={savingDefault}
|
||||
className="text-sm font-medium px-4 py-2 rounded-lg bg-accent text-white hover:bg-accent/90 transition-colors disabled:opacity-50"
|
||||
className="text-sm font-medium px-4 py-2 rounded-lg bg-accent text-surface-0 hover:bg-accent/90 transition-colors disabled:opacity-50"
|
||||
>
|
||||
{savingDefault ? tCommon("loading") : t("saveDefault")}
|
||||
</button>
|
||||
@@ -265,7 +265,7 @@ function TenantOverrideRow({
|
||||
type="button"
|
||||
onClick={() => submit(false)}
|
||||
disabled={saving || !tag.trim()}
|
||||
className="text-xs px-3 py-1.5 rounded-lg bg-accent text-white hover:bg-accent/90 transition-colors disabled:opacity-50"
|
||||
className="text-xs px-3 py-1.5 rounded-lg bg-accent text-surface-0 hover:bg-accent/90 transition-colors disabled:opacity-50"
|
||||
>
|
||||
{saving ? tCommon("loading") : t("saveOverride")}
|
||||
</button>
|
||||
|
||||
@@ -99,6 +99,7 @@ export function PendingSkillRequests({ initialRows }: Props) {
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="text-xs text-text-muted text-left">
|
||||
<tr>
|
||||
@@ -146,7 +147,7 @@ export function PendingSkillRequests({ initialRows }: Props) {
|
||||
<button
|
||||
onClick={() => approve(row.id)}
|
||||
disabled={busyId !== null}
|
||||
className="text-xs px-3 py-1.5 rounded-md bg-accent text-white disabled:opacity-50"
|
||||
className="text-xs px-3 py-1.5 rounded-md bg-accent text-surface-0 disabled:opacity-50"
|
||||
>
|
||||
{busyId === row.id ? t("working") : t("approveBtn")}
|
||||
</button>
|
||||
@@ -199,6 +200,7 @@ export function PendingSkillRequests({ initialRows }: Props) {
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -36,6 +36,7 @@ export function CustomerCreditNoteList({ creditNotes }: Props) {
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="text-xs text-text-muted text-left">
|
||||
<tr>
|
||||
@@ -96,6 +97,7 @@ export function CustomerCreditNoteList({ creditNotes }: Props) {
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -107,6 +107,7 @@ export function CustomerInvoiceDetail({ invoice, lines }: Props) {
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="text-xs text-text-muted text-left">
|
||||
<tr>
|
||||
@@ -160,6 +161,7 @@ export function CustomerInvoiceDetail({ invoice, lines }: Props) {
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -46,6 +46,7 @@ export function CustomerInvoiceList({ invoices }: Props) {
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="text-xs text-text-muted text-left">
|
||||
<tr>
|
||||
@@ -104,6 +105,7 @@ export function CustomerInvoiceList({ invoices }: Props) {
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -50,7 +50,7 @@ export function PayInvoiceButton({ invoiceNumber }: Props) {
|
||||
<button
|
||||
onClick={onClick}
|
||||
disabled={busy}
|
||||
className="px-4 py-2 rounded-md bg-accent text-white text-sm font-medium hover:bg-accent-dim transition-colors disabled:opacity-50 cursor-pointer"
|
||||
className="px-4 py-2 rounded-md bg-accent text-surface-0 text-sm font-medium hover:bg-accent-dim transition-colors disabled:opacity-50 cursor-pointer"
|
||||
>
|
||||
{busy ? t("redirectingToStripe") : t("payWithCard")}
|
||||
</button>
|
||||
|
||||
@@ -86,7 +86,7 @@ export function RunningTotalWidget({ isOwner }: Props) {
|
||||
{noConfig && isOwner && (
|
||||
<Link
|
||||
href="/settings/billing"
|
||||
className="inline-block mt-2 px-4 py-2 rounded-md bg-accent text-white text-sm font-medium hover:bg-accent-dim transition-colors"
|
||||
className="inline-block mt-2 px-4 py-2 rounded-md bg-accent text-surface-0 text-sm font-medium hover:bg-accent-dim transition-colors"
|
||||
>
|
||||
{t("configureBillingCta")}
|
||||
</Link>
|
||||
@@ -160,6 +160,7 @@ export function RunningTotalWidget({ isOwner }: Props) {
|
||||
<summary className="cursor-pointer text-text-muted hover:text-text-secondary">
|
||||
{t("breakdownToggle", { count: draft.lines.length })}
|
||||
</summary>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full mt-2 text-xs">
|
||||
<tbody>
|
||||
{draft.lines.map((ln, i) => (
|
||||
@@ -188,6 +189,7 @@ export function RunningTotalWidget({ isOwner }: Props) {
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</details>
|
||||
)}
|
||||
<p className="text-[10px] text-text-muted mt-3 italic">{t("draftNote")}</p>
|
||||
|
||||
@@ -328,7 +328,7 @@ export function ChannelUsers({
|
||||
<button
|
||||
onClick={() => handleAdd(channel)}
|
||||
disabled={saving || !inputValues[channel]?.trim()}
|
||||
className="px-4 py-2 text-sm font-medium bg-accent text-white rounded-lg hover:bg-accent-dim transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
className="px-4 py-2 text-sm font-medium bg-accent text-surface-0 rounded-lg hover:bg-accent-dim transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{saving ? "…" : t("add")}
|
||||
</button>
|
||||
|
||||
@@ -263,7 +263,7 @@ export function BudgetEditableCard({
|
||||
<button
|
||||
type="submit"
|
||||
disabled={saving}
|
||||
className="text-sm px-4 py-2 rounded-lg bg-accent text-white hover:bg-accent/90 transition-colors disabled:opacity-50"
|
||||
className="text-sm px-4 py-2 rounded-lg bg-accent text-surface-0 hover:bg-accent/90 transition-colors disabled:opacity-50"
|
||||
>
|
||||
{saving ? tCommon("loading") : tCommon("save")}
|
||||
</button>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useTranslations, useLocale } from "next-intl";
|
||||
import { useEffect, useState, useCallback } from "react";
|
||||
import { BudgetEditableCard } from "@/components/dashboard/budget-editable-card";
|
||||
|
||||
@@ -84,42 +84,149 @@ function formatMonth(month: string, locale: string): string {
|
||||
}
|
||||
|
||||
function UsageChart({ data }: { data: DailyUsage[] }) {
|
||||
const t = useTranslations("usage");
|
||||
const locale = useLocale();
|
||||
// Which day's detail is shown in the readout. Defaults to the most
|
||||
// recent day; hover (mouse), tap (touch) or focus (keyboard) all
|
||||
// update it. The previous version put per-day numbers only in SVG
|
||||
// <title> hover tooltips, which are unreachable on touch devices and
|
||||
// invisible to keyboard users — this readout fixes both.
|
||||
const [selected, setSelected] = useState<number | null>(null);
|
||||
|
||||
if (!data.length) return null;
|
||||
const maxTokens = Math.max(...data.map((d) => d.inputTokens + d.outputTokens), 1);
|
||||
|
||||
const maxTokens = Math.max(
|
||||
...data.map((d) => d.inputTokens + d.outputTokens),
|
||||
1
|
||||
);
|
||||
const barW = Math.max(4, Math.floor(600 / data.length) - 2);
|
||||
const h = 120;
|
||||
|
||||
const activeIndex = selected ?? data.length - 1;
|
||||
const active = data[activeIndex];
|
||||
|
||||
const dayLabel = (iso: string) => {
|
||||
const [y, m, dd] = iso.split("-").map(Number);
|
||||
return new Date(y, m - 1, dd).toLocaleDateString(locale, {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
});
|
||||
};
|
||||
|
||||
const barAria = (d: DailyUsage) =>
|
||||
`${dayLabel(d.date)}: ${fmt(d.inputTokens)} ${t("inputTokens")}, ${fmt(
|
||||
d.outputTokens
|
||||
)} ${t("outputTokens")}, ${chf(d.spend)}`;
|
||||
|
||||
return (
|
||||
<div className="overflow-x-auto">
|
||||
<svg
|
||||
viewBox={`0 0 ${Math.max(data.length * (barW + 2), 600)} ${h + 24}`}
|
||||
className="w-full h-36"
|
||||
preserveAspectRatio="xMinYMid meet"
|
||||
>
|
||||
{data.map((d, i) => {
|
||||
const total = d.inputTokens + d.outputTokens;
|
||||
const totalH = (total / maxTokens) * h;
|
||||
const inputH = (d.inputTokens / maxTokens) * h;
|
||||
const x = i * (barW + 2);
|
||||
return (
|
||||
<g key={d.date}>
|
||||
<title>{d.date}: {fmt(d.inputTokens)} in / {fmt(d.outputTokens)} out — {chf(d.spend)}</title>
|
||||
<rect x={x} y={h - totalH} width={barW} height={totalH - inputH} rx={1} fill="var(--color-accent)" opacity={0.3} />
|
||||
<rect x={x} y={h - inputH} width={barW} height={inputH} rx={1} fill="var(--color-accent)" opacity={0.7} />
|
||||
{i % 7 === 0 && (
|
||||
<text x={x + barW / 2} y={h + 14} textAnchor="middle" fill="var(--color-text-muted)" fontSize="8">{d.date.slice(8)}</text>
|
||||
)}
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
</svg>
|
||||
<div>
|
||||
{/* Readout — the touch/keyboard-accessible equivalent of the old
|
||||
hover-only tooltip. Always reflects the active day. */}
|
||||
<div className="flex flex-wrap items-baseline gap-x-3 gap-y-1 mb-2 text-xs">
|
||||
<span className="font-medium text-text-primary">
|
||||
{dayLabel(active.date)}
|
||||
</span>
|
||||
<span className="text-text-secondary tabular-nums">
|
||||
{fmt(active.inputTokens)} {t("inputTokens")}
|
||||
</span>
|
||||
<span className="text-text-secondary tabular-nums">
|
||||
{fmt(active.outputTokens)} {t("outputTokens")}
|
||||
</span>
|
||||
<span className="text-accent tabular-nums">{chf(active.spend)}</span>
|
||||
</div>
|
||||
|
||||
<div className="overflow-x-auto">
|
||||
<svg
|
||||
viewBox={`0 0 ${Math.max(data.length * (barW + 2), 600)} ${h + 24}`}
|
||||
className="w-full h-36"
|
||||
preserveAspectRatio="xMinYMid meet"
|
||||
role="group"
|
||||
aria-label={t("dailyBreakdown")}
|
||||
>
|
||||
{data.map((d, i) => {
|
||||
const total = d.inputTokens + d.outputTokens;
|
||||
const totalH = (total / maxTokens) * h;
|
||||
const inputH = (d.inputTokens / maxTokens) * h;
|
||||
const x = i * (barW + 2);
|
||||
const isActive = i === activeIndex;
|
||||
return (
|
||||
<g
|
||||
key={d.date}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-label={barAria(d)}
|
||||
aria-pressed={isActive}
|
||||
className="cursor-pointer focus:outline-none"
|
||||
onClick={() => setSelected(i)}
|
||||
onMouseEnter={() => setSelected(i)}
|
||||
onFocus={() => setSelected(i)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
setSelected(i);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<title>{barAria(d)}</title>
|
||||
{/* Full-height transparent hit area so thin bars stay
|
||||
easy to tap on touch screens. */}
|
||||
<rect x={x} y={0} width={barW} height={h} fill="transparent" />
|
||||
<rect
|
||||
x={x}
|
||||
y={h - totalH}
|
||||
width={barW}
|
||||
height={Math.max(0, totalH - inputH)}
|
||||
rx={1}
|
||||
fill="var(--color-accent)"
|
||||
opacity={isActive ? 0.5 : 0.3}
|
||||
/>
|
||||
<rect
|
||||
x={x}
|
||||
y={h - inputH}
|
||||
width={barW}
|
||||
height={inputH}
|
||||
rx={1}
|
||||
fill="var(--color-accent)"
|
||||
opacity={isActive ? 1 : 0.7}
|
||||
/>
|
||||
{isActive && (
|
||||
<rect
|
||||
x={x - 1}
|
||||
y={Math.max(0, h - totalH) - 1}
|
||||
width={barW + 2}
|
||||
height={Math.max(2, totalH) + 1}
|
||||
rx={1.5}
|
||||
fill="none"
|
||||
stroke="var(--color-accent)"
|
||||
strokeWidth={1}
|
||||
/>
|
||||
)}
|
||||
{i % 7 === 0 && (
|
||||
<text
|
||||
x={x + barW / 2}
|
||||
y={h + 14}
|
||||
textAnchor="middle"
|
||||
fill="var(--color-text-muted)"
|
||||
fontSize="8"
|
||||
>
|
||||
{d.date.slice(8)}
|
||||
</text>
|
||||
)}
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
</svg>
|
||||
</div>
|
||||
<div className="flex items-center gap-4 text-xs text-text-muted mt-1">
|
||||
<span className="flex items-center gap-1">
|
||||
<span className="inline-block h-2 w-2 rounded-sm bg-accent opacity-70" /> Input
|
||||
<span className="inline-block h-2 w-2 rounded-sm bg-accent opacity-70" />{" "}
|
||||
{t("legendInput")}
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<span className="inline-block h-2 w-2 rounded-sm bg-accent opacity-30" /> Output
|
||||
<span className="inline-block h-2 w-2 rounded-sm bg-accent opacity-30" />{" "}
|
||||
{t("legendOutput")}
|
||||
</span>
|
||||
<span className="ml-auto text-text-muted/70">{t("chartHint")}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -161,6 +268,7 @@ export function UsageDisplay({
|
||||
canEditBudget?: boolean;
|
||||
}) {
|
||||
const t = useTranslations("usage");
|
||||
const locale = useLocale();
|
||||
const [month, setMonth] = useState(getCurrentMonth);
|
||||
const [data, setData] = useState<UsageData | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
@@ -202,7 +310,7 @@ export function UsageDisplay({
|
||||
←
|
||||
</button>
|
||||
<span className="font-display text-sm font-medium text-text-primary">
|
||||
{formatMonth(month, "en")}
|
||||
{formatMonth(month, locale)}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => setMonth((m) => shiftMonth(m, 1))}
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { signOut, useSession } from "next-auth/react";
|
||||
import { usePathname } from "@/i18n/navigation";
|
||||
import { Link } from "@/i18n/navigation";
|
||||
import { SessionProvider } from "next-auth/react";
|
||||
import type { Session } from "next-auth";
|
||||
import { LanguageSwitcher } from "@/components/ui/language-switcher";
|
||||
|
||||
function NavBar() {
|
||||
@@ -13,6 +15,15 @@ function NavBar() {
|
||||
const pathname = usePathname();
|
||||
const user = (session as any)?.platformUser;
|
||||
|
||||
const [mobileOpen, setMobileOpen] = useState(false);
|
||||
|
||||
// Close the mobile menu on any navigation. Without this the panel
|
||||
// would stay open across route changes (the component doesn't
|
||||
// unmount — it lives in the layout).
|
||||
useEffect(() => {
|
||||
setMobileOpen(false);
|
||||
}, [pathname]);
|
||||
|
||||
// Hide the nav entirely on auth-only routes. These pages have no
|
||||
// session yet — showing "Dashboard" / "Sign Out" is misleading at
|
||||
// best (the buttons would 401 or redirect-loop). Keep this list
|
||||
@@ -21,6 +32,47 @@ function NavBar() {
|
||||
const isAuthRoute = pathname === "/login" || pathname === "/register";
|
||||
if (isAuthRoute) return null;
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Visibility gates — computed once, shared by the desktop nav and the
|
||||
// mobile panel so the two can never diverge.
|
||||
//
|
||||
// - team: owner+platform only AND not a personal account (Bug 8 —
|
||||
// personal accounts have no team). Matches `canMutate` /
|
||||
// `user.isPersonal === false` server-side.
|
||||
// - settings: anyone who can mutate org-level state (owners + platform).
|
||||
// `user`-role customers don't see it (canMutate is false).
|
||||
// - billing / support: any signed-in user (org-scoped server-side).
|
||||
// - admin: platform only.
|
||||
// ------------------------------------------------------------------
|
||||
const isOwner =
|
||||
user && Array.isArray(user.roles) && user.roles.includes("owner");
|
||||
const showTeam = !!user && !user.isPersonal && (user.isPlatform || isOwner);
|
||||
const showSettings = !!user && (user.isPlatform || isOwner);
|
||||
const showBilling = !!user;
|
||||
const showSupport = !!user;
|
||||
const showAdmin = !!user?.isPlatform;
|
||||
|
||||
// Active-state helper. Dashboard/Admin previously used exact `===`,
|
||||
// so sub-routes (/dashboard/new, /admin/billing, …) showed no active
|
||||
// item. startsWith keeps the parent lit on its children too.
|
||||
const isActive = (href: string) =>
|
||||
pathname === href || pathname.startsWith(`${href}/`);
|
||||
|
||||
const links = [
|
||||
{ href: "/dashboard", label: t("dashboard"), show: !!user },
|
||||
{ href: "/team", label: t("team"), show: showTeam },
|
||||
{ href: "/settings", label: t("settings"), show: showSettings },
|
||||
{ href: "/billing", label: t("billing"), show: showBilling },
|
||||
{ href: "/support", label: t("support"), show: showSupport },
|
||||
{ href: "/admin", label: t("admin"), show: showAdmin },
|
||||
].filter((l) => l.show);
|
||||
|
||||
const displayName = user
|
||||
? user.isPersonal
|
||||
? user.name || (user.email ? user.email.split("@")[0] : user.orgName)
|
||||
: user.orgName
|
||||
: "";
|
||||
|
||||
return (
|
||||
<header className="sticky top-0 z-50 border-b border-border bg-surface-1/80 backdrop-blur-md">
|
||||
<div className="mx-auto flex h-14 max-w-6xl items-center justify-between px-5">
|
||||
@@ -40,98 +92,96 @@ function NavBar() {
|
||||
</span>
|
||||
</Link>
|
||||
|
||||
{/* Nav links */}
|
||||
{/* Desktop nav links */}
|
||||
<nav className="hidden sm:flex items-center gap-1 ml-2">
|
||||
<NavLink href="/dashboard" active={pathname === "/dashboard"}>
|
||||
{t("dashboard")}
|
||||
</NavLink>
|
||||
{/* Slice 7: /team is owner+platform only AND personal
|
||||
accounts are excluded — they have no team to manage
|
||||
(Bug 8). Match server-side gates (`canMutate`,
|
||||
`user.isPersonal === false`). The roles array carries
|
||||
either "owner" or "user" for customer sessions;
|
||||
isPlatform covers the platform side. */}
|
||||
{user &&
|
||||
!user.isPersonal &&
|
||||
(user.isPlatform ||
|
||||
(Array.isArray(user.roles) && user.roles.includes("owner"))) && (
|
||||
<NavLink href="/team" active={pathname === "/team"}>
|
||||
{t("team")}
|
||||
</NavLink>
|
||||
)}
|
||||
{/* Bug 35: /settings is shown to anyone who can mutate org-level
|
||||
state — owners and platform admins. Personal accounts also
|
||||
see it; their billing page is optional but the entry point
|
||||
exists for consistency. `user`-role customers don't see it
|
||||
(canMutate is false). */}
|
||||
{user &&
|
||||
(user.isPlatform ||
|
||||
(Array.isArray(user.roles) && user.roles.includes("owner"))) && (
|
||||
<NavLink
|
||||
href="/settings"
|
||||
active={pathname.startsWith("/settings")}
|
||||
>
|
||||
{t("settings")}
|
||||
</NavLink>
|
||||
)}
|
||||
{/* Phase 3: Billing visible to anyone signed in. The
|
||||
page is org-scoped server-side — non-owner members
|
||||
see the same invoice history their owner does, but
|
||||
actions like "configure billing details" are gated
|
||||
separately on the settings page. Personal accounts
|
||||
see their own (single-tenant) invoices. */}
|
||||
{user && (
|
||||
<NavLink
|
||||
href="/billing"
|
||||
active={pathname.startsWith("/billing")}
|
||||
>
|
||||
{t("billing")}
|
||||
{links.map((l) => (
|
||||
<NavLink key={l.href} href={l.href} active={isActive(l.href)}>
|
||||
{l.label}
|
||||
</NavLink>
|
||||
)}
|
||||
{/* Feature 5: Support is available to every signed-in
|
||||
user. Customers see their own tickets only; platform
|
||||
admins see the queue. */}
|
||||
{user && (
|
||||
<NavLink
|
||||
href="/support"
|
||||
active={pathname.startsWith("/support")}
|
||||
>
|
||||
{t("support")}
|
||||
</NavLink>
|
||||
)}
|
||||
{user?.isPlatform && (
|
||||
<NavLink href="/admin" active={pathname === "/admin"}>
|
||||
{t("admin")}
|
||||
</NavLink>
|
||||
)}
|
||||
))}
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
{/* Right side */}
|
||||
<div className="flex items-center gap-4">
|
||||
{user && (
|
||||
// For personal accounts the orgName is opaque
|
||||
// ("personal-3f2a8b1c") or a synthetic legacy
|
||||
// "Name (Personal)" — neither is what we want in the nav.
|
||||
// Show the user's display name instead. The detection logic
|
||||
// and fallback chain live in `lib/personal-org.ts`; keeping
|
||||
// a thin inline branch here avoids importing a server-only
|
||||
// helper into a client component.
|
||||
<span className="hidden md:inline text-xs text-text-secondary font-mono">
|
||||
{user.isPersonal
|
||||
? user.name || (user.email ? user.email.split("@")[0] : user.orgName)
|
||||
: user.orgName}
|
||||
{displayName}
|
||||
</span>
|
||||
)}
|
||||
<LanguageSwitcher />
|
||||
<button
|
||||
onClick={() => signOut({ callbackUrl: "/login" })}
|
||||
className="text-xs font-medium text-text-secondary hover:text-error transition-colors cursor-pointer"
|
||||
className="hidden sm:inline text-xs font-medium text-text-secondary hover:text-error transition-colors cursor-pointer"
|
||||
>
|
||||
{t("logout")}
|
||||
</button>
|
||||
|
||||
{/* Mobile menu toggle — only shown below the `sm` breakpoint,
|
||||
where the desktop nav and logout button are hidden. */}
|
||||
{user && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setMobileOpen((v) => !v)}
|
||||
aria-expanded={mobileOpen}
|
||||
aria-controls="mobile-nav"
|
||||
aria-label={t("menu")}
|
||||
className="sm:hidden inline-flex items-center justify-center h-8 w-8 -mr-1 rounded-md text-text-secondary hover:text-text-primary hover:bg-surface-2 transition-colors cursor-pointer"
|
||||
>
|
||||
<svg
|
||||
className="h-5 w-5"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.75"
|
||||
strokeLinecap="round"
|
||||
>
|
||||
{mobileOpen ? (
|
||||
<path d="M6 6l12 12M18 6L6 18" />
|
||||
) : (
|
||||
<path d="M4 7h16M4 12h16M4 17h16" />
|
||||
)}
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Mobile panel */}
|
||||
{user && mobileOpen && (
|
||||
<nav
|
||||
id="mobile-nav"
|
||||
className="sm:hidden border-t border-border bg-surface-1 px-3 py-3"
|
||||
>
|
||||
<div className="flex flex-col gap-1">
|
||||
{links.map((l) => (
|
||||
<Link
|
||||
key={l.href}
|
||||
href={l.href}
|
||||
className={`px-3 py-2.5 rounded-md text-sm font-medium transition-colors ${
|
||||
isActive(l.href)
|
||||
? "bg-surface-3 text-text-primary"
|
||||
: "text-text-secondary hover:text-text-primary hover:bg-surface-2"
|
||||
}`}
|
||||
>
|
||||
{l.label}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="mt-3 pt-3 border-t border-border flex items-center justify-between px-3">
|
||||
<span className="text-xs text-text-secondary font-mono truncate">
|
||||
{displayName}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => signOut({ callbackUrl: "/login" })}
|
||||
className="text-xs font-medium text-text-secondary hover:text-error transition-colors cursor-pointer shrink-0 ml-3"
|
||||
>
|
||||
{t("logout")}
|
||||
</button>
|
||||
</div>
|
||||
</nav>
|
||||
)}
|
||||
</header>
|
||||
);
|
||||
}
|
||||
@@ -162,9 +212,19 @@ function NavLink({
|
||||
);
|
||||
}
|
||||
|
||||
export function NavShell({ children }: { children: React.ReactNode }) {
|
||||
export function NavShell({
|
||||
children,
|
||||
session,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
// Server-resolved session passed down from the locale layout. Seeding
|
||||
// SessionProvider with it means useSession() is populated on the first
|
||||
// client render, so the nav links render immediately instead of
|
||||
// popping in after the client-side session fetch (CLS / flash).
|
||||
session: Session | null;
|
||||
}) {
|
||||
return (
|
||||
<SessionProvider>
|
||||
<SessionProvider session={session}>
|
||||
<NavBar />
|
||||
<main className="mx-auto max-w-6xl px-5 py-8">{children}</main>
|
||||
</SessionProvider>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useRouter } from "@/i18n/navigation";
|
||||
import { OnboardingWizard } from "./wizard";
|
||||
import type { OrgBilling } from "@/types";
|
||||
|
||||
@@ -31,6 +31,12 @@ interface OnboardingFlowProps {
|
||||
* step. Forwarded straight to the wizard.
|
||||
*/
|
||||
setupFeeChf?: number | null;
|
||||
/**
|
||||
* Recurring per-tenant monthly fee (net CHF). Forwarded to the
|
||||
* wizard's review-step cost summary so the customer sees the ongoing
|
||||
* commitment, not just the one-time setup fee.
|
||||
*/
|
||||
monthlyFeeChf?: number | null;
|
||||
/**
|
||||
* Bug 6: when present, the wizard is rendered in edit mode against
|
||||
* the given pending request. See `OnboardingWizard` for the full
|
||||
@@ -59,6 +65,7 @@ export function OnboardingFlow({
|
||||
hasOrgBilling,
|
||||
existingOrgBilling,
|
||||
setupFeeChf,
|
||||
monthlyFeeChf,
|
||||
editingRequest,
|
||||
}: OnboardingFlowProps) {
|
||||
const router = useRouter();
|
||||
@@ -71,6 +78,7 @@ export function OnboardingFlow({
|
||||
hasOrgBilling={hasOrgBilling}
|
||||
existingOrgBilling={existingOrgBilling}
|
||||
setupFeeChf={setupFeeChf}
|
||||
monthlyFeeChf={monthlyFeeChf}
|
||||
editingRequest={editingRequest}
|
||||
onComplete={() => {
|
||||
// Navigate back to /dashboard and re-fetch on the server. The
|
||||
|
||||
@@ -432,25 +432,35 @@ export function ProvisioningStatus({ requestId, canAct }: Props) {
|
||||
<span className="text-xs text-text-muted">{t("phase")}</span>
|
||||
<StatusBadge phase={phase} />
|
||||
</div>
|
||||
{conditions.map((c, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="flex items-center justify-between bg-surface-2 border border-border rounded-lg px-4 py-2"
|
||||
>
|
||||
<span className="text-xs text-text-muted">{c.type}</span>
|
||||
<span
|
||||
className={`text-xs font-mono ${
|
||||
c.status === "True"
|
||||
? "text-emerald-400"
|
||||
: c.status === "False"
|
||||
? "text-red-400"
|
||||
: "text-text-muted"
|
||||
}`}
|
||||
>
|
||||
{c.reason || c.status}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
{/* Setup progress. The operator reports readiness as a list of
|
||||
internal K8s conditions (OpenBao policy, LiteLLM key, network
|
||||
policy, …) — meaningful to operators, jargon to customers.
|
||||
We surface the *shape* of that progress (how many steps are
|
||||
done) without leaking the internal names. */}
|
||||
{conditions.length > 0 &&
|
||||
(() => {
|
||||
const done = conditions.filter((c) => c.status === "True").length;
|
||||
const total = conditions.length;
|
||||
const pct = Math.round((done / total) * 100);
|
||||
return (
|
||||
<div className="bg-surface-2 border border-border rounded-lg px-4 py-3">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-xs text-text-muted">
|
||||
{t("setupProgress")}
|
||||
</span>
|
||||
<span className="text-xs font-medium text-text-secondary tabular-nums">
|
||||
{t("setupStepsComplete", { done, total })}
|
||||
</span>
|
||||
</div>
|
||||
<div className="h-1.5 w-full rounded-full bg-surface-3 overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-accent transition-all duration-500"
|
||||
style={{ width: `${pct}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
@@ -487,12 +497,27 @@ export function ProvisioningStatus({ requestId, canAct }: Props) {
|
||||
<p className="text-sm text-text-secondary max-w-sm mx-auto mb-4">
|
||||
{t("readyDescription")}
|
||||
</p>
|
||||
<button
|
||||
onClick={() => window.location.reload()}
|
||||
className="py-2 px-6 bg-accent text-white text-sm font-medium rounded-lg hover:bg-accent-dim transition-colors"
|
||||
>
|
||||
{t("goToDashboard")}
|
||||
</button>
|
||||
{(() => {
|
||||
// Prefer deep-linking straight to the tenant page, where the
|
||||
// ConnectPanel shows how to start chatting. Fall back to a
|
||||
// reload only if we somehow don't have a tenant name yet.
|
||||
const tenantName = data.tenant?.name || data.request.tenantName;
|
||||
return tenantName ? (
|
||||
<Link
|
||||
href={`/tenants/${tenantName}`}
|
||||
className="inline-block py-2 px-6 bg-accent text-surface-0 text-sm font-medium rounded-lg hover:bg-accent-dim transition-colors"
|
||||
>
|
||||
{t("connectCta")}
|
||||
</Link>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => window.location.reload()}
|
||||
className="py-2 px-6 bg-accent text-surface-0 text-sm font-medium rounded-lg hover:bg-accent-dim transition-colors"
|
||||
>
|
||||
{t("goToDashboard")}
|
||||
</button>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useTranslations } from "next-intl";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { PACKAGE_CATALOG, DEFAULT_PACKAGE_IDS, type PackageDef } from "@/lib/packages";
|
||||
import { isPersonalOrgName, displayOrgNameFor } from "@/lib/personal-org";
|
||||
import { THREEMA_GATEWAY } from "@/lib/threema-gateway-config";
|
||||
import {
|
||||
configureStepSchema,
|
||||
billingStepSchema,
|
||||
@@ -116,6 +117,13 @@ interface WizardProps {
|
||||
* the order skips the Checkout redirect (handled server-side).
|
||||
*/
|
||||
setupFeeChf?: number | null;
|
||||
/**
|
||||
* The platform's recurring per-tenant monthly fee (net CHF, before
|
||||
* VAT). Shown on the review step alongside the setup fee so the
|
||||
* customer sees the ongoing commitment — not just the one-time
|
||||
* charge — before submitting. Null/0 hides the monthly line.
|
||||
*/
|
||||
monthlyFeeChf?: number | null;
|
||||
/**
|
||||
* Bug 6: when present, the wizard renders in "edit" mode — fields
|
||||
* are pre-populated from the request, the SOUL.md auto-fetch is
|
||||
@@ -156,6 +164,7 @@ export function OnboardingWizard({
|
||||
hasOrgBilling,
|
||||
existingOrgBilling,
|
||||
setupFeeChf,
|
||||
monthlyFeeChf,
|
||||
editingRequest,
|
||||
onComplete,
|
||||
}: WizardProps) {
|
||||
@@ -254,6 +263,14 @@ export function OnboardingWizard({
|
||||
const [disclaimerAccepted, setDisclaimerAccepted] = useState<
|
||||
Record<string, boolean>
|
||||
>({});
|
||||
// Phase 9b: per-channel customer user id collected at onboarding.
|
||||
// Keyed by package id (e.g. "telegram" → "1234567"). Applied on
|
||||
// admin approval — see /api/admin/requests/[id]/approve. Optional
|
||||
// per channel; the customer can also leave it blank and add their
|
||||
// id later from the tenant's channel-users page.
|
||||
const [channelUserIds, setChannelUserIds] = useState<Record<string, string>>(
|
||||
{}
|
||||
);
|
||||
|
||||
// Fetch DB-stored defaults on mount
|
||||
useEffect(() => {
|
||||
@@ -411,18 +428,51 @@ export function OnboardingWizard({
|
||||
[]
|
||||
);
|
||||
|
||||
// Validate that all secret-requiring enabled packages have complete credentials
|
||||
const packageCredentialsValid = (): boolean => {
|
||||
// Enabled packages that still need something from the user before the
|
||||
// configure step can advance — a missing credential field or an
|
||||
// unaccepted disclaimer. Returns the package defs so the UI can name
|
||||
// exactly what's blocking the (otherwise silently disabled) Next
|
||||
// button instead of greying it out with no explanation.
|
||||
const incompletePackages = (): PackageDef[] => {
|
||||
const out: PackageDef[] = [];
|
||||
for (const pkgId of config.packages) {
|
||||
const def = PACKAGE_CATALOG.find((p) => p.id === pkgId);
|
||||
if (!def?.requiresSecrets) continue;
|
||||
const secrets = packageSecrets[pkgId] || {};
|
||||
for (const field of def.secrets || []) {
|
||||
if (!secrets[field.key]?.trim()) return false;
|
||||
if (!def) continue;
|
||||
let incomplete = false;
|
||||
if (def.requiresSecrets) {
|
||||
const secrets = packageSecrets[pkgId] || {};
|
||||
for (const field of def.secrets || []) {
|
||||
if (!secrets[field.key]?.trim()) {
|
||||
incomplete = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (def.disclaimerKey && !disclaimerAccepted[pkgId]) return false;
|
||||
if (def.disclaimerKey && !disclaimerAccepted[pkgId]) incomplete = true;
|
||||
if (incomplete) out.push(def);
|
||||
}
|
||||
return true;
|
||||
return out;
|
||||
};
|
||||
|
||||
const packageCredentialsValid = (): boolean =>
|
||||
incompletePackages().length === 0;
|
||||
|
||||
// Map zod field paths to human labels for the confirm-step error
|
||||
// summary, so a stray validation failure reads "Postal code" rather
|
||||
// than "billingAddress.postalCode". Unknown paths fall back to the
|
||||
// raw path (this defence-in-depth list should rarely render at all).
|
||||
const fieldLabel = (path: string): string => {
|
||||
const map: Record<string, string> = {
|
||||
instanceName: t("instanceName"),
|
||||
agentName: t("agentName"),
|
||||
"billingAddress.company": t("billingCompany"),
|
||||
"billingAddress.street": t("billingStreet"),
|
||||
"billingAddress.postalCode": t("billingPostalCode"),
|
||||
"billingAddress.city": t("billingCity"),
|
||||
"billingAddress.country": t("billingCountry"),
|
||||
"billingAddress.vatNumber": t("billingVatNumber"),
|
||||
};
|
||||
return map[path] ?? path;
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
@@ -473,6 +523,20 @@ export function OnboardingWizard({
|
||||
})()
|
||||
: config;
|
||||
|
||||
// Phase 9b: build the channelUsers payload from the per-package
|
||||
// ids collected during onboarding. Only include channels that
|
||||
// (a) are enabled in the wizard's packages list AND
|
||||
// (b) have a non-empty id entered.
|
||||
// Shape matches PiecedTenantSpec.channelUsers — { channel: [id] }
|
||||
// — so the approve handler can pass it straight through.
|
||||
const channelUsersPayload: Record<string, string[]> = {};
|
||||
for (const [pkgId, rawId] of Object.entries(channelUserIds)) {
|
||||
const trimmed = (rawId ?? "").trim();
|
||||
if (!trimmed) continue;
|
||||
if (!config.packages.includes(pkgId)) continue;
|
||||
channelUsersPayload[pkgId] = [trimmed];
|
||||
}
|
||||
|
||||
const res = await fetch(url, {
|
||||
method,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
@@ -482,6 +546,10 @@ export function OnboardingWizard({
|
||||
Object.keys(secretsPayload).length > 0
|
||||
? secretsPayload
|
||||
: undefined,
|
||||
channelUsers:
|
||||
Object.keys(channelUsersPayload).length > 0
|
||||
? channelUsersPayload
|
||||
: undefined,
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -579,7 +647,7 @@ export function OnboardingWizard({
|
||||
<div className="flex justify-end">
|
||||
<button
|
||||
onClick={goNext}
|
||||
className="py-2 px-6 bg-accent text-white text-sm font-medium rounded-lg hover:bg-accent-dim transition-colors"
|
||||
className="py-2 px-6 bg-accent text-surface-0 text-sm font-medium rounded-lg hover:bg-accent-dim transition-colors"
|
||||
>
|
||||
{t("getStarted")}
|
||||
</button>
|
||||
@@ -814,6 +882,40 @@ export function OnboardingWizard({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Threema: show the bot's Threema ID
|
||||
and QR right here in the wizard. The
|
||||
instructions text refers to a QR
|
||||
that isn't visible until after
|
||||
provisioning — without this block
|
||||
the message is confusing. The QR is
|
||||
the platform's shared gateway QR
|
||||
(*AIAGENT), identical for every
|
||||
tenant, so we can render it before
|
||||
the tenant even exists. */}
|
||||
{pkg.id === "threema" && (
|
||||
<div className="rounded-lg border border-accent/30 bg-surface-1 p-3 flex items-start gap-3">
|
||||
<div className="bg-white p-1.5 rounded-md shrink-0">
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={THREEMA_GATEWAY.qrCodePath}
|
||||
alt={`QR code for ${THREEMA_GATEWAY.displayName}`}
|
||||
width={96}
|
||||
height={96}
|
||||
style={{ display: "block" }}
|
||||
/>
|
||||
</div>
|
||||
<div className="text-xs text-text-secondary leading-relaxed">
|
||||
<div className="text-text-primary font-medium mb-1">
|
||||
{tPkg("threemaBotIdHeading")}
|
||||
</div>
|
||||
<div className="font-mono text-sm text-accent mb-2">
|
||||
{THREEMA_GATEWAY.displayName}
|
||||
</div>
|
||||
<div>{tPkg("threemaBotIdHint")}</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(pkg.secrets || []).map((field) => (
|
||||
<label key={field.key} className="block">
|
||||
<span className="text-xs text-text-secondary mb-1 block">
|
||||
@@ -842,6 +944,46 @@ export function OnboardingWizard({
|
||||
</label>
|
||||
))}
|
||||
|
||||
{/* Phase 9b: channel-user-id capture
|
||||
during onboarding. For channels
|
||||
where the customer's own user id
|
||||
is needed for routing (Telegram,
|
||||
Discord, Threema), collect it here
|
||||
so the assistant is usable
|
||||
immediately on provisioning. The
|
||||
help text comes from the existing
|
||||
channelUsers.<id>IdHelp keys
|
||||
(same copy as the post-provisioning
|
||||
page uses). Field is optional —
|
||||
blank means "I'll add it later". */}
|
||||
{pkg.collectsChannelUserId && (
|
||||
<label className="block">
|
||||
<span className="text-xs text-text-secondary mb-1 block">
|
||||
{t(`yourChannelIdLabel.${pkg.id}`)}{" "}
|
||||
<span className="text-text-muted normal-case">
|
||||
({t("optional")})
|
||||
</span>
|
||||
</span>
|
||||
<input
|
||||
type="text"
|
||||
placeholder={t(
|
||||
`yourChannelIdPlaceholder.${pkg.id}`
|
||||
)}
|
||||
value={channelUserIds[pkg.id] ?? ""}
|
||||
onChange={(e) =>
|
||||
setChannelUserIds((prev) => ({
|
||||
...prev,
|
||||
[pkg.id]: e.target.value,
|
||||
}))
|
||||
}
|
||||
className="w-full px-3 py-2 bg-surface-2 border border-border rounded-lg text-sm text-text-primary placeholder:text-text-muted font-mono focus:outline-none focus:ring-1 focus:ring-accent focus:border-accent transition-colors"
|
||||
/>
|
||||
<p className="text-[11px] text-text-muted mt-1 leading-relaxed whitespace-pre-line">
|
||||
{t(`yourChannelIdHelp.${pkg.id}`)}
|
||||
</p>
|
||||
</label>
|
||||
)}
|
||||
|
||||
{pkg.disclaimerKey && (
|
||||
<label className="flex items-start gap-2 text-xs text-text-secondary">
|
||||
<input
|
||||
@@ -883,20 +1025,33 @@ export function OnboardingWizard({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between mt-6">
|
||||
<button
|
||||
onClick={goBack}
|
||||
className="py-2 px-4 text-sm text-text-secondary hover:text-text-primary transition-colors"
|
||||
>
|
||||
{t("back")}
|
||||
</button>
|
||||
<button
|
||||
onClick={goNext}
|
||||
disabled={!packageCredentialsValid()}
|
||||
className="py-2 px-6 bg-accent text-white text-sm font-medium rounded-lg hover:bg-accent-dim transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{t("next")}
|
||||
</button>
|
||||
<div className="mt-6">
|
||||
{(() => {
|
||||
const blocking = incompletePackages();
|
||||
if (blocking.length === 0) return null;
|
||||
return (
|
||||
<p className="text-xs text-amber-400/90 mb-3 text-right">
|
||||
{t("packagesIncompleteHint", {
|
||||
packages: blocking.map((p) => p.name).join(", "),
|
||||
})}
|
||||
</p>
|
||||
);
|
||||
})()}
|
||||
<div className="flex justify-between">
|
||||
<button
|
||||
onClick={goBack}
|
||||
className="py-2 px-4 text-sm text-text-secondary hover:text-text-primary transition-colors"
|
||||
>
|
||||
{t("back")}
|
||||
</button>
|
||||
<button
|
||||
onClick={goNext}
|
||||
disabled={!packageCredentialsValid()}
|
||||
className="py-2 px-6 bg-accent text-surface-0 text-sm font-medium rounded-lg hover:bg-accent-dim transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{t("next")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
@@ -1081,7 +1236,7 @@ export function OnboardingWizard({
|
||||
</button>
|
||||
<button
|
||||
onClick={goNext}
|
||||
className="py-2 px-6 bg-accent text-white text-sm font-medium rounded-lg hover:bg-accent-dim transition-colors"
|
||||
className="py-2 px-6 bg-accent text-surface-0 text-sm font-medium rounded-lg hover:bg-accent-dim transition-colors"
|
||||
>
|
||||
{t("next")}
|
||||
</button>
|
||||
@@ -1235,28 +1390,46 @@ export function OnboardingWizard({
|
||||
|
||||
<p className="text-xs text-text-muted">{t("confirmNote")}</p>
|
||||
|
||||
{/* Phase 9b: order-time setup-fee notice + amount. The
|
||||
figure shown is the net platform fee (before VAT);
|
||||
VAT is added server-side based on the billing
|
||||
country. We show "+ VAT" rather than a computed
|
||||
gross to avoid mis-displaying a country-dependent
|
||||
total. If setupFeeChf is null/0, no charge happens
|
||||
and the whole block is suppressed. */}
|
||||
{typeof setupFeeChf === "number" && setupFeeChf > 0 && (
|
||||
{/* Cost summary. Surfaces the full commitment before
|
||||
submitting — not just the one-time setup fee but the
|
||||
recurring monthly per-assistant fee and the fact that
|
||||
AI usage is billed by consumption (with the budget-cap
|
||||
control as the reassurance). All figures are net (before
|
||||
VAT); VAT is added server-side per billing country, so
|
||||
we show "+ VAT" rather than a country-dependent gross.
|
||||
The block is suppressed only when there are no fixed
|
||||
fees at all. */}
|
||||
{((typeof setupFeeChf === "number" && setupFeeChf > 0) ||
|
||||
(typeof monthlyFeeChf === "number" && monthlyFeeChf > 0)) && (
|
||||
<div className="text-xs rounded-md border border-accent/30 bg-accent/10 text-text-secondary px-3 py-3 mt-4">
|
||||
<strong className="block text-text-primary mb-1">
|
||||
{t("setupFeeNoticeHeading")}
|
||||
<strong className="block text-text-primary mb-2">
|
||||
{t("costSummaryHeading")}
|
||||
</strong>
|
||||
<div className="flex items-baseline justify-between mb-2 pb-2 border-b border-accent/20">
|
||||
<span>{t("setupFeeAmountLabel")}</span>
|
||||
<span className="text-sm font-semibold text-text-primary">
|
||||
CHF {setupFeeChf.toFixed(2)}{" "}
|
||||
<span className="text-[10px] font-normal text-text-muted">
|
||||
{t("setupFeePlusVat")}
|
||||
{typeof setupFeeChf === "number" && setupFeeChf > 0 && (
|
||||
<div className="flex items-baseline justify-between mb-1.5">
|
||||
<span>{t("costSetupLabel")}</span>
|
||||
<span className="text-sm font-semibold text-text-primary">
|
||||
CHF {setupFeeChf.toFixed(2)}{" "}
|
||||
<span className="text-[10px] font-normal text-text-muted">
|
||||
{t("setupFeePlusVat")}
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{typeof monthlyFeeChf === "number" && monthlyFeeChf > 0 && (
|
||||
<div className="flex items-baseline justify-between mb-1.5">
|
||||
<span>{t("costMonthlyLabel")}</span>
|
||||
<span className="text-sm font-semibold text-text-primary">
|
||||
CHF {monthlyFeeChf.toFixed(2)}{" "}
|
||||
<span className="text-[10px] font-normal text-text-muted">
|
||||
{t("setupFeePlusVat")}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="mt-2 pt-2 border-t border-accent/20 leading-relaxed">
|
||||
{t("costUsageNote")}
|
||||
</div>
|
||||
{t("setupFeeNoticeBody")}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -1279,7 +1452,8 @@ export function OnboardingWizard({
|
||||
<ul className="list-disc list-inside space-y-0.5">
|
||||
{Object.entries(errors).map(([path, msg]) => (
|
||||
<li key={path}>
|
||||
<span className="font-mono">{path}</span>: {msg}
|
||||
<span className="font-medium">{fieldLabel(path)}</span>:{" "}
|
||||
{msg}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
@@ -1296,7 +1470,7 @@ export function OnboardingWizard({
|
||||
<button
|
||||
onClick={handleSubmit}
|
||||
disabled={submitting}
|
||||
className="py-2.5 px-6 bg-accent text-white text-sm font-medium rounded-lg hover:bg-accent-dim transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
className="py-2.5 px-6 bg-accent text-surface-0 text-sm font-medium rounded-lg hover:bg-accent-dim transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{submitting
|
||||
? tCommon("loading")
|
||||
|
||||
@@ -9,6 +9,7 @@ import type {
|
||||
SkillPricing,
|
||||
} from "@/types";
|
||||
import { SkillCostDialog } from "./skill-cost-dialog";
|
||||
import { ThreemaQrModal } from "@/components/channel-users/threema-qr-modal";
|
||||
|
||||
interface Props {
|
||||
pkg: PackageDef;
|
||||
@@ -51,6 +52,11 @@ export function PackageCard({
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
// Phase 2.5: cost-disclosure flow + activation-request flow.
|
||||
const [showCostDialog, setShowCostDialog] = useState(false);
|
||||
// Threema: after a successful enable on customProvisioning, surface
|
||||
// the gateway QR + bot Threema ID so the customer immediately knows
|
||||
// how to add the assistant to their Threema contacts. Without this,
|
||||
// the toggle just flips silently with no actionable info.
|
||||
const [showThreemaInfo, setShowThreemaInfo] = useState(false);
|
||||
const isPriced =
|
||||
(pricing?.dailyPriceChf ?? 0) > 0 || (pricing?.setupFeeChf ?? 0) > 0;
|
||||
|
||||
@@ -79,6 +85,14 @@ export function PackageCard({
|
||||
throw new Error(err.error || `Provisioning failed (HTTP ${provRes.status})`);
|
||||
}
|
||||
await togglePackage(true);
|
||||
// For Threema specifically: now that the relay's minted the
|
||||
// per-tenant token and the package is enabled, show the
|
||||
// gateway QR + bot Threema ID so the customer can add the
|
||||
// assistant to their Threema contacts straight away. Other
|
||||
// customProvisioning packages don't need this confirmation.
|
||||
if (pkg.id === "threema") {
|
||||
setShowThreemaInfo(true);
|
||||
}
|
||||
} catch (e: any) {
|
||||
setError(e.message);
|
||||
} finally {
|
||||
@@ -283,17 +297,33 @@ export function PackageCard({
|
||||
</button>
|
||||
</div>
|
||||
) : canEdit ? (
|
||||
<button
|
||||
onClick={enabled ? handleDisable : handleEnable}
|
||||
disabled={saving}
|
||||
className={`ml-auto rounded-lg px-3 py-1.5 text-xs font-medium transition-all cursor-pointer ${
|
||||
enabled
|
||||
? "bg-surface-3 text-text-secondary hover:text-text-primary hover:bg-surface-2"
|
||||
: "bg-accent text-surface-0 hover:bg-accent-dim shadow-lg shadow-accent/20"
|
||||
} disabled:opacity-50`}
|
||||
>
|
||||
{saving ? "…" : enabled ? t("packages.disable") : t("packages.enable")}
|
||||
</button>
|
||||
<div className="ml-auto flex items-center gap-2">
|
||||
{/* Phase 9b: re-open the Threema info popup at any time
|
||||
while Threema is enabled. The popup auto-opens after
|
||||
a fresh enable; this button lets the customer see the
|
||||
QR + bot ID again without having to disable + re-enable. */}
|
||||
{pkg.id === "threema" && enabled && (
|
||||
<button
|
||||
onClick={() => setShowThreemaInfo(true)}
|
||||
className="rounded-lg px-2 py-1.5 text-xs font-medium bg-surface-3 text-text-secondary hover:text-text-primary hover:bg-surface-2 transition-colors cursor-pointer"
|
||||
title={t("packages.showInfoTitle")}
|
||||
aria-label={t("packages.showInfoTitle")}
|
||||
>
|
||||
ⓘ {t("packages.showInfo")}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={enabled ? handleDisable : handleEnable}
|
||||
disabled={saving}
|
||||
className={`rounded-lg px-3 py-1.5 text-xs font-medium transition-all cursor-pointer ${
|
||||
enabled
|
||||
? "bg-surface-3 text-text-secondary hover:text-text-primary hover:bg-surface-2"
|
||||
: "bg-accent text-surface-0 hover:bg-accent-dim shadow-lg shadow-accent/20"
|
||||
} disabled:opacity-50`}
|
||||
>
|
||||
{saving ? "…" : enabled ? t("packages.disable") : t("packages.enable")}
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
// Slice 5: read-only viewers see a static badge instead of a
|
||||
// toggle. The status badge above the divider already conveys
|
||||
@@ -320,6 +350,16 @@ export function PackageCard({
|
||||
busy={saving}
|
||||
/>
|
||||
|
||||
{/* Threema: post-enable confirmation showing the gateway QR
|
||||
and bot Threema ID. Only rendered for the threema package
|
||||
and only after a successful enable. The same modal is also
|
||||
reachable later on the channel-users page. */}
|
||||
{pkg.id === "threema" && (
|
||||
<ThreemaQrModal
|
||||
open={showThreemaInfo}
|
||||
onClose={() => setShowThreemaInfo(false)}
|
||||
/>
|
||||
)}
|
||||
{showModal && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm p-4">
|
||||
<div className="w-full max-w-md bg-surface-1 border border-border rounded-2xl p-6 space-y-4 shadow-2xl shadow-black/40">
|
||||
|
||||
@@ -104,7 +104,7 @@ export function SkillCostDialog({
|
||||
<button
|
||||
onClick={onConfirm}
|
||||
disabled={busy}
|
||||
className="px-4 py-2 rounded-md bg-accent text-white text-sm disabled:opacity-50"
|
||||
className="px-4 py-2 rounded-md bg-accent text-surface-0 text-sm disabled:opacity-50"
|
||||
>
|
||||
{busy ? t("confirming") : t("confirm")}
|
||||
</button>
|
||||
|
||||
@@ -227,7 +227,7 @@ export function BillingSettingsForm({ initial, isPersonal }: Props) {
|
||||
<button
|
||||
onClick={submit}
|
||||
disabled={busy}
|
||||
className="px-4 py-2 rounded-md bg-accent text-white text-sm font-medium hover:bg-accent-dim transition-colors disabled:opacity-50 cursor-pointer"
|
||||
className="px-4 py-2 rounded-md bg-accent text-surface-0 text-sm font-medium hover:bg-accent-dim transition-colors disabled:opacity-50 cursor-pointer"
|
||||
>
|
||||
{busy ? t("saving") : initial ? t("saveChanges") : t("createBilling")}
|
||||
</button>
|
||||
|
||||
@@ -268,7 +268,7 @@ export function BillingSettingsForm({
|
||||
<button
|
||||
type="submit"
|
||||
disabled={submitting}
|
||||
className="ml-auto text-sm font-medium px-4 py-2 rounded-lg bg-accent text-white hover:bg-accent/90 transition-colors disabled:opacity-50"
|
||||
className="ml-auto text-sm font-medium px-4 py-2 rounded-lg bg-accent text-surface-0 hover:bg-accent/90 transition-colors disabled:opacity-50"
|
||||
>
|
||||
{submitting ? tCommon("loading") : t("save")}
|
||||
</button>
|
||||
|
||||
@@ -153,7 +153,7 @@ export function ProfileSettingsForm({ initial, isPersonal, orgName }: Props) {
|
||||
<button
|
||||
onClick={submit}
|
||||
disabled={busy}
|
||||
className="px-4 py-2 rounded-md bg-accent text-white text-sm font-medium hover:bg-accent-dim transition-colors disabled:opacity-50 cursor-pointer"
|
||||
className="px-4 py-2 rounded-md bg-accent text-surface-0 text-sm font-medium hover:bg-accent-dim transition-colors disabled:opacity-50 cursor-pointer"
|
||||
>
|
||||
{busy ? t("saving") : t("saveChanges")}
|
||||
</button>
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { useRouter } from "@/i18n/navigation";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Card, CardHeader } from "@/components/ui/card";
|
||||
import type { OrgBillingConfig } from "@/types";
|
||||
@@ -136,7 +137,7 @@ export function SavedCardSection({
|
||||
<button
|
||||
onClick={startSetup}
|
||||
disabled={busy !== null}
|
||||
className="px-4 py-2 rounded-md bg-accent text-white text-sm disabled:opacity-50"
|
||||
className="px-4 py-2 rounded-md bg-accent text-surface-0 text-sm disabled:opacity-50"
|
||||
>
|
||||
{busy === "setup" ? t("savedCardRedirecting") : t("savedCardSetupBtn")}
|
||||
</button>
|
||||
|
||||
@@ -119,7 +119,7 @@ export function TicketCreateForm() {
|
||||
<button
|
||||
type="submit"
|
||||
disabled={submitting}
|
||||
className="text-sm font-medium px-4 py-2 rounded-lg bg-accent text-white hover:bg-accent/90 transition-colors disabled:opacity-50"
|
||||
className="text-sm font-medium px-4 py-2 rounded-lg bg-accent text-surface-0 hover:bg-accent/90 transition-colors disabled:opacity-50"
|
||||
>
|
||||
{submitting ? tCommon("loading") : t("submitTicket")}
|
||||
</button>
|
||||
|
||||
@@ -186,7 +186,7 @@ export function TicketThread({
|
||||
<button
|
||||
type="submit"
|
||||
disabled={submitting || closing || body.trim().length === 0}
|
||||
className="text-sm font-medium px-4 py-2 rounded-lg bg-accent text-white hover:bg-accent/90 transition-colors disabled:opacity-50"
|
||||
className="text-sm font-medium px-4 py-2 rounded-lg bg-accent text-surface-0 hover:bg-accent/90 transition-colors disabled:opacity-50"
|
||||
>
|
||||
{submitting ? tCommon("loading") : t("sendReply")}
|
||||
</button>
|
||||
|
||||
219
src/components/team/access-overview.tsx
Normal file
219
src/components/team/access-overview.tsx
Normal file
@@ -0,0 +1,219 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
/**
|
||||
* AccessOverview
|
||||
*
|
||||
* Read-only "who can reach which assistant" matrix for owners. Access
|
||||
* was previously only visible per-tenant (the AssignedUsersPanel on each
|
||||
* tenant page) and per-member (the team roster) — with no single place
|
||||
* to see the whole picture, which made it easy to lose track across
|
||||
* several tenants and members.
|
||||
*
|
||||
* This composes existing endpoints only (no new API surface):
|
||||
* - GET /api/team → org members
|
||||
* - GET /api/tenants → the org's tenants
|
||||
* - GET /api/tenants/{name}/assignments → per-tenant assignees
|
||||
*
|
||||
* Owners implicitly see every tenant, so their row is marked
|
||||
* "all assistants" rather than per-cell.
|
||||
*/
|
||||
|
||||
interface Member {
|
||||
userId: string;
|
||||
email: string;
|
||||
displayName?: string;
|
||||
roles: string[];
|
||||
}
|
||||
|
||||
interface TenantLite {
|
||||
name: string;
|
||||
displayName: string;
|
||||
}
|
||||
|
||||
export function AccessOverview() {
|
||||
const t = useTranslations("team");
|
||||
|
||||
const [members, setMembers] = useState<Member[] | null>(null);
|
||||
const [tenants, setTenants] = useState<TenantLite[] | null>(null);
|
||||
// tenant name → set of assigned userIds
|
||||
const [assignments, setAssignments] = useState<Record<string, Set<string>>>(
|
||||
{}
|
||||
);
|
||||
const [error, setError] = useState("");
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
const [teamRes, tenantsRes] = await Promise.all([
|
||||
fetch("/api/team"),
|
||||
fetch("/api/tenants"),
|
||||
]);
|
||||
if (!teamRes.ok || !tenantsRes.ok) throw new Error("load");
|
||||
|
||||
const teamData = await teamRes.json();
|
||||
const tenantsData = await tenantsRes.json();
|
||||
|
||||
const mem: Member[] = teamData.members ?? [];
|
||||
const ten: TenantLite[] = (tenantsData ?? []).map((x: any) => ({
|
||||
name: x.metadata.name,
|
||||
displayName: x.spec?.displayName || x.metadata.name,
|
||||
}));
|
||||
|
||||
// Per-tenant assignment lookups in parallel. A failed lookup
|
||||
// degrades to "no assignees" for that tenant rather than
|
||||
// failing the whole view.
|
||||
const entries = await Promise.all(
|
||||
ten.map(async (tn) => {
|
||||
try {
|
||||
const r = await fetch(
|
||||
`/api/tenants/${encodeURIComponent(tn.name)}/assignments`
|
||||
);
|
||||
if (!r.ok) return [tn.name, new Set<string>()] as const;
|
||||
const data = await r.json();
|
||||
const ids = new Set<string>(
|
||||
(data.assignments ?? data ?? []).map((a: any) => a.userId)
|
||||
);
|
||||
return [tn.name, ids] as const;
|
||||
} catch {
|
||||
return [tn.name, new Set<string>()] as const;
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
if (cancelled) return;
|
||||
setMembers(mem);
|
||||
setTenants(ten);
|
||||
setAssignments(Object.fromEntries(entries));
|
||||
} catch {
|
||||
if (!cancelled) setError(t("accessLoadFailed"));
|
||||
} finally {
|
||||
if (!cancelled) setLoading(false);
|
||||
}
|
||||
})();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [t]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="bg-surface-1 border border-border rounded-xl p-6 animate-pulse">
|
||||
<div className="h-4 w-40 bg-surface-3 rounded mb-4" />
|
||||
<div className="h-24 bg-surface-2 rounded" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="bg-surface-1 border border-border rounded-xl p-6">
|
||||
<p className="text-sm text-text-secondary">{error}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!tenants || tenants.length === 0) {
|
||||
return (
|
||||
<div className="bg-surface-1 border border-border rounded-xl p-6">
|
||||
<p className="text-sm text-text-secondary">{t("accessNoTenants")}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const isOwner = (m: Member) => m.roles?.includes("owner");
|
||||
|
||||
return (
|
||||
<div className="bg-surface-1 border border-border rounded-xl overflow-hidden">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm border-collapse">
|
||||
<thead>
|
||||
<tr className="border-b border-border">
|
||||
<th className="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider text-text-muted sticky left-0 bg-surface-1">
|
||||
{t("accessMemberCol")}
|
||||
</th>
|
||||
{tenants.map((tn) => (
|
||||
<th
|
||||
key={tn.name}
|
||||
className="px-3 py-3 text-center text-xs font-semibold text-text-secondary min-w-[7rem]"
|
||||
title={tn.name}
|
||||
>
|
||||
{tn.displayName}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{(members ?? []).map((m) => (
|
||||
<tr
|
||||
key={m.userId}
|
||||
className="border-b border-border last:border-0 hover:bg-surface-2/50 transition-colors"
|
||||
>
|
||||
<td className="px-4 py-3 sticky left-0 bg-surface-1">
|
||||
<div className="text-sm text-text-primary truncate max-w-[14rem]">
|
||||
{m.displayName || m.email}
|
||||
</div>
|
||||
<div className="text-xs text-text-muted truncate max-w-[14rem]">
|
||||
{m.email}
|
||||
</div>
|
||||
</td>
|
||||
{tenants.map((tn) => {
|
||||
const owner = isOwner(m);
|
||||
const has = owner || assignments[tn.name]?.has(m.userId);
|
||||
const label = owner
|
||||
? t("accessOwnerAll")
|
||||
: has
|
||||
? t("accessHasLabel")
|
||||
: t("accessHasNotLabel");
|
||||
return (
|
||||
<td
|
||||
key={tn.name}
|
||||
className="px-3 py-3 text-center"
|
||||
title={label}
|
||||
>
|
||||
<span className="sr-only">{label}</span>
|
||||
{owner ? (
|
||||
<span aria-hidden="true" className="text-accent">
|
||||
●
|
||||
</span>
|
||||
) : has ? (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="text-emerald-400 font-semibold"
|
||||
>
|
||||
✓
|
||||
</span>
|
||||
) : (
|
||||
<span aria-hidden="true" className="text-text-muted/50">
|
||||
–
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
);
|
||||
})}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div className="px-4 py-2.5 border-t border-border flex flex-wrap items-center gap-x-4 gap-y-1 text-xs text-text-muted">
|
||||
<span className="flex items-center gap-1.5">
|
||||
<span className="text-accent">●</span> {t("accessOwnerAll")}
|
||||
</span>
|
||||
<span className="flex items-center gap-1.5">
|
||||
<span className="text-emerald-400 font-semibold">✓</span>{" "}
|
||||
{t("accessHasLabel")}
|
||||
</span>
|
||||
<span className="flex items-center gap-1.5">
|
||||
<span className="text-text-muted/50">–</span> {t("accessHasNotLabel")}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -141,7 +141,7 @@ export function InviteForm() {
|
||||
<button
|
||||
type="submit"
|
||||
disabled={state === "submitting"}
|
||||
className="w-full py-2.5 px-4 bg-accent text-white text-sm font-medium rounded-lg hover:bg-accent-dim transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
className="w-full py-2.5 px-4 bg-accent text-surface-0 text-sm font-medium rounded-lg hover:bg-accent-dim transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{state === "submitting" ? tCommon("loading") : t("inviteButton")}
|
||||
</button>
|
||||
|
||||
@@ -179,7 +179,7 @@ export function TeamList({
|
||||
type="button"
|
||||
onClick={() => saveEdit(m)}
|
||||
disabled={submitting || !m.authorizationId}
|
||||
className="text-xs px-2.5 py-1 rounded-md bg-accent text-white hover:bg-accent-dim transition-colors disabled:opacity-50"
|
||||
className="text-xs px-2.5 py-1 rounded-md bg-accent text-surface-0 hover:bg-accent-dim transition-colors disabled:opacity-50"
|
||||
>
|
||||
{t("save")}
|
||||
</button>
|
||||
|
||||
@@ -218,7 +218,7 @@ export function AssignedUsersPanel({ tenantName, canEdit }: Props) {
|
||||
<button
|
||||
onClick={handleAssign}
|
||||
disabled={busy || !pickedUserId}
|
||||
className="px-4 py-2 text-sm font-medium bg-accent text-white rounded-lg hover:bg-accent-dim transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
className="px-4 py-2 text-sm font-medium bg-accent text-surface-0 rounded-lg hover:bg-accent-dim transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{busy ? "…" : t("assign")}
|
||||
</button>
|
||||
|
||||
149
src/components/tenants/connect-panel.tsx
Normal file
149
src/components/tenants/connect-panel.tsx
Normal file
@@ -0,0 +1,149 @@
|
||||
"use client";
|
||||
|
||||
import { useTranslations } from "next-intl";
|
||||
import { THREEMA_GATEWAY } from "@/lib/threema-gateway-config";
|
||||
|
||||
/**
|
||||
* ConnectPanel
|
||||
*
|
||||
* The portal is a *management* console — config, billing, usage — but
|
||||
* the assistant itself lives in the customer's messaging app. Nothing
|
||||
* previously told the customer how to actually start talking to the
|
||||
* thing they just provisioned ("Your assistant is ready… now what?").
|
||||
*
|
||||
* This panel closes that gap on the tenant-detail page: for each
|
||||
* enabled channel it shows the concrete first-contact steps, and when
|
||||
* NO channel is enabled it says so explicitly (a running assistant with
|
||||
* no channel is unreachable).
|
||||
*
|
||||
* It is intentionally complementary to ChannelUsers below it:
|
||||
* - ConnectPanel → "how do *I* reach the assistant"
|
||||
* - ChannelUsers → "*who* is allowed to reach it"
|
||||
* The Threema/Telegram/Discord steps reference the authorised-users
|
||||
* list rather than duplicating it.
|
||||
*/
|
||||
|
||||
// Render order is fixed (not the order packages happen to appear in
|
||||
// spec.packages) so the panel layout is stable across tenants.
|
||||
const CHANNEL_ORDER = ["threema", "telegram", "discord"] as const;
|
||||
|
||||
const CHANNEL_NAMES: Record<string, string> = {
|
||||
threema: "Threema",
|
||||
telegram: "Telegram",
|
||||
discord: "Discord",
|
||||
};
|
||||
|
||||
// Per-channel instruction key in the `connect` message namespace.
|
||||
const CHANNEL_STEPS_KEY: Record<string, string> = {
|
||||
threema: "threemaSteps",
|
||||
telegram: "telegramSteps",
|
||||
discord: "discordSteps",
|
||||
};
|
||||
|
||||
export function ConnectPanel({
|
||||
enabledChannels,
|
||||
phase,
|
||||
}: {
|
||||
enabledChannels: string[];
|
||||
/** Tenant phase — connection details only "work" once it's Ready. */
|
||||
phase: string;
|
||||
}) {
|
||||
const t = useTranslations("connect");
|
||||
|
||||
const channels = CHANNEL_ORDER.filter((c) => enabledChannels.includes(c));
|
||||
const ready = phase === "Ready" || phase === "Running" || phase === "Active";
|
||||
|
||||
// No channel at all → the assistant is unreachable. Make it loud.
|
||||
if (channels.length === 0) {
|
||||
return (
|
||||
<div className="rounded-xl border border-amber-500/30 bg-amber-500/10 p-5">
|
||||
<div className="flex items-start gap-3">
|
||||
<svg
|
||||
className="h-5 w-5 text-amber-400 shrink-0 mt-0.5"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={1.5}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M12 9v3.75m9-.75a9 9 0 11-18 0 9 9 0 0118 0zM12 15.75h.008v.008H12v-.008z"
|
||||
/>
|
||||
</svg>
|
||||
<div className="min-w-0">
|
||||
<div className="text-sm font-semibold text-amber-300">
|
||||
{t("noChannelsTitle")}
|
||||
</div>
|
||||
<p className="text-xs text-text-secondary mt-1 leading-relaxed">
|
||||
{t("noChannelsBody")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="rounded-xl border border-accent/30 bg-accent/5 p-5">
|
||||
<h2 className="font-display text-base font-semibold text-text-primary mb-1">
|
||||
{t("title")}
|
||||
</h2>
|
||||
<p className="text-xs text-text-secondary mb-4 leading-relaxed">
|
||||
{t("description")}
|
||||
</p>
|
||||
|
||||
{!ready && (
|
||||
<p className="text-xs text-amber-300 bg-amber-500/10 border border-amber-500/20 rounded-lg px-3 py-2 mb-4 leading-relaxed">
|
||||
{t("notReadyNote")}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="space-y-3">
|
||||
{channels.map((c) => (
|
||||
<div
|
||||
key={c}
|
||||
className="rounded-lg border border-border bg-surface-1 p-3"
|
||||
>
|
||||
<div className="text-sm font-medium text-text-primary mb-1.5">
|
||||
{CHANNEL_NAMES[c]}
|
||||
</div>
|
||||
|
||||
{c === "threema" ? (
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="bg-white p-1.5 rounded-md shrink-0">
|
||||
{/* Shared gateway QR — identical for every tenant, so
|
||||
it can render before/after provisioning alike.
|
||||
eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={THREEMA_GATEWAY.qrCodePath}
|
||||
alt={`QR code for ${THREEMA_GATEWAY.displayName}`}
|
||||
width={88}
|
||||
height={88}
|
||||
style={{ display: "block" }}
|
||||
/>
|
||||
</div>
|
||||
<div className="text-xs text-text-secondary leading-relaxed">
|
||||
<div className="mb-1.5">
|
||||
<span className="text-text-muted">
|
||||
{t("threemaBotIdLabel")}:{" "}
|
||||
</span>
|
||||
<span className="font-mono text-sm text-accent">
|
||||
{THREEMA_GATEWAY.displayName}
|
||||
</span>
|
||||
</div>
|
||||
<div className="whitespace-pre-line">{t("threemaSteps")}</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-xs text-text-secondary leading-relaxed whitespace-pre-line">
|
||||
{t(CHANNEL_STEPS_KEY[c])}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
58
src/components/ui/button.tsx
Normal file
58
src/components/ui/button.tsx
Normal file
@@ -0,0 +1,58 @@
|
||||
import { forwardRef } from "react";
|
||||
|
||||
/**
|
||||
* Shared button primitive.
|
||||
*
|
||||
* Why this exists
|
||||
* ---------------
|
||||
* The accent fill (#00d4aa) is bright; white text on it measures ~1.9:1,
|
||||
* which fails WCAG even for large/UI text. Dark text (surface-0) on the
|
||||
* same accent is ~10:1. The codebase had ~40 hand-rolled accent buttons,
|
||||
* most using `text-white`. This component centralises the correct token
|
||||
* (`text-surface-0` on accent) so the contrast can't drift again — reach
|
||||
* for `<Button>` instead of re-deriving the class string.
|
||||
*/
|
||||
|
||||
type Variant = "primary" | "secondary" | "ghost" | "danger";
|
||||
type Size = "sm" | "md";
|
||||
|
||||
const BASE =
|
||||
"inline-flex items-center justify-center gap-1.5 font-medium rounded-lg " +
|
||||
"transition-colors cursor-pointer focus:outline-none focus-visible:ring-2 " +
|
||||
"focus-visible:ring-accent/50 disabled:opacity-50 disabled:cursor-not-allowed";
|
||||
|
||||
const VARIANTS: Record<Variant, string> = {
|
||||
// surface-0 (dark) text — the contrast-correct pairing for the accent.
|
||||
primary: "bg-accent text-surface-0 hover:bg-accent-dim shadow-sm shadow-accent/20",
|
||||
secondary:
|
||||
"bg-surface-2 text-text-primary border border-border hover:bg-surface-3 hover:border-border-active",
|
||||
ghost: "text-text-secondary hover:text-text-primary hover:bg-surface-2",
|
||||
danger: "bg-error text-surface-0 hover:opacity-90",
|
||||
};
|
||||
|
||||
const SIZES: Record<Size, string> = {
|
||||
sm: "text-xs px-3 py-1.5",
|
||||
md: "text-sm px-4 py-2",
|
||||
};
|
||||
|
||||
export interface ButtonProps
|
||||
extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
variant?: Variant;
|
||||
size?: Size;
|
||||
}
|
||||
|
||||
export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
function Button(
|
||||
{ variant = "primary", size = "md", className = "", type = "button", ...rest },
|
||||
ref
|
||||
) {
|
||||
return (
|
||||
<button
|
||||
ref={ref}
|
||||
type={type}
|
||||
className={`${BASE} ${VARIANTS[variant]} ${SIZES[size]} ${className}`}
|
||||
{...rest}
|
||||
/>
|
||||
);
|
||||
}
|
||||
);
|
||||
@@ -16,6 +16,9 @@ interface Props {
|
||||
ariaLabel?: string;
|
||||
}
|
||||
|
||||
const FOCUSABLE =
|
||||
'a[href],button:not([disabled]),textarea:not([disabled]),input:not([disabled]),select:not([disabled]),[tabindex]:not([tabindex="-1"])';
|
||||
|
||||
/**
|
||||
* Portal-based modal.
|
||||
*
|
||||
@@ -25,45 +28,86 @@ interface Props {
|
||||
* ancestor's containing block, not the viewport, when ANY ancestor
|
||||
* has a `transform`, `perspective`, or `filter` applied. Our
|
||||
* `animate-in` utility sets `transform: translateY(0)` on a lot of
|
||||
* dashboard/tenant-detail containers (because of the fade-up
|
||||
* animation, which uses `animation-fill-mode: both` to keep the
|
||||
* transform on after the animation finishes). That broke modals
|
||||
* rendered as in-place children — they centred to the panel they
|
||||
* lived in, not to the page.
|
||||
* dashboard/tenant-detail containers, which broke modals rendered as
|
||||
* in-place children — they centred to the panel they lived in, not to
|
||||
* the page. Rendering at `document.body` via `createPortal` escapes
|
||||
* every containing-block ancestor and gives us true viewport coords.
|
||||
*
|
||||
* Rendering at `document.body` via `createPortal` escapes every
|
||||
* containing-block ancestor and gives us true viewport coordinates.
|
||||
*
|
||||
* UX details
|
||||
* ----------
|
||||
* - Backdrop click triggers `onClose`. (Bubbling check: only fires
|
||||
* when the click target IS the backdrop, not the panel inside.)
|
||||
* - Escape key triggers `onClose`. Standard modal expectation.
|
||||
* - `body` overflow is locked while open so background content
|
||||
* doesn't scroll behind the modal.
|
||||
* - Renders nothing on first paint server-side, then mounts on
|
||||
* client. `useEffect` gating ensures `document.body` is available;
|
||||
* without it Next.js SSR would throw on `document` reference.
|
||||
* UX / a11y details
|
||||
* -----------------
|
||||
* - Backdrop click triggers `onClose` (only when the click target IS
|
||||
* the backdrop, not the panel inside).
|
||||
* - Escape triggers `onClose`.
|
||||
* - `body` overflow is locked while open so background content doesn't
|
||||
* scroll behind the modal.
|
||||
* - Focus is moved into the panel on open, trapped within it while open
|
||||
* (Tab / Shift+Tab cycle), and restored to the previously focused
|
||||
* element on close — so keyboard and screen-reader users can't tab
|
||||
* out to the inert page behind the dialog.
|
||||
*/
|
||||
export function Modal({ open, onClose, children, ariaLabel }: Props) {
|
||||
const closeRef = useRef(onClose);
|
||||
closeRef.current = onClose;
|
||||
const panelRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
|
||||
// Lock background scroll. Restore on unmount/close.
|
||||
// Remember what had focus so we can restore it on close.
|
||||
const previouslyFocused = document.activeElement as HTMLElement | null;
|
||||
|
||||
// Lock background scroll.
|
||||
const previousOverflow = document.body.style.overflow;
|
||||
document.body.style.overflow = "hidden";
|
||||
|
||||
// Move focus into the dialog — first focusable element, else the
|
||||
// panel itself (it carries tabIndex={-1}).
|
||||
const panel = panelRef.current;
|
||||
const focusables = panel
|
||||
? Array.from(panel.querySelectorAll<HTMLElement>(FOCUSABLE))
|
||||
: [];
|
||||
(focusables[0] ?? panel)?.focus();
|
||||
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") closeRef.current();
|
||||
if (e.key === "Escape") {
|
||||
closeRef.current();
|
||||
return;
|
||||
}
|
||||
if (e.key !== "Tab" || !panel) return;
|
||||
|
||||
// Re-query each time — modal content can change between tabs.
|
||||
const items = Array.from(
|
||||
panel.querySelectorAll<HTMLElement>(FOCUSABLE)
|
||||
).filter((el) => el.offsetParent !== null || el === document.activeElement);
|
||||
if (items.length === 0) {
|
||||
e.preventDefault();
|
||||
panel.focus();
|
||||
return;
|
||||
}
|
||||
const first = items[0];
|
||||
const last = items[items.length - 1];
|
||||
const active = document.activeElement;
|
||||
|
||||
if (e.shiftKey) {
|
||||
if (active === first || active === panel) {
|
||||
e.preventDefault();
|
||||
last.focus();
|
||||
}
|
||||
} else if (active === last) {
|
||||
e.preventDefault();
|
||||
first.focus();
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("keydown", onKey);
|
||||
|
||||
return () => {
|
||||
document.body.style.overflow = previousOverflow;
|
||||
window.removeEventListener("keydown", onKey);
|
||||
// Restore focus to the trigger (if it's still in the document).
|
||||
if (previouslyFocused && document.contains(previouslyFocused)) {
|
||||
previouslyFocused.focus();
|
||||
}
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
@@ -72,15 +116,19 @@ export function Modal({ open, onClose, children, ariaLabel }: Props) {
|
||||
|
||||
return createPortal(
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={ariaLabel}
|
||||
className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/60 backdrop-blur-sm"
|
||||
onClick={(e) => {
|
||||
if (e.target === e.currentTarget) onClose();
|
||||
}}
|
||||
>
|
||||
<div className="bg-surface-1 border border-border rounded-xl p-6 max-w-md w-full max-h-[90vh] overflow-y-auto">
|
||||
<div
|
||||
ref={panelRef}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={ariaLabel}
|
||||
tabIndex={-1}
|
||||
className="bg-surface-1 border border-border rounded-xl p-6 max-w-md w-full max-h-[90vh] overflow-y-auto focus:outline-none"
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</div>,
|
||||
|
||||
@@ -105,6 +105,14 @@ const MIGRATION_SQL = `
|
||||
ON tenant_requests(setup_invoice_id)
|
||||
WHERE setup_invoice_id IS NOT NULL;
|
||||
|
||||
-- Phase 9b: optional initial channel-user ids per channel package
|
||||
-- collected during onboarding. JSONB so the shape can vary by
|
||||
-- channel (today it's a string[] per channel id, matching
|
||||
-- PiecedTenantSpec.channelUsers). Default '{}' so reads on legacy
|
||||
-- rows return an empty object rather than null.
|
||||
ALTER TABLE tenant_requests
|
||||
ADD COLUMN IF NOT EXISTS channel_users JSONB NOT NULL DEFAULT '{}'::jsonb;
|
||||
|
||||
-- Feature 6: free-form customer note attached to the request.
|
||||
-- Currently surfaced only by resume requests (where the customer
|
||||
-- explains why they want reactivation), but the column is generic
|
||||
@@ -896,8 +904,8 @@ export async function createTenantRequest(
|
||||
(zitadel_org_id, zitadel_user_id, company_name, instance_name,
|
||||
contact_name, contact_email, agent_name, soul_md, agents_md,
|
||||
packages, billing_address, billing_notes, encrypted_secrets,
|
||||
is_personal)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14)
|
||||
is_personal, channel_users)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15::jsonb)
|
||||
RETURNING *`,
|
||||
[
|
||||
params.zitadelOrgId,
|
||||
@@ -914,6 +922,7 @@ export async function createTenantRequest(
|
||||
params.billingNotes,
|
||||
params.encryptedSecrets ?? null,
|
||||
params.isPersonal ?? false,
|
||||
JSON.stringify(params.channelUsers ?? {}),
|
||||
]
|
||||
);
|
||||
return mapRow(result.rows[0]);
|
||||
@@ -1449,6 +1458,7 @@ function mapRow(row: any): TenantRequest {
|
||||
adminNotes: row.admin_notes,
|
||||
tenantName: row.tenant_name,
|
||||
setupInvoiceId: row.setup_invoice_id ?? null,
|
||||
channelUsers: (row.channel_users ?? {}) as Record<string, string[]>,
|
||||
encryptedSecrets: row.encrypted_secrets ?? null,
|
||||
isPersonal: row.is_personal ?? false,
|
||||
dismissedAt:
|
||||
@@ -4235,6 +4245,7 @@ export async function createTenantRequestPendingPayment(params: {
|
||||
billingNotes?: string;
|
||||
encryptedSecrets?: Buffer | null;
|
||||
isPersonal: boolean;
|
||||
channelUsers?: Record<string, string[]>;
|
||||
}): Promise<TenantRequest> {
|
||||
await ensureSchema();
|
||||
const result = await getPool().query(
|
||||
@@ -4244,10 +4255,11 @@ export async function createTenantRequestPendingPayment(params: {
|
||||
agent_name, soul_md, agents_md, packages,
|
||||
billing_address, billing_notes,
|
||||
encrypted_secrets, is_personal,
|
||||
channel_users,
|
||||
status, request_type
|
||||
) VALUES (
|
||||
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11::jsonb, $12,
|
||||
$13, $14, 'pending_payment', 'provision'
|
||||
$13, $14, $15::jsonb, 'pending_payment', 'provision'
|
||||
)
|
||||
RETURNING *`,
|
||||
[
|
||||
@@ -4265,6 +4277,7 @@ export async function createTenantRequestPendingPayment(params: {
|
||||
params.billingNotes ?? null,
|
||||
params.encryptedSecrets ?? null,
|
||||
params.isPersonal,
|
||||
JSON.stringify(params.channelUsers ?? {}),
|
||||
]
|
||||
);
|
||||
return mapRow(result.rows[0]);
|
||||
|
||||
@@ -84,6 +84,21 @@ export interface PackageDef {
|
||||
* stays opt-in.
|
||||
*/
|
||||
recommended?: boolean;
|
||||
/**
|
||||
* Phase 9b: when true, the onboarding wizard collects the
|
||||
* customer's own user id for this channel (e.g. their Telegram
|
||||
* numeric id, their Threema ID) at request time. The collected
|
||||
* id is forwarded with the tenant request, stored on the row,
|
||||
* and applied on admin approval:
|
||||
* - spec.channelUsers[<channel>] gets the id seeded so the
|
||||
* operator's first reconcile already has it
|
||||
* - for Threema specifically, the approve handler additionally
|
||||
* calls the relay's createRoute() so inbound messages from
|
||||
* that id reach the new tenant
|
||||
* Customers can add more ids later via the channel-users page.
|
||||
* Help copy and label come from channelUsers.<id>IdHelp.
|
||||
*/
|
||||
collectsChannelUserId?: boolean;
|
||||
}
|
||||
|
||||
export const PACKAGE_CATALOG: PackageDef[] = [
|
||||
@@ -137,6 +152,7 @@ export const PACKAGE_CATALOG: PackageDef[] = [
|
||||
instructionsKey: "packages.telegram.instructions",
|
||||
disclaimerKey: "packages.telegram.disclaimer",
|
||||
category: "channel",
|
||||
collectsChannelUserId: true,
|
||||
},
|
||||
{
|
||||
id: "discord",
|
||||
@@ -166,6 +182,7 @@ export const PACKAGE_CATALOG: PackageDef[] = [
|
||||
instructionsKey: "packages.discord.instructions",
|
||||
disclaimerKey: "packages.discord.disclaimer",
|
||||
category: "channel",
|
||||
collectsChannelUserId: true,
|
||||
},
|
||||
{
|
||||
id: "threema",
|
||||
@@ -182,6 +199,7 @@ export const PACKAGE_CATALOG: PackageDef[] = [
|
||||
disclaimerKey: "packages.threema.disclaimer",
|
||||
category: "channel",
|
||||
recommended: true,
|
||||
collectsChannelUserId: true,
|
||||
},
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
@@ -152,6 +152,12 @@ export const onboardingSchema = z.object({
|
||||
packageSecrets: z
|
||||
.record(z.string(), z.record(z.string(), z.string()))
|
||||
.optional(),
|
||||
// Phase 9b: per-channel initial user ids collected during
|
||||
// onboarding. Map of channel package id → list of user ids the
|
||||
// customer wants to authorize. Applied at admin approval time.
|
||||
channelUsers: z
|
||||
.record(z.string(), z.array(z.string().trim().min(1).max(200)))
|
||||
.optional(),
|
||||
billingAddress: billingAddressSchema.optional(),
|
||||
billingNotes: z.string().max(2_000).optional(),
|
||||
});
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
"tagline": "KI-Plattform",
|
||||
"login": "Anmelden",
|
||||
"logout": "Abmelden",
|
||||
"menu": "Menü",
|
||||
"dashboard": "Dashboard",
|
||||
"admin": "Admin",
|
||||
"loading": "Laden…",
|
||||
@@ -93,7 +94,7 @@
|
||||
"provisioningDescription": "Ihr KI-Assistent wird bereitgestellt. Dies dauert in der Regel wenige Minuten.",
|
||||
"phase": "Phase",
|
||||
"readyTitle": "Ihr Assistent ist bereit!",
|
||||
"readyDescription": "Ihr KI-Assistent wurde bereitgestellt und ist aktiv. Sie können ihn nun über das Dashboard verwalten.",
|
||||
"readyDescription": "Ihr KI-Assistent wurde bereitgestellt und läuft. Verbinden Sie ihn als Nächstes mit Ihrer Messaging-App, um den Chat zu starten.",
|
||||
"goToDashboard": "Zum Dashboard",
|
||||
"submittedAt": "Eingereicht",
|
||||
"instanceName": "Instanzname",
|
||||
@@ -126,7 +127,31 @@
|
||||
"setupFeeNoticeHeading": "Einrichtungsgebühr wird beim Senden belastet",
|
||||
"setupFeeNoticeBody": "Mit dem nächsten Klick werden Sie zu Stripe weitergeleitet, um Ihre Zahlungsdetails einzugeben und die einmalige Einrichtungsgebühr zu bezahlen. Ihre Karte wird automatisch für die zukünftige monatliche Abrechnung gespeichert. Anschliessend gelangen Sie direkt zurück zum Dashboard. Die Instanz startet erst nach Admin-Freigabe — monatliche Gebühren beginnen ab dem Freigabedatum.",
|
||||
"setupFeeAmountLabel": "Einmalige Einrichtungsgebühr",
|
||||
"setupFeePlusVat": "+ MwSt."
|
||||
"setupFeePlusVat": "+ MwSt.",
|
||||
"optional": "optional",
|
||||
"yourChannelIdLabel": {
|
||||
"telegram": "Ihre Telegram-Benutzer-ID",
|
||||
"discord": "Ihre Discord-Benutzer-ID",
|
||||
"threema": "Ihre Threema-ID"
|
||||
},
|
||||
"yourChannelIdPlaceholder": {
|
||||
"telegram": "z.B. 1234567890",
|
||||
"discord": "z.B. 234567890123456789",
|
||||
"threema": "z.B. ABCD1234"
|
||||
},
|
||||
"yourChannelIdHelp": {
|
||||
"telegram": "Öffnen Sie Telegram, schreiben Sie an @userinfobot und fügen Sie die zurückgegebene numerische ID hier ein. Weitere Benutzer können Sie später auf der Mandantenseite hinzufügen.",
|
||||
"discord": "Aktivieren Sie den Entwicklermodus in Discord (Erweiterte Einstellungen), Rechtsklick auf Ihren Namen → Benutzer-ID kopieren, und hier einfügen. Weitere Benutzer können Sie später auf der Mandantenseite hinzufügen.",
|
||||
"threema": "Die 8 Zeichen, die in Ihrer Threema-App unter Einstellungen → Meine Threema-ID angezeigt werden. Sobald Ihr Mandant freigegeben ist und Threema aktiviert wurde, können Sie aus diesem Account heraus mit dem Assistenten chatten. Weitere autorisierte IDs können später auf der Mandantenseite hinzugefügt werden."
|
||||
},
|
||||
"connectCta": "Assistenten verbinden",
|
||||
"packagesIncompleteHint": "Bitte ergänzen Sie die erforderlichen Angaben für: {packages}",
|
||||
"setupProgress": "Einrichtungsfortschritt",
|
||||
"setupStepsComplete": "{done} von {total} Schritten",
|
||||
"costSummaryHeading": "Was Sie bezahlen",
|
||||
"costSetupLabel": "Einmalige Einrichtung",
|
||||
"costMonthlyLabel": "Monatlich, pro Assistent",
|
||||
"costUsageNote": "Zuzüglich nutzungsabhängiger KI-Kosten, monatlich in CHF abgerechnet. Sie können jederzeit ein Ausgabenlimit pro Assistent festlegen."
|
||||
},
|
||||
"dashboard": {
|
||||
"title": "Dashboard",
|
||||
@@ -209,7 +234,10 @@
|
||||
"budgetCadence_1mo": "Monatlich",
|
||||
"budgetCadence_1y": "Jährlich",
|
||||
"budgetInvalid": "Bitte einen positiven Betrag eingeben.",
|
||||
"budgetSaveFailed": "Budget konnte nicht gespeichert werden. Bitte erneut versuchen."
|
||||
"budgetSaveFailed": "Budget konnte nicht gespeichert werden. Bitte erneut versuchen.",
|
||||
"legendInput": "Input",
|
||||
"legendOutput": "Output",
|
||||
"chartHint": "Für Details auf einen Balken tippen"
|
||||
},
|
||||
"workspace": {
|
||||
"save": "Speichern",
|
||||
@@ -315,7 +343,7 @@
|
||||
},
|
||||
"threema": {
|
||||
"description": "Senden und empfangen Sie Nachrichten über Threema. Jede eingehende und ausgehende Nachricht läuft über den gemeinsamen PieCed-Messaging-Dienst und verursacht eine Gebühr pro Nachricht bei Threema — eine Drittanbieter-Kostenposition, unabhängig von Ihrem PieCed-Abonnement.",
|
||||
"instructions": "1. Aktivieren Sie dieses Paket.\n2. Öffnen Sie Threema auf Ihrem Telefon, scannen Sie den QR-Code unter Autorisierte Benutzer → threema und akzeptieren Sie den Kontakt.\n3. Tragen Sie Ihre eigene Threema-ID unter Autorisierte Benutzer → threema ein, damit der Assistent Ihre Nachrichten erkennt.\n4. Schreiben Sie eine Nachricht aus Threema, um das Gespräch zu beginnen.",
|
||||
"instructions": "1. Öffnen Sie Threema auf Ihrem Telefon und scannen Sie den unten angezeigten QR-Code — am besten gleich jetzt, damit Sie loslegen können, sobald Ihr Mandant läuft.\n2. Tragen Sie Ihre eigene Threema-ID im Feld weiter unten ein (die 8 Zeichen aus Einstellungen → Meine Threema-ID in der Threema-App), damit der Assistent Ihre Nachrichten annimmt.\n3. Sobald Ihr Mandant freigegeben ist und läuft, senden Sie eine Nachricht aus Threema, um das Gespräch zu beginnen.",
|
||||
"disclaimer": "Nachrichten zwischen Threema und PieCed werden Ende-zu-Ende verschlüsselt bis zum PieCed-Messaging-Dienst, wo sie entschlüsselt und an Ihren Assistenten weitergeleitet werden. Jede gesendete oder empfangene Nachricht wird gemäss Threema-Tarif pro Nachricht abgerechnet — die aktuellen Preise finden Sie in Ihrem Plan."
|
||||
},
|
||||
"manualReviewPending": "Manuelle Prüfung ausstehend",
|
||||
@@ -324,7 +352,11 @@
|
||||
"tryAgain": "Erneut versuchen",
|
||||
"credentialsSaved": "Zugangsdaten gespeichert",
|
||||
"credentialsSavedTip": "Die eingegebenen Zugangsdaten sind sicher gespeichert und werden verwendet, sobald die Aktivierung vom Admin genehmigt wurde. Sie müssen sie nicht erneut eingeben.",
|
||||
"recommended": "Empfohlen"
|
||||
"recommended": "Empfohlen",
|
||||
"threemaBotIdHeading": "Bot-Threema-ID",
|
||||
"threemaBotIdHint": "Das ist die Threema-ID des Assistenten — bei jedem PieCed-Mandanten identisch. Scannen Sie den QR jetzt mit Ihrer Threema-App, damit Sie startklar sind, sobald Ihr Mandant freigegeben und Threema aktiviert ist.",
|
||||
"showInfo": "Info",
|
||||
"showInfoTitle": "Setup-Info erneut anzeigen"
|
||||
},
|
||||
"admin": {
|
||||
"title": "Plattform-Admin",
|
||||
@@ -400,7 +432,11 @@
|
||||
"openclawTool": "OpenClaw-Versionen",
|
||||
"billingTool": "Abrechnung →",
|
||||
"skillsQueueTool": "Aktivierungs-Warteschlange",
|
||||
"cronTool": "Automatisierung"
|
||||
"cronTool": "Automatisierung",
|
||||
"approveTitle": "Anfrage genehmigen?",
|
||||
"approveWarning": "Dadurch wird die Infrastruktur des Mandanten bereitgestellt, die Einrichtungsgebühr berechnet und der Kunde benachrichtigt. Bitte prüfen Sie die Angaben, bevor Sie fortfahren.",
|
||||
"approveReapproveWarning": "Dies genehmigt eine zuvor abgelehnte Anfrage erneut: Die Infrastruktur des Mandanten wird bereitgestellt, die Einrichtungsgebühr berechnet und der Kunde benachrichtigt.",
|
||||
"confirmApprove": "Genehmigen & bereitstellen"
|
||||
},
|
||||
"channelUsers": {
|
||||
"title": "Autorisierte Benutzer",
|
||||
@@ -417,7 +453,7 @@
|
||||
"title": "Assistenten zu Threema hinzufügen",
|
||||
"step1": "Öffnen Sie Threema auf Ihrem Telefon.",
|
||||
"step2": "Tippen Sie auf das Scan-Symbol und scannen Sie diesen QR-Code, um den Assistenten als Kontakt hinzuzufügen.",
|
||||
"step3": "Fügen Sie anschliessend unten Ihre eigene Threema-ID hinzu.",
|
||||
"step3": "Stellen Sie sicher, dass Ihre Threema-ID als autorisierter Benutzer eingetragen ist, damit der Assistent Ihre Nachrichten annimmt.",
|
||||
"qrAlt": "QR-Code, um {gateway} als Threema-Kontakt hinzuzufügen",
|
||||
"bannerTitle": "Threema einrichten",
|
||||
"bannerBody": "Öffnen Sie Threema auf Ihrem Telefon und scannen Sie unseren QR-Code, um den Assistenten als Kontakt hinzuzufügen. Geben Sie anschliessend unten Ihre eigene Threema-ID ein.",
|
||||
@@ -447,7 +483,15 @@
|
||||
"roleUpdateFailed": "Rolle konnte nicht aktualisiert werden.",
|
||||
"cancel": "Abbrechen",
|
||||
"save": "Speichern",
|
||||
"selfChangeBlocked": "Sie können Ihre eigene Rolle nicht ändern."
|
||||
"selfChangeBlocked": "Sie können Ihre eigene Rolle nicht ändern.",
|
||||
"accessTitle": "Zugriffsübersicht",
|
||||
"accessDescription": "Welches Mitglied auf welchen Assistenten zugreifen kann.",
|
||||
"accessMemberCol": "Mitglied",
|
||||
"accessOwnerAll": "Alle Assistenten (Eigentümer)",
|
||||
"accessHasLabel": "Zugriff",
|
||||
"accessHasNotLabel": "Kein Zugriff",
|
||||
"accessNoTenants": "Noch keine Assistenten.",
|
||||
"accessLoadFailed": "Zugriffsübersicht konnte nicht geladen werden."
|
||||
},
|
||||
"assignments": {
|
||||
"loading": "Zuweisungen werden geladen…",
|
||||
@@ -943,5 +987,24 @@
|
||||
"saving": "Speichern…",
|
||||
"saved": "Gespeichert.",
|
||||
"missingRequired": "Vor- und Nachname sind erforderlich."
|
||||
},
|
||||
"errors": {
|
||||
"title": "Etwas ist schiefgelaufen",
|
||||
"description": "Beim Laden dieser Seite ist ein Fehler aufgetreten. Bitte versuchen Sie es erneut.",
|
||||
"retry": "Erneut versuchen",
|
||||
"backToDashboard": "Zurück zum Dashboard",
|
||||
"notFoundTitle": "Seite nicht gefunden",
|
||||
"notFoundDescription": "Die angeforderte Seite existiert nicht oder wurde verschoben."
|
||||
},
|
||||
"connect": {
|
||||
"title": "Mit Ihrem Assistenten verbinden",
|
||||
"description": "Ihr Assistent läuft in Ihrer Messaging-App. So beginnen Sie den Chat mit ihm.",
|
||||
"notReadyNote": "Ihr Assistent wird noch eingerichtet. Diese Verbindungsdetails funktionieren, sobald er bereit ist.",
|
||||
"noChannelsTitle": "Noch kein Messaging-Kanal",
|
||||
"noChannelsBody": "Ihr Assistent läuft, hat aber keinen Kanal zum Chatten. Aktivieren Sie unten im Bereich Pakete einen Kanal – Threema, Telegram oder Discord –, um ihn zu nutzen.",
|
||||
"threemaBotIdLabel": "Threema-ID",
|
||||
"threemaSteps": "1. Öffnen Sie Threema und scannen Sie diesen QR-Code (oder fügen Sie die obige ID als Kontakt hinzu).\n2. Senden Sie eine Nachricht, um den Chat zu starten.\nStellen Sie sicher, dass Ihre eigene Threema-ID in der Liste der autorisierten Benutzer unten steht – nur gelistete IDs erhalten eine Antwort.",
|
||||
"telegramSteps": "Öffnen Sie den verbundenen Telegram-Bot und senden Sie ihm eine Nachricht, um den Chat zu starten. Nur die Benutzer-IDs in der Liste der autorisierten Benutzer unten erhalten eine Antwort.",
|
||||
"discordSteps": "Schreiben Sie dem verbundenen Discord-Bot oder erwähnen Sie ihn in einem Kanal, dem er beigetreten ist. Nur die Benutzer-IDs in der Liste der autorisierten Benutzer unten erhalten eine Antwort."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
"tagline": "AI Platform",
|
||||
"login": "Sign In",
|
||||
"logout": "Sign Out",
|
||||
"menu": "Menu",
|
||||
"dashboard": "Dashboard",
|
||||
"admin": "Admin",
|
||||
"loading": "Loading…",
|
||||
@@ -93,7 +94,7 @@
|
||||
"provisioningDescription": "Your AI assistant is being provisioned. This usually takes a few minutes.",
|
||||
"phase": "Phase",
|
||||
"readyTitle": "Your assistant is ready!",
|
||||
"readyDescription": "Your AI assistant has been provisioned and is running. You can now manage it from the dashboard.",
|
||||
"readyDescription": "Your AI assistant has been provisioned and is running. Next, connect it to your messaging app to start chatting.",
|
||||
"goToDashboard": "Go to Dashboard",
|
||||
"submittedAt": "Submitted",
|
||||
"instanceName": "Instance name",
|
||||
@@ -126,7 +127,31 @@
|
||||
"setupFeeNoticeHeading": "Setup fee will be charged on submit",
|
||||
"setupFeeNoticeBody": "On the next click you'll be redirected to Stripe to enter your payment details and pay the one-time setup fee. Your card is saved automatically for future monthly billing. You'll be brought back to your dashboard immediately afterwards. The instance starts running only after admin approval — monthly fees begin from the approval date.",
|
||||
"setupFeeAmountLabel": "One-time setup fee",
|
||||
"setupFeePlusVat": "+ VAT"
|
||||
"setupFeePlusVat": "+ VAT",
|
||||
"optional": "optional",
|
||||
"yourChannelIdLabel": {
|
||||
"telegram": "Your Telegram user ID",
|
||||
"discord": "Your Discord user ID",
|
||||
"threema": "Your Threema ID"
|
||||
},
|
||||
"yourChannelIdPlaceholder": {
|
||||
"telegram": "e.g. 1234567890",
|
||||
"discord": "e.g. 234567890123456789",
|
||||
"threema": "e.g. ABCD1234"
|
||||
},
|
||||
"yourChannelIdHelp": {
|
||||
"telegram": "Open Telegram, message @userinfobot, and paste the numeric id it returns. You can add more users later from the tenant page.",
|
||||
"discord": "Enable Developer Mode in Discord (Advanced settings), right-click your name → Copy User ID, and paste it here. You can add more users later from the tenant page.",
|
||||
"threema": "The 8 characters shown in your Threema app under Settings → My Threema ID. Once your tenant is approved and Threema is enabled, you'll be able to chat with the assistant from this account. More authorized IDs can be added later from the tenant page."
|
||||
},
|
||||
"connectCta": "Connect your assistant",
|
||||
"packagesIncompleteHint": "Add the required details for: {packages}",
|
||||
"setupProgress": "Setup progress",
|
||||
"setupStepsComplete": "{done} of {total} steps",
|
||||
"costSummaryHeading": "What you'll pay",
|
||||
"costSetupLabel": "One-time setup",
|
||||
"costMonthlyLabel": "Monthly, per assistant",
|
||||
"costUsageNote": "Plus usage-based AI costs, billed monthly in CHF. You can set a spending cap per assistant at any time."
|
||||
},
|
||||
"dashboard": {
|
||||
"title": "Dashboard",
|
||||
@@ -209,7 +234,10 @@
|
||||
"budgetCadence_1mo": "Monthly",
|
||||
"budgetCadence_1y": "Yearly",
|
||||
"budgetInvalid": "Please enter a positive amount.",
|
||||
"budgetSaveFailed": "Could not save budget. Please try again."
|
||||
"budgetSaveFailed": "Could not save budget. Please try again.",
|
||||
"legendInput": "Input",
|
||||
"legendOutput": "Output",
|
||||
"chartHint": "Tap a bar for that day"
|
||||
},
|
||||
"workspace": {
|
||||
"save": "Save",
|
||||
@@ -315,7 +343,7 @@
|
||||
},
|
||||
"threema": {
|
||||
"description": "Send and receive messages through Threema. Each inbound and outbound message uses the shared PieCed messaging service and incurs a per-message charge from Threema — a third-party cost, separate from your PieCed subscription.",
|
||||
"instructions": "1. Enable this package.\n2. Open Threema on your phone, scan the QR code shown under Authorized Users → threema, and accept the contact.\n3. Add your own Threema ID under Authorized Users → threema so the assistant recognises your messages.\n4. Send a message from Threema to start chatting with the assistant.",
|
||||
"instructions": "1. Open Threema on your phone and scan the QR code shown below — do it now so you're ready to chat the moment your tenant is running.\n2. Enter your own Threema ID in the field below (the 8 characters from Settings → My Threema ID in your Threema app) so the assistant accepts your messages.\n3. When your tenant is approved and running, send a message from Threema to start chatting.",
|
||||
"disclaimer": "Messages between Threema and PieCed are end-to-end encrypted up to PieCed's messaging service, where they are decrypted to be routed to your assistant. Each message sent or received is counted toward Threema's per-message billing — see your plan for current rates."
|
||||
},
|
||||
"manualReviewPending": "Manual review pending",
|
||||
@@ -324,7 +352,11 @@
|
||||
"tryAgain": "Try again",
|
||||
"credentialsSaved": "credentials saved",
|
||||
"credentialsSavedTip": "The credentials you entered are securely stored and will be used as soon as admin approves the activation. You don't need to re-enter them.",
|
||||
"recommended": "Recommended"
|
||||
"recommended": "Recommended",
|
||||
"threemaBotIdHeading": "Bot Threema ID",
|
||||
"threemaBotIdHint": "This is the assistant's Threema ID — identical for every PieCed tenant. Scan the QR now with your Threema app so you're ready the moment your tenant is approved and Threema is enabled.",
|
||||
"showInfo": "Info",
|
||||
"showInfoTitle": "Show setup info again"
|
||||
},
|
||||
"admin": {
|
||||
"title": "Platform Admin",
|
||||
@@ -400,7 +432,11 @@
|
||||
"openclawTool": "OpenClaw versions",
|
||||
"billingTool": "Billing →",
|
||||
"skillsQueueTool": "Activation Queue",
|
||||
"cronTool": "Automation"
|
||||
"cronTool": "Automation",
|
||||
"approveTitle": "Approve request?",
|
||||
"approveWarning": "This provisions the tenant's infrastructure, charges the setup fee, and notifies the customer. Check the request details are correct before continuing.",
|
||||
"approveReapproveWarning": "This re-approves a previously rejected request: it provisions the tenant's infrastructure, charges the setup fee, and notifies the customer.",
|
||||
"confirmApprove": "Approve & provision"
|
||||
},
|
||||
"channelUsers": {
|
||||
"title": "Authorized Users",
|
||||
@@ -417,7 +453,7 @@
|
||||
"title": "Add the assistant to your Threema",
|
||||
"step1": "Open Threema on your phone.",
|
||||
"step2": "Tap the scan icon and scan this QR code to add the assistant as a contact.",
|
||||
"step3": "Then add your own Threema ID below.",
|
||||
"step3": "Make sure your Threema ID is registered as an authorized user so the assistant accepts your messages.",
|
||||
"qrAlt": "QR code to add {gateway} as a Threema contact",
|
||||
"bannerTitle": "Set up Threema",
|
||||
"bannerBody": "Open Threema on your phone and scan our QR code to add the assistant as a contact. Then add your own Threema ID below.",
|
||||
@@ -447,7 +483,15 @@
|
||||
"roleUpdateFailed": "Could not update role.",
|
||||
"cancel": "Cancel",
|
||||
"save": "Save",
|
||||
"selfChangeBlocked": "You cannot change your own role."
|
||||
"selfChangeBlocked": "You cannot change your own role.",
|
||||
"accessTitle": "Access overview",
|
||||
"accessDescription": "Which member can reach which assistant.",
|
||||
"accessMemberCol": "Member",
|
||||
"accessOwnerAll": "All assistants (owner)",
|
||||
"accessHasLabel": "Has access",
|
||||
"accessHasNotLabel": "No access",
|
||||
"accessNoTenants": "No assistants yet.",
|
||||
"accessLoadFailed": "Couldn't load the access overview."
|
||||
},
|
||||
"assignments": {
|
||||
"loading": "Loading assignments…",
|
||||
@@ -943,5 +987,24 @@
|
||||
"saving": "Saving…",
|
||||
"saved": "Saved.",
|
||||
"missingRequired": "First and last name are required."
|
||||
},
|
||||
"errors": {
|
||||
"title": "Something went wrong",
|
||||
"description": "An error occurred while loading this page. Please try again.",
|
||||
"retry": "Try again",
|
||||
"backToDashboard": "Back to dashboard",
|
||||
"notFoundTitle": "Page not found",
|
||||
"notFoundDescription": "The page you're looking for doesn't exist or has moved."
|
||||
},
|
||||
"connect": {
|
||||
"title": "Connect to your assistant",
|
||||
"description": "Your assistant runs inside your messaging app. Here's how to start chatting with it.",
|
||||
"notReadyNote": "Your assistant is still being set up. These connection details will work as soon as it's ready.",
|
||||
"noChannelsTitle": "No messaging channel yet",
|
||||
"noChannelsBody": "Your assistant is running but has no channel to chat through. Enable a channel — Threema, Telegram, or Discord — in the Packages section below to start using it.",
|
||||
"threemaBotIdLabel": "Threema ID",
|
||||
"threemaSteps": "1. Open Threema and scan this QR code (or add the ID above as a contact).\n2. Send it a message to start chatting.\nMake sure your own Threema ID is on the authorised users list below — only listed IDs get a reply.",
|
||||
"telegramSteps": "Open the Telegram bot you connected and send it a message to start chatting. Only the user IDs on the authorised users list below get a reply.",
|
||||
"discordSteps": "Message the Discord bot you connected, or mention it in a channel it has joined. Only the user IDs on the authorised users list below get a reply."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
"tagline": "Plateforme IA",
|
||||
"login": "Connexion",
|
||||
"logout": "Déconnexion",
|
||||
"menu": "Menu",
|
||||
"dashboard": "Tableau de bord",
|
||||
"admin": "Admin",
|
||||
"loading": "Chargement…",
|
||||
@@ -93,7 +94,7 @@
|
||||
"provisioningDescription": "Votre assistant IA est en cours de mise en service. Cela prend généralement quelques minutes.",
|
||||
"phase": "Phase",
|
||||
"readyTitle": "Votre assistant est prêt !",
|
||||
"readyDescription": "Votre assistant IA a été mis en service et est actif. Vous pouvez maintenant le gérer depuis le tableau de bord.",
|
||||
"readyDescription": "Votre assistant IA a été provisionné et fonctionne. Connectez-le maintenant à votre application de messagerie pour commencer à discuter.",
|
||||
"goToDashboard": "Aller au tableau de bord",
|
||||
"submittedAt": "Soumis",
|
||||
"instanceName": "Nom de l'instance",
|
||||
@@ -126,7 +127,31 @@
|
||||
"setupFeeNoticeHeading": "Les frais de configuration seront facturés à l'envoi",
|
||||
"setupFeeNoticeBody": "Au prochain clic vous serez redirigé vers Stripe pour saisir vos coordonnées de paiement et régler les frais d'activation uniques. Votre carte est enregistrée automatiquement pour la facturation mensuelle future. Vous reviendrez immédiatement au tableau de bord. L'instance ne démarre qu'après validation par l'administrateur — les frais mensuels commencent à compter de la date de validation.",
|
||||
"setupFeeAmountLabel": "Frais d'activation uniques",
|
||||
"setupFeePlusVat": "+ TVA"
|
||||
"setupFeePlusVat": "+ TVA",
|
||||
"optional": "facultatif",
|
||||
"yourChannelIdLabel": {
|
||||
"telegram": "Votre ID utilisateur Telegram",
|
||||
"discord": "Votre ID utilisateur Discord",
|
||||
"threema": "Votre ID Threema"
|
||||
},
|
||||
"yourChannelIdPlaceholder": {
|
||||
"telegram": "ex. 1234567890",
|
||||
"discord": "ex. 234567890123456789",
|
||||
"threema": "ex. ABCD1234"
|
||||
},
|
||||
"yourChannelIdHelp": {
|
||||
"telegram": "Ouvrez Telegram, écrivez à @userinfobot et collez l'ID numérique qu'il retourne. Vous pourrez ajouter d'autres utilisateurs plus tard depuis la page du tenant.",
|
||||
"discord": "Activez le mode développeur dans Discord (paramètres avancés), clic-droit sur votre nom → Copier l'ID utilisateur, puis collez-le ici. Vous pourrez ajouter d'autres utilisateurs plus tard depuis la page du tenant.",
|
||||
"threema": "Les 8 caractères affichés dans votre app Threema sous Réglages → Mon identifiant Threema. Une fois votre tenant approuvé et Threema activé, vous pourrez discuter avec l'assistant depuis ce compte. D'autres ID autorisés peuvent être ajoutés plus tard depuis la page du tenant."
|
||||
},
|
||||
"connectCta": "Connecter votre assistant",
|
||||
"packagesIncompleteHint": "Complétez les informations requises pour : {packages}",
|
||||
"setupProgress": "Progression de la configuration",
|
||||
"setupStepsComplete": "{done} sur {total} étapes",
|
||||
"costSummaryHeading": "Ce que vous paierez",
|
||||
"costSetupLabel": "Installation unique",
|
||||
"costMonthlyLabel": "Mensuel, par assistant",
|
||||
"costUsageNote": "Plus les coûts d'IA à l'usage, facturés mensuellement en CHF. Vous pouvez définir un plafond de dépenses par assistant à tout moment."
|
||||
},
|
||||
"dashboard": {
|
||||
"title": "Tableau de bord",
|
||||
@@ -209,7 +234,10 @@
|
||||
"budgetCadence_1mo": "Mensuelle",
|
||||
"budgetCadence_1y": "Annuelle",
|
||||
"budgetInvalid": "Veuillez saisir un montant positif.",
|
||||
"budgetSaveFailed": "Impossible d'enregistrer le budget. Veuillez réessayer."
|
||||
"budgetSaveFailed": "Impossible d'enregistrer le budget. Veuillez réessayer.",
|
||||
"legendInput": "Entrée",
|
||||
"legendOutput": "Sortie",
|
||||
"chartHint": "Touchez une barre pour le détail"
|
||||
},
|
||||
"workspace": {
|
||||
"save": "Enregistrer",
|
||||
@@ -315,7 +343,7 @@
|
||||
},
|
||||
"threema": {
|
||||
"description": "Envoyez et recevez des messages via Threema. Chaque message entrant ou sortant transite par le service de messagerie PieCed partagé et entraîne des frais par message facturés par Threema — un coût tiers, distinct de votre abonnement PieCed.",
|
||||
"instructions": "1. Activez ce package.\n2. Ouvrez Threema sur votre téléphone, scannez le QR code affiché dans Utilisateurs autorisés → threema, puis acceptez le contact.\n3. Ajoutez votre propre identifiant Threema sous Utilisateurs autorisés → threema afin que l'assistant reconnaisse vos messages.\n4. Envoyez un message depuis Threema pour commencer la conversation.",
|
||||
"instructions": "1. Ouvrez Threema sur votre téléphone et scannez le QR code affiché ci-dessous — faites-le dès maintenant pour être prêt à discuter dès que votre tenant sera opérationnel.\n2. Saisissez votre propre identifiant Threema dans le champ ci-dessous (les 8 caractères figurant dans Réglages → Mon identifiant Threema dans l'app Threema) afin que l'assistant accepte vos messages.\n3. Une fois votre tenant approuvé et opérationnel, envoyez un message depuis Threema pour démarrer la conversation.",
|
||||
"disclaimer": "Les messages entre Threema et PieCed sont chiffrés de bout en bout jusqu'au service de messagerie PieCed, où ils sont déchiffrés pour être acheminés vers votre assistant. Chaque message envoyé ou reçu est facturé par Threema selon son tarif par message — consultez votre plan pour les tarifs en vigueur."
|
||||
},
|
||||
"manualReviewPending": "Revue manuelle en attente",
|
||||
@@ -324,7 +352,11 @@
|
||||
"tryAgain": "Réessayer",
|
||||
"credentialsSaved": "identifiants enregistrés",
|
||||
"credentialsSavedTip": "Les identifiants saisis sont stockés en sécurité et seront utilisés dès l'approbation de l'activation par l'administrateur. Vous n'avez pas besoin de les ressaisir.",
|
||||
"recommended": "Recommandé"
|
||||
"recommended": "Recommandé",
|
||||
"threemaBotIdHeading": "ID Threema du bot",
|
||||
"threemaBotIdHint": "Voici l'identifiant Threema de l'assistant — identique pour chaque tenant PieCed. Scannez le QR dès maintenant avec votre app Threema afin d'être prêt dès l'approbation de votre tenant et l'activation de Threema.",
|
||||
"showInfo": "Info",
|
||||
"showInfoTitle": "Réafficher les infos de configuration"
|
||||
},
|
||||
"admin": {
|
||||
"title": "Admin plateforme",
|
||||
@@ -400,7 +432,11 @@
|
||||
"openclawTool": "Versions OpenClaw",
|
||||
"billingTool": "Facturation →",
|
||||
"skillsQueueTool": "File d'activation",
|
||||
"cronTool": "Automatisation"
|
||||
"cronTool": "Automatisation",
|
||||
"approveTitle": "Approuver la demande ?",
|
||||
"approveWarning": "Cela provisionne l'infrastructure du locataire, facture les frais d'installation et notifie le client. Vérifiez l'exactitude des détails de la demande avant de continuer.",
|
||||
"approveReapproveWarning": "Ceci réapprouve une demande précédemment rejetée : l'infrastructure du locataire est provisionnée, les frais d'installation sont facturés et le client est notifié.",
|
||||
"confirmApprove": "Approuver et provisionner"
|
||||
},
|
||||
"channelUsers": {
|
||||
"title": "Utilisateurs autorisés",
|
||||
@@ -417,7 +453,7 @@
|
||||
"title": "Ajouter l'assistant à Threema",
|
||||
"step1": "Ouvrez Threema sur votre téléphone.",
|
||||
"step2": "Appuyez sur l'icône de scan et scannez ce QR code pour ajouter l'assistant comme contact.",
|
||||
"step3": "Puis ajoutez votre propre identifiant Threema ci-dessous.",
|
||||
"step3": "Assurez-vous que votre identifiant Threema est enregistré comme utilisateur autorisé pour que l'assistant accepte vos messages.",
|
||||
"qrAlt": "QR code pour ajouter {gateway} comme contact Threema",
|
||||
"bannerTitle": "Configurer Threema",
|
||||
"bannerBody": "Ouvrez Threema sur votre téléphone et scannez notre QR code pour ajouter l'assistant comme contact. Saisissez ensuite votre propre identifiant Threema ci-dessous.",
|
||||
@@ -447,7 +483,15 @@
|
||||
"roleUpdateFailed": "Impossible de mettre à jour le rôle.",
|
||||
"cancel": "Annuler",
|
||||
"save": "Enregistrer",
|
||||
"selfChangeBlocked": "Vous ne pouvez pas modifier votre propre rôle."
|
||||
"selfChangeBlocked": "Vous ne pouvez pas modifier votre propre rôle.",
|
||||
"accessTitle": "Aperçu des accès",
|
||||
"accessDescription": "Quel membre peut accéder à quel assistant.",
|
||||
"accessMemberCol": "Membre",
|
||||
"accessOwnerAll": "Tous les assistants (propriétaire)",
|
||||
"accessHasLabel": "Accès",
|
||||
"accessHasNotLabel": "Aucun accès",
|
||||
"accessNoTenants": "Aucun assistant pour l'instant.",
|
||||
"accessLoadFailed": "Impossible de charger l'aperçu des accès."
|
||||
},
|
||||
"assignments": {
|
||||
"loading": "Chargement des attributions…",
|
||||
@@ -943,5 +987,24 @@
|
||||
"saving": "Enregistrement…",
|
||||
"saved": "Enregistré.",
|
||||
"missingRequired": "Le prénom et le nom sont obligatoires."
|
||||
},
|
||||
"errors": {
|
||||
"title": "Une erreur est survenue",
|
||||
"description": "Une erreur s'est produite lors du chargement de cette page. Veuillez réessayer.",
|
||||
"retry": "Réessayer",
|
||||
"backToDashboard": "Retour au tableau de bord",
|
||||
"notFoundTitle": "Page introuvable",
|
||||
"notFoundDescription": "La page que vous recherchez n'existe pas ou a été déplacée."
|
||||
},
|
||||
"connect": {
|
||||
"title": "Connectez-vous à votre assistant",
|
||||
"description": "Votre assistant fonctionne dans votre application de messagerie. Voici comment commencer à discuter avec lui.",
|
||||
"notReadyNote": "Votre assistant est encore en cours de configuration. Ces informations de connexion fonctionneront dès qu'il sera prêt.",
|
||||
"noChannelsTitle": "Aucun canal de messagerie",
|
||||
"noChannelsBody": "Votre assistant fonctionne mais n'a aucun canal pour discuter. Activez un canal — Threema, Telegram ou Discord — dans la section Forfaits ci-dessous pour commencer à l'utiliser.",
|
||||
"threemaBotIdLabel": "Identifiant Threema",
|
||||
"threemaSteps": "1. Ouvrez Threema et scannez ce QR code (ou ajoutez l'identifiant ci-dessus comme contact).\n2. Envoyez-lui un message pour commencer à discuter.\nAssurez-vous que votre propre identifiant Threema figure dans la liste des utilisateurs autorisés ci-dessous — seuls les identifiants listés reçoivent une réponse.",
|
||||
"telegramSteps": "Ouvrez le bot Telegram que vous avez connecté et envoyez-lui un message pour commencer à discuter. Seuls les identifiants utilisateur de la liste des utilisateurs autorisés ci-dessous reçoivent une réponse.",
|
||||
"discordSteps": "Écrivez au bot Discord que vous avez connecté, ou mentionnez-le dans un salon qu'il a rejoint. Seuls les identifiants utilisateur de la liste des utilisateurs autorisés ci-dessous reçoivent une réponse."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,14 +2,15 @@
|
||||
"common": {
|
||||
"appName": "PieCed",
|
||||
"tagline": "Piattaforma IA",
|
||||
"login": "Accedi",
|
||||
"login": "Acceda",
|
||||
"logout": "Esci",
|
||||
"menu": "Menu",
|
||||
"dashboard": "Dashboard",
|
||||
"admin": "Admin",
|
||||
"loading": "Caricamento…",
|
||||
"language": "Lingua",
|
||||
"cancel": "Annulla",
|
||||
"save": "Salva",
|
||||
"cancel": "Annulli",
|
||||
"save": "Salvi",
|
||||
"error": "Si è verificato un errore",
|
||||
"register": "Registrati",
|
||||
"team": "Team",
|
||||
@@ -20,14 +21,14 @@
|
||||
},
|
||||
"login": {
|
||||
"title": "Portale PieCed",
|
||||
"subtitle": "Accedi per gestire il tuo assistente IA",
|
||||
"button": "Continua con ZITADEL",
|
||||
"subtitle": "Acceda per gestire il suo assistente IA",
|
||||
"button": "Continui con ZITADEL",
|
||||
"footer": "Ospitato on-premises in Svizzera",
|
||||
"noAccount": "Non hai ancora un account?",
|
||||
"register": "Crea un account"
|
||||
"noAccount": "Non ha ancora un account?",
|
||||
"register": "Crei un account"
|
||||
},
|
||||
"register": {
|
||||
"title": "Crea il tuo account",
|
||||
"title": "Crei il suo account",
|
||||
"subtitle": "Configuri il suo assistente IA ospitato in Svizzera",
|
||||
"companyName": "Nome azienda",
|
||||
"companyNamePlaceholder": "Esempio SA",
|
||||
@@ -35,14 +36,14 @@
|
||||
"familyName": "Cognome",
|
||||
"email": "Indirizzo e-mail",
|
||||
"submit": "Registrati",
|
||||
"hasAccount": "Hai già un account?",
|
||||
"footer": "I tuoi dati sono ospitati esclusivamente on-premises in Svizzera.",
|
||||
"hasAccount": "Ha già un account?",
|
||||
"footer": "I suoi dati sono ospitati esclusivamente on-premises in Svizzera.",
|
||||
"successTitle": "Registrazione ricevuta",
|
||||
"successDescription": "Riceverai un'e-mail di invito con un link per impostare la password e verificare il tuo indirizzo e-mail. Dopodiché potrai accedere e configurare il tuo assistente IA.",
|
||||
"goToLogin": "Vai all'accesso",
|
||||
"duplicateDomain": "Un account per il dominio e-mail {domain} è già registrato. Contatta l'amministratore della tua azienda per essere invitato, oppure contatta il supporto PieCed IT se ritieni che si tratti di un errore.",
|
||||
"successDescription": "Riceverà un'e-mail di invito con un link per impostare la password e verificare il suo indirizzo e-mail. Dopodiché potrà accedere e configurare il suo assistente IA.",
|
||||
"goToLogin": "Vada all'accesso",
|
||||
"duplicateDomain": "Un account per il dominio e-mail {domain} è già registrato. Contatti l'amministratore della sua azienda per essere invitato, oppure contatti il supporto PieCed IT se ritiene che si tratti di un errore.",
|
||||
"individualToggle": "Registrati come privato",
|
||||
"individualHint": "Seleziona questa opzione se non ti stai registrando per conto di un'azienda. Il tuo account sarà configurato come area di lavoro personale.",
|
||||
"individualHint": "Selezioni questa opzione se non Le sta registrando per conto di un'azienda. Il suo account sarà configurato come area di lavoro personale.",
|
||||
"accountTypeLabel": "Tipo di account",
|
||||
"personalCardTitle": "Privato",
|
||||
"personalCardDescription": "Per lei.",
|
||||
@@ -51,26 +52,26 @@
|
||||
},
|
||||
"onboarding": {
|
||||
"loading": "Caricamento stato…",
|
||||
"welcomeTitle": "Configura il tuo assistente IA",
|
||||
"welcomeDescription": "In pochi passaggi avrai il tuo assistente IA — ospitato esclusivamente in Svizzera, completamente sotto il tuo controllo.",
|
||||
"welcomeFeature_swissHosted": "Ospitato on-premises in Svizzera — i tuoi dati non lasciano mai il Paese",
|
||||
"welcomeFeature_privacy": "Nessun dato condiviso con terzi — privacy completa",
|
||||
"welcomeTitle": "Configura il suo assistente IA",
|
||||
"welcomeDescription": "In pochi passaggi avrà il suo assistente IA — ospitato esclusivamente in Svizzera, completamente sotto il suo controllo.",
|
||||
"welcomeFeature_swissHosted": "Ospitato on-premises in Svizzera — i suoi dati non lasciano mai il Paese",
|
||||
"welcomeFeature_privacy": "Nessun dato condiviso con terzi — privacy completi",
|
||||
"welcomeFeature_customizable": "Personalità, pacchetti e integrazioni completamente personalizzabili",
|
||||
"getStarted": "Inizia",
|
||||
"configureTitle": "Configura il tuo assistente",
|
||||
"configureDescription": "Dai un nome e una personalità al tuo assistente. Puoi sempre modificarli in seguito.",
|
||||
"getStarted": "Inizi",
|
||||
"configureTitle": "Configura il suo assistente",
|
||||
"configureDescription": "Dia un nome e una personalità al suo assistente. Può sempre modificarli in seguito.",
|
||||
"agentName": "Nome agente",
|
||||
"soulMd": "Personalità (SOUL.md)",
|
||||
"soulMdHint": "Definisce il comportamento del tuo assistente. Formato Markdown. Modificabile in seguito.",
|
||||
"soulMdHint": "Definisce il comportamento del suo assistente. Formato Markdown. Modificabile in seguito.",
|
||||
"agentsMd": "Istruzioni agente (AGENTS.md)",
|
||||
"agentsMdHint": "Definisce cosa fa il tuo assistente all'avvio della sessione. Opzionale — i valori predefiniti funzionano per la maggior parte delle configurazioni.",
|
||||
"agentsMdHint": "Definisce cosa fa il suo assistente all'avvio della sessione. Opzionale — i valori predefiniti funzionano per la maggior parte delle configurazioni.",
|
||||
"toolsMd": "Strumenti disponibili (TOOLS.md)",
|
||||
"toolsMdHint": "Generato automaticamente in base ai pacchetti selezionati. Questo file viene gestito automaticamente.",
|
||||
"advancedConfig": "Configurazione avanzata",
|
||||
"packages": "Pacchetti",
|
||||
"packagesHint": "Integrazioni opzionali. I pacchetti che richiedono credenziali le chiederanno inline. Puoi attivarli anche in seguito.",
|
||||
"packagesHint": "Integrazioni opzionali. I pacchetti che richiedono credenziali le chiederanno inline. Può attivarli anche in seguito.",
|
||||
"billingTitle": "Informazioni di fatturazione",
|
||||
"billingDescription": "Abbiamo bisogno del tuo indirizzo di fatturazione. Un fornitore di pagamento verrà integrato in futuro.",
|
||||
"billingDescription": "Abbiamo bisogno del suo indirizzo di fatturazione. Un fornitore di pagamento verrà integrato in futuro.",
|
||||
"billingCompany": "Azienda",
|
||||
"billingStreet": "Via",
|
||||
"billingPostalCode": "CAP",
|
||||
@@ -78,38 +79,38 @@
|
||||
"billingCountry": "Paese",
|
||||
"billingNotes": "Note",
|
||||
"billingNotesPlaceholder": "Note sulla fatturazione (numero ordine, partita IVA, metodo di pagamento preferito, ecc.)",
|
||||
"confirmTitle": "Verifica e invia",
|
||||
"confirmDescription": "Verifica la tua configurazione. La tua richiesta verrà esaminata dal nostro team prima dell'attivazione.",
|
||||
"confirmNote": "Dopo l'invio, il nostro team esaminerà la tua richiesta e i dati di fatturazione. Riceverai l'accesso dopo l'approvazione — di solito entro un giorno lavorativo.",
|
||||
"confirmTitle": "Verifichi e invii",
|
||||
"confirmDescription": "Verifichi la sua configurazione. La sua richiesta verrà esaminata dal nostro team prima dell'attivazione.",
|
||||
"confirmNote": "Dopo l'invio, il nostro team esaminerà la sua richiesta e i dati di fatturazione. Riceverà l'accesso dopo l'approvazione — di solito entro un giorno lavorativo.",
|
||||
"credentialsProvided": "Credenziali fornite",
|
||||
"submitRequest": "Invia richiesta",
|
||||
"submitRequest": "Invii richiesta",
|
||||
"back": "Indietro",
|
||||
"next": "Avanti",
|
||||
"pendingTitle": "Richiesta inviata",
|
||||
"pendingDescription": "La tua richiesta è stata inviata ed è in fase di esame da parte del nostro team. Riceverai l'accesso dopo l'approvazione — di solito entro un giorno lavorativo.",
|
||||
"pendingDescription": "La sua richiesta è stata inviata ed è in fase di esame da parte del nostro team. Riceverà l'accesso dopo l'approvazione — di solito entro un giorno lavorativo.",
|
||||
"rejectedTitle": "Richiesta non approvata",
|
||||
"rejectedDescription": "Purtroppo la tua richiesta non è stata approvata. Contattaci per ulteriori informazioni.",
|
||||
"rejectedDescription": "Purtroppo la sua richiesta non è stata approvata. Contattaci per ulteriori informazioni.",
|
||||
"provisioningTitle": "Configurazione dell'istanza",
|
||||
"provisioningDescription": "Il tuo assistente IA è in fase di attivazione. Di solito richiede pochi minuti.",
|
||||
"provisioningDescription": "Il suo assistente IA è in fase di attivazione. Di solito richiede pochi minuti.",
|
||||
"phase": "Fase",
|
||||
"readyTitle": "Il tuo assistente è pronto!",
|
||||
"readyDescription": "Il tuo assistente IA è stato attivato ed è operativo. Ora puoi gestirlo dalla dashboard.",
|
||||
"goToDashboard": "Vai alla dashboard",
|
||||
"readyTitle": "Il suo assistente è pronto!",
|
||||
"readyDescription": "Il tuo assistente IA è stato provisionato ed è in funzione. Ora collegalo alla tua app di messaggistica per iniziare a chattare.",
|
||||
"goToDashboard": "Vada alla dashboard",
|
||||
"submittedAt": "Inviato",
|
||||
"instanceName": "Nome istanza",
|
||||
"instanceNamePlaceholder": "es. Produzione, Dev, Vendite",
|
||||
"instanceNameHint": "Nome leggibile facoltativo per distinguere questa istanza dalle altre nella dashboard. Lasciare vuoto per usare il nome dell'azienda.",
|
||||
"validationError": "Correggere gli errori prima di inviare.",
|
||||
"validationErrorsTitle": "Alcuni campi obbligatori sono mancanti o non validi:",
|
||||
"reviewInstanceDefault": "(predefinito — usa il nome dell'azienda)",
|
||||
"reviewInstanceDefault": "(predefinito — usi il nome dell'azienda)",
|
||||
"reviewNoPackages": "Nessuno selezionato",
|
||||
"reviewBillingTo": "Fatturare a",
|
||||
"reviewContactEmail": "Email di contatto",
|
||||
"editRequestTitle": "Modifica la sua richiesta",
|
||||
"editRequestTitle": "Modifichi la sua richiesta",
|
||||
"editRequestDescription": "Modifichi la configurazione prima che il nostro team la esamini.",
|
||||
"editRequest": "Modifica",
|
||||
"cancelRequest": "Annulla richiesta",
|
||||
"cancelRequestConfirm": "Sì, annulla la richiesta",
|
||||
"editRequest": "Modifichi",
|
||||
"cancelRequest": "Annulli richiesta",
|
||||
"cancelRequestConfirm": "Sì, annulli la richiesta",
|
||||
"cancelConfirmRequestTitle": "Annullare questa richiesta?",
|
||||
"cancelConfirmRequestDescription": "La sua richiesta in attesa sarà contrassegnata come annullata e rimossa dalla coda di revisione. Può inviare una nuova richiesta in qualsiasi momento.",
|
||||
"cancelFailed": "Impossibile annullare la richiesta.",
|
||||
@@ -118,15 +119,39 @@
|
||||
"dismiss": "Nascondi",
|
||||
"dismissFailed": "Impossibile nascondere.",
|
||||
"rejectionReason": "Motivo indicato",
|
||||
"saveChanges": "Salva modifiche",
|
||||
"saveChanges": "Salvi modifiche",
|
||||
"billingVatNumber": "Partita IVA",
|
||||
"billingVatHelp": "Il tuo identificativo IVA registrato. Se la tua azienda è esente IVA, lascia vuoto e spiega nelle note.",
|
||||
"billingVatHelp": "Il suo identificativo IVA registrato. Se la sua azienda è esente IVA, lascia vuoto e spiega nelle note.",
|
||||
"billingNotesPlaceholderPersonal": "Qualsiasi cosa dovremmo sapere — metodo di pagamento preferito, riferimento per fatturazione, ecc.",
|
||||
"reviewContactPersonPrefix": "c.a.",
|
||||
"setupFeeNoticeHeading": "Le spese di attivazione saranno addebitate all'invio",
|
||||
"setupFeeNoticeBody": "Al clic successivo sarà reindirizzato a Stripe per inserire i dati di pagamento e pagare le spese di attivazione una tantum. La sua carta viene salvata automaticamente per la fatturazione mensile futura. Tornerà subito alla dashboard. L'istanza si avvia solo dopo l'approvazione dell'admin — i canoni mensili decorrono dalla data di approvazione.",
|
||||
"setupFeeAmountLabel": "Spese di attivazione una tantum",
|
||||
"setupFeePlusVat": "+ IVA"
|
||||
"setupFeePlusVat": "+ IVA",
|
||||
"optional": "facoltativo",
|
||||
"yourChannelIdLabel": {
|
||||
"telegram": "Il suo ID utente Telegram",
|
||||
"discord": "Il suo ID utente Discord",
|
||||
"threema": "Il suo ID Threema"
|
||||
},
|
||||
"yourChannelIdPlaceholder": {
|
||||
"telegram": "es. 1234567890",
|
||||
"discord": "es. 234567890123456789",
|
||||
"threema": "es. ABCD1234"
|
||||
},
|
||||
"yourChannelIdHelp": {
|
||||
"telegram": "Apra Telegram, scriva a @userinfobot e incolli qui l'ID numerico restituito. Potrà aggiungere altri utenti in seguito dalla pagina del tenant.",
|
||||
"discord": "Attivi la Modalità sviluppatore in Discord (Impostazioni avanzate), clic destro sul suo nome → Copia ID utente, poi incolli qui. Potrà aggiungere altri utenti in seguito dalla pagina del tenant.",
|
||||
"threema": "Gli 8 caratteri mostrati nella sua app Threema in Impostazioni → Il mio ID Threema. Una volta approvato il suo tenant e attivato Threema, potrà chattare con l'assistente da questo account. Altri ID autorizzati possono essere aggiunti in seguito dalla pagina del tenant."
|
||||
},
|
||||
"connectCta": "Collega il tuo assistente",
|
||||
"packagesIncompleteHint": "Completa i dettagli richiesti per: {packages}",
|
||||
"setupProgress": "Avanzamento configurazione",
|
||||
"setupStepsComplete": "{done} di {total} passaggi",
|
||||
"costSummaryHeading": "Quanto pagherai",
|
||||
"costSetupLabel": "Attivazione una tantum",
|
||||
"costMonthlyLabel": "Mensile, per assistente",
|
||||
"costUsageNote": "Più i costi dell'IA in base all'utilizzo, fatturati mensilmente in CHF. Puoi impostare un limite di spesa per assistente in qualsiasi momento."
|
||||
},
|
||||
"dashboard": {
|
||||
"title": "Dashboard",
|
||||
@@ -136,17 +161,17 @@
|
||||
"packages": "Pacchetti",
|
||||
"noInstance": "Nessuna istanza attivata.",
|
||||
"comingSoon": "Vista dettagliata in arrivo nella Sessione 6.2",
|
||||
"noInstanceDescription": "Configura la tua istanza di assistente IA per iniziare con PieCed IT.",
|
||||
"manage": "Gestisci istanza e pacchetti",
|
||||
"instances": "Le tue istanze",
|
||||
"noInstanceDescription": "Configura la sua istanza di assistente IA per iniziare con PieCed IT.",
|
||||
"manage": "Gestisca istanza e pacchetti",
|
||||
"instances": "Le sue istanze",
|
||||
"inflightRequests": "Richieste in corso",
|
||||
"createInstance": "Crea nuova istanza",
|
||||
"createInstanceDescription": "Effettua il provisioning di un'ulteriore istanza dell'assistente IA per la tua organizzazione. La richiesta sarà esaminata da un amministratore prima della creazione dell'istanza.",
|
||||
"noAccessNoInstances": "La tua organizzazione non ha ancora istanze. Chiedi al proprietario dell'organizzazione di configurarne una.",
|
||||
"createInstance": "Crei nuova istanza",
|
||||
"createInstanceDescription": "Effettua il provisioning di un'ulteriore istanza dell'assistente IA per la sua organizzazione. La richiesta sarà esaminata da un amministratore prima della creazione dell'istanza.",
|
||||
"noAccessNoInstances": "La sua organizzazione non ha ancora istanze. Chieda al proprietario dell'organizzazione di configurarne una.",
|
||||
"noAssignmentsTitle": "Nessuna istanza assegnata",
|
||||
"noAssignmentsDescription": "La tua organizzazione ha delle istanze, ma non ti è stato concesso l'accesso a nessuna di esse. Chiedi al proprietario della tua organizzazione di assegnarti a un'istanza.",
|
||||
"noAssignmentsDescription": "La sua organizzazione ha delle istanze, ma non Le è stato concesso l'accesso a nessuna di esse. Chieda al proprietario della sua organizzazione di assegnarLa a un'istanza.",
|
||||
"noInstancesYetTitle": "Nessuna istanza ancora",
|
||||
"noInstancesYetDescription": "La tua organizzazione non ha ancora istanze. Chiedi al proprietario della tua organizzazione di configurarne una."
|
||||
"noInstancesYetDescription": "La sua organizzazione non ha ancora istanze. Chieda al proprietario della sua organizzazione di configurarne una."
|
||||
},
|
||||
"tenantDetail": {
|
||||
"agent": "Agente",
|
||||
@@ -159,9 +184,9 @@
|
||||
"subscriptionTitle": "Abbonamento",
|
||||
"subscriptionDescriptionActive": "Annulli il suo abbonamento se non ha più bisogno di questo assistente. I suoi dati saranno preservati e potrà riprendere in qualsiasi momento.",
|
||||
"subscriptionDescriptionSuspended": "Il suo abbonamento è annullato. Riprenda per riportare l'assistente online.",
|
||||
"cancelSubscription": "Annulla abbonamento",
|
||||
"cancelSubscriptionConfirm": "Sì, annulla",
|
||||
"resumeSubscription": "Riprendi abbonamento",
|
||||
"cancelSubscription": "Annulli abbonamento",
|
||||
"cancelSubscriptionConfirm": "Sì, annulli",
|
||||
"resumeSubscription": "Riprenda abbonamento",
|
||||
"cancelConfirmTitle": "Annullare questo abbonamento?",
|
||||
"cancelConfirmDescription": "Il suo assistente diventerà non disponibile. Può riprendere in qualsiasi momento — i suoi dati sono preservati.",
|
||||
"cancelConfirmBullet1": "I file del workspace (SOUL.md, AGENTS.md) sono mantenuti",
|
||||
@@ -169,16 +194,16 @@
|
||||
"cancelConfirmBullet3": "Le informazioni di fatturazione sono mantenute",
|
||||
"subscriptionUpdateFailed": "Impossibile aggiornare l'abbonamento.",
|
||||
"suspendedTitle": "Abbonamento annullato",
|
||||
"suspendedDescription": "Il suo assistente è in pausa. Configurazione e dati sono preservati. Usi il controllo Riprendi in fondo a questa pagina per riportarlo online.",
|
||||
"requestReactivation": "Richiedi riattivazione",
|
||||
"suspendedDescription": "Il suo assistente è in pausa. Configurazione e dati sono preservati. Usi il controllo Riprenda in fondo a questa pagina per riportarlo online.",
|
||||
"requestReactivation": "Richieda riattivazione",
|
||||
"requestReactivationConfirmTitle": "Richiedere la riattivazione?",
|
||||
"requestReactivationConfirmDescription": "Un amministratore esaminerà la tua richiesta e riattiverà il tuo tenant. Riceverai un'email non appena la richiesta sarà approvata.",
|
||||
"requestReactivationConfirm": "Invia richiesta",
|
||||
"cancelResumeRequest": "Annulla richiesta",
|
||||
"requestReactivationConfirmDescription": "Un amministratore esaminerà la sua richiesta e riattiverà il suo tenant. Riceverà un'email non appena la richiesta sarà approvata.",
|
||||
"requestReactivationConfirm": "Invii richiesta",
|
||||
"cancelResumeRequest": "Annulli richiesta",
|
||||
"resumeRequestPendingTitle": "Richiesta di riattivazione in sospeso",
|
||||
"resumeRequestPendingDescription": "Inviata {when}. Un amministratore la esaminerà a breve.",
|
||||
"resumeRequestPendingNoteAdmin": "Un proprietario ha richiesto la riattivazione; puoi riprendere direttamente sopra o elaborare la richiesta dalla coda di amministrazione.",
|
||||
"cancelConfirmRetentionWarning": "I tuoi dati sono conservati per 60 giorni dopo l'annullamento. Trascorso tale periodo, tutti i dati del tenant — configurazione, segreti, conversazioni e file — verranno eliminati definitivamente.",
|
||||
"resumeRequestPendingNoteAdmin": "Un proprietario ha richiesto la riattivazione; può riprendere direttamente sopra o elaborare la richiesta dalla coda di amministrazione.",
|
||||
"cancelConfirmRetentionWarning": "I suoi dati sono conservati per 60 giorni dopo l'annullamento. Trascorso tale periodo, tutti i dati del tenant — configurazione, segreti, conversazioni e file — verranno eliminati definitivamente.",
|
||||
"suspendedSince": "Sospeso il {date}",
|
||||
"suspendedDeletionIn": "eliminazione dei dati tra {days, plural, one {# giorno} other {# giorni}} ({date})",
|
||||
"suspendedDeletionImminent": "i dati vengono eliminati ora",
|
||||
@@ -196,26 +221,29 @@
|
||||
"noData": "Nessun dato di utilizzo disponibile.",
|
||||
"dailyBreakdown": "Dettaglio giornaliero",
|
||||
"requests": "richieste",
|
||||
"budgetEdit": "Modifica",
|
||||
"budgetEditTitle": "Imposta budget",
|
||||
"budgetEditDescription": "Limita quanto gli assistenti di questo tenant possono spendere prima che le richieste vengano rifiutate.",
|
||||
"budgetEdit": "Modifichi",
|
||||
"budgetEditTitle": "Imposti budget",
|
||||
"budgetEditDescription": "Limiti quanto gli assistenti di questo tenant possono spendere prima che le richieste vengano rifiutate.",
|
||||
"budgetModeUnlimited": "Nessun limite",
|
||||
"budgetModeUnlimitedDescription": "Spesa libera, nessun tetto.",
|
||||
"budgetModeCapped": "Imposta un tetto",
|
||||
"budgetModeCapped": "Imposti un tetto",
|
||||
"budgetModeCappedDescription": "Rifiuta le richieste una volta raggiunto questo importo.",
|
||||
"budgetAmount": "Importo",
|
||||
"budgetResetCadence": "Ripristino",
|
||||
"budgetCadence_30d": "Ogni 30 giorni",
|
||||
"budgetCadence_1mo": "Mensile",
|
||||
"budgetCadence_1y": "Annuale",
|
||||
"budgetInvalid": "Inserisci un importo positivo.",
|
||||
"budgetSaveFailed": "Impossibile salvare il budget. Riprova."
|
||||
"budgetInvalid": "Inserisca un importo positivo.",
|
||||
"budgetSaveFailed": "Impossibile salvare il budget. Riprova.",
|
||||
"legendInput": "Input",
|
||||
"legendOutput": "Output",
|
||||
"chartHint": "Tocca una barra per i dettagli"
|
||||
},
|
||||
"workspace": {
|
||||
"save": "Salva",
|
||||
"placeholder": "Inserisci il contenuto per {file}…",
|
||||
"save": "Salvi",
|
||||
"placeholder": "Inserisca il contenuto per {file}…",
|
||||
"readonlyNote": "Questo file viene generato automaticamente e non può essere modificato manualmente.",
|
||||
"seedingNote": "I file workspace vengono inizializzati al primo avvio. Un aggiornamento su un'istanza esistente attiva un aggiornamento del ConfigMap e un riavvio del pod."
|
||||
"seedingNote": "I file workspace vengono inizializzati al primo avvio. Un aggiornamento su un'istanza esistente attivi un aggiornamento del ConfigMap e un riavvio del pod."
|
||||
},
|
||||
"packages": {
|
||||
"categories": {
|
||||
@@ -223,9 +251,9 @@
|
||||
"skills": "Capacità",
|
||||
"core": "Core"
|
||||
},
|
||||
"enable": "Attiva",
|
||||
"disable": "Disattiva",
|
||||
"enableAndSave": "Attiva e salva",
|
||||
"enable": "Attivi",
|
||||
"disable": "Disattivi",
|
||||
"enableAndSave": "Attivi e salvi",
|
||||
"configure": "Configura",
|
||||
"requiresApiKey": "Richiede chiave API",
|
||||
"missingFields": "Compilare tutti i campi obbligatori.",
|
||||
@@ -235,17 +263,17 @@
|
||||
"error": "Errore"
|
||||
},
|
||||
"telegram": {
|
||||
"description": "Collega il tuo assistente IA a un bot Telegram.",
|
||||
"description": "Collega il suo assistente IA a un bot Telegram.",
|
||||
"botTokenLabel": "Token bot Telegram",
|
||||
"botTokenPlaceholder": "123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11",
|
||||
"instructions": "1. Apri @BotFather su Telegram\n2. Invia /newbot e segui le istruzioni\n3. Copia il token del bot",
|
||||
"instructions": "1. Apra @BotFather su Telegram\n2. Invii /newbot e segua le istruzioni\n3. Copi il token del bot",
|
||||
"disclaimer": "Confermo di possedere questo bot Telegram e autorizzo PieCed IT a collegarlo al mio assistente IA."
|
||||
},
|
||||
"discord": {
|
||||
"description": "Collega il tuo assistente IA a un server Discord tramite un bot.",
|
||||
"description": "Collega il suo assistente IA a un server Discord tramite un bot.",
|
||||
"botTokenLabel": "Token bot Discord",
|
||||
"botTokenPlaceholder": "MTAxNjQ0OTk2NjAz...",
|
||||
"instructions": "1. Vai su discord.com/developers/applications\n2. Crea una nuova applicazione e aggiungi un bot\n3. Copia il token del bot",
|
||||
"instructions": "1. Vada su discord.com/developers/applications\n2. Crei una nuova applicazione e aggiunga un bot\n3. Copi il token del bot",
|
||||
"disclaimer": "Confermo di possedere questo bot Discord e autorizzo PieCed IT a collegarlo al mio assistente IA.",
|
||||
"appIdLabel": "ID applicazione Discord",
|
||||
"appIdPlaceholder": "ID numerico di 18–19 cifre dal Developer Portal"
|
||||
@@ -253,7 +281,7 @@
|
||||
"statusEnabled": "abilitato",
|
||||
"statusDisabled": "disabilitato",
|
||||
"coreHeartbeat": {
|
||||
"description": "Esecuzione periodica dell'agente ogni 30 minuti che consente all'assistente di controllare posta, calendario e altre fonti configurate e di avvisarti proattivamente quando serve attenzione. Senza questa opzione, l'assistente risponde solo quando lo contatti."
|
||||
"description": "Esecuzione periodica dell'agente ogni 30 minuti che consente all'assistente di controllare posta, calendario e altre fonti configurate e di avvisarLa proattivamente quando serve attenzione. Senza questa opzione, l'assistente risponde solo quando lo contatti."
|
||||
},
|
||||
"coreCron": {
|
||||
"description": "Consente all'assistente di eseguire attività pianificate (briefing giornalieri, promemoria ricorrenti, report periodici). Disattivato per impostazione predefinita. Quando è disattivato, lo strumento cron resta disponibile ma nessuna attività pianificata viene eseguita."
|
||||
@@ -262,42 +290,42 @@
|
||||
"description": "Consente all'assistente di richiamare preferenze stabili, abitudini ricorrenti e contesto a lungo termine dalle conversazioni precedenti. Utilizza un turno extra di sub-agente per ogni messaggio in entrata per interrogare lo store di memoria. Solo messaggi diretti. Aggiunge un piccolo costo in token in cambio di continuità e personalizzazione."
|
||||
},
|
||||
"coreVoice": {
|
||||
"description": "Riconoscimento vocale sui messaggi audio in entrata e sintesi vocale sulle risposte, instradati attraverso il gateway PieCed LiteLLM per tracciare il costo audio per tenant. L'integrazione runtime arriverà nel prossimo rilascio della piattaforma; attivare ora salva la preferenza per quel rilascio."
|
||||
"description": "Riconoscimento vocale sui messaggi audio in entrata e sintesi vocale sulle risposte, instradati attraverso il gateway PieCed LiteLLM per tracciare il costo audio per tenant. L'integrazione runtime arriverà nel prossimo rilascio della piattaforma; attivare ora salvi la preferenza per quel rilascio."
|
||||
},
|
||||
"gitCli": {
|
||||
"description": "Operazioni git da riga di comando autonome (clone, commit, branch, diff, log, status). Per i repository privati, configura le credenziali nel tuo workspace."
|
||||
"description": "Operazioni git da riga di comando autonome (clone, commit, branch, diff, log, status). Per i repository privati, configura le credenziali nel suo workspace."
|
||||
},
|
||||
"github": {
|
||||
"description": "Interagisci con repository GitHub tramite la CLI gh — issue, pull request, esecuzioni CI, release, gist. Richiede un token di accesso personale.",
|
||||
"tokenLabel": "Token di accesso personale GitHub",
|
||||
"tokenPlaceholder": "ghp_… o github_pat_…",
|
||||
"instructions": "1. Apri https://github.com/settings/tokens\n2. Genera un token di accesso personale fine con gli ambiti repo desiderati\n3. Copia il token (viene mostrato una sola volta)"
|
||||
"instructions": "1. Apra https://github.com/settings/tokens\n2. Generi un token di accesso personale fine con gli ambiti repo desiderati\n3. Copi il token (viene mostrato una sola volta)"
|
||||
},
|
||||
"gitea": {
|
||||
"description": "Interagisci con un'istanza Gitea — repository, issue, pull request, release. Per impostazione predefinita, l'istanza Gitea PieCed su git.c5ai.ch.",
|
||||
"tokenLabel": "Token di accesso Gitea",
|
||||
"tokenPlaceholder": "Generato in Impostazioni → Applicazioni",
|
||||
"instructions": "1. Accedi alla tua istanza Gitea (predefinito https://git.c5ai.ch)\n2. Vai a Impostazioni → Applicazioni → Genera nuovo token\n3. Concedi gli ambiti desiderati (repo, issue, user)\n4. Copia il token"
|
||||
"instructions": "1. Acceda alla sua istanza Gitea (predefinito https://git.c5ai.ch)\n2. Vada a Impostazioni → Applicazioni → Generi nuovo token\n3. Conceda gli ambiti desiderati (repo, issue, user)\n4. Copi il token"
|
||||
},
|
||||
"whisperSelfHosted": {
|
||||
"description": "Trascrivi file audio tramite l'istanza Whisper auto-ospitata della piattaforma. Utile per attività di trascrizione ad hoc avviate dalla chat."
|
||||
},
|
||||
"searxngLocalSearch": {
|
||||
"description": "Ricerca web rispettosa della privacy tramite l'istanza SearXNG interna della piattaforma. Cerca sul web, nelle immagini e nelle notizie senza chiamate ad API esterne né tracker."
|
||||
"description": "Ricerca web rispettosa della privacy tramite l'istanza SearXNG interna della piattaforma. Cerchi sul web, nelle immagini e nelle notizie senza chiamate ad API esterne né tracker."
|
||||
},
|
||||
"gog": {
|
||||
"description": "Accesso integrato a Gmail, Calendar, Drive, Docs, Sheets e Contatti tramite Google OAuth. La configurazione richiede un progetto Google Cloud — contatta il supporto PieCed per l'onboarding.",
|
||||
"description": "Accesso integrato a Gmail, Calendar, Drive, Docs, Sheets e Contatti tramite Google OAuth. La configurazione richiede un progetto Google Cloud — contatti il supporto PieCed per l'onboarding.",
|
||||
"clientIdLabel": "ID client Google OAuth",
|
||||
"clientIdPlaceholder": "xxxxxxxxxxx.apps.googleusercontent.com",
|
||||
"clientSecretLabel": "Client secret Google OAuth",
|
||||
"clientSecretPlaceholder": "GOCSPX-…",
|
||||
"refreshTokenLabel": "Token di refresh Google OAuth",
|
||||
"refreshTokenPlaceholder": "1//0g…",
|
||||
"instructions": "Google Workspace utilizza OAuth. Crea un client OAuth nel tuo progetto Google Cloud, autorizzalo con gli scope necessari (Gmail, Calendar, Drive, ecc.), quindi incolla le credenziali qui sotto. L'invio le memorizza in modo sicuro e mette in coda l'attivazione per la revisione amministrativa — dopo l'approvazione, l'integrazione si attiva automaticamente.",
|
||||
"disclaimer": "Abilitando l'integrazione con Google Workspace autorizzi PieCed ad accedere per tuo conto a Gmail, Calendar, Drive, Docs, Sheets e Contatti. I dati transitano attraverso le API di Google, soggetti ai termini di Google."
|
||||
"instructions": "Google Workspace utilizza OAuth. Crei un client OAuth nel suo progetto Google Cloud, lo autorizzi con gli scope necessari (Gmail, Calendar, Drive, ecc.), quindi incolla le credenziali qui sotto. L'invio le memorizza in modo sicuro e mette in coda l'attivazione per la revisione amministrativa — dopo l'approvazione, l'integrazione si attivi automaticamente.",
|
||||
"disclaimer": "Abilitando l'integrazione con Google Workspace autorizzi PieCed ad accedere per suo conto a Gmail, Calendar, Drive, Docs, Sheets e Contatti. I dati transitano attraverso le API di Google, soggetti ai termini di Google."
|
||||
},
|
||||
"mail": {
|
||||
"description": "Leggi, cerca e gestisci le e-mail via IMAP; invia tramite SMTP. Funziona con Gmail (con una password per app), Outlook, Fastmail e qualsiasi host IMAP/SMTP standard.",
|
||||
"description": "Legga, cerchi e gestisca le e-mail via IMAP; invii tramite SMTP. Funziona con Gmail (con una password per app), Outlook, Fastmail e qualsiasi host IMAP/SMTP standard.",
|
||||
"imapHostLabel": "Host IMAP",
|
||||
"imapHostPlaceholder": "imap.example.com",
|
||||
"imapUserLabel": "Username IMAP",
|
||||
@@ -310,13 +338,13 @@
|
||||
"smtpUserPlaceholder": "utente@example.com",
|
||||
"smtpPassLabel": "Password SMTP",
|
||||
"smtpPassPlaceholder": "••••••••",
|
||||
"instructions": "1. Per Gmail: abilita la verifica in due passaggi, quindi crea una password per app su https://myaccount.google.com/apppasswords e usala come password IMAP e SMTP.\n2. Per Outlook / Microsoft 365 con MFA: genera una password per app nelle impostazioni di sicurezza del tuo account.\n3. Per altri provider: consulta la loro documentazione IMAP/SMTP per nomi host e porte.\n4. Host IMAP tipici: imap.gmail.com, outlook.office365.com.\n5. Host SMTP tipici: smtp.gmail.com, smtp.office365.com.",
|
||||
"disclaimer": "L'assistente ottiene accesso in lettura/scrittura alla casella di posta che configuri. Valuta l'uso di un indirizzo dedicato anziché di una casella personale se vuoi limitare la portata."
|
||||
"instructions": "1. Per Gmail: abiliti la verifica in due passaggi, quindi crei una password per app su https://myaccount.google.com/apppasswords e la usi come password IMAP e SMTP.\n2. Per Outlook / Microsoft 365 con MFA: generi una password per app nelle impostazioni di sicurezza del suo account.\n3. Per altri provider: consulti la loro documentazione IMAP/SMTP per nomi host e porte.\n4. Host IMAP tipici: imap.gmail.com, outlook.office365.com.\n5. Host SMTP tipici: smtp.gmail.com, smtp.office365.com.",
|
||||
"disclaimer": "L'assistente ottiene accesso in lettura/scrittura alla casella di posta che configuri. Valuta l'uso di un indirizzo dedicato anziché di una casella personale se vuole limitare la portata."
|
||||
},
|
||||
"threema": {
|
||||
"description": "Invia e ricevi messaggi tramite Threema. Ogni messaggio in entrata e in uscita passa attraverso il servizio di messaggistica condiviso di PieCed e comporta un addebito per messaggio da parte di Threema — un costo di terzi, separato dall'abbonamento PieCed.",
|
||||
"instructions": "1. Attiva questo pacchetto.\n2. Apri Threema sul tuo telefono, scansiona il QR code mostrato in Utenti autorizzati → threema e accetta il contatto.\n3. Aggiungi il tuo ID Threema sotto Utenti autorizzati → threema affinché l'assistente riconosca i tuoi messaggi.\n4. Invia un messaggio da Threema per iniziare la conversazione.",
|
||||
"disclaimer": "I messaggi tra Threema e PieCed sono cifrati end-to-end fino al servizio di messaggistica PieCed, dove vengono decifrati per essere inoltrati al tuo assistente. Ogni messaggio inviato o ricevuto viene addebitato da Threema secondo la sua tariffa per messaggio — consulta il tuo piano per i prezzi attuali."
|
||||
"description": "Invii e riceva messaggi tramite Threema. Ogni messaggio in entrata e in uscita passa attraverso il servizio di messaggistica condiviso di PieCed e comporta un addebito per messaggio da parte di Threema — un costo di terzi, separato dall'abbonamento PieCed.",
|
||||
"instructions": "1. Apra Threema sul suo telefono e scansioni il QR code mostrato qui sotto — lo faccia subito, così sarà pronto a chattare appena il suo tenant sarà operativo.\n2. Inserisca il suo ID Threema nel campo qui sotto (gli 8 caratteri da Impostazioni → Il mio ID Threema nell'app Threema) affinché l'assistente accetti i suoi messaggi.\n3. Una volta che il suo tenant è approvato e operativo, invii un messaggio da Threema per iniziare la conversazione.",
|
||||
"disclaimer": "I messaggi tra Threema e PieCed sono cifrati end-to-end fino al servizio di messaggistica PieCed, dove vengono decifrati per essere inoltrati al suo assistente. Ogni messaggio inviato o ricevuto viene addebitato da Threema secondo la sua tariffa per messaggio — consulti il suo piano per i prezzi attuali."
|
||||
},
|
||||
"manualReviewPending": "Revisione manuale in attesa",
|
||||
"withdraw": "Ritira",
|
||||
@@ -324,11 +352,15 @@
|
||||
"tryAgain": "Riprova",
|
||||
"credentialsSaved": "credenziali salvate",
|
||||
"credentialsSavedTip": "Le credenziali inserite sono memorizzate in modo sicuro e saranno utilizzate non appena l'attivazione viene approvata dall'amministratore. Non è necessario reinserirle.",
|
||||
"recommended": "Consigliato"
|
||||
"recommended": "Consigliato",
|
||||
"threemaBotIdHeading": "ID Threema del bot",
|
||||
"threemaBotIdHint": "Questo è l'ID Threema dell'assistente — identico per ogni tenant PieCed. Scansioni il QR ora con la sua app Threema, così sarà pronto non appena il suo tenant verrà approvato e Threema attivato.",
|
||||
"showInfo": "Info",
|
||||
"showInfoTitle": "Mostra di nuovo le info di setup"
|
||||
},
|
||||
"admin": {
|
||||
"title": "Admin piattaforma",
|
||||
"subtitle": "Gestisci le richieste di onboarding e il ciclo di vita dei tenant",
|
||||
"subtitle": "Gestisca le richieste di onboarding e il ciclo di vita dei tenant",
|
||||
"allTenants": "Tenant",
|
||||
"noTenants": "Nessun tenant attivato.",
|
||||
"noAccess": "Permessi insufficienti per questa vista.",
|
||||
@@ -337,7 +369,7 @@
|
||||
"phase": "Fase",
|
||||
"packages": "Pacchetti",
|
||||
"created": "Creato",
|
||||
"manage": "Gestisci",
|
||||
"manage": "Gestisca",
|
||||
"requests": "Richieste",
|
||||
"pendingRequests": "Richieste in attesa",
|
||||
"approve": "Approva",
|
||||
@@ -357,9 +389,9 @@
|
||||
"rejectTitle": "Rifiuta richiesta",
|
||||
"adminNotesLabel": "Note (opzionale)",
|
||||
"adminNotesPlaceholder": "Motivo del rifiuto…",
|
||||
"cancelAction": "Annulla",
|
||||
"cancelAction": "Annulli",
|
||||
"confirmReject": "Rifiuta",
|
||||
"viewTenant": "Visualizza",
|
||||
"viewTenant": "Visualizzi",
|
||||
"filter_all": "Tutti",
|
||||
"filter_pending": "In attesa",
|
||||
"filter_provisioning": "Attivazione",
|
||||
@@ -370,13 +402,13 @@
|
||||
"provisioning": "Attivazione",
|
||||
"errors": "Errori",
|
||||
"suspend": "Sospendi",
|
||||
"resume": "Riprendi",
|
||||
"resume": "Riprenda",
|
||||
"suspended": "Sospeso",
|
||||
"suspendedBadge": "SOSPESO",
|
||||
"deleteTenant": "Elimina",
|
||||
"deleteTitle": "Elimina tenant",
|
||||
"deleteTenant": "Elimini",
|
||||
"deleteTitle": "Elimini tenant",
|
||||
"deleteWarning": "Questo eliminerà permanentemente il tenant, il suo namespace, i secrets e tutti i dati associati. Questa azione non può essere annullata.",
|
||||
"confirmDelete": "Elimina definitivamente",
|
||||
"confirmDelete": "Elimini definitivamente",
|
||||
"loadingTenants": "Caricamento tenant…",
|
||||
"filter_deleted": "Eliminato",
|
||||
"filter_active": "Attivo",
|
||||
@@ -389,7 +421,7 @@
|
||||
"globalSpend": "Costi globali (CHF)",
|
||||
"activeTenants": "Tenant attivi",
|
||||
"tenantsWithSpend": "tenant con spese registrate",
|
||||
"refresh": "Aggiorna",
|
||||
"refresh": "Aggiorni",
|
||||
"healthUnavailable": "Dati di stato non disponibili.",
|
||||
"loadingHealth": "Caricamento dati di stato…",
|
||||
"statusHealthy": "OK",
|
||||
@@ -400,37 +432,41 @@
|
||||
"openclawTool": "Versioni OpenClaw",
|
||||
"billingTool": "Fatturazione →",
|
||||
"skillsQueueTool": "Coda di attivazione",
|
||||
"cronTool": "Automazione"
|
||||
"cronTool": "Automazione",
|
||||
"approveTitle": "Approvare la richiesta?",
|
||||
"approveWarning": "Questa operazione effettua il provisioning dell'infrastruttura del tenant, addebita il costo di attivazione e notifica il cliente. Verifica che i dettagli della richiesta siano corretti prima di continuare.",
|
||||
"approveReapproveWarning": "Questo riapprova una richiesta precedentemente rifiutata: effettua il provisioning dell'infrastruttura del tenant, addebita il costo di attivazione e notifica il cliente.",
|
||||
"confirmApprove": "Approva e avvia provisioning"
|
||||
},
|
||||
"channelUsers": {
|
||||
"title": "Utenti autorizzati",
|
||||
"description": "Gestisci quali utenti possono interagire con il tuo assistente su ogni canale. Aggiungi il loro ID numerico per autorizzare l'accesso.",
|
||||
"description": "Gestisca quali utenti possono interagire con il suo assistente su ogni canale. Aggiunga il loro ID numerico per autorizzare l'accesso.",
|
||||
"users": "utenti",
|
||||
"placeholder": "Inserisci l'ID numerico…",
|
||||
"add": "Aggiungi",
|
||||
"placeholder": "Inserisca l'ID numerico…",
|
||||
"add": "Aggiunga",
|
||||
"remove": "Rimuovi",
|
||||
"alreadyAdded": "Questo ID utente è già autorizzato.",
|
||||
"telegramIdHelp": "Per trovare il tuo ID Telegram:\n1. Apri Telegram e invia un messaggio a @userinfobot\n2. Risponde istantaneamente con il tuo ID numerico\n3. Inserisci quel numero qui",
|
||||
"discordIdHelp": "Per trovare il tuo ID Discord:\n1. Attiva la Modalità sviluppatore nelle impostazioni Discord (Avanzate)\n2. Clic destro sul tuo nome → Copia ID utente\n3. Inserisci quel numero qui",
|
||||
"threemaIdHelp": "Inserisci il tuo ID Threema — gli 8 caratteri mostrati nella tua app Threema sotto Impostazioni → Il mio ID Threema. Una volta aggiunto, potrai conversare con l'assistente direttamente da Threema.",
|
||||
"telegramIdHelp": "Per trovare il suo ID Telegram:\n1. Apra Telegram e invii un messaggio a @userinfobot\n2. Risponde istantaneamente con il suo ID numerico\n3. Inserisca quel numero qui",
|
||||
"discordIdHelp": "Per trovare il suo ID Discord:\n1. Attivi la Modalità sviluppatore nelle impostazioni Discord (Avanzate)\n2. Clic destro sul suo nome → Copia ID utente\n3. Inserisca quel numero qui",
|
||||
"threemaIdHelp": "Inserisca il suo ID Threema — gli 8 caratteri mostrati nella sua app Threema sotto Impostazioni → Il mio ID Threema. Una volta aggiunto, potrà conversare con l'assistente direttamente da Threema.",
|
||||
"threemaSetup": {
|
||||
"title": "Aggiungi l'assistente a Threema",
|
||||
"step1": "Apri Threema sul tuo telefono.",
|
||||
"step2": "Tocca l'icona di scansione e scansiona questo QR code per aggiungere l'assistente ai contatti.",
|
||||
"step3": "Quindi aggiungi il tuo ID Threema qui sotto.",
|
||||
"title": "Aggiunga l'assistente a Threema",
|
||||
"step1": "Apra Threema sul suo telefono.",
|
||||
"step2": "Tocchi l'icona di scansione e scansioni questo QR code per aggiungere l'assistente ai contatti.",
|
||||
"step3": "Si assicuri che il suo ID Threema sia registrato come utente autorizzato così l'assistente accetterà i suoi messaggi.",
|
||||
"qrAlt": "QR code per aggiungere {gateway} come contatto Threema",
|
||||
"bannerTitle": "Configura Threema",
|
||||
"bannerBody": "Apri Threema sul tuo telefono e scansiona il nostro QR code per aggiungere l'assistente ai contatti. Inserisci poi il tuo ID Threema qui sotto.",
|
||||
"bannerBody": "Apra Threema sul suo telefono e scansioni il nostro QR code per aggiungere l'assistente ai contatti. Inserisca poi il suo ID Threema qui sotto.",
|
||||
"bannerButton": "Mostra QR code"
|
||||
}
|
||||
},
|
||||
"team": {
|
||||
"title": "Team",
|
||||
"description": "Gestisci i membri della tua organizzazione. Invita colleghi e assegnali alle istanze.",
|
||||
"description": "Gestisca i membri della sua organizzazione. Invita colleghi e assegnali alle istanze.",
|
||||
"inviteSectionTitle": "Invita un membro",
|
||||
"membersSectionTitle": "Membri",
|
||||
"noMembers": "Nessun membro ancora.",
|
||||
"you": "Tu",
|
||||
"you": "Lei",
|
||||
"noRole": "nessun ruolo",
|
||||
"givenName": "Nome",
|
||||
"familyName": "Cognome",
|
||||
@@ -439,21 +475,29 @@
|
||||
"roleUser": "Utente (sola lettura, deve essere assegnato a istanze)",
|
||||
"roleOwner": "Proprietario (accesso completo a tutte le istanze)",
|
||||
"roleHint": "I proprietari possono gestire istanze, fatturazione e membri del team. Gli utenti possono solo visualizzare le istanze a loro assegnate.",
|
||||
"inviteButton": "Invia invito",
|
||||
"inviteButton": "Invii invito",
|
||||
"inviteSent": "Invito inviato. L'utente riceverà un'e-mail con un link per impostare la password.",
|
||||
"inviteUserExists": "Un utente con questa e-mail è già registrato.",
|
||||
"changeRole": "Modifica ruolo",
|
||||
"changeRole": "Modifichi ruolo",
|
||||
"roleUpdated": "Ruolo aggiornato.",
|
||||
"roleUpdateFailed": "Impossibile aggiornare il ruolo.",
|
||||
"cancel": "Annulla",
|
||||
"save": "Salva",
|
||||
"selfChangeBlocked": "Non puoi modificare il tuo ruolo."
|
||||
"cancel": "Annulli",
|
||||
"save": "Salvi",
|
||||
"selfChangeBlocked": "Non può modificare il suo ruolo.",
|
||||
"accessTitle": "Panoramica accessi",
|
||||
"accessDescription": "Quale membro può accedere a quale assistente.",
|
||||
"accessMemberCol": "Membro",
|
||||
"accessOwnerAll": "Tutti gli assistenti (proprietario)",
|
||||
"accessHasLabel": "Accesso",
|
||||
"accessHasNotLabel": "Nessun accesso",
|
||||
"accessNoTenants": "Ancora nessun assistente.",
|
||||
"accessLoadFailed": "Impossibile caricare la panoramica degli accessi."
|
||||
},
|
||||
"assignments": {
|
||||
"loading": "Caricamento assegnazioni…",
|
||||
"noneAssigned": "Nessun utente è ancora assegnato a questa istanza.",
|
||||
"noCandidates": "Nessun membro del team disponibile per l'assegnazione. Invita prima gli utenti dalla pagina Team.",
|
||||
"pickUser": "Seleziona un utente…",
|
||||
"pickUser": "Selezioni un utente…",
|
||||
"assign": "Assegna",
|
||||
"revoke": "Rimuovi"
|
||||
},
|
||||
@@ -481,17 +525,17 @@
|
||||
},
|
||||
"settings": {
|
||||
"title": "Impostazioni",
|
||||
"subtitle": "Gestisci la configurazione a livello di organizzazione, valida per tutti i tuoi tenant.",
|
||||
"subtitle": "Gestisca la configurazione a livello di organizzazione, valida per tutti i suoi tenant.",
|
||||
"billingTitle": "Fatturazione",
|
||||
"billingDescription": "Indirizzo, numero di IVA ed e-mail di fatturazione usati per tutti i tuoi tenant.",
|
||||
"nothingForYou": "Al momento non c'è nulla qui per il tuo ruolo. I proprietari possono gestire le impostazioni dell'organizzazione.",
|
||||
"billingDescriptionPersonal": "Indirizzo ed e-mail di fatturazione usati per tutti i tuoi tenant.",
|
||||
"billingDescription": "Indirizzo, numero di IVA ed e-mail di fatturazione usati per tutti i suoi tenant.",
|
||||
"nothingForYou": "Al momento non c'è nulla qui per il suo ruolo. I proprietari possono gestire le impostazioni dell'organizzazione.",
|
||||
"billingDescriptionPersonal": "Indirizzo ed e-mail di fatturazione usati per tutti i suoi tenant.",
|
||||
"profileTitle": "Profilo",
|
||||
"profileDescription": "Modifica il tuo nome e cognome come appaiono nel portale."
|
||||
"profileDescription": "Modifichi il suo nome e cognome come appaiono nel portale."
|
||||
},
|
||||
"settingsBilling": {
|
||||
"title": "Dati di fatturazione",
|
||||
"subtitle": "Indirizzo di fatturazione, partita IVA e contatto fatture della tua azienda. Necessari prima che possano essere emesse fatture per la tua organizzazione.",
|
||||
"subtitle": "Indirizzo di fatturazione, partita IVA e contatto fatture della sua azienda. Necessari prima che possano essere emesse fatture per la sua organizzazione.",
|
||||
"companyNameLabel": "Nome azienda",
|
||||
"streetAddressLabel": "Indirizzo",
|
||||
"postalCodeLabel": "CAP",
|
||||
@@ -504,22 +548,22 @@
|
||||
"billingEmailHint": "Le fatture e i solleciti vengono inviati a questo indirizzo. Può differire dall'e-mail dell'account.",
|
||||
"notesLabel": "Note (facoltative)",
|
||||
"notesHint": "Numeri di riferimento, ordini d'acquisto o altre informazioni da riportare in fattura.",
|
||||
"saveChanges": "Salva modifiche",
|
||||
"createBilling": "Salva dati di fatturazione",
|
||||
"saveChanges": "Salvi modifiche",
|
||||
"createBilling": "Salvi dati di fatturazione",
|
||||
"saving": "Salvataggio…",
|
||||
"saved": "Salvato.",
|
||||
"missingRequired": "Compila tutti i campi obbligatori.",
|
||||
"missingRequired": "Compili tutti i campi obbligatori.",
|
||||
"invalidCountry": "Il codice paese deve essere di 2 lettere (es. CH).",
|
||||
"invalidEmail": "Inserisci un indirizzo e-mail valido.",
|
||||
"invalidEmail": "Inserisca un indirizzo e-mail valido.",
|
||||
"fullNameLabel": "Nome e cognome",
|
||||
"subtitlePersonal": "Il tuo indirizzo di fatturazione e contatto. Necessari prima che possano essere emesse fatture.",
|
||||
"subtitlePersonal": "Il suo indirizzo di fatturazione e contatto. Necessari prima che possano essere emesse fatture.",
|
||||
"contactNameLabel": "Persona di contatto (facoltativa)",
|
||||
"contactNameHint": "Stampato come 'c.a. <nome>' sulla fattura, sotto il nome dell'azienda. Utile per l'instradamento contabile in grandi organizzazioni.",
|
||||
"savedCardHeading": "Carta salvata",
|
||||
"savedCardEmptyBody": "Salvi una carta per il pagamento automatico delle fatture. I dati della sua carta sono memorizzati in modo sicuro da Stripe — vediamo solo la marca, le ultime quattro cifre e la scadenza.",
|
||||
"savedCardSetupBtn": "Configura pagamento automatico",
|
||||
"savedCardRedirecting": "Reindirizzamento…",
|
||||
"savedCardUpdateBtn": "Aggiorna carta",
|
||||
"savedCardUpdateBtn": "Aggiorni carta",
|
||||
"savedCardRemoveBtn": "Rimuovi carta",
|
||||
"savedCardRemoving": "Rimozione…",
|
||||
"savedCardRemoveConfirm": "Rimuovere questa carta? Dovrà riconfigurare il pagamento automatico affinché le future fatture vengano addebitate automaticamente.",
|
||||
@@ -527,8 +571,8 @@
|
||||
"savedCardExpires": "scade {date}",
|
||||
"savedCardAutoChargeOn": "Pagamento auto. attivo",
|
||||
"savedCardAutoChargeOff": "Pagamento auto. disattivo",
|
||||
"savedCardDisableAutoChargeBtn": "Disattiva pagamento automatico",
|
||||
"savedCardEnableAutoChargeBtn": "Attiva pagamento automatico",
|
||||
"savedCardDisableAutoChargeBtn": "Disattivi pagamento automatico",
|
||||
"savedCardEnableAutoChargeBtn": "Attivi pagamento automatico",
|
||||
"savedCardPayByInvoiceNote": "Il suo account è impostato per il pagamento tramite bonifico; la carta salvata non viene utilizzata per gli addebiti automatici. Contatti l'assistenza se desidera tornare al pagamento con carta.",
|
||||
"savedCardBankTransferHint": "Il pagamento tramite bonifico è disponibile su richiesta.",
|
||||
"savedCardBankTransferLink": "Ci contatti per organizzarlo.",
|
||||
@@ -538,22 +582,22 @@
|
||||
},
|
||||
"support": {
|
||||
"title": "Supporto",
|
||||
"subtitle": "Apri un ticket per fare una domanda, segnalare un bug o condividere un feedback. Le risposte verranno inviate alla tua email registrata.",
|
||||
"subtitle": "Apra un ticket per fare una domanda, segnalare un bug o condividere un feedback. Le risposte verranno inviate alla sua email registrata.",
|
||||
"titleAdmin": "Coda supporto",
|
||||
"subtitleAdmin": "Ticket di tutti i clienti, attività più recente per prima.",
|
||||
"newTicket": "Nuovo ticket",
|
||||
"newTicketTitle": "Apri un ticket di supporto",
|
||||
"newTicketSubtitle": "Raccontaci cosa succede. Più dettagli ci dai, più velocemente possiamo aiutarti.",
|
||||
"empty": "Non hai ancora aperto ticket.",
|
||||
"newTicketTitle": "Apra un ticket di supporto",
|
||||
"newTicketSubtitle": "Ci racconti cosa succede. Più dettagli ci dà, più velocemente possiamo aiutarLa.",
|
||||
"empty": "Non ha ancora aperto ticket.",
|
||||
"emptyAdmin": "Nessun ticket di supporto in coda.",
|
||||
"fieldCategory": "Categoria",
|
||||
"fieldTitle": "Titolo",
|
||||
"fieldDescription": "Descrizione",
|
||||
"fieldStatus": "Stato",
|
||||
"titlePlaceholder": "Breve riassunto della tua richiesta",
|
||||
"descriptionPlaceholder": "Descrivi cosa è successo, cosa ti aspettavi e qualsiasi messaggio d'errore visto.",
|
||||
"descriptionHelp": "Puoi incollare messaggi d'errore e log. Niente password o altri segreti.",
|
||||
"submitTicket": "Invia ticket",
|
||||
"titlePlaceholder": "Breve riassunto della sua richiesta",
|
||||
"descriptionPlaceholder": "Descriva cosa è successo, cosa Le aspettavi e qualsiasi messaggio d'errore visto.",
|
||||
"descriptionHelp": "Può incollare messaggi d'errore e log. Niente password o altri segreti.",
|
||||
"submitTicket": "Invii ticket",
|
||||
"createFailed": "Impossibile creare il ticket. Riprova.",
|
||||
"category_bug": "Bug",
|
||||
"category_feature_request": "Richiesta funzionalità",
|
||||
@@ -562,20 +606,20 @@
|
||||
"category_other": "Altro",
|
||||
"status_open": "Aperto",
|
||||
"status_in_progress": "In corso",
|
||||
"status_waiting_for_customer": "In attesa della tua risposta",
|
||||
"status_waiting_for_customer": "In attesa della sua risposta",
|
||||
"status_resolved": "Risolto",
|
||||
"status_reopened": "Riaperto",
|
||||
"openedBy": "Aperto da {name} il {when}",
|
||||
"authorTagAdmin": "Supporto PieCed",
|
||||
"replyLabel": "Aggiungi una risposta",
|
||||
"replyPlaceholder": "Il tuo messaggio…",
|
||||
"replyLabel": "Aggiunga una risposta",
|
||||
"replyPlaceholder": "Il suo messaggio…",
|
||||
"replyPlaceholderReopen": "Risposta (questo riaprirà il ticket)…",
|
||||
"sendReply": "Invia risposta",
|
||||
"sendReply": "Invii risposta",
|
||||
"commentFailed": "Impossibile inviare la risposta. Riprova.",
|
||||
"closeTicket": "Segna come risolto",
|
||||
"confirmClose": "Segnare questo ticket come risolto? Potrai riaprirlo in seguito rispondendo.",
|
||||
"closeTicket": "Segni come risolto",
|
||||
"confirmClose": "Segnare questo ticket come risolto? Potrà riaprirlo in seguito rispondendo.",
|
||||
"closeFailed": "Impossibile chiudere il ticket. Riprova.",
|
||||
"resolvedBanner": "Questo ticket è risolto. Rispondi qui sotto se hai bisogno di un seguito — questo lo riaprirà.",
|
||||
"resolvedBanner": "Questo ticket è risolto. Risponda qui sotto se ha bisogno di un seguito — questo lo riaprirà.",
|
||||
"adminControlsTitle": "Controlli admin",
|
||||
"updateFailed": "Impossibile salvare le modifiche. Riprova."
|
||||
},
|
||||
@@ -586,7 +630,7 @@
|
||||
"defaultDescription": "Usato da ogni tenant senza override proprio.",
|
||||
"fieldTag": "Tag",
|
||||
"emptyHint": "Lascia vuoto per usare il predefinito integrato dell'operatore.",
|
||||
"saveDefault": "Salva predefinito",
|
||||
"saveDefault": "Salvi predefinito",
|
||||
"defaultSaved": "Predefinito salvato. I tenant senza override lo applicheranno al prossimo reconcile.",
|
||||
"saveFailed": "Salvataggio fallito. Riprova.",
|
||||
"overridesSection": "Override per tenant",
|
||||
@@ -595,27 +639,27 @@
|
||||
"statusFollowsDefault": "Segue predefinito",
|
||||
"builtinFallback": "(fallback integrato)",
|
||||
"defaultPrefix": "Predefinito:",
|
||||
"saveOverride": "Salva override",
|
||||
"saveOverride": "Salvi override",
|
||||
"clearOverride": "Rimuovi override"
|
||||
},
|
||||
"adminBilling": {
|
||||
"title": "Amministrazione fatturazione",
|
||||
"subtitle": "Gestire prezzi della piattaforma, generare fatture e verificare lo stato di fatturazione delle organizzazioni.",
|
||||
"backToAdmin": "Torna ad amministrazione",
|
||||
"backToBilling": "Torna alla fatturazione",
|
||||
"backToInvoices": "Torna alle fatture",
|
||||
"backToAdmin": "Torni ad amministrazione",
|
||||
"backToBilling": "Torni alla fatturazione",
|
||||
"backToInvoices": "Torni alle fatture",
|
||||
"totalOpenBalance": "Saldo aperto totale",
|
||||
"orgsWithBalance": "Organizzazioni con saldo",
|
||||
"overdueInvoices": "Fatture scadute",
|
||||
"pricingTitle": "Prezzi",
|
||||
"pricingDesc": "Prezzi piattaforma & skill, aliquota IVA.",
|
||||
"pricingPageDesc": "Modificare i prezzi della piattaforma e i prezzi giornalieri per skill.",
|
||||
"generateTitle": "Genera fattura",
|
||||
"generateTitle": "Generi fattura",
|
||||
"generateDesc": "Calcolare ed emettere una fattura per organizzazione e mese.",
|
||||
"generatePageDesc": "Scegli organizzazione, periodo e lingua. L'anteprima mostra le righe calcolate; conferma emette la fattura e genera il PDF.",
|
||||
"generatePageDesc": "Scelga organizzazione, periodo e lingua. L'anteprima mostra le righe calcolate; confermi emette la fattura e generi il PDF.",
|
||||
"invoicesTitle": "Fatture",
|
||||
"invoicesDesc": "Sfoglia le fatture, segna come pagate, scarica i PDF.",
|
||||
"invoicesPageDesc": "Tutte le fatture emesse dalla piattaforma. Usa il filtro di stato per focalizzarti su voci aperte o scadute.",
|
||||
"invoicesDesc": "Sfogli le fatture, segni come pagate, scarichi i PDF.",
|
||||
"invoicesPageDesc": "Tutte le fatture emesse dalla piattaforma. Usi il filtro di stato per concentrarsi su voci aperte o scadute.",
|
||||
"balancesTitle": "Organizzazioni con saldo aperto",
|
||||
"orgIdCol": "ID org Zitadel",
|
||||
"openCountCol": "Aperte",
|
||||
@@ -626,22 +670,22 @@
|
||||
"setupFeeLabel": "Spese di attivazione tenant",
|
||||
"threemaMessageLabel": "Threema per messaggio",
|
||||
"vatRateLabel": "Aliquota IVA (CH/LI)",
|
||||
"save": "Salva",
|
||||
"save": "Salvi",
|
||||
"saving": "Salvataggio…",
|
||||
"savedOk": "Salvato",
|
||||
"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.",
|
||||
"skillPricingDesc": "Tariffa giornaliera e spese di attivazione una tantum per qualsiasi pacchetto — core, canale o skill. La tariffazione si applica a ogni tenant che attivi il pacchetto.",
|
||||
"skillCol": "Pacchetto",
|
||||
"dailyPriceCol": "Prezzo/giorno",
|
||||
"actionsCol": "",
|
||||
"remove": "Rimuovi",
|
||||
"noSkillsPriced": "Nessun pacchetto con prezzo.",
|
||||
"addSkillLabel": "Aggiungi pacchetto",
|
||||
"addSkillLabel": "Aggiunga pacchetto",
|
||||
"dailyPriceLabel": "Prezzo/giorno",
|
||||
"add": "Aggiungi",
|
||||
"add": "Aggiunga",
|
||||
"confirmDeleteSkillPrice": "Rimuovere la tariffazione per {skill}? I periodi già fatturati non sono influenzati.",
|
||||
"clickToEdit": "Clicca per modificare",
|
||||
"generateFormTitle": "Genera fattura",
|
||||
"clickToEdit": "Clicchi per modificare",
|
||||
"generateFormTitle": "Generi fattura",
|
||||
"noOrgsToGenerate": "Nessuna organizzazione con tenant trovata.",
|
||||
"orgLabel": "Organizzazione",
|
||||
"noBillingAddrTag": "nessun indirizzo di fatturazione",
|
||||
@@ -652,9 +696,9 @@
|
||||
"localeLabel": "Lingua PDF",
|
||||
"localeAuto": "Auto",
|
||||
"previewBtn": "Anteprima",
|
||||
"commitBtn": "Conferma & emetti",
|
||||
"commitBtn": "Confermi & emetti",
|
||||
"computing": "Calcolo…",
|
||||
"confirmGenerate": "Emettere questa fattura? L'operazione assegna un numero di fattura e genera il PDF.",
|
||||
"confirmGenerate": "Emettere questa fattura? L'operazione assegna un numero di fattura e generi il PDF.",
|
||||
"previewTitle": "Anteprima bozza",
|
||||
"warningsTitle": "Avvisi",
|
||||
"noLinesGenerated": "Nessuna riga fatturabile per questo periodo.",
|
||||
@@ -685,12 +729,12 @@
|
||||
"status_uncollectible": "Inesigibile",
|
||||
"dueOnLabel": "Scadenza",
|
||||
"totalLabel": "Totale",
|
||||
"downloadPdfBtn": "Scarica PDF",
|
||||
"markPaidBtn": "Segna come pagata",
|
||||
"downloadPdfBtn": "Scarichi PDF",
|
||||
"markPaidBtn": "Segni come pagata",
|
||||
"paidNotePlaceholder": "Nota opzionale (es. riferimento bancario, data di pagamento)",
|
||||
"confirm": "Conferma",
|
||||
"cancel": "Annulla",
|
||||
"deleteBtn": "Elimina",
|
||||
"confirm": "Confermi",
|
||||
"cancel": "Annulli",
|
||||
"deleteBtn": "Elimini",
|
||||
"deleting": "Eliminazione…",
|
||||
"deleteHint": "Eliminazione definitiva (strumento di test). Il numero rimane consumato.",
|
||||
"confirmDeleteInvoice": "Eliminare la fattura {num}? Eliminazione definitiva — il numero rimane consumato.",
|
||||
@@ -701,10 +745,10 @@
|
||||
"skillSetupFeeLabel": "Spese di attivazione",
|
||||
"status_partially_refunded": "Rimborsata parzialmente",
|
||||
"status_fully_refunded": "Rimborsata integralmente",
|
||||
"voidBtn": "Annulla",
|
||||
"voidBtn": "Annulli",
|
||||
"voidReasonPlaceholder": "Motivo dell'annullamento (stampato sulla nota di credito)",
|
||||
"voidReasonRequired": "Indicare un motivo per l'annullamento.",
|
||||
"confirmVoid": "Conferma annullamento",
|
||||
"confirmVoid": "Confermi annullamento",
|
||||
"voidedOnLabel": "Annullata",
|
||||
"refundBtn": "Rimborsa",
|
||||
"refundReasonPlaceholder": "Motivo del rimborso (stampato sulla nota di credito)",
|
||||
@@ -712,7 +756,7 @@
|
||||
"refundAmountInvalid": "L'importo del rimborso deve essere un numero positivo.",
|
||||
"refundAmountExceeds": "L'importo supera il residuo rimborsabile di CHF {max}.",
|
||||
"refundRemainingHint": "Residuo rimborsabile: CHF {max}",
|
||||
"confirmRefund": "Conferma rimborso",
|
||||
"confirmRefund": "Confermi rimborso",
|
||||
"refundedTotalLabel": "Rimborsato",
|
||||
"refundedRemainingLabel": "Residuo rimborsabile",
|
||||
"creditNotesPanelTitle": "Note di credito",
|
||||
@@ -730,20 +774,20 @@
|
||||
"refundAmountInclVatHint": "IVA inclusa",
|
||||
"newInvoiceBtn": "Nuova fattura",
|
||||
"draftsLink": "Bozze",
|
||||
"backToDrafts": "Torna alle bozze",
|
||||
"backToDrafts": "Torni alle bozze",
|
||||
"newInvoicePageTitle": "Nuova fattura",
|
||||
"newInvoicePageSubtitle": "Scegli il cliente da fatturare. Aggiungerai le righe nel passaggio successivo.",
|
||||
"newInvoicePageSubtitle": "Scelga il cliente da fatturare. Aggiungerai le righe nel passaggio successivo.",
|
||||
"newInvoiceOrgLabel": "Cliente",
|
||||
"newInvoiceOrgPlaceholder": "— seleziona cliente —",
|
||||
"newInvoiceOrgPlaceholder": "— selezioni cliente —",
|
||||
"newInvoiceOrgNoBilling": "nessun indirizzo di fatturazione",
|
||||
"newInvoiceOrgBillingMissing": "Questo cliente non ha un indirizzo di fatturazione registrato. Chiedi al cliente di completare l'onboarding o imposta i dati dal pannello admin prima di emettere.",
|
||||
"newInvoiceOrgBillingMissing": "Questo cliente non ha un indirizzo di fatturazione registrato. Chieda al cliente di completare l'onboarding o imposti i dati dal pannello admin prima di emettere.",
|
||||
"newInvoiceLocaleLabel": "Lingua del documento",
|
||||
"newInvoiceOrgRequired": "Selezionare un cliente.",
|
||||
"newInvoiceContinueBtn": "Continua",
|
||||
"newInvoiceContinueBtn": "Continui",
|
||||
"creating": "Creazione…",
|
||||
"draftsPageTitle": "Bozze di fatture",
|
||||
"draftsPageSubtitle": "Fatture personalizzate in corso. Riprendi la modifica o scarta.",
|
||||
"draftsEmpty": "Ancora nessuna bozza. Inizia una nuova fattura.",
|
||||
"draftsPageSubtitle": "Fatture personalizzate in corso. Riprenda la modifichi o scarta.",
|
||||
"draftsEmpty": "Ancora nessuna bozza. Inizi una nuova fattura.",
|
||||
"draftOrgCol": "Cliente",
|
||||
"draftIssueDateCol": "Data emissione",
|
||||
"draftLinesCol": "Righe",
|
||||
@@ -751,8 +795,8 @@
|
||||
"draftUpdatedCol": "Modificato",
|
||||
"draftActionsCol": "Azioni",
|
||||
"draftDeleteConfirm": "Scartare questa bozza? Operazione irreversibile.",
|
||||
"editBtn": "Modifica",
|
||||
"editorPageTitle": "Modifica bozza di fattura",
|
||||
"editBtn": "Modifichi",
|
||||
"editorPageTitle": "Modifichi bozza di fattura",
|
||||
"editorBillToHeading": "Destinatario",
|
||||
"editorNoBillingSnapshot": "Nessun indirizzo di fatturazione per questo cliente. L'emissione fallirà finché i dati di fatturazione non saranno impostati.",
|
||||
"editorMetadataHeading": "Dettagli fattura",
|
||||
@@ -769,9 +813,9 @@
|
||||
"editorLineUnitPrice": "Prezzo unitario",
|
||||
"editorLineAmount": "Importo",
|
||||
"editorLineRemove": "Rimuovi riga",
|
||||
"editorAddLine": "Aggiungi riga",
|
||||
"editorAddDiscount": "Aggiungi sconto",
|
||||
"editorAddDiscountHint": "Aggiunge una riga con prezzo unitario negativo. Modifica descrizione e importo se necessario.",
|
||||
"editorAddLine": "Aggiunga riga",
|
||||
"editorAddDiscount": "Aggiunga sconto",
|
||||
"editorAddDiscountHint": "Aggiunge una riga con prezzo unitario negativo. Modifichi descrizione e importo se necessario.",
|
||||
"editorRabattDefaultDescription": "Sconto",
|
||||
"editorNotesHeading": "Note interne",
|
||||
"editorNotesPlaceholder": "Note visibili solo all'admin (non sul PDF)",
|
||||
@@ -781,7 +825,7 @@
|
||||
"editorVat": "IVA",
|
||||
"editorTotal": "Totale",
|
||||
"editorTotalsEstimateNote": "Stima basata sul paese del cliente. L'IVA finale è calcolata all'emissione.",
|
||||
"editorSaveBtn": "Salva bozza",
|
||||
"editorSaveBtn": "Salvi bozza",
|
||||
"editorSavedBtn": "Salvato",
|
||||
"editorPreviewBtn": "Anteprima PDF",
|
||||
"editorIssueBtn": "Emetti fattura",
|
||||
@@ -806,7 +850,7 @@
|
||||
"orgsAutoChargeOff": "disattivo"
|
||||
},
|
||||
"skillCostDialog": {
|
||||
"title": "Conferma costi di attivazione",
|
||||
"title": "Confermi costi di attivazione",
|
||||
"intro": "L'attivazione di {skill} comporterà i seguenti costi:",
|
||||
"setupFeeLabel": "Spese di attivazione",
|
||||
"setupFeeNote": "Una tantum, addebitate solo alla prima attivazione",
|
||||
@@ -814,14 +858,14 @@
|
||||
"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",
|
||||
"cancel": "Annulli",
|
||||
"confirm": "Confermi & attivi",
|
||||
"confirming": "Attivazione…"
|
||||
},
|
||||
"adminSkills": {
|
||||
"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",
|
||||
"backToAdmin": "Torni ad amministrazione",
|
||||
"emptyQueue": "Nessuna richiesta di attivazione skill in attesa.",
|
||||
"requestedAtCol": "Richiesta",
|
||||
"skillCol": "Skill",
|
||||
@@ -830,9 +874,9 @@
|
||||
"actionsCol": "",
|
||||
"approveBtn": "Approva",
|
||||
"rejectBtn": "Rifiuta",
|
||||
"confirmRejectBtn": "Conferma rifiuto",
|
||||
"confirmRejectBtn": "Confermi rifiuto",
|
||||
"working": "In corso…",
|
||||
"cancel": "Annulla",
|
||||
"cancel": "Annulli",
|
||||
"reasonLabel": "Motivo (mostrato al cliente)",
|
||||
"reasonPlaceholder": "Spiega perché l'attivazione non può procedere — es. dati cliente mancanti, hardware non disponibile, ecc.",
|
||||
"reasonRequired": "Un motivo è necessario per rifiutare."
|
||||
@@ -840,16 +884,16 @@
|
||||
"customerBilling": {
|
||||
"title": "Fatturazione",
|
||||
"subtitle": "Periodo corrente e cronologia delle fatture. Le fatture emesse sono disponibili come download PDF.",
|
||||
"backToBilling": "Torna alla fatturazione",
|
||||
"backToBilling": "Torni alla fatturazione",
|
||||
"currentPeriodHeading": "Periodo corrente",
|
||||
"historyHeading": "Cronologia fatture",
|
||||
"computing": "Calcolo del totale del periodo corrente…",
|
||||
"currentPeriodError": "Impossibile caricare il totale del periodo corrente. Riprova più tardi.",
|
||||
"noBillingConfig": "I dati di fatturazione non sono ancora configurati. Una volta registrato l'indirizzo di fatturazione della tua organizzazione, il totale corrente apparirà qui.",
|
||||
"noBillingConfig": "I dati di fatturazione non sono ancora configurati. Una volta registrato l'indirizzo di fatturazione della sua organizzazione, il totale corrente apparirà qui.",
|
||||
"accruedSoFar": "Accumulato questo mese",
|
||||
"estimatedTotal": "Totale stimato",
|
||||
"currentInvoiceIssued": "Mese corrente già fatturato",
|
||||
"refresh": "aggiorna",
|
||||
"refresh": "aggiorni",
|
||||
"breakdownToggle": "Mostra dettaglio ({count} voci)",
|
||||
"draftNote": "Stima in tempo reale. La fattura finale può variare leggermente per arrotondamenti di fine mese, dati di utilizzo in ritardo o aggiustamenti manuali.",
|
||||
"emptyHistory": "Nessuna fattura emessa ancora. Dopo la chiusura del primo mese, appariranno qui.",
|
||||
@@ -869,7 +913,7 @@
|
||||
"subtotalLabel": "Subtotale",
|
||||
"vatLabel": "IVA ({rate}%)",
|
||||
"totalLabel": "Totale",
|
||||
"downloadPdf": "Scarica PDF",
|
||||
"downloadPdf": "Scarichi PDF",
|
||||
"status": {
|
||||
"draft": "Bozza",
|
||||
"open": "Aperta",
|
||||
@@ -899,7 +943,7 @@
|
||||
},
|
||||
"adminCron": {
|
||||
"title": "Automazione fatturazione",
|
||||
"subtitle": "Emissione mensile e invio quotidiano dei solleciti. Entrambi vengono eseguiti automaticamente; usa i pulsanti sotto per avviare un'esecuzione su richiesta.",
|
||||
"subtitle": "Emissione mensile e invio quotidiano dei solleciti. Entrambi vengono eseguiti automaticamente; usi i pulsanti sotto per avviare un'esecuzione su richiesta.",
|
||||
"monthlyIssue": "Emissione mensile",
|
||||
"reminders": "Solleciti",
|
||||
"scheduleIssueLabel": "Pianificazione",
|
||||
@@ -927,21 +971,40 @@
|
||||
"reminders": "Solleciti"
|
||||
},
|
||||
"failureBannerTitle": "Fallimenti recenti rilevati",
|
||||
"failureBannerBody": "{count} esecuzione/i recente/i hanno segnalato almeno un fallimento. Controlla la tabella sotto — le righe interessate sono in rosso."
|
||||
"failureBannerBody": "{count} esecuzione/i recente/i hanno segnalato almeno un fallimento. Controlli la tabella sotto — le righe interessate sono in rosso."
|
||||
},
|
||||
"settingsProfile": {
|
||||
"title": "Profilo",
|
||||
"subtitle": "Il tuo nome visualizzato come appare nel portale, nelle richieste tenant e nei ticket di supporto.",
|
||||
"subtitlePersonal": "Il tuo nome visualizzato come appare nel portale. Per modificare il tuo nome in fattura, modificalo in Dati di fatturazione.",
|
||||
"subtitle": "Il suo nome visualizzato come appare nel portale, nelle richieste tenant e nei ticket di supporto.",
|
||||
"subtitlePersonal": "Il suo nome visualizzato come appare nel portale. Per modificare il suo nome in fattura, modificalo in Dati di fatturazione.",
|
||||
"firstNameLabel": "Nome",
|
||||
"lastNameLabel": "Cognome",
|
||||
"emailLabel": "E-mail",
|
||||
"emailReadOnlyHint": "L'e-mail non può essere modificata qui. Usa le impostazioni self-service del tuo provider di identità.",
|
||||
"personalAccountHint": "Questo è un account personale. Modificare il tuo nome qui NON cambia come appare in fattura — modificalo separatamente in Dati di fatturazione.",
|
||||
"companyAccountHint": "Sei connesso come membro di {orgName}.",
|
||||
"saveChanges": "Salva modifiche",
|
||||
"emailReadOnlyHint": "L'e-mail non può essere modificata qui. Usi le impostazioni self-service del suo provider di identità.",
|
||||
"personalAccountHint": "Questo è un account personale. Modificare il suo nome qui NON cambia come appare in fattura — modificalo separatamente in Dati di fatturazione.",
|
||||
"companyAccountHint": "È connesso come membro di {orgName}.",
|
||||
"saveChanges": "Salvi modifiche",
|
||||
"saving": "Salvataggio…",
|
||||
"saved": "Salvato.",
|
||||
"missingRequired": "Nome e cognome sono obbligatori."
|
||||
},
|
||||
"errors": {
|
||||
"title": "Si è verificato un errore",
|
||||
"description": "Si è verificato un errore durante il caricamento di questa pagina. Riprova.",
|
||||
"retry": "Riprova",
|
||||
"backToDashboard": "Torna alla dashboard",
|
||||
"notFoundTitle": "Pagina non trovata",
|
||||
"notFoundDescription": "La pagina che stai cercando non esiste o è stata spostata."
|
||||
},
|
||||
"connect": {
|
||||
"title": "Collegati al tuo assistente",
|
||||
"description": "Il tuo assistente funziona all'interno della tua app di messaggistica. Ecco come iniziare a chattare con lui.",
|
||||
"notReadyNote": "Il tuo assistente è ancora in fase di configurazione. Questi dettagli di connessione funzioneranno non appena sarà pronto.",
|
||||
"noChannelsTitle": "Nessun canale di messaggistica",
|
||||
"noChannelsBody": "Il tuo assistente è in funzione ma non ha alcun canale per chattare. Attiva un canale — Threema, Telegram o Discord — nella sezione Pacchetti qui sotto per iniziare a usarlo.",
|
||||
"threemaBotIdLabel": "ID Threema",
|
||||
"threemaSteps": "1. Apri Threema e scansiona questo codice QR (oppure aggiungi l'ID sopra come contatto).\n2. Inviagli un messaggio per iniziare a chattare.\nAssicurati che il tuo ID Threema sia presente nell'elenco degli utenti autorizzati qui sotto: solo gli ID elencati ricevono una risposta.",
|
||||
"telegramSteps": "Apri il bot Telegram che hai collegato e inviagli un messaggio per iniziare a chattare. Solo gli ID utente nell'elenco degli utenti autorizzati qui sotto ricevono una risposta.",
|
||||
"discordSteps": "Scrivi al bot Discord che hai collegato, oppure menzionalo in un canale a cui si è unito. Solo gli ID utente nell'elenco degli utenti autorizzati qui sotto ricevono una risposta."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -298,6 +298,16 @@ export interface TenantRequest {
|
||||
* rejection refunds this invoice via the existing refund flow.
|
||||
*/
|
||||
setupInvoiceId?: string | null;
|
||||
/**
|
||||
* Phase 9b: optional initial channel-user ids the customer entered
|
||||
* during onboarding for each enabled channel package (e.g.
|
||||
* { telegram: ["1234567"], threema: ["ABCD1234"] }). Empty/absent
|
||||
* on requests that pre-date the field. Applied on admin approval:
|
||||
* the values get seeded into PiecedTenantSpec.channelUsers, and
|
||||
* for Threema specifically, the relay's route table is updated so
|
||||
* inbound messages from those ids reach the newly-created tenant.
|
||||
*/
|
||||
channelUsers?: Record<string, string[]>;
|
||||
encryptedSecrets?: Buffer | null;
|
||||
/**
|
||||
* Slice 4: true for personal accounts. Drives CR-naming (`p-{suffix}`
|
||||
@@ -361,6 +371,14 @@ export interface OnboardingInput {
|
||||
*/
|
||||
billingAddress?: BillingAddress;
|
||||
billingNotes?: string;
|
||||
/**
|
||||
* Phase 9b: initial channel-user ids the customer entered during
|
||||
* onboarding, keyed by channel package id (e.g. { telegram:
|
||||
* ["1234567"], threema: ["ABCD1234"] }). Optional — customers
|
||||
* can also leave channels blank and add ids later from the
|
||||
* tenant's channel-users page.
|
||||
*/
|
||||
channelUsers?: Record<string, string[]>;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user