288 lines
7.7 KiB
Svelte
288 lines
7.7 KiB
Svelte
<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)} zł
|
||
<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)} zł
|
||
<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>
|