Add some features
This commit is contained in:
@@ -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}");
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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)} zł</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>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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} zł ${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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
162
src/lib/components/ReservationSearch.svelte
Normal file
162
src/lib/components/ReservationSearch.svelte
Normal 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>
|
||||
@@ -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;
|
||||
|
||||
160
src/lib/components/StayPopover.svelte
Normal file
160
src/lib/components/StayPopover.svelte
Normal 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)} zł</strong></p>
|
||||
{/if}
|
||||
{#if energyUsage != null}
|
||||
<p>Energia: {energyUsage} kWh</p>
|
||||
{/if}
|
||||
{#if r.deposit_amount != null}
|
||||
<p>
|
||||
Zaliczka: {formatMoney(r.deposit_amount)} zł
|
||||
<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
28
src/lib/csv.ts
Normal 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;
|
||||
}
|
||||
177
src/lib/db.ts
177
src/lib/db.ts
@@ -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[]>(
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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)} zł</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>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
281
src/routes/sprzatanie/+page.svelte
Normal file
281
src/routes/sprzatanie/+page.svelte
Normal 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>
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user