feat(sms): implement SMS messaging and metered billing
CI / Frontend (push) Successful in 9m50s
CI / Go - apps/auth-service (push) Failing after 4s
CI / Go - apps/backend (push) Successful in 10m18s
CI / Docker publish - auth-service (push) Has been skipped
CI / Docker publish - backend (push) Has been skipped

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.
This commit is contained in:
Tomas Dvorak
2026-05-10 11:40:53 +02:00
parent 164a37e997
commit 7d3e3448cf
28 changed files with 3633 additions and 3190 deletions
@@ -0,0 +1,239 @@
import { createSignal, createMemo, Show, For } from "solid-js";
import { useI18n } from "../../providers/i18n-provider";
import { ChevronLeftIcon, ChevronRightIcon, XIcon } from "./icons";
export function CalendarView(props: { bookings: any[]; locale?: string; onBookingClick?: (b: any) => void }) {
const i18n = useI18n();
const [currentDate, setCurrentDate] = createSignal(new Date());
const [selectedDay, setSelectedDay] = createSignal<number | null>(null);
const [modalDay, setModalDay] = createSignal<number | null>(null);
const [isAnimating, setIsAnimating] = createSignal(false);
const isCs = () => props.locale === "cs";
const weekDays = isCs()
? ["Po", "Út", "St", "Čt", "Pá", "So", "Ne"]
: ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"];
const monthYear = createMemo(() =>
new Intl.DateTimeFormat(isCs() ? "cs-CZ" : "en-US", { month: "long", year: "numeric" }).format(currentDate())
);
const calendarDays = createMemo(() => {
const date = currentDate();
const year = date.getFullYear();
const month = date.getMonth();
const firstDayOfMonth = new Date(year, month, 1).getDay();
// Adjust so Monday = 0
const firstDay = (firstDayOfMonth + 6) % 7;
const daysInMonth = new Date(year, month + 1, 0).getDate();
const days: Array<{ day: number | null; bookings: any[]; isToday: boolean }> = [];
for (let i = 0; i < firstDay; i++) days.push({ day: null, bookings: [], isToday: false });
const today = new Date();
for (let day = 1; day <= daysInMonth; day++) {
const dayBookings = props.bookings.filter((b) => {
const bd = new Date(b.startsAt);
return bd.getDate() === day && bd.getMonth() === month && bd.getFullYear() === year;
});
const isToday = today.getDate() === day && today.getMonth() === month && today.getFullYear() === year;
days.push({ day, bookings: dayBookings, isToday });
}
return days;
});
const changeMonth = (direction: number) => {
setIsAnimating(true);
setTimeout(() => {
setCurrentDate(new Date(currentDate().getFullYear(), currentDate().getMonth() + direction, 1));
setIsAnimating(false);
setSelectedDay(null);
setModalDay(null);
}, 150);
};
const handleDayClick = (day: number | null) => {
if (!day) return;
const dayData = calendarDays().find((d) => d.day === day);
if (dayData && dayData.bookings.length > 0) {
setModalDay(day);
} else {
setSelectedDay(selectedDay() === day ? null : day);
}
};
const handleBookingClick = (booking: any) => {
props.onBookingClick?.(booking);
setModalDay(null);
};
const modalBookings = createMemo(() => {
const day = modalDay();
if (!day) return [];
return calendarDays().find((d) => d.day === day)?.bookings ?? [];
});
return (
<>
<div class="surface-card p-6 shadow-sm hover:shadow-md transition-all duration-500">
<div class="flex items-center justify-between mb-6">
<div>
<h3 class="text-lg font-semibold text-ink">{monthYear()}</h3>
<p class="text-sm text-ink-muted mt-0.5">
{props.bookings.length} {isCs() ? i18n.t("dashboard.calendar.bookingsCount") : "bookings"}
</p>
</div>
<div class="flex gap-1">
<button
onClick={() => changeMonth(-1)}
aria-label={i18n.t("dashboard.prevMonth")}
class="p-2 hover:bg-canvas-subtle rounded-xl transition-all hover:scale-105 active:scale-95"
>
<ChevronLeftIcon />
</button>
<button
onClick={() => changeMonth(1)}
aria-label={i18n.t("dashboard.nextMonth")}
class="p-2 hover:bg-canvas-subtle rounded-xl transition-all hover:scale-105 active:scale-95"
>
<ChevronRightIcon />
</button>
</div>
</div>
<div class="grid grid-cols-7 gap-1 mb-2">
{weekDays.map((day) => (
<div class="text-center text-[10px] sm:text-xs font-semibold text-ink-subtle uppercase tracking-wider py-2">
{day}
</div>
))}
</div>
<div class={`grid grid-cols-7 gap-1 transition-opacity duration-200 ${isAnimating() ? "opacity-0" : "opacity-100"}`}>
{calendarDays().map(({ day, bookings, isToday }, index) => (
<button
onClick={() => handleDayClick(day)}
class={`
aspect-square p-1 sm:p-1.5 rounded-xl border transition-all duration-200 relative overflow-hidden
${day ? "bg-canvas border-border/60 hover:border-accent/40 hover:shadow-sm" : "bg-transparent border-transparent pointer-events-none"}
${isToday ? "ring-1 ring-accent bg-accent-subtle/40" : ""}
${selectedDay() === day ? "ring-2 ring-accent ring-offset-1 ring-offset-canvas" : ""}
${bookings.length > 0 && !isToday ? "bg-accent-subtle/20" : ""}
`}
style={{ "animation-delay": `${index * 15}ms` }}
disabled={!day}
>
{day && (
<>
<span class={`text-xs sm:text-sm font-medium ${isToday ? "text-accent" : "text-ink"}`}>{day}</span>
{bookings.length > 0 && (
<div class="absolute bottom-1 left-1 right-1 flex gap-0.5 justify-center">
{bookings.slice(0, 3).map((b: any) => (
<span
class={`w-1 h-1 rounded-full ${
b.status === "confirmed" ? "bg-ink/60" : b.status === "pending" ? "bg-ink/40" : "bg-ink/20"
}`}
/>
))}
{bookings.length > 3 && (
<span class="text-[7px] font-bold text-ink/50 leading-none">+</span>
)}
</div>
)}
</>
)}
</button>
))}
</div>
{/* Inline selected day preview for empty days */}
<Show when={selectedDay() && !modalDay()}>
<div class="mt-4 p-4 bg-canvas-subtle/50 rounded-xl border border-border/60 animate-fade-in">
<div class="flex items-center justify-between mb-2">
<span class="text-sm font-medium text-ink">
{selectedDay()}. {monthYear()}
</span>
<button onClick={() => setSelectedDay(null)} class="text-xs text-ink-muted hover:text-ink">
{i18n.t("dashboard.close")}
</button>
</div>
<p class="text-sm text-ink-muted">{i18n.t("dashboard.calendar.noBookings")}</p>
</div>
</Show>
</div>
{/* Day bookings modal */}
<Show when={modalDay()}>
<div class="fixed inset-0 z-50 flex items-center justify-center p-4">
<div class="absolute inset-0 bg-ink/40 backdrop-blur-sm" onClick={() => setModalDay(null)} />
<div class="relative bg-canvas rounded-2xl shadow-2xl w-full max-w-md max-h-[80vh] overflow-hidden animate-scale-in">
<div class="p-5 border-b border-border flex items-center justify-between">
<div>
<h3 class="text-lg font-bold text-ink">
{modalDay()}. {monthYear()}
</h3>
<p class="text-xs text-ink-muted mt-0.5">
{modalBookings().length} {isCs() ? "rezervací" : "bookings"}
</p>
</div>
<button
onClick={() => setModalDay(null)}
class="p-2 hover:bg-canvas-subtle rounded-lg transition-colors"
aria-label={i18n.t("dashboard.close")}
>
<XIcon />
</button>
</div>
<div class="p-5 overflow-y-auto max-h-[60vh] space-y-3">
<For each={modalBookings()}>
{(booking) => (
<button
onClick={() => handleBookingClick(booking)}
class="w-full text-left flex items-center gap-3 p-3 rounded-xl border border-border/60 hover:border-accent/30 hover:bg-accent-subtle/20 transition-all"
>
<div
class={`w-10 h-10 rounded-full flex items-center justify-center text-sm font-bold shrink-0 ${
booking.status === "confirmed"
? "bg-accent text-canvas"
: booking.status === "pending"
? "bg-canvas-muted text-ink"
: "bg-canvas-subtle text-ink-muted"
}`}
>
{(booking.customerName ?? "?")
.split(" ")
.map((n: string) => n[0])
.join("")
.slice(0, 2)
.toUpperCase()}
</div>
<div class="flex-1 min-w-0">
<p class="text-sm font-medium text-ink truncate">{booking.customerName}</p>
<p class="text-xs text-ink-muted">{booking.service}</p>
</div>
<div class="text-right shrink-0">
<p class="text-xs font-medium text-ink">
{new Date(booking.startsAt).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })}
</p>
<span
class={`text-[10px] px-1.5 py-0.5 rounded-full font-medium ${
booking.status === "confirmed"
? "bg-accent/10 text-accent"
: booking.status === "pending"
? "bg-canvas-muted text-ink-muted"
: "bg-canvas-subtle text-ink-subtle"
}`}
>
{i18n.t(`dashboard.${booking.status}`)}
</span>
</div>
</button>
)}
</For>
</div>
</div>
</div>
</Show>
</>
);
}