refactor(db): replace sqlc with handwritten pgx repos

- Finish Paddle-to-Stripe billing migration; drop paddle/stripe libs
- Renumber migrations; add 00012 memberships+manage_token,
  00013 location_type_capacity
- Add tests: admin, auth, bookings customer service
- Remove graphify-out artifacts and stale env files

Authored By: TDvorak <[email protected]>

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
Tomas DvorakandDevin committed 2026-09-17 20:21:34 +02:00
1 parent 2039669e2c
commit f2f7439050
93 files changed
+6987 -69060

No files matched your search

@@ -83,17 +83,29 @@ export function CalendarView(props: { bookings: any[]; locale?: string; onBookin
return calendarDays().find((d) => d.day === day)?.bookings ?? [];
});
// Booking visual density for bars
const eventDot = (status: string) =>
status === "confirmed" ? "bg-accent" : status === "pending" ? "bg-ink-subtle" : "bg-border-strong";
// Up to two readable event chips, then a "+N" overflow marker.
const getBookingBars = (bookings: any[]) => {
if (bookings.length === 0) return null;
const count = bookings.length;
const hasMultiple = count >= 2;
const hasMany = count >= 3;
const visible = bookings.slice(0, 2);
const overflow = bookings.length - visible.length;
return (
<div class="mt-2 space-y-1.5">
<div class="h-2 bg-accent/30 rounded-full w-full group-hover:bg-accent/40 transition-colors" />
{hasMultiple && <div class="h-2 bg-accent/20 rounded-full w-3/4 group-hover:bg-accent/30 transition-colors" />}
{hasMany && <div class="h-2 bg-accent/10 rounded-full w-1/2 group-hover:bg-accent/20 transition-colors" />}
<div class="mt-1.5 space-y-1">
<For each={visible}>
{(b) => (
<div class="flex items-center gap-1 rounded-md bg-accent-subtle/60 px-1.5 py-0.5 group-hover:bg-accent-subtle transition-colors">
<span class={`w-1.5 h-1.5 rounded-full shrink-0 ${eventDot(b.status)}`} />
<span class="text-[10px] font-medium text-ink leading-tight whitespace-nowrap">
{new Date(b.startsAt).toLocaleTimeString(isCs() ? "cs-CZ" : "en-US", { hour: "2-digit", minute: "2-digit" })}
</span>
</div>
)}
</For>
<Show when={overflow > 0}>
<p class="text-[10px] font-medium text-ink-subtle pl-1">+{overflow} {isCs() ? "další" : "more"}</p>
</Show>
</div>
);
};
@@ -145,29 +157,25 @@ export function CalendarView(props: { bookings: any[]; locale?: string; onBookin
<div class={`grid grid-cols-7 gap-px bg-border/50 transition-opacity duration-200 ${isAnimating() ? "opacity-0" : "opacity-100"}`}>
{calendarDays().map(({ day, bookings, isToday }) => {
if (!day) {
return <div class="p-3 min-h-[80px] lg:min-h-[100px] bg-canvas/50" />;
return <div class="p-2 min-h-[80px] lg:min-h-[96px] bg-canvas/50" />;
}
return (
<button
onClick={() => handleDayClick(day)}
class={`
p-3 min-h-[80px] lg:min-h-[100px] bg-canvas transition-all duration-200 hover:bg-canvas-subtle group relative text-left
${isToday ? "ring-2 ring-inset ring-accent bg-accent-soft" : ""}
${selectedDay() === day ? "ring-2 ring-inset ring-accent bg-accent-subtle/30" : ""}
${bookings.length > 0 && !isToday ? "bg-accent-subtle/10" : ""}
p-2 min-h-[80px] lg:min-h-[96px] bg-canvas transition-all duration-200 hover:bg-canvas-subtle group relative text-left flex flex-col
${isToday ? "bg-accent-soft" : ""}
${selectedDay() === day ? "bg-accent-subtle/40" : ""}
`}
>
<span class={`text-sm transition-colors ${isToday ? "font-semibold text-accent" : "text-ink-muted group-hover:text-ink"}`}>
<span class={`inline-flex items-center justify-center w-6 h-6 rounded-full text-sm transition-colors ${
isToday
? "bg-accent text-canvas font-semibold"
: "text-ink-muted group-hover:text-ink"
}`}>
{day}
</span>
{getBookingBars(bookings)}
{bookings.length > 0 && (
<div class="absolute top-2 right-2">
<span class="text-[10px] font-semibold text-accent bg-accent/10 px-1.5 py-0.5 rounded-full">
{bookings.length}
</span>
</div>
)}
</button>
);
})}
@@ -26,8 +26,8 @@ export function NotificationDropdown(props: { bookings?: any[]; billing?: any })
id: `booking-${i}`,
type: "booking",
title: `${cs ? "Nová rezervace" : "New booking"} ${b.customerName}`,
message: `${b.service} — ${new Date(b.startsAt).toLocaleDateString()}`,
time: new Date(b.startsAt).toLocaleDateString(undefined, { month: "short", day: "numeric" }),
message: `${b.service} — ${new Date(b.startsAt).toLocaleDateString(cs ? "cs-CZ" : "en-US")}`,
time: new Date(b.startsAt).toLocaleDateString(cs ? "cs-CZ" : "en-US", { month: "short", day: "numeric" }),
read: false,
});
});
@@ -1,32 +1,43 @@
import { createMemo } from "solid-js";
import { createMemo, Show } from "solid-js";
import { useI18n } from "../../providers/i18n-provider";
interface DataPoint {
label: string;
dateLabel: string;
value: number;
isToday: boolean;
isFuture: boolean;
}
export function RevenueChart(props: { data?: DataPoint[]; bookings?: any[] }) {
const i18n = useI18n();
const isCs = () => i18n.locale() === "cs";
const chartData = createMemo(() => {
const chartData = createMemo<DataPoint[]>(() => {
if (props.data && props.data.length > 0) return props.data;
// Build last 7 days from bookings
const days: DataPoint[] = [];
// Bucket bookings into the current calendar week (Mon-Sun) so both
// completed and upcoming bookings are visible.
const bookings = props.bookings ?? [];
const today = new Date();
for (let i = 6; i >= 0; i--) {
const d = new Date(today);
d.setDate(d.getDate() - i);
const dateStr = d.toISOString().split("T")[0];
const monday = new Date(today);
monday.setDate(today.getDate() - ((today.getDay() + 6) % 7));
const days: DataPoint[] = [];
for (let i = 0; i < 7; i++) {
const d = new Date(monday);
d.setDate(monday.getDate() + i);
const isToday = d.toDateString() === today.toDateString();
const count = bookings.filter((b: any) => {
const bd = new Date(b.startsAt).toISOString().split("T")[0];
return bd === dateStr;
const bd = new Date(b.startsAt);
return bd.getFullYear() === d.getFullYear() && bd.getMonth() === d.getMonth() && bd.getDate() === d.getDate();
}).length;
days.push({
label: d.toLocaleDateString(i18n.locale() === "cs" ? "cs-CZ" : "en-US", { weekday: "short" }),
label: d.toLocaleDateString(isCs() ? "cs-CZ" : "en-US", { weekday: "short" }),
dateLabel: d.toLocaleDateString(isCs() ? "cs-CZ" : "en-US", { day: "numeric", month: "numeric" }),
value: count,
isToday,
isFuture: d > today,
});
}
return days;
@@ -36,34 +47,46 @@ export function RevenueChart(props: { data?: DataPoint[]; bookings?: any[] }) {
const total = createMemo(() => chartData().reduce((s, d) => s + d.value, 0));
return (
<div class="surface-card p-6">
<div class="flex items-start justify-between mb-6">
<div class="surface-card p-6 h-full flex flex-col">
<div class="flex items-start justify-between mb-8">
<div>
<h3 class="text-lg font-semibold text-ink">{i18n.t("dashboard.revenueTitle")}</h3>
<h3 class="text-lg font-semibold text-ink font-display">{i18n.t("dashboard.revenueTitle")}</h3>
<p class="text-sm text-ink-muted mt-0.5">{i18n.t("dashboard.revenueSubtitle")}</p>
</div>
<div class="text-right">
<p class="text-2xl font-bold text-ink tracking-tight">{total()}</p>
<div class="text-right shrink-0">
<p class="text-3xl font-bold text-ink tracking-tight font-display">{total()}</p>
<p class="text-xs text-ink-muted">{i18n.t("dashboard.totalBookings")}</p>
</div>
</div>
<div class="flex items-end gap-2 sm:gap-3 h-40">
{chartData().map((point, i) => {
<div class="flex items-end gap-2 sm:gap-3 flex-1 min-h-40">
{chartData().map((point) => {
const heightPct = Math.round((point.value / maxValue()) * 100);
return (
<div class="flex-1 flex flex-col items-center gap-2">
<div class="w-full flex-1 flex items-end">
<div class="flex-1 flex flex-col items-center gap-2 h-full" title={`${point.dateLabel}: ${point.value}`}>
<div class="w-full flex-1 flex items-end relative group">
<div
class="w-full rounded-t-md bg-accent/80 hover:bg-accent transition-all duration-300 relative group"
style={{ height: `${Math.max(heightPct, 4)}%` }}
>
<div class="absolute -top-7 left-1/2 -translate-x-1/2 px-2 py-0.5 rounded-md bg-ink text-canvas text-xs font-medium opacity-0 group-hover:opacity-100 transition-opacity whitespace-nowrap pointer-events-none">
class={`w-full rounded-t-lg transition-all duration-300 ${
point.isToday
? "bg-accent"
: point.isFuture
? "bg-accent/40 group-hover:bg-accent/60"
: "bg-accent/70 group-hover:bg-accent"
}`}
style={{ height: `${Math.max(heightPct, point.value > 0 ? 8 : 3)}%`, "min-height": point.value > 0 ? "6px" : "3px", opacity: point.value === 0 ? 0.15 : 1 }}
/>
<Show when={point.value > 0}>
<span class={`absolute -top-6 left-1/2 -translate-x-1/2 text-xs font-semibold ${point.isToday ? "text-accent" : "text-ink-muted"} opacity-0 group-hover:opacity-100 transition-opacity`}>
{point.value}
</div>
</div>
</span>
</Show>
</div>
<div class="text-center">
<span class={`text-[11px] font-medium uppercase tracking-wide ${point.isToday ? "text-accent font-semibold" : "text-ink-subtle"}`}>
{point.label}
</span>
<p class={`text-[10px] leading-tight ${point.isToday ? "text-accent" : "text-ink-subtle/70"}`}>{point.dateLabel}</p>
</div>
<span class="text-[10px] sm:text-xs text-ink-muted font-medium uppercase">{point.label}</span>
</div>
);
})}
+4 -4
View File
@@ -82,21 +82,21 @@ export function PinnedList(props: PinnedListProps) {
const ItemCard = (p: { item: PinnedListItem; pinned: boolean }) => {
const content = (
<div
class={`flex items-center gap-3 rounded-xl px-3 py-3 border transition-all duration-200 ${
class={`flex items-center gap-2.5 rounded-lg px-2.5 py-2.5 border transition-all duration-200 ${
p.pinned
? "bg-accent-soft border-accent/20"
: "bg-canvas-subtle/40 border-border/50 hover:border-border"
}`}
>
<Show when={p.item.icon}>
<div class="flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-lg bg-canvas border border-border text-ink-muted">
<div class="flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-lg bg-canvas border border-border text-ink-muted">
{p.item.icon}
</div>
</Show>
<div class="min-w-0 flex-1">
<p class="truncate text-sm font-medium text-ink">{p.item.name}</p>
<p class="truncate text-xs text-ink-muted">{p.item.subtitle}</p>
<p class="truncate text-[13px] font-medium text-ink">{p.item.name}</p>
<p class="truncate text-[11px] text-ink-muted">{p.item.subtitle}</p>
</div>
<DotToggle id={p.item.id} />