mirror of
https://github.com/Dvorinka/Bookra.git
synced 2026-06-03 20:13:00 +00:00
7d3e3448cf
Implement a complete SMS messaging system including: - Integration with SMS Manager.cz API for sending messages. - Metered billing via Stripe using monthly aggregate invoice items. - Backend services for managing SMS settings, usage logging, and monthly reporting. - Database migrations for tenant settings, usage logs, and monthly reports. - Frontend dashboard components for SMS configuration, usage tracking, and history. - Support for customer phone numbers in the booking flow. Includes new migrations, backend services, and frontend UI components.
330 lines
12 KiB
TypeScript
330 lines
12 KiB
TypeScript
import { createResource, createSignal, Show, For, Accessor } from "solid-js";
|
|
import { apiClient } from "../lib/api-client";
|
|
import { Input } from "./ui/input";
|
|
import { useI18n } from "../providers/i18n-provider";
|
|
|
|
interface SMSSettingsData {
|
|
enabled: boolean;
|
|
senderName: string;
|
|
monthlyLimit: number;
|
|
messagesSent: number;
|
|
totalCostCents: number;
|
|
available: boolean;
|
|
}
|
|
|
|
interface SMSReport {
|
|
yearMonth: string;
|
|
messageCount: number;
|
|
totalCostCents: number;
|
|
stripeInvoiceId?: string;
|
|
invoiceSentAt?: string;
|
|
}
|
|
|
|
interface SMSLog {
|
|
id: string;
|
|
recipientPhone: string;
|
|
status: string;
|
|
costCents: number;
|
|
createdAt: string;
|
|
}
|
|
|
|
function formatCents(cents: number) {
|
|
return `${(cents / 100).toFixed(2)} Kč`;
|
|
}
|
|
|
|
function formatMonth(yearMonth: string) {
|
|
const [y, m] = yearMonth.split("-");
|
|
return `${m}/${y}`;
|
|
}
|
|
|
|
interface SMSSettingsProps {
|
|
token: Accessor<string | null | undefined>;
|
|
}
|
|
|
|
export function SMSSettings(props: SMSSettingsProps) {
|
|
const i18n = useI18n();
|
|
const token = props.token;
|
|
|
|
const [saving, setSaving] = createSignal(false);
|
|
const [notice, setNotice] = createSignal<string | null>(null);
|
|
const [error, setError] = createSignal<string | null>(null);
|
|
|
|
const [settings, { refetch: refetchSettings }] = createResource(token, async (bearer) => {
|
|
if (!bearer || bearer.startsWith("demo.")) {
|
|
return {
|
|
enabled: false,
|
|
senderName: "",
|
|
monthlyLimit: 0,
|
|
messagesSent: 0,
|
|
totalCostCents: 0,
|
|
available: true,
|
|
} as SMSSettingsData;
|
|
}
|
|
const response = await (apiClient as any).GET("/v1/sms/settings", {
|
|
headers: { Authorization: `Bearer ${bearer}` },
|
|
});
|
|
return response.data as SMSSettingsData;
|
|
});
|
|
|
|
const [reports] = createResource(token, async (bearer) => {
|
|
if (!bearer || bearer.startsWith("demo.")) return [] as SMSReport[];
|
|
const response = await (apiClient as any).GET("/v1/sms/invoices", {
|
|
headers: { Authorization: `Bearer ${bearer}` },
|
|
});
|
|
return (response.data as any)?.reports ?? [];
|
|
});
|
|
|
|
const [logs] = createResource(token, async (bearer) => {
|
|
if (!bearer || bearer.startsWith("demo.")) return [] as SMSLog[];
|
|
const response = await (apiClient as any).GET("/v1/sms/history", {
|
|
headers: { Authorization: `Bearer ${bearer}` },
|
|
});
|
|
return (response.data as any)?.logs ?? [];
|
|
});
|
|
|
|
const handleToggle = async () => {
|
|
const current = settings();
|
|
if (!current) return;
|
|
const bearer = token();
|
|
if (!bearer) return;
|
|
|
|
setSaving(true);
|
|
setError(null);
|
|
setNotice(null);
|
|
|
|
try {
|
|
await (apiClient as any).POST("/v1/sms/settings", {
|
|
headers: { Authorization: `Bearer ${bearer}` },
|
|
body: {
|
|
enabled: !current.enabled,
|
|
senderName: current.senderName,
|
|
monthlyLimit: current.monthlyLimit,
|
|
},
|
|
});
|
|
await refetchSettings();
|
|
setNotice(
|
|
i18n.locale() === "cs"
|
|
? `SMS ${!current.enabled ? "aktivováno" : "deaktivováno"}`
|
|
: `SMS ${!current.enabled ? "enabled" : "disabled"}`
|
|
);
|
|
} catch (e: any) {
|
|
setError(
|
|
e?.response?.data?.error ||
|
|
(i18n.locale() === "cs" ? "Nepodařilo se uložit nastavení." : "Failed to save settings.")
|
|
);
|
|
} finally {
|
|
setSaving(false);
|
|
}
|
|
};
|
|
|
|
const handleSaveSettings = async (e: Event) => {
|
|
e.preventDefault();
|
|
const current = settings();
|
|
if (!current) return;
|
|
const bearer = token();
|
|
if (!bearer) return;
|
|
|
|
setSaving(true);
|
|
setError(null);
|
|
setNotice(null);
|
|
|
|
try {
|
|
await (apiClient as any).POST("/v1/sms/settings", {
|
|
headers: { Authorization: `Bearer ${bearer}` },
|
|
body: {
|
|
enabled: current.enabled,
|
|
senderName: current.senderName,
|
|
monthlyLimit: current.monthlyLimit,
|
|
},
|
|
});
|
|
await refetchSettings();
|
|
setNotice(i18n.locale() === "cs" ? "Nastavení uloženo." : "Settings saved.");
|
|
} catch (e: any) {
|
|
setError(
|
|
e?.response?.data?.error ||
|
|
(i18n.locale() === "cs" ? "Nepodařilo se uložit nastavení." : "Failed to save settings.")
|
|
);
|
|
} finally {
|
|
setSaving(false);
|
|
}
|
|
};
|
|
|
|
const cs = () => i18n.locale() === "cs";
|
|
|
|
return (
|
|
<div class="surface-card p-6">
|
|
<div class="flex items-center gap-3 mb-5">
|
|
<div class="w-10 h-10 rounded-full bg-[hsl(var(--info-subtle))] flex items-center justify-center text-[hsl(var(--info))]">
|
|
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
|
|
<rect width="20" height="16" x="2" y="4" rx="2" />
|
|
<path d="m22 7-8.97 5.7a1.94 1.94 0 0 1-2.06 0L2 7" />
|
|
</svg>
|
|
</div>
|
|
<div>
|
|
<h3 class="text-lg font-semibold text-ink">{cs() ? "SMS zprávy" : "SMS Messages"}</h3>
|
|
<p class="text-sm text-ink-muted">
|
|
{cs()
|
|
? "1.50 Kč / SMS. Fakturováno měsíčně přes Stripe."
|
|
: "1.50 CZK / SMS. Billed monthly via Stripe."}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
<Show when={!settings.loading} fallback={<div class="text-ink-muted">{cs() ? "Načítání..." : "Loading..."}</div>}>
|
|
<Show when={settings()?.available === false}>
|
|
<div class="p-4 bg-canvas-subtle rounded-xl text-ink-muted text-sm">
|
|
{cs()
|
|
? "SMS není v této instanci nakonfigurováno. Kontaktujte administrátora."
|
|
: "SMS is not configured for this instance. Contact your administrator."}
|
|
</div>
|
|
</Show>
|
|
|
|
<Show when={settings()?.available === true}>
|
|
<Show when={notice()}>
|
|
<div class="mb-4 p-3 bg-emerald-50 text-emerald-700 rounded-lg text-sm">{notice()}</div>
|
|
</Show>
|
|
<Show when={error()}>
|
|
<div class="mb-4 p-3 bg-red-50 text-red-700 rounded-lg text-sm">{error()}</div>
|
|
</Show>
|
|
|
|
{/* Toggle */}
|
|
<div class="flex items-center justify-between mb-6 p-4 bg-canvas-subtle rounded-xl">
|
|
<div>
|
|
<p class="font-medium text-ink">{cs() ? "SMS odesílání" : "SMS sending"}</p>
|
|
<p class="text-sm text-ink-muted">
|
|
{settings()?.enabled
|
|
? (cs() ? "Aktivní — účtováno za každou zprávu" : "Active — charged per message")
|
|
: (cs() ? "Neaktivní" : "Inactive")}
|
|
</p>
|
|
</div>
|
|
<button
|
|
onClick={handleToggle}
|
|
disabled={saving()}
|
|
class={`relative inline-flex h-7 w-12 items-center rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-accent focus:ring-offset-2 ${
|
|
settings()?.enabled ? "bg-accent" : "bg-gray-300"
|
|
}`}
|
|
>
|
|
<span
|
|
class={`inline-block h-5 w-5 transform rounded-full bg-white transition-transform ${
|
|
settings()?.enabled ? "translate-x-6" : "translate-x-1"
|
|
}`}
|
|
/>
|
|
</button>
|
|
</div>
|
|
|
|
<Show when={settings()?.enabled}>
|
|
<form onSubmit={handleSaveSettings} class="space-y-4">
|
|
<Input
|
|
type="text"
|
|
label={cs() ? "Jméno odesílatele (max 11 znaků)" : "Sender name (max 11 chars)"}
|
|
value={settings()?.senderName || ""}
|
|
onInput={(e) => {
|
|
const s = settings();
|
|
if (s) s.senderName = e.currentTarget.value;
|
|
}}
|
|
maxLength={11}
|
|
placeholder="Bookra"
|
|
/>
|
|
<Input
|
|
type="number"
|
|
label={cs() ? "Měsíční limit (0 = bez limitu)" : "Monthly limit (0 = unlimited)"}
|
|
value={settings()?.monthlyLimit || 0}
|
|
onInput={(e) => {
|
|
const s = settings();
|
|
if (s) s.monthlyLimit = parseInt(e.currentTarget.value) || 0;
|
|
}}
|
|
min={0}
|
|
/>
|
|
<div class="flex justify-end">
|
|
<button
|
|
type="submit"
|
|
disabled={saving()}
|
|
class="px-4 py-2 bg-accent text-white rounded-lg text-sm font-medium hover:bg-accent/90 transition-colors disabled:opacity-50"
|
|
>
|
|
{saving() ? (cs() ? "Ukládání..." : "Saving...") : cs() ? "Uložit" : "Save"}
|
|
</button>
|
|
</div>
|
|
</form>
|
|
|
|
{/* Current month stats */}
|
|
<div class="mt-6 p-4 bg-canvas-subtle rounded-xl">
|
|
<h4 class="text-sm font-medium text-ink mb-3">
|
|
{cs() ? "Aktuální měsíc" : "Current month"}
|
|
</h4>
|
|
<div class="grid grid-cols-2 gap-4">
|
|
<div>
|
|
<p class="text-2xl font-bold text-ink">{settings()?.messagesSent ?? 0}</p>
|
|
<p class="text-xs text-ink-muted">{cs() ? "Odeslaných zpráv" : "Messages sent"}</p>
|
|
</div>
|
|
<div>
|
|
<p class="text-2xl font-bold text-ink">{formatCents(settings()?.totalCostCents ?? 0)}</p>
|
|
<p class="text-xs text-ink-muted">{cs() ? "Celková cena" : "Total cost"}</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Recent logs */}
|
|
<Show when={logs() && logs()!.length > 0}>
|
|
<div class="mt-6">
|
|
<h4 class="text-sm font-medium text-ink mb-3">
|
|
{cs() ? "Historie odesílání" : "Send history"}
|
|
</h4>
|
|
<div class="space-y-2 max-h-48 overflow-y-auto">
|
|
<For each={logs()}>
|
|
{(log) => (
|
|
<div class="flex items-center justify-between p-3 bg-canvas-subtle rounded-lg text-sm">
|
|
<div class="flex items-center gap-3">
|
|
<span class="text-ink-muted">{log.recipientPhone}</span>
|
|
<span
|
|
class={`px-2 py-0.5 rounded-full text-xs ${
|
|
log.status === "sent"
|
|
? "bg-emerald-100 text-emerald-700"
|
|
: "bg-red-100 text-red-700"
|
|
}`}
|
|
>
|
|
{log.status}
|
|
</span>
|
|
</div>
|
|
<span class="text-ink-muted">{formatCents(log.costCents)}</span>
|
|
</div>
|
|
)}
|
|
</For>
|
|
</div>
|
|
</div>
|
|
</Show>
|
|
|
|
{/* Monthly invoice reports */}
|
|
<Show when={reports() && reports()!.length > 0}>
|
|
<div class="mt-6">
|
|
<h4 class="text-sm font-medium text-ink mb-3">
|
|
{cs() ? "Fakturační přehledy" : "Invoice reports"}
|
|
</h4>
|
|
<div class="space-y-2">
|
|
<For each={reports()}>
|
|
{(report) => (
|
|
<div class="flex items-center justify-between p-3 bg-canvas-subtle rounded-lg text-sm">
|
|
<div>
|
|
<span class="font-medium text-ink">{formatMonth(report.yearMonth)}</span>
|
|
<span class="text-ink-muted ml-2">{report.messageCount} SMS</span>
|
|
</div>
|
|
<div class="flex items-center gap-3">
|
|
<span class="font-medium text-ink">{formatCents(report.totalCostCents)}</span>
|
|
<Show when={report.stripeInvoiceId}>
|
|
<span class="text-xs text-emerald-600 bg-emerald-50 px-2 py-0.5 rounded-full">
|
|
Stripe
|
|
</span>
|
|
</Show>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</For>
|
|
</div>
|
|
</div>
|
|
</Show>
|
|
</Show>
|
|
</Show>
|
|
</Show>
|
|
</div>
|
|
);
|
|
}
|