Files
Sloneczko-manager/src/routes/+page.svelte
2026-07-05 15:53:52 +02:00

288 lines
7.7 KiB
Svelte
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script lang="ts">
import { onMount } from "svelte";
import { ScheduleState } from "$lib/schedule.svelte";
import { formatShort, MONTH_NAMES, todayISO } from "$lib/dates";
import ScheduleGrid from "$lib/components/ScheduleGrid.svelte";
import ReservationDialog from "$lib/components/ReservationDialog.svelte";
import CabinsDialog from "$lib/components/CabinsDialog.svelte";
import InvoiceDialog from "$lib/components/InvoiceDialog.svelte";
import InvoicePreview from "$lib/components/InvoicePreview.svelte";
import { formatMoney } from "$lib/money";
import * as db from "$lib/db";
import type {
Invoice,
InvoiceDialogParams,
InvoiceItem,
Reservation,
ReservationDialogParams
} from "$lib/types";
const schedule = new ScheduleState();
let dialogParams = $state<ReservationDialogParams | null>(null);
let cabinsOpen = $state(false);
let invoiceParams = $state<InvoiceDialogParams | null>(null);
let preview = $state<{ invoice: Invoice; items: InvoiceItem[] } | null>(null);
async function openPreview(invoiceId: number) {
const [invoice, items] = await Promise.all([
db.getInvoice(invoiceId),
db.getInvoiceItems(invoiceId)
]);
if (invoice) {
preview = { invoice, items };
}
}
onMount(() => {
void schedule.reload();
});
function newReservation(cabinId?: number, date?: string) {
const cid = cabinId ?? schedule.cabins[0]?.id;
if (cid == null) {
cabinsOpen = true;
return;
}
dialogParams = { cabin_id: cid, arrival: date ?? todayISO() };
}
function editReservation(r: Reservation) {
dialogParams = { reservation: r };
}
let moveError = $state<string | null>(null);
async function moveReservation(
r: Reservation,
cabinId: number,
arrival: string,
departure: string
) {
moveError = null;
try {
const conflicts = await db.findConflicts(cabinId, arrival, departure, r.id);
if (conflicts.length > 0) {
const c = conflicts[0];
moveError = `Nie można przenieść — termin koliduje z rezerwacją: ${c.guest_name} (${formatShort(c.arrival)}${formatShort(c.departure)}).`;
setTimeout(() => (moveError = null), 5000);
return;
}
await db.updateReservation(r.id, { ...r, cabin_id: cabinId, arrival, departure });
await schedule.reload();
} catch (e) {
moveError = `Nie udało się przenieść rezerwacji: ${e}`;
}
}
</script>
<svelte:head>
<title>Słoneczko — grafik rezerwacji</title>
</svelte:head>
<main>
<header class="toolbar">
<button type="button" class="icon" onclick={() => schedule.prevMonth()} aria-label="Poprzedni miesiąc"></button>
<span class="month">{MONTH_NAMES[schedule.month]} {schedule.year}</span>
<button type="button" class="icon" onclick={() => schedule.nextMonth()} aria-label="Następny miesiąc"></button>
<button type="button" onclick={() => schedule.goToday()}>Dziś</button>
<span class="spacer"></span>
<button type="button" onclick={() => (cabinsOpen = true)}>Domki</button>
<button type="button" class="primary" onclick={() => newReservation()}>+ Nowa rezerwacja</button>
</header>
{#if schedule.monthStats && schedule.yearStats}
<div class="stats">
<span>
<strong>{MONTH_NAMES[schedule.month]}:</strong>
{Math.round(schedule.monthStats.nights_total)} dób ·
{formatMoney(schedule.monthStats.revenue_total)}
<span class="confirmed">(potwierdzone: {formatMoney(schedule.monthStats.revenue_confirmed)} zł)</span>
</span>
<span>
<strong>Rok {schedule.year}:</strong>
{Math.round(schedule.yearStats.nights_total)} dób ·
{formatMoney(schedule.yearStats.revenue_total)}
<span class="confirmed">(potwierdzone: {formatMoney(schedule.yearStats.revenue_confirmed)} zł)</span>
</span>
</div>
{/if}
{#if moveError}
<p class="error">{moveError}</p>
{/if}
{#if schedule.error}
<p class="error">{schedule.error}</p>
{:else if !schedule.loading && schedule.cabins.length === 0}
<div class="empty">
<p>Nie ma jeszcze żadnych domków. Dodaj je, aby zacząć planować rezerwacje.</p>
<button type="button" class="primary" onclick={() => (cabinsOpen = true)}>Dodaj domki</button>
</div>
{:else}
<ScheduleGrid
cabins={schedule.cabins}
days={schedule.days}
reservations={schedule.reservations}
onCellClick={(cabinId, date) => newReservation(cabinId, date)}
onReservationClick={editReservation}
onReservationMove={moveReservation}
/>
<footer class="legend">
<span><span class="sw confirmed"></span>Potwierdzona</span>
<span><span class="sw option"></span>Opcja (niepotwierdzona)</span>
<span><span class="sw ok"></span>Rozliczony</span>
<span class="hint">✓ = zaliczka · klik w pustą komórkę dodaje · przeciąganie paska przenosi pobyt</span>
</footer>
{/if}
</main>
{#if dialogParams}
<ReservationDialog
params={dialogParams}
cabins={schedule.cabins}
onclose={() => (dialogParams = null)}
onsaved={() => {
dialogParams = null;
void schedule.reload();
}}
oninvoice={(reservation) => {
dialogParams = null;
invoiceParams = { reservation };
void schedule.reload();
}}
/>
{/if}
{#if invoiceParams}
<InvoiceDialog
params={invoiceParams}
onclose={() => (invoiceParams = null)}
onsaved={(id) => {
invoiceParams = null;
void openPreview(id);
}}
/>
{/if}
{#if preview}
<InvoicePreview invoice={preview.invoice} items={preview.items} onclose={() => (preview = null)} />
{/if}
{#if cabinsOpen}
<CabinsDialog
cabins={schedule.cabins}
onclose={() => (cabinsOpen = false)}
onchanged={() => void schedule.reload()}
/>
{/if}
<style>
main {
padding: 14px 16px;
}
.toolbar {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 12px;
}
.toolbar .month {
min-width: 150px;
text-align: center;
font-size: 16px;
font-weight: 600;
}
.toolbar .spacer {
flex: 1;
}
.toolbar button {
font: inherit;
font-size: 14px;
padding: 7px 14px;
border-radius: 7px;
border: 1px solid var(--border);
background: var(--surface);
color: var(--text);
cursor: pointer;
}
.toolbar button.icon {
width: 34px;
padding: 7px 0;
font-size: 16px;
line-height: 1;
}
.toolbar button.primary {
background: var(--accent);
border-color: var(--accent);
color: #fff;
}
.toolbar button:hover:not(.primary) {
background: var(--bg);
}
.error {
color: var(--danger);
}
.stats {
display: flex;
gap: 24px;
flex-wrap: wrap;
margin-bottom: 10px;
font-size: 12px;
color: var(--muted);
}
.stats strong {
color: var(--text);
}
.stats .confirmed {
color: var(--muted);
}
.empty {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
padding: 60px 20px;
border: 1px dashed var(--border);
border-radius: 10px;
color: var(--muted);
}
.empty button.primary {
font: inherit;
padding: 8px 16px;
border-radius: 7px;
border: 1px solid var(--accent);
background: var(--accent);
color: #fff;
cursor: pointer;
}
.legend {
display: flex;
align-items: center;
gap: 18px;
margin-top: 10px;
font-size: 12px;
color: var(--muted);
}
.legend .sw {
display: inline-block;
width: 12px;
height: 12px;
border-radius: 3px;
vertical-align: -2px;
margin-right: 6px;
}
.legend .sw.confirmed {
background: var(--bar-confirmed-bg);
}
.legend .sw.option {
background: var(--bar-option-bg);
}
.legend .sw.ok {
background: var(--ok);
}
.legend .hint {
margin-left: auto;
}
</style>