Add some features

This commit is contained in:
Kazimierz Ciołek
2026-07-07 11:32:06 +02:00
parent b584cbc8ce
commit b38aa268ef
20 changed files with 1367 additions and 319 deletions

View File

@@ -128,6 +128,26 @@ fn migrations() -> Vec<Migration> {
);
",
kind: MigrationKind::Up,
},
Migration {
version: 4,
description: "add_cleaned_flag",
sql: "
ALTER TABLE reservations ADD COLUMN cleaned INTEGER NOT NULL DEFAULT 0;
-- wyjazdy sprzed migracji uznajemy za posprzątane, żeby nie zalać widoku zaległościami
UPDATE reservations SET cleaned = 1 WHERE departure < date('now');
CREATE INDEX idx_reservations_departure ON reservations (departure);
",
kind: MigrationKind::Up,
},
Migration {
version: 5,
description: "add_prepared_flag",
sql: "
ALTER TABLE reservations ADD COLUMN prepared INTEGER NOT NULL DEFAULT 0;
UPDATE reservations SET prepared = 1 WHERE arrival < date('now');
",
kind: MigrationKind::Up,
}]
}
@@ -166,9 +186,16 @@ fn auto_backup(app: &tauri::AppHandle) -> std::io::Result<()> {
Ok(())
}
/// Zapis tekstu do pliku wskazanego przez okno "Zapisz jako" (eksport CSV).
#[tauri::command]
fn save_text_file(path: String, contents: String) -> Result<(), String> {
std::fs::write(&path, contents).map_err(|e| e.to_string())
}
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
tauri::Builder::default()
.invoke_handler(tauri::generate_handler![save_text_file])
.setup(|app| {
if let Err(e) = auto_backup(app.handle()) {
eprintln!("Nie udało się wykonać kopii zapasowej: {e}");

View File

@@ -101,9 +101,9 @@
placeholder="—"
/>
<span class="row-actions">
<button type="button" onclick={() => move(i, -1)} disabled={i === 0} aria-label="Przesuń wyżej"></button>
<button type="button" onclick={() => move(i, 1)} disabled={i === cabins.length - 1} aria-label="Przesuń niżej"></button>
<button type="button" class="danger" onclick={() => remove(cabin)}>Usuń</button>
<button type="button" class="btn sm" onclick={() => move(i, -1)} disabled={i === 0} aria-label="Przesuń wyżej"></button>
<button type="button" class="btn sm" onclick={() => move(i, 1)} disabled={i === cabins.length - 1} aria-label="Przesuń niżej"></button>
<button type="button" class="btn sm danger" onclick={() => remove(cabin)}>Usuń</button>
</span>
{/each}
</div>
@@ -112,7 +112,7 @@
<form class="add" onsubmit={add}>
<input type="text" bind:value={newName} placeholder="Nazwa nowego domku" required />
<input type="number" min="1" bind:value={newCapacity} placeholder="Osób" />
<button type="submit" class="primary">Dodaj</button>
<button type="submit" class="btn primary">Dodaj</button>
</form>
{#if error}
@@ -120,21 +120,14 @@
{/if}
<div class="actions">
<button type="button" onclick={onclose}>Zamknij</button>
<button type="button" class="btn" onclick={onclose}>Zamknij</button>
</div>
</dialog>
<style>
dialog {
width: min(480px, calc(100vw - 32px));
border: 1px solid var(--border);
border-radius: 12px;
padding: 20px;
background: var(--surface);
color: var(--text);
}
dialog::backdrop {
background: rgb(0 0 0 / 0.35);
}
h2 {
margin: 0 0 14px;
@@ -170,29 +163,6 @@
display: flex;
gap: 4px;
}
button {
font: inherit;
font-size: 13px;
padding: 5px 9px;
border-radius: 6px;
border: 1px solid var(--border);
background: var(--surface);
color: var(--text);
cursor: pointer;
}
button:disabled {
opacity: 0.4;
cursor: default;
}
button.danger {
color: var(--danger);
border-color: var(--danger);
}
button.primary {
background: var(--accent);
border-color: var(--accent);
color: #fff;
}
.add {
display: grid;
grid-template-columns: 1fr 80px auto;

View File

@@ -340,7 +340,7 @@
></button>
{/each}
</div>
<button type="button" class="add-item" onclick={addItem}>+ Dodaj pozycję</button>
<button type="button" class="btn sm add-item" onclick={addItem}>+ Dodaj pozycję</button>
<div class="totals">
<span>Netto: {formatMoney(totals.net)}</span>
@@ -353,8 +353,8 @@
{/if}
<div class="actions">
<button type="button" onclick={onclose} disabled={saving}>Anuluj</button>
<button type="submit" class="primary" disabled={saving}>Zapisz i pokaż</button>
<button type="button" class="btn" onclick={onclose} disabled={saving}>Anuluj</button>
<button type="submit" class="btn primary" disabled={saving}>Zapisz i pokaż</button>
</div>
</form>
{/if}
@@ -363,14 +363,7 @@
<style>
dialog {
width: min(720px, calc(100vw - 32px));
border: 1px solid var(--border);
border-radius: 12px;
padding: 20px;
background: var(--surface);
color: var(--text);
}
dialog::backdrop {
background: rgb(0 0 0 / 0.35);
}
h2 {
margin: 0 0 4px;
@@ -446,14 +439,6 @@
}
.add-item {
margin-top: 8px;
font: inherit;
font-size: 13px;
padding: 5px 10px;
border-radius: 6px;
border: 1px solid var(--border);
background: var(--surface);
color: var(--text);
cursor: pointer;
}
.totals {
display: flex;
@@ -481,23 +466,4 @@
justify-content: flex-end;
margin-top: 18px;
}
.actions button {
font: inherit;
font-size: 14px;
padding: 8px 16px;
border-radius: 7px;
border: 1px solid var(--border);
background: var(--surface);
color: var(--text);
cursor: pointer;
}
.actions button.primary {
background: var(--accent);
border-color: var(--accent);
color: #fff;
}
.actions button:disabled {
opacity: 0.6;
cursor: default;
}
</style>

View File

@@ -33,8 +33,8 @@
<div class="overlay" role="dialog" aria-modal="true" aria-label="Podgląd faktury {invoice.invoice_number}">
<div class="toolbar">
<span class="title">Faktura {invoice.invoice_number}</span>
<button type="button" class="primary" onclick={print}>Drukuj / zapisz PDF</button>
<button type="button" onclick={onclose}>Zamknij</button>
<button type="button" class="btn primary" onclick={print}>Drukuj / zapisz PDF</button>
<button type="button" class="btn" onclick={onclose}>Zamknij</button>
</div>
<div class="sheet print-sheet">
@@ -165,21 +165,6 @@
color: #fff;
font-weight: 600;
}
.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.primary {
background: var(--accent);
border-color: var(--accent);
color: #fff;
}
/* Arkusz A4 — zawsze czarno na białym, niezależnie od motywu aplikacji */
.sheet {

View File

@@ -22,9 +22,21 @@
arrival: string,
departure: string
) => void;
/** Najechanie myszą: rect paska albo null przy opuszczeniu — rodzic pokazuje popover. */
onhover: (reservation: Reservation, rect: DOMRect | null) => void;
highlight?: boolean;
}
let { reservation: r, days, cabinIds, cabinIndex, onclick, onmove }: Props = $props();
let {
reservation: r,
days,
cabinIds,
cabinIndex,
onclick,
onmove,
onhover,
highlight = false
}: Props = $props();
let n = $derived(days.length);
let clippedStart = $derived(r.arrival < days[0]);
@@ -77,8 +89,18 @@
return "move";
}
function onpointerenter(event: PointerEvent) {
if (event.pointerType !== "mouse" || mode != null) return;
onhover(r, (event.currentTarget as HTMLElement).getBoundingClientRect());
}
function onpointerleave() {
onhover(r, null);
}
function onpointerdown(event: PointerEvent) {
if (event.button !== 0) return;
onhover(r, null);
const el = event.currentTarget as HTMLElement;
const m = modeForEvent(event, el);
// przycięty pobyt (przełom miesiąca) można tylko skracać/wydłużać widoczną krawędzią
@@ -160,22 +182,6 @@
return `left: calc(${startPos * 100}% + ${dayDelta * dayWidth}px); width: calc(${(endPos - startPos) * 100}% - ${dayDelta * dayWidth}px); z-index: 6; opacity: 0.75;`;
});
let tooltip = $derived(
[
`${r.guest_name} · ${formatShort(r.arrival)}${formatShort(r.departure)}`,
r.status === "option" ? "Opcja (niepotwierdzona)" : "Potwierdzona",
r.settled ? "Pobyt rozliczony" : "",
r.phone ? `Tel.: ${r.phone}` : "",
r.num_guests ? `Osób: ${r.num_guests}` : "",
r.deposit_amount != null
? `Zaliczka: ${r.deposit_amount}${r.deposit_paid ? "(wpłacona)" : "(niewpłacona)"}`
: "",
r.notes ?? "",
"Przeciągnij, aby przenieść; krawędź zmienia daty"
]
.filter(Boolean)
.join("\n")
);
</script>
<svelte:window onkeydown={onwindowkeydown} />
@@ -187,12 +193,16 @@
class:clip-start={clippedStart}
class:clip-end={clippedEnd}
class:dragging
class:highlight
style="left: {startPos * 100}%; width: {(endPos - startPos) * 100}%; {dragStyle}"
title={tooltip}
data-rid={r.id}
aria-label="{r.guest_name}, {formatShort(r.arrival)} do {formatShort(r.departure)}"
onclick={handleClick}
{onpointerdown}
{onpointermove}
{onpointerup}
{onpointerenter}
{onpointerleave}
>
{#if !clippedStart}<span class="handle l" aria-hidden="true"></span>{/if}
{#if r.deposit_paid}<span class="paid" aria-label="Zaliczka wpłacona"></span>{/if}
@@ -222,6 +232,23 @@
.bar.dragging {
cursor: grabbing;
}
.bar:hover:not(.dragging) {
filter: brightness(0.96);
box-shadow: 0 1px 4px rgb(0 0 0 / 0.14);
}
.bar.highlight {
animation: bar-pulse 1.1s ease-in-out 3;
z-index: 5;
}
@keyframes bar-pulse {
0%,
100% {
box-shadow: 0 0 0 0 transparent;
}
50% {
box-shadow: 0 0 0 3px var(--accent);
}
}
.handle {
position: absolute;
top: 0;

View File

@@ -48,7 +48,9 @@
meter_start: null,
meter_end: null,
guest_id: null,
settled: false
settled: false,
cleaned: false,
prepared: false
};
let cabinId = $state(init.cabin_id);
@@ -144,7 +146,9 @@
meter_start: normNumber(meterStart),
meter_end: normNumber(meterEnd),
guest_id: init.guest_id,
settled
settled,
cleaned: init.cleaned,
prepared: init.prepared
};
}
@@ -333,12 +337,12 @@
<div class="actions">
{#if editingId != null}
<button type="button" class="danger" onclick={remove} disabled={saving}>Usuń</button>
<button type="button" onclick={issueInvoice} disabled={saving}>Wystaw fakturę</button>
<button type="button" class="btn danger" onclick={remove} disabled={saving}>Usuń</button>
<button type="button" class="btn" onclick={issueInvoice} disabled={saving}>Wystaw fakturę</button>
{/if}
<span class="spacer"></span>
<button type="button" onclick={onclose} disabled={saving}>Anuluj</button>
<button type="submit" class="primary" disabled={saving}>Zapisz</button>
<button type="button" class="btn" onclick={onclose} disabled={saving}>Anuluj</button>
<button type="submit" class="btn primary" disabled={saving}>Zapisz</button>
</div>
</form>
</dialog>
@@ -346,14 +350,7 @@
<style>
dialog {
width: min(560px, calc(100vw - 32px));
border: 1px solid var(--border);
border-radius: 12px;
padding: 20px;
background: var(--surface);
color: var(--text);
}
dialog::backdrop {
background: rgb(0 0 0 / 0.35);
}
h2 {
margin: 0 0 16px;
@@ -459,27 +456,4 @@
.spacer {
flex: 1;
}
.actions button {
font: inherit;
font-size: 14px;
padding: 8px 16px;
border-radius: 7px;
border: 1px solid var(--border);
background: var(--surface);
color: var(--text);
cursor: pointer;
}
.actions button.primary {
background: var(--accent);
border-color: var(--accent);
color: #fff;
}
.actions button.danger {
color: var(--danger);
border-color: var(--danger);
}
.actions button:disabled {
opacity: 0.6;
cursor: default;
}
</style>

View File

@@ -0,0 +1,162 @@
<script lang="ts">
import type { Reservation } from "$lib/types";
import { formatShort } from "$lib/dates";
import * as db from "$lib/db";
interface Props {
cabinName: (id: number) => string;
onpick: (r: Reservation) => void;
}
let { cabinName, onpick }: Props = $props();
let query = $state("");
let results = $state<Reservation[]>([]);
let open = $state(false);
let highlighted = $state(0);
let searchToken = 0;
async function oninput() {
const q = query.trim();
if (q.length < 2) {
results = [];
open = false;
return;
}
const token = ++searchToken;
const found = await db.searchReservations(q);
if (token !== searchToken) return;
results = found;
highlighted = 0;
open = found.length > 0;
}
function pick(r: Reservation) {
open = false;
query = "";
onpick(r);
}
function onkeydown(event: KeyboardEvent) {
if (!open) return;
if (event.key === "ArrowDown") {
event.preventDefault();
highlighted = (highlighted + 1) % results.length;
} else if (event.key === "ArrowUp") {
event.preventDefault();
highlighted = (highlighted - 1 + results.length) % results.length;
} else if (event.key === "Enter") {
event.preventDefault();
pick(results[highlighted]);
} else if (event.key === "Escape") {
event.preventDefault();
event.stopPropagation();
open = false;
}
}
function onblur() {
open = false;
}
</script>
<div class="wrap">
<input
type="search"
bind:value={query}
{oninput}
{onkeydown}
{onblur}
placeholder="Szukaj gościa…"
autocomplete="off"
role="combobox"
aria-expanded={open}
aria-autocomplete="list"
aria-controls="reservation-search-results"
aria-label="Szukaj rezerwacji"
/>
{#if open}
<ul class="results" id="reservation-search-results" role="listbox">
{#each results as r, i (r.id)}
<li role="option" aria-selected={i === highlighted}>
<button
type="button"
class:hl={i === highlighted}
onclick={() => pick(r)}
onpointerdown={(e) => e.preventDefault()}
>
<span class="n">{r.guest_name}</span>
<span class="d">{cabinName(r.cabin_id)}</span>
<span class="d">
{formatShort(r.arrival)}{formatShort(r.departure)}.{r.departure.slice(0, 4)}
</span>
</button>
</li>
{/each}
</ul>
{/if}
</div>
<style>
.wrap {
position: relative;
width: 220px;
}
input {
width: 100%;
font: inherit;
font-size: 13px;
color: var(--text);
background: var(--surface);
border: 1px solid var(--border);
border-radius: 7px;
padding: 7px 11px;
}
.results {
position: absolute;
top: calc(100% + 4px);
right: 0;
min-width: 300px;
z-index: 20;
margin: 0;
padding: 4px;
list-style: none;
background: var(--surface);
border: 1px solid var(--border);
border-radius: 8px;
box-shadow: 0 4px 14px rgb(0 0 0 / 0.15);
max-height: 300px;
overflow-y: auto;
}
.results button {
display: flex;
gap: 10px;
align-items: baseline;
width: 100%;
font: inherit;
font-size: 13px;
text-align: left;
padding: 6px 8px;
border: none;
border-radius: 5px;
background: transparent;
color: var(--text);
cursor: pointer;
}
.results button:hover,
.results button.hl {
background: var(--today);
}
.n {
font-weight: 600;
flex: 1;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
.d {
flex: none;
font-size: 12px;
color: var(--muted);
}
</style>

View File

@@ -2,6 +2,7 @@
import type { Cabin, Reservation } from "$lib/types";
import { isWeekend, todayISO, weekdayShort } from "$lib/dates";
import ReservationBar from "./ReservationBar.svelte";
import StayPopover from "./StayPopover.svelte";
interface Props {
cabins: Cabin[];
@@ -15,13 +16,63 @@
arrival: string,
departure: string
) => void;
highlightId?: number | null;
}
let { cabins, days, reservations, onCellClick, onReservationClick, onReservationMove }: Props =
$props();
let {
cabins,
days,
reservations,
onCellClick,
onReservationClick,
onReservationMove,
highlightId = null
}: Props = $props();
let cabinIds = $derived(cabins.map((c) => c.id));
const POPOVER_WIDTH = 280;
const POPOVER_MAX_HEIGHT = 260;
let scrollEl: HTMLDivElement | undefined = $state();
let hoverInfo = $state<{ r: Reservation; x: number; y: number; above: boolean } | null>(null);
let hoverTimer: ReturnType<typeof setTimeout> | undefined;
function barHover(r: Reservation | null, rect: DOMRect | null) {
clearTimeout(hoverTimer);
if (!rect || !r) {
hoverInfo = null;
return;
}
hoverTimer = setTimeout(() => {
const above = rect.bottom + POPOVER_MAX_HEIGHT > window.innerHeight;
hoverInfo = {
r,
x: Math.min(Math.max(rect.left, 8), window.innerWidth - POPOVER_WIDTH - 8),
y: above ? rect.top - 6 : rect.bottom + 6,
above
};
}, 200);
}
function cabinNameById(id: number): string {
return cabins.find((c) => c.id === id)?.name ?? `Domek #${id}`;
}
// po skoku z wyszukiwarki dosuwamy siatkę do podświetlonego paska
$effect(() => {
const id = highlightId;
const el = scrollEl;
if (id == null || !el) return;
requestAnimationFrame(() => {
el.querySelector(`[data-rid="${id}"]`)?.scrollIntoView({
block: "nearest",
inline: "nearest",
behavior: "smooth"
});
});
});
const today = todayISO();
let byCabin = $derived.by(() => {
@@ -38,7 +89,9 @@
});
</script>
<div class="scroll">
<!-- pointerleave tylko chowa popover pobytu — to nie jest element interaktywny -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="scroll" bind:this={scrollEl} onpointerleave={() => barHover(null, null)}>
<div class="grid" style="grid-template-columns: 140px repeat({days.length}, minmax(34px, 1fr));">
<div class="corner"></div>
{#each days as d (d)}
@@ -49,11 +102,11 @@
{/each}
{#each cabins as cabin, cabinIndex (cabin.id)}
<div class="cabin-name">
<div class="cabin-name" class:odd={cabinIndex % 2 === 1}>
<span class="cn">{cabin.name}</span>
{#if cabin.capacity}<span class="cap">{cabin.capacity} os.</span>{/if}
</div>
<div class="track">
<div class="track" class:odd={cabinIndex % 2 === 1}>
{#each days as d (d)}
<button
type="button"
@@ -70,8 +123,10 @@
{days}
{cabinIds}
{cabinIndex}
highlight={r.id === highlightId}
onclick={() => onReservationClick(r)}
onmove={onReservationMove}
onhover={barHover}
/>
{/each}
</div>
@@ -79,6 +134,16 @@
</div>
</div>
{#if hoverInfo}
<StayPopover
reservation={hoverInfo.r}
cabinName={cabinNameById(hoverInfo.r.cabin_id)}
x={hoverInfo.x}
y={hoverInfo.y}
above={hoverInfo.above}
/>
{/if}
<style>
.scroll {
overflow: auto;
@@ -124,6 +189,7 @@
}
.day-head.today {
background: var(--today);
box-shadow: inset 0 -2px 0 var(--accent);
}
.day-head.today .num {
color: var(--accent);
@@ -152,6 +218,10 @@
font-size: 11px;
flex: none;
}
.cabin-name.odd,
.track.odd {
background: color-mix(in srgb, var(--surface) 55%, var(--bg));
}
.track {
grid-column: 2 / -1;
position: relative;

View File

@@ -0,0 +1,160 @@
<script lang="ts">
import type { Reservation } from "$lib/types";
import { formatShort, nightsBetween } from "$lib/dates";
import { formatMoney } from "$lib/money";
interface Props {
reservation: Reservation;
cabinName: string;
x: number;
y: number;
above: boolean;
}
let { reservation: r, cabinName, x, y, above }: Props = $props();
let nights = $derived(nightsBetween(r.arrival, r.departure));
let stayTotal = $derived(r.price_per_night != null ? nights * r.price_per_night : null);
let energyUsage = $derived(
r.meter_start != null && r.meter_end != null && r.meter_end > r.meter_start
? Math.round((r.meter_end - r.meter_start) * 10) / 10
: null
);
</script>
<div
class="popover"
class:above
style="left: {x}px; top: {y}px;"
role="tooltip"
>
<div class="head">
<span class="name">{r.guest_name}</span>
<span class="badge {r.status}">{r.status === "option" ? "Opcja" : "Potwierdzona"}</span>
{#if r.settled}<span class="badge ok">Rozliczony</span>{/if}
</div>
<p class="dates">
{cabinName} · {formatShort(r.arrival)}{formatShort(r.departure)} · {nights}
{nights === 1 ? "doba" : nights < 5 ? "doby" : "dób"}
</p>
{#if r.phone || r.num_guests}
<p>
{#if r.phone}<span>tel. {r.phone}</span>{/if}
{#if r.phone && r.num_guests}<span> · </span>{/if}
{#if r.num_guests}<span>{r.num_guests} os.</span>{/if}
</p>
{/if}
{#if r.address}
<p class="muted">{r.address}</p>
{/if}
{#if stayTotal != null || r.deposit_amount != null || energyUsage != null}
<div class="money">
{#if stayTotal != null}
<p>{nights} × {formatMoney(r.price_per_night ?? 0)} zł = <strong>{formatMoney(stayTotal)}</strong></p>
{/if}
{#if energyUsage != null}
<p>Energia: {energyUsage} kWh</p>
{/if}
{#if r.deposit_amount != null}
<p>
Zaliczka: {formatMoney(r.deposit_amount)}
<span class={r.deposit_paid ? "paid" : "unpaid"}>
{r.deposit_paid ? "✓ wpłacona" : "niewpłacona"}
</span>
</p>
{/if}
</div>
{/if}
{#if r.notes}
<p class="notes">{r.notes}</p>
{/if}
</div>
<style>
.popover {
position: fixed;
z-index: 100;
width: 280px;
pointer-events: none;
background: var(--surface);
border: 1px solid var(--border);
border-radius: 10px;
padding: 10px 12px;
box-shadow: 0 6px 22px rgb(0 0 0 / 0.2);
font-size: 12px;
line-height: 1.5;
}
.popover.above {
transform: translateY(-100%);
}
.popover p {
margin: 0;
}
.head {
display: flex;
align-items: center;
gap: 6px;
flex-wrap: wrap;
margin-bottom: 2px;
}
.name {
font-size: 13px;
font-weight: 600;
flex: 1;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
.badge {
flex: none;
font-size: 10px;
font-weight: 600;
padding: 1px 7px;
border-radius: 10px;
}
.badge.confirmed {
background: var(--bar-confirmed-bg);
color: var(--bar-confirmed-text);
}
.badge.option {
background: var(--bar-option-bg);
color: var(--bar-option-text);
}
.badge.ok {
background: var(--ok);
color: var(--surface);
}
.dates {
color: var(--text);
font-weight: 500;
}
.muted {
color: var(--muted);
}
.money {
margin-top: 6px;
padding-top: 6px;
border-top: 1px solid var(--border);
}
.paid {
color: var(--ok);
font-weight: 600;
}
.unpaid {
color: var(--warn-text);
font-weight: 600;
}
.notes {
margin-top: 6px;
padding-top: 6px;
border-top: 1px solid var(--border);
color: var(--muted);
font-style: italic;
max-height: 60px;
overflow: hidden;
}
</style>

28
src/lib/csv.ts Normal file
View File

@@ -0,0 +1,28 @@
import { save } from "@tauri-apps/plugin-dialog";
import { invoke } from "@tauri-apps/api/core";
/** Liczba w formacie polskiego Excela: przecinek dziesiętny, 2 miejsca. */
export function csvNumber(n: number): string {
return n.toFixed(2).replace(".", ",");
}
/** CSV rozdzielany średnikami (polski Excel) z BOM, żeby UTF-8 otwierał się poprawnie. */
export function toCsv(rows: (string | number | null | undefined)[][]): string {
const esc = (v: string | number | null | undefined): string => {
const s = v == null ? "" : String(v);
return /[";\n\r]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s;
};
return String.fromCharCode(0xfeff) + rows.map((row) => row.map(esc).join(";")).join("\r\n");
}
/** Okno "Zapisz jako" i zapis pliku. Zwraca ścieżkę albo null przy anulowaniu. */
export async function saveCsv(defaultName: string, csv: string): Promise<string | null> {
const path = await save({
title: "Zapisz plik CSV",
defaultPath: defaultName,
filters: [{ name: "Plik CSV", extensions: ["csv"] }]
});
if (!path) return null;
await invoke("save_text_file", { path, contents: csv });
return path;
}

View File

@@ -26,13 +26,22 @@ async function getDb(): Promise<Database> {
return db;
}
interface ReservationRow extends Omit<Reservation, "deposit_paid" | "settled"> {
interface ReservationRow
extends Omit<Reservation, "deposit_paid" | "settled" | "cleaned" | "prepared"> {
deposit_paid: number;
settled: number;
cleaned: number;
prepared: number;
}
function toReservation(row: ReservationRow): Reservation {
return { ...row, deposit_paid: !!row.deposit_paid, settled: !!row.settled };
return {
...row,
deposit_paid: !!row.deposit_paid,
settled: !!row.settled,
cleaned: !!row.cleaned,
prepared: !!row.prepared
};
}
// --- Domki ---
@@ -100,38 +109,8 @@ export async function createReservation(r: ReservationInput): Promise<void> {
await d.execute(
`INSERT INTO reservations
(cabin_id, guest_name, address, phone, num_guests, deposit_amount, deposit_paid, status,
arrival, departure, notes, price_per_night, meter_start, meter_end, guest_id, settled)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16)`,
[
r.cabin_id,
r.guest_name,
r.address,
r.phone,
r.num_guests,
r.deposit_amount,
r.deposit_paid ? 1 : 0,
r.status,
r.arrival,
r.departure,
r.notes,
r.price_per_night,
r.meter_start,
r.meter_end,
r.guest_id,
r.settled ? 1 : 0
]
);
}
export async function updateReservation(id: number, r: ReservationInput): Promise<void> {
const d = await getDb();
await d.execute(
`UPDATE reservations SET
cabin_id = $1, guest_name = $2, address = $3, phone = $4, num_guests = $5,
deposit_amount = $6, deposit_paid = $7, status = $8, arrival = $9, departure = $10,
notes = $11, price_per_night = $12, meter_start = $13, meter_end = $14,
guest_id = $15, settled = $16
WHERE id = $17`,
arrival, departure, notes, price_per_night, meter_start, meter_end, guest_id, settled, cleaned, prepared)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18)`,
[
r.cabin_id,
r.guest_name,
@@ -149,6 +128,40 @@ export async function updateReservation(id: number, r: ReservationInput): Promis
r.meter_end,
r.guest_id,
r.settled ? 1 : 0,
r.cleaned ? 1 : 0,
r.prepared ? 1 : 0
]
);
}
export async function updateReservation(id: number, r: ReservationInput): Promise<void> {
const d = await getDb();
await d.execute(
`UPDATE reservations SET
cabin_id = $1, guest_name = $2, address = $3, phone = $4, num_guests = $5,
deposit_amount = $6, deposit_paid = $7, status = $8, arrival = $9, departure = $10,
notes = $11, price_per_night = $12, meter_start = $13, meter_end = $14,
guest_id = $15, settled = $16, cleaned = $17, prepared = $18
WHERE id = $19`,
[
r.cabin_id,
r.guest_name,
r.address,
r.phone,
r.num_guests,
r.deposit_amount,
r.deposit_paid ? 1 : 0,
r.status,
r.arrival,
r.departure,
r.notes,
r.price_per_night,
r.meter_start,
r.meter_end,
r.guest_id,
r.settled ? 1 : 0,
r.cleaned ? 1 : 0,
r.prepared ? 1 : 0,
id
]
);
@@ -207,6 +220,96 @@ export async function cabinReports(from: string, to: string): Promise<CabinRepor
);
}
/** Rezerwacje pasujące do frazy (gość lub telefon), najnowsze najpierw. */
export async function searchReservations(query: string): Promise<Reservation[]> {
const d = await getDb();
const rows = await d.select<ReservationRow[]>(
"SELECT * FROM reservations WHERE guest_name LIKE $1 OR phone LIKE $1 ORDER BY arrival DESC LIMIT 10",
[`%${query}%`]
);
return rows.map(toReservation);
}
/** Przyjazdy i wyjazdy przypadające dokładnie w danym dniu. */
export async function dayEvents(
date: string
): Promise<{ arrivals: Reservation[]; departures: Reservation[] }> {
const d = await getDb();
const [arrivals, departures] = await Promise.all([
d.select<ReservationRow[]>("SELECT * FROM reservations WHERE arrival = $1 ORDER BY cabin_id", [
date
]),
d.select<ReservationRow[]>(
"SELECT * FROM reservations WHERE departure = $1 ORDER BY cabin_id",
[date]
)
]);
return { arrivals: arrivals.map(toReservation), departures: departures.map(toReservation) };
}
// --- Sprzątanie ---
/** Zaległe sprzątania: wyjazd przed `today`, jeszcze nieposprzątane. */
export async function overdueCleanings(today: string): Promise<Reservation[]> {
const d = await getDb();
const rows = await d.select<ReservationRow[]>(
"SELECT * FROM reservations WHERE departure < $1 AND cleaned = 0 ORDER BY departure",
[today]
);
return rows.map(toReservation);
}
/** Wyjazdy (sprzątania) w zakresie [from, to). */
export async function upcomingCleanings(from: string, to: string): Promise<Reservation[]> {
const d = await getDb();
const rows = await d.select<ReservationRow[]>(
"SELECT * FROM reservations WHERE departure >= $1 AND departure < $2 ORDER BY departure, cabin_id",
[from, to]
);
return rows.map(toReservation);
}
/** Przyjazdy w zakresie [from, to) — do wykrywania rotacji „wyjazd i przyjazd tego samego dnia". */
export async function arrivalsInRange(
from: string,
to: string
): Promise<{ cabin_id: number; arrival: string }[]> {
const d = await getDb();
return d.select<{ cabin_id: number; arrival: string }[]>(
"SELECT cabin_id, arrival FROM reservations WHERE arrival >= $1 AND arrival < $2",
[from, to]
);
}
export async function setReservationCleaned(id: number, cleaned: boolean): Promise<void> {
const d = await getDb();
await d.execute("UPDATE reservations SET cleaned = $1 WHERE id = $2", [cleaned ? 1 : 0, id]);
}
/**
* Przyjazdy w zakresie [from, to), przed którymi w danym domku nie było żadnego pobytu —
* domek trzeba przygotować, mimo że nie ma wyjazdu, który by to zgłosił.
*/
export async function firstArrivalsNeedingPrep(from: string, to: string): Promise<Reservation[]> {
const d = await getDb();
const rows = await d.select<ReservationRow[]>(
`SELECT r.* FROM reservations r
WHERE r.arrival >= $1 AND r.arrival < $2
AND NOT EXISTS (
SELECT 1 FROM reservations p
WHERE p.cabin_id = r.cabin_id AND p.id != r.id AND p.departure <= r.arrival
)
ORDER BY r.arrival, r.cabin_id`,
[from, to]
);
return rows.map(toReservation);
}
export async function setReservationPrepared(id: number, prepared: boolean): Promise<void> {
const d = await getDb();
await d.execute("UPDATE reservations SET prepared = $1 WHERE id = $2", [prepared ? 1 : 0, id]);
}
// --- Goście ---
export async function searchGuests(query: string): Promise<Guest[]> {
@@ -394,6 +497,12 @@ export async function getInvoiceItems(invoiceId: number): Promise<InvoiceItem[]>
);
}
/** Wszystkie pozycje wszystkich faktur — do eksportu CSV z sumami per faktura. */
export async function listAllInvoiceItems(): Promise<InvoiceItem[]> {
const d = await getDb();
return d.select<InvoiceItem[]>("SELECT * FROM invoice_items ORDER BY invoice_id, position");
}
export async function getInvoiceForReservation(reservationId: number): Promise<Invoice | null> {
const d = await getDb();
const rows = await d.select<Invoice[]>(

View File

@@ -1,6 +1,6 @@
import * as db from "./db";
import type { Cabin, Reservation, RevenueStats } from "./types";
import { isoDate, monthDays } from "./dates";
import { isoDate, monthDays, todayISO } from "./dates";
export class ScheduleState {
year = $state(new Date().getFullYear());
@@ -9,6 +9,8 @@ export class ScheduleState {
reservations = $state<Reservation[]>([]);
monthStats = $state<RevenueStats | null>(null);
yearStats = $state<RevenueStats | null>(null);
todayArrivals = $state<Reservation[]>([]);
todayDepartures = $state<Reservation[]>([]);
loading = $state(true);
error = $state<string | null>(null);
@@ -21,16 +23,19 @@ export class ScheduleState {
const from = isoDate(this.year, this.month, 1);
const to =
this.month === 11 ? isoDate(this.year + 1, 0, 1) : isoDate(this.year, this.month + 1, 1);
const [cabins, reservations, monthStats, yearStats] = await Promise.all([
const [cabins, reservations, monthStats, yearStats, today] = await Promise.all([
db.listCabins(),
db.listReservations(from, to),
db.revenueStats(from, to),
db.revenueStats(isoDate(this.year, 0, 1), isoDate(this.year + 1, 0, 1))
db.revenueStats(isoDate(this.year, 0, 1), isoDate(this.year + 1, 0, 1)),
db.dayEvents(todayISO())
]);
this.cabins = cabins;
this.reservations = reservations;
this.monthStats = monthStats;
this.yearStats = yearStats;
this.todayArrivals = today.arrivals;
this.todayDepartures = today.departures;
} catch (e) {
this.error = `Nie udało się wczytać danych: ${e}`;
} finally {

View File

@@ -31,6 +31,10 @@ export interface Reservation {
guest_id: number | null;
/** Czy pobyt został rozliczony z gościem */
settled: boolean;
/** Czy domek został posprzątany po wyjeździe */
cleaned: boolean;
/** Czy domek został przygotowany przed przyjazdem (istotne przy pierwszym gościu w domku) */
prepared: boolean;
created_at: string;
}

View File

@@ -8,13 +8,14 @@
{ href: "/", label: "Grafik" },
{ href: "/faktury", label: "Faktury" },
{ href: "/goscie", label: "Goście" },
{ href: "/sprzatanie", label: "Sprzątanie" },
{ href: "/raporty", label: "Raporty" },
{ href: "/ustawienia", label: "Ustawienia" }
];
</script>
<nav class="topnav">
<span class="brand">Słoneczko</span>
<span class="brand"><span class="sun" aria-hidden="true"></span>Słoneczko</span>
{#each links as link (link.href)}
<a href={link.href} class:active={page.url.pathname === link.href}>{link.label}</a>
{/each}
@@ -73,19 +74,148 @@
color: var(--text);
-webkit-font-smoothing: antialiased;
}
:global(button) {
transition:
background-color 0.12s ease,
border-color 0.12s ease,
color 0.12s ease,
opacity 0.12s ease,
filter 0.12s ease;
}
:global(:focus-visible) {
outline: 2px solid var(--accent);
outline-offset: 1px;
}
:global(input),
:global(select),
:global(textarea) {
transition: border-color 0.12s ease;
}
:global(input:hover:not(:focus)),
:global(select:hover:not(:focus)) {
border-color: var(--muted);
}
/* --- Wspólny system przycisków --- */
:global(.btn) {
font: inherit;
font-size: 14px;
padding: 7px 14px;
border-radius: 7px;
border: 1px solid var(--border);
background: var(--surface);
color: var(--text);
cursor: pointer;
}
:global(.btn:hover:not(:disabled)) {
background: var(--bg);
border-color: var(--muted);
}
:global(.btn:active:not(:disabled)) {
transform: translateY(0.5px);
}
:global(.btn:disabled) {
opacity: 0.5;
cursor: default;
}
:global(.btn.primary) {
background: var(--accent);
border-color: var(--accent);
color: #fff;
}
:global(.btn.primary:hover:not(:disabled)) {
background: var(--accent);
border-color: var(--accent);
filter: brightness(1.08);
}
:global(.btn.danger) {
color: var(--danger);
border-color: var(--danger);
}
:global(.btn.danger:hover:not(:disabled)) {
background: color-mix(in srgb, var(--danger) 8%, var(--surface));
border-color: var(--danger);
}
:global(.btn.sm) {
font-size: 12px;
padding: 4px 10px;
border-radius: 6px;
}
:global(.btn.icon) {
width: 34px;
padding: 7px 0;
font-size: 16px;
line-height: 1;
text-align: center;
}
/* --- Dialogi: wejście z animacją, przyciemnione i rozmyte tło --- */
:global(dialog) {
border: 1px solid var(--border);
border-radius: 12px;
background: var(--surface);
color: var(--text);
box-shadow: 0 12px 40px rgb(0 0 0 / 0.18);
animation: dialog-in 0.16s ease-out;
}
:global(dialog::backdrop) {
background: rgb(0 0 0 / 0.35);
backdrop-filter: blur(2px);
}
@keyframes -global-dialog-in {
from {
opacity: 0;
transform: translateY(8px) scale(0.98);
}
}
/* --- Cienkie scrollbary --- */
:global(::-webkit-scrollbar) {
width: 10px;
height: 10px;
}
:global(::-webkit-scrollbar-track) {
background: transparent;
}
:global(::-webkit-scrollbar-thumb) {
background: var(--border);
border-radius: 6px;
}
:global(::-webkit-scrollbar-thumb:hover) {
background: var(--muted);
}
/* --- Delikatne cienie kart i tabel --- */
:global(main :where(section, table, .empty, .scroll, .today-panel)) {
box-shadow: 0 1px 3px rgb(0 0 0 / 0.05);
}
.topnav {
position: sticky;
top: 0;
z-index: 30;
display: flex;
align-items: center;
gap: 4px;
padding: 8px 16px;
background: var(--surface);
border-bottom: 1px solid var(--border);
box-shadow: 0 1px 3px rgb(0 0 0 / 0.04);
}
.brand {
display: flex;
align-items: center;
gap: 8px;
font-weight: 700;
font-size: 15px;
margin-right: 14px;
}
.sun {
width: 12px;
height: 12px;
border-radius: 50%;
background: #f0a52c;
box-shadow: 0 0 0 3px rgb(240 165 44 / 0.25);
}
.topnav a {
padding: 6px 12px;
border-radius: 7px;

View File

@@ -7,6 +7,7 @@
import CabinsDialog from "$lib/components/CabinsDialog.svelte";
import InvoiceDialog from "$lib/components/InvoiceDialog.svelte";
import InvoicePreview from "$lib/components/InvoicePreview.svelte";
import ReservationSearch from "$lib/components/ReservationSearch.svelte";
import { formatMoney } from "$lib/money";
import * as db from "$lib/db";
import type {
@@ -52,6 +53,39 @@
}
let moveError = $state<string | null>(null);
let highlightId = $state<number | null>(null);
function cabinName(id: number): string {
return schedule.cabins.find((c) => c.id === id)?.name ?? `Domek #${id}`;
}
function jumpToReservation(r: Reservation) {
schedule.year = Number(r.arrival.slice(0, 4));
schedule.month = Number(r.arrival.slice(5, 7)) - 1;
void schedule.reload().then(() => {
highlightId = r.id;
setTimeout(() => (highlightId = null), 3500);
});
}
function onkeydown(event: KeyboardEvent) {
if (dialogParams || cabinsOpen || invoiceParams || preview) return;
const t = event.target;
if (
t instanceof HTMLInputElement ||
t instanceof HTMLTextAreaElement ||
t instanceof HTMLSelectElement
) {
return;
}
if (event.key === "ArrowLeft") {
schedule.prevMonth();
} else if (event.key === "ArrowRight") {
schedule.nextMonth();
} else if (event.key === "Home") {
schedule.goToday();
}
}
async function moveReservation(
r: Reservation,
@@ -80,17 +114,37 @@
<title>Słoneczko — grafik rezerwacji</title>
</svelte:head>
<svelte:window {onkeydown} />
<main>
<header class="toolbar">
<button type="button" class="icon" onclick={() => schedule.prevMonth()} aria-label="Poprzedni miesiąc"></button>
<button type="button" class="btn 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>
<button type="button" class="btn icon" onclick={() => schedule.nextMonth()} aria-label="Następny miesiąc"></button>
<button type="button" class="btn" 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>
<ReservationSearch {cabinName} onpick={jumpToReservation} />
<button type="button" class="btn" onclick={() => (cabinsOpen = true)}>Domki</button>
<button type="button" class="btn primary" onclick={() => newReservation()}>+ Nowa rezerwacja</button>
</header>
{#if schedule.todayArrivals.length > 0 || schedule.todayDepartures.length > 0}
<div class="today-panel">
<strong>Dziś:</strong>
{#each schedule.todayDepartures as r (r.id)}
<button type="button" class="chip out" onclick={() => editReservation(r)}>
{cabinName(r.cabin_id)} · {r.guest_name}
</button>
{/each}
{#each schedule.todayArrivals as r (r.id)}
<button type="button" class="chip in" onclick={() => editReservation(r)}>
{cabinName(r.cabin_id)} · {r.guest_name}
</button>
{/each}
<span class="chip-hint">↑ wyjazd · ↓ przyjazd</span>
</div>
{/if}
{#if schedule.monthStats && schedule.yearStats}
<div class="stats">
<span>
@@ -117,7 +171,7 @@
{: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>
<button type="button" class="btn primary" onclick={() => (cabinsOpen = true)}>Dodaj domki</button>
</div>
{:else}
<ScheduleGrid
@@ -127,6 +181,7 @@
onCellClick={(cabinId, date) => newReservation(cabinId, date)}
onReservationClick={editReservation}
onReservationMove={moveReservation}
{highlightId}
/>
<footer class="legend">
<span><span class="sw confirmed"></span>Potwierdzona</span>
@@ -196,33 +251,45 @@
.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);
}
.today-panel {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
margin-bottom: 10px;
padding: 8px 12px;
background: var(--surface);
border: 1px solid var(--border);
border-radius: 9px;
font-size: 13px;
}
.chip {
font: inherit;
font-size: 12px;
padding: 3px 10px;
border-radius: 20px;
border: none;
cursor: pointer;
}
.chip.out {
background: var(--bar-option-bg);
color: var(--bar-option-text);
}
.chip.in {
background: var(--bar-confirmed-bg);
color: var(--bar-confirmed-text);
}
.chip:hover {
filter: brightness(0.95);
}
.chip-hint {
margin-left: auto;
font-size: 11px;
color: var(--muted);
}
.stats {
display: flex;
gap: 24px;
@@ -247,15 +314,6 @@
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;

View File

@@ -2,7 +2,8 @@
import { onMount } from "svelte";
import * as db from "$lib/db";
import { formatShort } from "$lib/dates";
import { formatMoney, PAYMENT_METHOD_LABELS } from "$lib/money";
import { computeTotals, formatMoney, PAYMENT_METHOD_LABELS } from "$lib/money";
import { csvNumber, saveCsv, toCsv } from "$lib/csv";
import InvoiceDialog from "$lib/components/InvoiceDialog.svelte";
import InvoicePreview from "$lib/components/InvoicePreview.svelte";
import type { Invoice, InvoiceDialogParams, InvoiceItem } from "$lib/types";
@@ -12,6 +13,22 @@
let error = $state<string | null>(null);
let dialogParams = $state<InvoiceDialogParams | null>(null);
let preview = $state<{ invoice: Invoice; items: InvoiceItem[] } | null>(null);
let query = $state("");
let yearFilter = $state("all");
let years = $derived(
[...new Set(invoices.map((i) => i.issue_date.slice(0, 4)))].sort().reverse()
);
let filtered = $derived.by(() => {
const q = query.trim().toLowerCase();
return invoices.filter(
(i) =>
(yearFilter === "all" || i.issue_date.startsWith(yearFilter)) &&
(q === "" ||
i.invoice_number.toLowerCase().includes(q) ||
i.buyer_name.toLowerCase().includes(q))
);
});
async function reload() {
loading = true;
@@ -37,6 +54,50 @@
dialogParams = { invoice, items: await db.getInvoiceItems(invoice.id) };
}
let exportInfo = $state<string | null>(null);
async function exportCsv() {
error = null;
exportInfo = null;
try {
const allItems = await db.listAllInvoiceItems();
const byInvoice = new Map<number, InvoiceItem[]>();
for (const it of allItems) {
const list = byInvoice.get(it.invoice_id);
if (list) {
list.push(it);
} else {
byInvoice.set(it.invoice_id, [it]);
}
}
const rows: (string | number | null)[][] = [
["Numer", "Data wystawienia", "Data sprzedaży", "Nabywca", "NIP", "Netto", "VAT", "Brutto", "Płatność"]
];
for (const inv of filtered) {
const totals = computeTotals(byInvoice.get(inv.id) ?? []);
rows.push([
inv.invoice_number,
inv.issue_date,
inv.sale_date,
inv.buyer_name,
inv.buyer_nip,
csvNumber(totals.net),
csvNumber(totals.tax),
csvNumber(totals.gross),
PAYMENT_METHOD_LABELS[inv.payment_method]
]);
}
const name = `faktury-${yearFilter === "all" ? "wszystkie" : yearFilter}.csv`;
const path = await saveCsv(name, toCsv(rows));
if (path) {
exportInfo = `Zapisano: ${path}`;
setTimeout(() => (exportInfo = null), 5000);
}
} catch (e) {
error = `Nie udało się wyeksportować: ${e}`;
}
}
async function remove(invoice: Invoice) {
if (!confirm(`Usunąć fakturę ${invoice.invoice_number} (${invoice.buyer_name})?`)) return;
try {
@@ -56,7 +117,17 @@
<header class="toolbar">
<h1>Faktury</h1>
<span class="spacer"></span>
<button type="button" class="primary" onclick={() => (dialogParams = { blank: true })}>
{#if invoices.length > 0}
<input type="search" bind:value={query} placeholder="Numer lub nabywca…" aria-label="Szukaj faktury" />
<select bind:value={yearFilter} aria-label="Rok">
<option value="all">Wszystkie lata</option>
{#each years as y (y)}
<option value={y}>{y}</option>
{/each}
</select>
<button type="button" class="btn" onclick={exportCsv}>Eksport CSV</button>
{/if}
<button type="button" class="btn primary" onclick={() => (dialogParams = { blank: true })}>
+ Nowa faktura
</button>
</header>
@@ -64,6 +135,9 @@
{#if error}
<p class="error">{error}</p>
{/if}
{#if exportInfo}
<p class="info">{exportInfo}</p>
{/if}
{#if !loading && invoices.length === 0}
<div class="empty">
@@ -72,7 +146,7 @@
Fakturę wystawisz z poziomu pobytu na grafiku (przycisk „Wystaw fakturę") albo przyciskiem powyżej.
</p>
</div>
{:else if invoices.length > 0}
{:else if filtered.length > 0}
<table>
<thead>
<tr>
@@ -86,7 +160,7 @@
</tr>
</thead>
<tbody>
{#each invoices as invoice (invoice.id)}
{#each filtered as invoice (invoice.id)}
<tr>
<td class="strong">{invoice.invoice_number}</td>
<td>{formatShort(invoice.issue_date)}.{invoice.issue_date.slice(0, 4)}</td>
@@ -95,14 +169,16 @@
<td class="num strong">{formatMoney(invoice.gross)}</td>
<td>{PAYMENT_METHOD_LABELS[invoice.payment_method]}</td>
<td class="row-actions">
<button type="button" onclick={() => openPreview(invoice)}>Podgląd</button>
<button type="button" onclick={() => edit(invoice)}>Edytuj</button>
<button type="button" class="danger" onclick={() => remove(invoice)}>Usuń</button>
<button type="button" class="btn sm" onclick={() => openPreview(invoice)}>Podgląd</button>
<button type="button" class="btn sm" onclick={() => edit(invoice)}>Edytuj</button>
<button type="button" class="btn sm danger" onclick={() => remove(invoice)}>Usuń</button>
</td>
</tr>
{/each}
</tbody>
</table>
{:else if invoices.length > 0}
<p class="hint">Brak faktur pasujących do filtrów.</p>
{/if}
</main>
@@ -140,19 +216,33 @@
.spacer {
flex: 1;
}
.toolbar button.primary {
.toolbar input[type="search"],
.toolbar select {
font: inherit;
font-size: 14px;
padding: 7px 14px;
font-size: 13px;
color: var(--text);
background: var(--surface);
border: 1px solid var(--border);
border-radius: 7px;
border: 1px solid var(--accent);
background: var(--accent);
color: #fff;
cursor: pointer;
padding: 7px 11px;
}
.toolbar input[type="search"] {
width: 200px;
}
.hint {
color: var(--muted);
font-size: 13px;
}
tbody tr:hover td {
background: var(--bg);
}
.error {
color: var(--danger);
}
.info {
color: var(--ok);
font-size: 13px;
}
.empty {
padding: 60px 20px;
text-align: center;
@@ -195,21 +285,4 @@
gap: 6px;
justify-content: flex-end;
}
.row-actions button {
font: inherit;
font-size: 12px;
padding: 4px 9px;
border-radius: 6px;
border: 1px solid var(--border);
background: var(--surface);
color: var(--text);
cursor: pointer;
}
.row-actions button:hover {
background: var(--bg);
}
.row-actions button.danger {
color: var(--danger);
border-color: var(--danger);
}
</style>

View File

@@ -132,10 +132,10 @@
<td class="num">{guest.stay_count}</td>
<td>{guest.last_departure ? `${formatShort(guest.last_departure)}.${guest.last_departure.slice(0, 4)}` : "—"}</td>
<td class="row-actions">
<button type="button" onclick={() => toggleHistory(guest)} disabled={guest.stay_count === 0}>
<button type="button" class="btn sm" onclick={() => toggleHistory(guest)} disabled={guest.stay_count === 0}>
{expandedId === guest.id ? "Zwiń" : "Historia"}
</button>
<button type="button" class="danger" onclick={() => remove(guest)}>Usuń</button>
<button type="button" class="btn sm danger" onclick={() => remove(guest)}>Usuń</button>
</td>
</tr>
{#if expandedId === guest.id}
@@ -243,6 +243,9 @@
.num {
text-align: right;
}
tbody tr:not(.history-row):hover td {
background: var(--bg);
}
.strong {
font-weight: 600;
}
@@ -254,24 +257,6 @@
gap: 6px;
justify-content: flex-end;
}
.row-actions button {
font: inherit;
font-size: 12px;
padding: 4px 9px;
border-radius: 6px;
border: 1px solid var(--border);
background: var(--surface);
color: var(--text);
cursor: pointer;
}
.row-actions button:disabled {
opacity: 0.4;
cursor: default;
}
.row-actions button.danger {
color: var(--danger);
border-color: var(--danger);
}
.history-row td {
background: var(--bg);
}

View File

@@ -3,6 +3,7 @@
import * as db from "$lib/db";
import { daysInMonth, isoDate, MONTH_NAMES } from "$lib/dates";
import { formatMoney } from "$lib/money";
import { csvNumber, saveCsv, toCsv } from "$lib/csv";
import type { Cabin, CabinReport, RevenueStats } from "$lib/types";
let year = $state(new Date().getFullYear());
@@ -62,6 +63,48 @@
}
let maxMonthRevenue = $derived(Math.max(1, ...monthRows.map((m) => m.revenue_total)));
let exportInfo = $state<string | null>(null);
async function exportCsv() {
error = null;
exportInfo = null;
try {
const rows: (string | number | null)[][] = [[`Raport ${year}`], []];
rows.push(["Wg domków"]);
rows.push(["Domek", "Doby", "Obłożenie %", "Przychód", "Przychód potwierdzony"]);
for (const cabin of cabins) {
const row = cabinRows.find((r) => r.cabin_id === cabin.id);
rows.push([
cabin.name,
Math.round(row?.nights_total ?? 0),
Math.round(((row?.nights_total ?? 0) / daysInYear) * 100),
csvNumber(row?.revenue_total ?? 0),
csvNumber(row?.revenue_confirmed ?? 0)
]);
}
rows.push([]);
rows.push(["Wg miesięcy"]);
rows.push(["Miesiąc", "Doby", "Obłożenie %", "Przychód", "Przychód potwierdzony"]);
monthRows.forEach((m, i) => {
const available = cabins.length * daysInMonth(year, i);
rows.push([
MONTH_NAMES[i],
Math.round(m.nights_total),
available > 0 ? Math.round((m.nights_total / available) * 100) : 0,
csvNumber(m.revenue_total),
csvNumber(m.revenue_confirmed)
]);
});
const path = await saveCsv(`raport-${year}.csv`, toCsv(rows));
if (path) {
exportInfo = `Zapisano: ${path}`;
setTimeout(() => (exportInfo = null), 5000);
}
} catch (e) {
error = `Nie udało się wyeksportować: ${e}`;
}
}
</script>
<svelte:head>
@@ -72,11 +115,16 @@
<header class="toolbar">
<h1>Raporty</h1>
<span class="spacer"></span>
<button type="button" class="icon" onclick={() => changeYear(-1)} aria-label="Poprzedni rok"></button>
<button type="button" class="btn" onclick={exportCsv} disabled={!yearStats}>Eksport CSV</button>
<button type="button" class="btn icon" onclick={() => changeYear(-1)} aria-label="Poprzedni rok"></button>
<span class="year">{year}</span>
<button type="button" class="icon" onclick={() => changeYear(1)} aria-label="Następny rok"></button>
<button type="button" class="btn icon" onclick={() => changeYear(1)} aria-label="Następny rok"></button>
</header>
{#if exportInfo}
<p class="info">{exportInfo}</p>
{/if}
{#if error}
<p class="error">{error}</p>
{:else if yearStats}
@@ -203,21 +251,14 @@
font-size: 16px;
font-weight: 600;
}
.toolbar button.icon {
font: inherit;
width: 34px;
padding: 7px 0;
font-size: 16px;
line-height: 1;
border-radius: 7px;
border: 1px solid var(--border);
background: var(--surface);
color: var(--text);
cursor: pointer;
}
.error {
color: var(--danger);
}
.info {
color: var(--ok);
font-size: 13px;
margin: 0 0 10px;
}
.hint {
color: var(--muted);
font-size: 12px;

View File

@@ -0,0 +1,281 @@
<script lang="ts">
import { onMount } from "svelte";
import * as db from "$lib/db";
import { addDays, formatShort, todayISO } from "$lib/dates";
import type { Cabin, Reservation } from "$lib/types";
const DAYS_AHEAD = 14;
interface Task {
kind: "cleaning" | "prep";
r: Reservation;
}
let cabins = $state<Cabin[]>([]);
let overdue = $state<Reservation[]>([]);
let upcoming = $state<Reservation[]>([]);
let preps = $state<Reservation[]>([]);
let sameDayArrivals = $state<Set<string>>(new Set());
let loading = $state(true);
let error = $state<string | null>(null);
const today = todayISO();
async function reload() {
loading = true;
error = null;
try {
const until = addDays(today, DAYS_AHEAD);
const [c, over, upc, firstArrivals, arrivals] = await Promise.all([
db.listCabins(),
db.overdueCleanings(today),
db.upcomingCleanings(today, until),
db.firstArrivalsNeedingPrep(today, until),
db.arrivalsInRange(today, until)
]);
cabins = c;
overdue = over;
upcoming = upc;
preps = firstArrivals;
sameDayArrivals = new Set(arrivals.map((a) => `${a.cabin_id}|${a.arrival}`));
} catch (e) {
error = `Nie udało się wczytać sprzątań: ${e}`;
} finally {
loading = false;
}
}
onMount(() => {
void reload();
});
let byDay = $derived.by(() => {
const map = new Map<string, Task[]>();
const push = (date: string, task: Task) => {
const list = map.get(date);
if (list) {
list.push(task);
} else {
map.set(date, [task]);
}
};
for (const r of upcoming) push(r.departure, { kind: "cleaning", r });
for (const r of preps) push(r.arrival, { kind: "prep", r });
return [...map.entries()].sort(([a], [b]) => (a < b ? -1 : 1));
});
function cabinName(id: number): string {
return cabins.find((c) => c.id === id)?.name ?? `Domek #${id}`;
}
function dayLabel(iso: string): string {
const weekday = new Date(iso + "T00:00:00").toLocaleDateString("pl-PL", { weekday: "long" });
const prefix = iso === today ? "Dziś — " : iso === addDays(today, 1) ? "Jutro — " : "";
return `${prefix}${weekday}, ${formatShort(iso)}`;
}
function hasSameDayArrival(r: Reservation): boolean {
return sameDayArrivals.has(`${r.cabin_id}|${r.departure}`);
}
async function toggleCleaned(r: Reservation, cleaned: boolean) {
error = null;
try {
await db.setReservationCleaned(r.id, cleaned);
r.cleaned = cleaned;
// zaległe znikają z listy dopiero po odświeżeniu — od razu, żeby lista była aktualna
if (overdue.some((o) => o.id === r.id)) {
await reload();
}
} catch (e) {
error = `Nie udało się zapisać: ${e}`;
}
}
async function togglePrepared(r: Reservation, prepared: boolean) {
error = null;
try {
await db.setReservationPrepared(r.id, prepared);
r.prepared = prepared;
} catch (e) {
error = `Nie udało się zapisać: ${e}`;
}
}
</script>
<svelte:head>
<title>Słoneczko — sprzątanie</title>
</svelte:head>
<main>
<header class="toolbar">
<h1>Sprzątanie</h1>
<span class="hint">wyjazdy z najbliższych {DAYS_AHEAD} dni</span>
</header>
{#if error}
<p class="error">{error}</p>
{/if}
{#if overdue.length > 0}
<section class="overdue">
<h2>Zaległe</h2>
{#each overdue as r (r.id)}
<label class="task">
<input
type="checkbox"
checked={r.cleaned}
onchange={(e) => toggleCleaned(r, e.currentTarget.checked)}
/>
<span class="cabin">{cabinName(r.cabin_id)}</span>
<span class="muted">wyjazd {formatShort(r.departure)} · {r.guest_name}</span>
</label>
{/each}
</section>
{/if}
{#if !loading && byDay.length === 0 && overdue.length === 0}
<div class="empty">
<p>Brak sprzątań w najbliższych dniach.</p>
</div>
{/if}
{#each byDay as [day, tasks] (day)}
<section class:today={day === today}>
<h2>{dayLabel(day)}</h2>
{#each tasks as task (`${task.kind}-${task.r.id}`)}
{#if task.kind === "cleaning"}
<label class="task">
<input
type="checkbox"
checked={task.r.cleaned}
onchange={(e) => toggleCleaned(task.r, e.currentTarget.checked)}
/>
<span class="cabin" class:done={task.r.cleaned}>{cabinName(task.r.cabin_id)}</span>
<span class="muted">po pobycie: {task.r.guest_name}</span>
{#if hasSameDayArrival(task.r)}
<span class="urgent">przyjazd tego samego dnia</span>
{/if}
</label>
{:else}
<label class="task">
<input
type="checkbox"
checked={task.r.prepared}
onchange={(e) => togglePrepared(task.r, e.currentTarget.checked)}
/>
<span class="cabin" class:done={task.r.prepared}>{cabinName(task.r.cabin_id)}</span>
<span class="muted">przygotować — przyjeżdża: {task.r.guest_name}</span>
<span class="prep">pierwszy gość w domku</span>
</label>
{/if}
{/each}
</section>
{/each}
</main>
<style>
main {
padding: 14px 16px;
max-width: 640px;
}
.toolbar {
display: flex;
align-items: baseline;
gap: 12px;
margin-bottom: 14px;
}
h1 {
margin: 0;
font-size: 18px;
}
.hint {
color: var(--muted);
font-size: 12px;
}
.error {
color: var(--danger);
}
.empty {
padding: 60px 20px;
text-align: center;
border: 1px dashed var(--border);
border-radius: 10px;
color: var(--muted);
}
section {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 10px;
padding: 12px 14px;
margin-bottom: 12px;
}
section.today {
border-color: var(--accent);
}
section.overdue {
border-color: var(--danger);
}
section.overdue h2 {
color: var(--danger);
}
h2 {
margin: 0 0 8px;
font-size: 13px;
font-weight: 600;
}
h2::first-letter {
text-transform: uppercase;
}
.task {
display: flex;
align-items: center;
gap: 10px;
padding: 6px 4px;
border-top: 1px solid var(--border);
font-size: 14px;
cursor: pointer;
}
.task input {
width: 17px;
height: 17px;
flex: none;
cursor: pointer;
}
.cabin {
font-weight: 600;
min-width: 110px;
}
.cabin.done {
text-decoration: line-through;
color: var(--muted);
font-weight: 400;
}
.muted {
color: var(--muted);
font-size: 12px;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
.urgent {
margin-left: auto;
flex: none;
font-size: 11px;
font-weight: 600;
color: var(--warn-text);
background: var(--bar-option-bg);
border-radius: 5px;
padding: 2px 8px;
}
.prep {
margin-left: auto;
flex: none;
font-size: 11px;
font-weight: 600;
color: var(--bar-confirmed-text);
background: var(--bar-confirmed-bg);
border-radius: 5px;
padding: 2px 8px;
}
</style>

View File

@@ -138,6 +138,8 @@
{/if}
{#if settings}
<div class="cols">
<div class="col">
<form onsubmit={save}>
<section>
<h2>Dane sprzedawcy (do faktur)</h2>
@@ -201,10 +203,12 @@
<div class="actions">
{#if savedFlash}<span class="saved">Zapisano</span>{/if}
<button type="submit" class="primary">Zapisz ustawienia</button>
<button type="submit" class="btn primary">Zapisz ustawienia</button>
</div>
</form>
</div>
<div class="col">
<section>
<h2>Cennik sezonowy</h2>
{#if seasons.length > 0}
@@ -224,7 +228,7 @@
<input type="date" bind:value={season.date_from} onchange={() => persistSeason(season)} aria-label="Początek okresu" />
<input type="date" bind:value={season.date_to} onchange={() => persistSeason(season)} aria-label="Koniec okresu" />
<input type="number" min="0" step="0.01" bind:value={season.price} onchange={() => persistSeason(season)} aria-label="Cena za dobę" />
<button type="button" class="danger" onclick={() => removeSeason(season)}>Usuń</button>
<button type="button" class="btn sm danger" onclick={() => removeSeason(season)}>Usuń</button>
{/each}
</div>
{:else}
@@ -240,7 +244,7 @@
<input type="date" bind:value={newSeason.date_from} required aria-label="Początek okresu" />
<input type="date" bind:value={newSeason.date_to} required aria-label="Koniec okresu" />
<input type="number" min="0" step="0.01" bind:value={newSeason.price} required placeholder="zł" aria-label="Cena za dobę" />
<button type="submit">Dodaj</button>
<button type="submit" class="btn">Dodaj</button>
</form>
<p class="hint">
Cennik konkretnego domku ma pierwszeństwo przed cennikiem „Wszystkie domki". Przy pobycie na
@@ -255,17 +259,30 @@
pliku bazy, przechowywanych jest 14 ostatnich). Możesz też zapisać kopię ręcznie, np. na pendrive.
</p>
<div class="actions" style="justify-content: flex-start; margin-top: 10px;">
<button type="button" class="primary" onclick={backup}>Zapisz kopię…</button>
<button type="button" class="btn primary" onclick={backup}>Zapisz kopię…</button>
{#if backupInfo}<span class="saved">{backupInfo}</span>{/if}
</div>
</section>
</div>
</div>
{/if}
</main>
<style>
main {
padding: 14px 16px;
max-width: 640px;
max-width: 1240px;
}
.cols {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
gap: 0 16px;
align-items: start;
}
@media (max-width: 980px) {
.cols {
grid-template-columns: minmax(0, 1fr);
}
}
h1 {
margin: 0 0 16px;
@@ -323,20 +340,6 @@
font-size: 11px;
color: var(--muted);
}
.seasons button {
font: inherit;
font-size: 13px;
padding: 6px 10px;
border-radius: 6px;
border: 1px solid var(--border);
background: var(--surface);
color: var(--text);
cursor: pointer;
}
.seasons button.danger {
color: var(--danger);
border-color: var(--danger);
}
.hint {
margin: 10px 0 0;
font-size: 12px;
@@ -355,14 +358,4 @@
font-size: 13px;
color: var(--accent);
}
.actions button.primary {
font: inherit;
font-size: 14px;
padding: 8px 16px;
border-radius: 7px;
border: 1px solid var(--accent);
background: var(--accent);
color: #fff;
cursor: pointer;
}
</style>