Dodanie raportów, dnd, listy gości i innych.

This commit is contained in:
Kazimierz Ciołek
2026-07-05 15:53:52 +02:00
parent 4828573cae
commit b584cbc8ce
18 changed files with 1804 additions and 56 deletions

10
package-lock.json generated
View File

@@ -10,6 +10,7 @@
"license": "MIT",
"dependencies": {
"@tauri-apps/api": "^2",
"@tauri-apps/plugin-dialog": "^2.7.1",
"@tauri-apps/plugin-opener": "^2",
"@tauri-apps/plugin-sql": "^2.4.0"
},
@@ -1219,6 +1220,15 @@
"node": ">= 10"
}
},
"node_modules/@tauri-apps/plugin-dialog": {
"version": "2.7.1",
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-dialog/-/plugin-dialog-2.7.1.tgz",
"integrity": "sha512-OK1UBXYt+ojcmxMktzzuyonYIFta8CmAASpX+CA+DTGK24KlHjhYI6x2iOJ/TjZF4N7/ACK1oFmEOjIY9IhzOQ==",
"license": "MIT OR Apache-2.0",
"dependencies": {
"@tauri-apps/api": "^2.11.0"
}
},
"node_modules/@tauri-apps/plugin-opener": {
"version": "2.5.4",
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-opener/-/plugin-opener-2.5.4.tgz",

View File

@@ -14,6 +14,7 @@
"license": "MIT",
"dependencies": {
"@tauri-apps/api": "^2",
"@tauri-apps/plugin-dialog": "^2.7.1",
"@tauri-apps/plugin-opener": "^2",
"@tauri-apps/plugin-sql": "^2.4.0"
},

147
src-tauri/Cargo.lock generated
View File

@@ -562,8 +562,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327"
dependencies = [
"iana-time-zone",
"js-sys",
"num-traits",
"serde",
"wasm-bindgen",
"windows-link 0.2.1",
]
@@ -2519,6 +2521,7 @@ checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272"
dependencies = [
"bitflags 2.13.0",
"block2",
"libc",
"objc2",
"objc2-core-foundation",
]
@@ -3154,6 +3157,30 @@ dependencies = [
"web-sys",
]
[[package]]
name = "rfd"
version = "0.16.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a15ad77d9e70a92437d8f74c35d99b4e4691128df018833e99f90bcd36152672"
dependencies = [
"block2",
"dispatch2",
"glib-sys",
"gobject-sys",
"gtk-sys",
"js-sys",
"log",
"objc2",
"objc2-app-kit",
"objc2-core-foundation",
"objc2-foundation",
"raw-window-handle",
"wasm-bindgen",
"wasm-bindgen-futures",
"web-sys",
"windows-sys 0.60.2",
]
[[package]]
name = "rkyv"
version = "0.7.46"
@@ -3607,10 +3634,12 @@ checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5"
name = "sloneczko-menager"
version = "0.1.0"
dependencies = [
"chrono",
"serde",
"serde_json",
"tauri",
"tauri-build",
"tauri-plugin-dialog",
"tauri-plugin-opener",
"tauri-plugin-sql",
]
@@ -4211,6 +4240,48 @@ dependencies = [
"walkdir",
]
[[package]]
name = "tauri-plugin-dialog"
version = "2.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "65981abb771e74e571a38196c3baa11c459379164791eba0e67abc1a5fac9884"
dependencies = [
"log",
"raw-window-handle",
"rfd",
"serde",
"serde_json",
"tauri",
"tauri-plugin",
"tauri-plugin-fs",
"thiserror 2.0.18",
"url",
]
[[package]]
name = "tauri-plugin-fs"
version = "2.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b7ecc274121aca0c036a2b42d1cbe83d368d348f54e0bb8a735c2b1548e8f371"
dependencies = [
"anyhow",
"dunce",
"glob",
"log",
"objc2-foundation",
"percent-encoding",
"schemars 0.8.22",
"serde",
"serde_json",
"serde_repr",
"tauri",
"tauri-plugin",
"tauri-utils",
"thiserror 2.0.18",
"toml 1.1.2+spec-1.1.0",
"url",
]
[[package]]
name = "tauri-plugin-opener"
version = "2.5.4"
@@ -5352,6 +5423,15 @@ dependencies = [
"windows-targets 0.52.6",
]
[[package]]
name = "windows-sys"
version = "0.60.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb"
dependencies = [
"windows-targets 0.53.5",
]
[[package]]
name = "windows-sys"
version = "0.61.2"
@@ -5400,13 +5480,30 @@ dependencies = [
"windows_aarch64_gnullvm 0.52.6",
"windows_aarch64_msvc 0.52.6",
"windows_i686_gnu 0.52.6",
"windows_i686_gnullvm",
"windows_i686_gnullvm 0.52.6",
"windows_i686_msvc 0.52.6",
"windows_x86_64_gnu 0.52.6",
"windows_x86_64_gnullvm 0.52.6",
"windows_x86_64_msvc 0.52.6",
]
[[package]]
name = "windows-targets"
version = "0.53.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3"
dependencies = [
"windows-link 0.2.1",
"windows_aarch64_gnullvm 0.53.1",
"windows_aarch64_msvc 0.53.1",
"windows_i686_gnu 0.53.1",
"windows_i686_gnullvm 0.53.1",
"windows_i686_msvc 0.53.1",
"windows_x86_64_gnu 0.53.1",
"windows_x86_64_gnullvm 0.53.1",
"windows_x86_64_msvc 0.53.1",
]
[[package]]
name = "windows-threading"
version = "0.1.0"
@@ -5443,6 +5540,12 @@ version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3"
[[package]]
name = "windows_aarch64_gnullvm"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53"
[[package]]
name = "windows_aarch64_msvc"
version = "0.42.2"
@@ -5461,6 +5564,12 @@ version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469"
[[package]]
name = "windows_aarch64_msvc"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006"
[[package]]
name = "windows_i686_gnu"
version = "0.42.2"
@@ -5479,12 +5588,24 @@ version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b"
[[package]]
name = "windows_i686_gnu"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3"
[[package]]
name = "windows_i686_gnullvm"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66"
[[package]]
name = "windows_i686_gnullvm"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c"
[[package]]
name = "windows_i686_msvc"
version = "0.42.2"
@@ -5503,6 +5624,12 @@ version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66"
[[package]]
name = "windows_i686_msvc"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2"
[[package]]
name = "windows_x86_64_gnu"
version = "0.42.2"
@@ -5521,6 +5648,12 @@ version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78"
[[package]]
name = "windows_x86_64_gnu"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499"
[[package]]
name = "windows_x86_64_gnullvm"
version = "0.42.2"
@@ -5539,6 +5672,12 @@ version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d"
[[package]]
name = "windows_x86_64_gnullvm"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1"
[[package]]
name = "windows_x86_64_msvc"
version = "0.42.2"
@@ -5557,6 +5696,12 @@ version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
[[package]]
name = "windows_x86_64_msvc"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650"
[[package]]
name = "winnow"
version = "0.5.40"

View File

@@ -23,4 +23,6 @@ tauri-plugin-opener = "2"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
tauri-plugin-sql = { version = "2.4.0", features = ["sqlite"] }
tauri-plugin-dialog = "2.7.1"
chrono = "0.4.45"

View File

@@ -7,6 +7,7 @@
"core:default",
"opener:default",
"sql:default",
"sql:allow-execute"
"sql:allow-execute",
"dialog:default"
]
}

View File

@@ -92,17 +92,95 @@ fn migrations() -> Vec<Migration> {
CREATE INDEX idx_invoice_items_invoice ON invoice_items (invoice_id);
",
kind: MigrationKind::Up,
},
Migration {
version: 3,
description: "add_guests_settled_price_seasons",
sql: "
CREATE TABLE guests (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL,
address TEXT,
phone TEXT,
notes TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
ALTER TABLE reservations ADD COLUMN guest_id INTEGER REFERENCES guests(id) ON DELETE SET NULL;
INSERT INTO guests (name, address, phone)
SELECT guest_name, MIN(address), phone
FROM reservations
GROUP BY guest_name, COALESCE(phone, '');
UPDATE reservations SET guest_id =
(SELECT g.id FROM guests g
WHERE g.name = reservations.guest_name
AND COALESCE(g.phone, '') = COALESCE(reservations.phone, ''));
ALTER TABLE reservations ADD COLUMN settled INTEGER NOT NULL DEFAULT 0;
CREATE TABLE price_seasons (
id INTEGER PRIMARY KEY,
cabin_id INTEGER REFERENCES cabins(id) ON DELETE CASCADE,
date_from TEXT NOT NULL,
date_to TEXT NOT NULL,
price REAL NOT NULL,
CHECK (date_to > date_from)
);
",
kind: MigrationKind::Up,
}]
}
/// Kopia bazy przy starcie (zanim plugin SQL otworzy połączenia):
/// backups/sloneczko-RRRR-MM-DD.db obok pliku bazy, retencja 14 ostatnich.
fn auto_backup(app: &tauri::AppHandle) -> std::io::Result<()> {
use tauri::Manager;
let candidates = [app.path().app_config_dir(), app.path().app_data_dir()];
for dir in candidates.into_iter().flatten() {
let db = dir.join("sloneczko.db");
if !db.exists() {
continue;
}
let backups = dir.join("backups");
std::fs::create_dir_all(&backups)?;
let name = format!("sloneczko-{}.db", chrono::Local::now().format("%Y-%m-%d"));
let dest = backups.join(name);
if !dest.exists() {
std::fs::copy(&db, &dest)?;
}
let mut files: Vec<_> = std::fs::read_dir(&backups)?
.flatten()
.map(|e| e.path())
.filter(|p| {
p.file_name()
.is_some_and(|n| n.to_string_lossy().starts_with("sloneczko-"))
})
.collect();
files.sort();
while files.len() > 14 {
let _ = std::fs::remove_file(files.remove(0));
}
break;
}
Ok(())
}
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
tauri::Builder::default()
.setup(|app| {
if let Err(e) = auto_backup(app.handle()) {
eprintln!("Nie udało się wykonać kopii zapasowej: {e}");
}
Ok(())
})
.plugin(
tauri_plugin_sql::Builder::default()
.add_migrations("sqlite:sloneczko.db", migrations())
.build(),
)
.plugin(tauri_plugin_dialog::init())
.plugin(tauri_plugin_opener::init())
.run(tauri::generate_context!())
.expect("error while running tauri application");

View File

@@ -0,0 +1,157 @@
<script lang="ts">
import type { Guest } from "$lib/types";
import * as db from "$lib/db";
interface Props {
value: string;
/** Wybranie gościa z podpowiedzi — rodzic uzupełnia pozostałe pola. */
onselect: (guest: Guest) => void;
}
let { value = $bindable(), onselect }: Props = $props();
let suggestions = $state<Guest[]>([]);
let open = $state(false);
let highlighted = $state(0);
let searchToken = 0;
async function oninput() {
const q = value.trim();
if (q.length < 2) {
suggestions = [];
open = false;
return;
}
const token = ++searchToken;
const found = await db.searchGuests(q);
if (token !== searchToken) return;
suggestions = found;
highlighted = 0;
open = found.length > 0;
}
function choose(guest: Guest) {
value = guest.name;
open = false;
onselect(guest);
}
function onkeydown(event: KeyboardEvent) {
if (!open) return;
if (event.key === "ArrowDown") {
event.preventDefault();
highlighted = (highlighted + 1) % suggestions.length;
} else if (event.key === "ArrowUp") {
event.preventDefault();
highlighted = (highlighted - 1 + suggestions.length) % suggestions.length;
} else if (event.key === "Enter") {
event.preventDefault();
choose(suggestions[highlighted]);
} else if (event.key === "Escape") {
// zamknij tylko podpowiedzi, nie cały dialog
event.preventDefault();
event.stopPropagation();
open = false;
}
}
function onblur() {
open = false;
}
</script>
<div class="wrap">
<input
type="text"
bind:value
{oninput}
{onkeydown}
{onblur}
required
placeholder="Jan Kowalski"
autocomplete="off"
role="combobox"
aria-expanded={open}
aria-autocomplete="list"
aria-controls="guest-suggestions"
/>
{#if open}
<ul class="suggestions" id="guest-suggestions" role="listbox">
{#each suggestions as guest, i (guest.id)}
<li role="option" aria-selected={i === highlighted}>
<button
type="button"
class:hl={i === highlighted}
onclick={() => choose(guest)}
onpointerdown={(e) => e.preventDefault()}
>
<span class="n">{guest.name}</span>
{#if guest.phone}<span class="d">{guest.phone}</span>{/if}
{#if guest.address}<span class="d">{guest.address}</span>{/if}
</button>
</li>
{/each}
</ul>
{/if}
</div>
<style>
.wrap {
position: relative;
}
input {
width: 100%;
font: inherit;
font-size: 14px;
color: var(--text);
background: var(--bg);
border: 1px solid var(--border);
border-radius: 6px;
padding: 7px 9px;
}
.suggestions {
position: absolute;
top: calc(100% + 2px);
left: 0;
right: 0;
z-index: 10;
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: 220px;
overflow-y: auto;
}
.suggestions button {
display: flex;
gap: 8px;
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;
}
.suggestions button:hover,
.suggestions button.hl {
background: var(--today);
}
.n {
font-weight: 600;
}
.d {
font-size: 12px;
color: var(--muted);
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
</style>

View File

@@ -1,14 +1,30 @@
<script lang="ts">
import type { Reservation } from "$lib/types";
import { formatShort } from "$lib/dates";
import { addDays, formatShort } from "$lib/dates";
const ROW_HEIGHT = 44;
const DRAG_THRESHOLD = 5;
const EDGE_WIDTH = 7;
type DragMode = "move" | "resize-l" | "resize-r";
interface Props {
reservation: Reservation;
days: string[];
/** Identyfikatory domków w kolejności wierszy grafiku. */
cabinIds: number[];
/** Indeks wiersza tego paska. */
cabinIndex: number;
onclick: () => void;
onmove: (
reservation: Reservation,
cabinId: number,
arrival: string,
departure: string
) => void;
}
let { reservation: r, days, onclick }: Props = $props();
let { reservation: r, days, cabinIds, cabinIndex, onclick, onmove }: Props = $props();
let n = $derived(days.length);
let clippedStart = $derived(r.arrival < days[0]);
@@ -16,33 +32,172 @@
let startPos = $derived(clippedStart ? 0 : (Number(r.arrival.slice(8)) - 0.5) / n);
let endPos = $derived(clippedEnd ? 1 : (Number(r.departure.slice(8)) - 0.5) / n);
// --- Drag & drop ---
let mode = $state<DragMode | null>(null);
let dragging = $state(false);
let dayDelta = $state(0);
let rowDelta = $state(0);
let dayWidth = 0;
let startX = 0;
let startY = 0;
let cancelled = false;
let suppressClick = false;
/** Maksymalny zakres przesunięcia dat tak, by pobyt pozostał w widocznym miesiącu. */
function clampDayDelta(raw: number, m: DragMode): number {
const monthStart = days[0];
const monthEndExclusive = addDays(days[n - 1], 1);
let min = -n;
let max = n;
if (m === "move") {
// pełne przesunięcie — tylko dla pobytów w całości widocznych
min = -daysApart(monthStart, r.arrival);
max = daysApart(r.departure, monthEndExclusive);
} else if (m === "resize-r") {
min = -(daysApart(r.arrival, r.departure) - 1);
max = daysApart(r.departure, monthEndExclusive);
} else {
min = -daysApart(monthStart, r.arrival);
max = daysApart(r.arrival, r.departure) - 1;
}
return Math.min(max, Math.max(min, raw));
}
function daysApart(a: string, b: string): number {
return Math.round(
(new Date(b + "T00:00:00").getTime() - new Date(a + "T00:00:00").getTime()) / 86_400_000
);
}
function modeForEvent(event: PointerEvent, el: HTMLElement): DragMode {
const rect = el.getBoundingClientRect();
const x = event.clientX - rect.left;
if (!clippedStart && x <= EDGE_WIDTH) return "resize-l";
if (!clippedEnd && x >= rect.width - EDGE_WIDTH) return "resize-r";
return "move";
}
function onpointerdown(event: PointerEvent) {
if (event.button !== 0) return;
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ą
if (m === "move" && (clippedStart || clippedEnd)) return;
mode = m;
dragging = false;
cancelled = false;
dayDelta = 0;
rowDelta = 0;
startX = event.clientX;
startY = event.clientY;
dayWidth = (el.parentElement?.getBoundingClientRect().width ?? 0) / n;
el.setPointerCapture(event.pointerId);
}
function onpointermove(event: PointerEvent) {
if (mode == null || cancelled || dayWidth === 0) return;
const dx = event.clientX - startX;
const dy = event.clientY - startY;
if (!dragging && Math.abs(dx) < DRAG_THRESHOLD && Math.abs(dy) < DRAG_THRESHOLD) return;
dragging = true;
dayDelta = clampDayDelta(Math.round(dx / dayWidth), mode);
if (mode === "move") {
const rawRow = Math.round(dy / ROW_HEIGHT);
rowDelta = Math.min(cabinIds.length - 1 - cabinIndex, Math.max(-cabinIndex, rawRow));
}
}
function onpointerup() {
if (mode == null) return;
const wasDragging = dragging && !cancelled;
const dDays = dayDelta;
const dRows = rowDelta;
const m = mode;
mode = null;
dragging = false;
dayDelta = 0;
rowDelta = 0;
if (!wasDragging || (dDays === 0 && dRows === 0)) return;
suppressClick = true;
setTimeout(() => (suppressClick = false), 0);
let arrival = r.arrival;
let departure = r.departure;
if (m === "move") {
arrival = addDays(arrival, dDays);
departure = addDays(departure, dDays);
} else if (m === "resize-r") {
departure = addDays(departure, dDays);
} else {
arrival = addDays(arrival, dDays);
}
const cabinId = m === "move" ? cabinIds[cabinIndex + dRows] : r.cabin_id;
onmove(r, cabinId, arrival, departure);
}
function onwindowkeydown(event: KeyboardEvent) {
if (event.key === "Escape" && mode != null) {
cancelled = true;
dragging = false;
dayDelta = 0;
rowDelta = 0;
mode = null;
}
}
function handleClick() {
if (suppressClick) return;
onclick();
}
let dragStyle = $derived.by(() => {
if (!dragging) return "";
if (mode === "move") {
return `transform: translate(${dayDelta * dayWidth}px, ${rowDelta * ROW_HEIGHT}px); z-index: 6; opacity: 0.75;`;
}
if (mode === "resize-r") {
return `width: calc(${(endPos - startPos) * 100}% + ${dayDelta * dayWidth}px); z-index: 6; opacity: 0.75;`;
}
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 ?? ""
r.notes ?? "",
"Przeciągnij, aby przenieść; krawędź zmienia daty"
]
.filter(Boolean)
.join("\n")
);
</script>
<svelte:window onkeydown={onwindowkeydown} />
<button
type="button"
class="bar {r.status}"
class:settled={r.settled}
class:clip-start={clippedStart}
class:clip-end={clippedEnd}
style="left: {startPos * 100}%; width: {(endPos - startPos) * 100}%;"
class:dragging
style="left: {startPos * 100}%; width: {(endPos - startPos) * 100}%; {dragStyle}"
title={tooltip}
{onclick}
onclick={handleClick}
{onpointerdown}
{onpointermove}
{onpointerup}
>
{#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}
<span class="name">{r.guest_name}</span>
{#if !clippedEnd}<span class="handle r" aria-hidden="true"></span>{/if}
</button>
<style>
@@ -60,8 +215,25 @@
font: inherit;
font-size: 12px;
text-align: left;
cursor: pointer;
cursor: grab;
overflow: hidden;
touch-action: none;
}
.bar.dragging {
cursor: grabbing;
}
.handle {
position: absolute;
top: 0;
bottom: 0;
width: 7px;
cursor: ew-resize;
}
.handle.l {
left: 0;
}
.handle.r {
right: 0;
}
.name {
overflow: hidden;
@@ -80,6 +252,12 @@
background: var(--bar-option-bg);
color: var(--bar-option-text);
}
.settled {
border-left: 3px solid var(--ok);
}
.settled.clip-start {
border-left: none;
}
.clip-start {
border-top-left-radius: 0;
border-bottom-left-radius: 0;

View File

@@ -1,14 +1,16 @@
<script lang="ts">
import type {
Cabin,
Guest,
Reservation,
ReservationDialogParams,
ReservationInput,
ReservationStatus
} from "$lib/types";
import { addDays, formatShort, nightsBetween } from "$lib/dates";
import { formatMoney } from "$lib/money";
import { formatMoney, round2 } from "$lib/money";
import * as db from "$lib/db";
import GuestField from "./GuestField.svelte";
interface Props {
params: ReservationDialogParams;
@@ -44,7 +46,9 @@
notes: null,
price_per_night: null,
meter_start: null,
meter_end: null
meter_end: null,
guest_id: null,
settled: false
};
let cabinId = $state(init.cabin_id);
@@ -61,8 +65,29 @@
let pricePerNight = $state<number | null>(init.price_per_night);
let meterStart = $state<number | null>(init.meter_start);
let meterEnd = $state<number | null>(init.meter_end);
let settled = $state(init.settled);
let error = $state<string | null>(null);
let saving = $state(false);
let energyPrice = $state<number | null>(null);
void db.getSettings().then((s) => (energyPrice = s.energy_price));
/** Podpowiedź z cennika sezonowego — tylko gdy pole ceny jest puste. */
async function maybeSuggestPrice() {
if (pricePerNight != null || !arrival || !departure || departure <= arrival) return;
const suggested = await db.suggestPrice(cabinId, arrival, departure);
if (suggested != null && pricePerNight == null) {
pricePerNight = suggested;
}
}
if (editingId == null) {
void maybeSuggestPrice();
}
function fillFromGuest(guest: Guest) {
if (guest.address) address = guest.address;
if (guest.phone) phone = guest.phone;
}
let selectedCabin = $derived(cabins.find((c) => c.id === cabinId));
let capacityWarning = $derived(
@@ -83,6 +108,16 @@
? meterEnd - meterStart
: null
);
let energyCost = $derived(
energyUsage != null && energyUsage > 0 && energyPrice != null
? round2(energyUsage * energyPrice)
: null
);
let balance = $derived.by(() => {
if (stayTotal == null && energyCost == null) return null;
const deposit = depositPaid ? (depositAmount ?? 0) : 0;
return round2((stayTotal ?? 0) + (energyCost ?? 0) - deposit);
});
function showModal(el: HTMLDialogElement) {
el.showModal();
@@ -107,7 +142,9 @@
notes: notes.trim() || null,
price_per_night: normNumber(pricePerNight),
meter_start: normNumber(meterStart),
meter_end: normNumber(meterEnd)
meter_end: normNumber(meterEnd),
guest_id: init.guest_id,
settled
};
}
@@ -129,10 +166,16 @@
error = `Termin koliduje z rezerwacją: ${c.guest_name} (${formatShort(c.arrival)}${formatShort(c.departure)}).`;
return false;
}
const guestId = await db.upsertGuestForReservation(
guestName.trim(),
address.trim() || null,
phone.trim() || null
);
const input = { ...buildInput(), guest_id: guestId };
if (editingId == null) {
await db.createReservation(buildInput());
await db.createReservation(input);
} else {
await db.updateReservation(editingId, buildInput());
await db.updateReservation(editingId, input);
}
return true;
} catch (e) {
@@ -179,7 +222,7 @@
<div class="grid2">
<label>
Domek
<select bind:value={cabinId} required>
<select bind:value={cabinId} required onchange={() => void maybeSuggestPrice()}>
{#each cabins as c (c.id)}
<option value={c.id}>{c.name}{c.capacity ? ` (${c.capacity} os.)` : ""}</option>
{/each}
@@ -194,15 +237,15 @@
</label>
<label>
Przyjazd
<input type="date" bind:value={arrival} required />
<input type="date" bind:value={arrival} required onchange={() => void maybeSuggestPrice()} />
</label>
<label>
Wyjazd
<input type="date" bind:value={departure} required />
<input type="date" bind:value={departure} required onchange={() => void maybeSuggestPrice()} />
</label>
<label class="wide">
Imię i nazwisko
<input type="text" bind:value={guestName} required placeholder="Jan Kowalski" />
<GuestField bind:value={guestName} onselect={fillFromGuest} />
</label>
<label class="wide">
Adres
@@ -250,7 +293,29 @@
Stan „po" jest mniejszy niż „przed" — sprawdź odczyty.
{:else}
Zużycie energii: <strong>{Math.round(energyUsage * 10) / 10} kWh</strong>
{#if energyCost != null}
× {formatMoney(energyPrice ?? 0)} zł = <strong>{formatMoney(energyCost)}</strong>
{/if}
{/if}
</div>
{/if}
{#if balance != null}
<div class="balance wide">
<label class="check settled-check">
<input type="checkbox" bind:checked={settled} />
Pobyt rozliczony
</label>
<span class="sum">
Do zapłaty:
<strong>{formatMoney(balance)}</strong>
<span class="detail">
(noclegi {formatMoney(stayTotal ?? 0)}{energyCost != null
? ` + energia ${formatMoney(energyCost)}`
: ""}{depositPaid && depositAmount
? ` zaliczka ${formatMoney(depositAmount)}`
: ""})
</span>
</span>
</div>
{/if}
<label class="wide">
@@ -348,6 +413,34 @@
.calc.bad {
color: var(--danger);
}
.balance {
display: flex;
align-items: center;
gap: 12px;
padding: 8px 10px;
border: 1px solid var(--border);
border-radius: 7px;
background: var(--bg);
font-size: 13px;
}
.balance .sum {
margin-left: auto;
text-align: right;
color: var(--muted);
}
.balance .sum strong {
color: var(--text);
font-size: 15px;
}
.balance .detail {
display: block;
font-size: 11px;
}
.settled-check {
padding-bottom: 0;
align-self: center;
white-space: nowrap;
}
.warning {
margin: 12px 0 0;
font-size: 13px;

View File

@@ -9,9 +9,18 @@
reservations: Reservation[];
onCellClick: (cabinId: number, date: string) => void;
onReservationClick: (r: Reservation) => void;
onReservationMove: (
r: Reservation,
cabinId: number,
arrival: string,
departure: string
) => void;
}
let { cabins, days, reservations, onCellClick, onReservationClick }: Props = $props();
let { cabins, days, reservations, onCellClick, onReservationClick, onReservationMove }: Props =
$props();
let cabinIds = $derived(cabins.map((c) => c.id));
const today = todayISO();
@@ -39,7 +48,7 @@
</div>
{/each}
{#each cabins as cabin (cabin.id)}
{#each cabins as cabin, cabinIndex (cabin.id)}
<div class="cabin-name">
<span class="cn">{cabin.name}</span>
{#if cabin.capacity}<span class="cap">{cabin.capacity} os.</span>{/if}
@@ -56,7 +65,14 @@
></button>
{/each}
{#each byCabin.get(cabin.id) ?? [] as r (r.id)}
<ReservationBar reservation={r} {days} onclick={() => onReservationClick(r)} />
<ReservationBar
reservation={r}
{days}
{cabinIds}
{cabinIndex}
onclick={() => onReservationClick(r)}
onmove={onReservationMove}
/>
{/each}
</div>
{/each}

View File

@@ -1,15 +1,21 @@
import Database from "@tauri-apps/plugin-sql";
import type {
Cabin,
CabinReport,
Guest,
GuestListRow,
Invoice,
InvoiceInput,
InvoiceItem,
InvoiceItemInput,
PriceSeason,
PriceSeasonInput,
Reservation,
ReservationInput,
RevenueStats,
Settings
} from "./types";
import { suggestPriceFromSeasons } from "./pricing";
let db: Database | null = null;
@@ -20,12 +26,13 @@ async function getDb(): Promise<Database> {
return db;
}
interface ReservationRow extends Omit<Reservation, "deposit_paid"> {
interface ReservationRow extends Omit<Reservation, "deposit_paid" | "settled"> {
deposit_paid: number;
settled: number;
}
function toReservation(row: ReservationRow): Reservation {
return { ...row, deposit_paid: !!row.deposit_paid };
return { ...row, deposit_paid: !!row.deposit_paid, settled: !!row.settled };
}
// --- Domki ---
@@ -93,35 +100,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)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14)`,
[
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
]
);
}
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
WHERE id = $15`,
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,
@@ -137,6 +117,38 @@ export async function updateReservation(id: number, r: ReservationInput): Promis
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`,
[
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,
id
]
);
@@ -176,6 +188,153 @@ export async function revenueStats(from: string, to: string): Promise<RevenueSta
};
}
/** Statystyki jak revenueStats, ale w rozbiciu na domki. */
export async function cabinReports(from: string, to: string): Promise<CabinReport[]> {
const d = await getDb();
return d.select<CabinReport[]>(
`SELECT
cabin_id,
SUM(julianday(MIN(departure, $1)) - julianday(MAX(arrival, $2))) AS nights_total,
SUM((julianday(MIN(departure, $1)) - julianday(MAX(arrival, $2))) * COALESCE(price_per_night, 0)) AS revenue_total,
SUM(CASE WHEN status = 'confirmed'
THEN julianday(MIN(departure, $1)) - julianday(MAX(arrival, $2)) ELSE 0 END) AS nights_confirmed,
SUM(CASE WHEN status = 'confirmed'
THEN (julianday(MIN(departure, $1)) - julianday(MAX(arrival, $2))) * COALESCE(price_per_night, 0) ELSE 0 END) AS revenue_confirmed
FROM reservations
WHERE arrival < $1 AND departure > $2
GROUP BY cabin_id`,
[to, from]
);
}
// --- Goście ---
export async function searchGuests(query: string): Promise<Guest[]> {
const d = await getDb();
return d.select<Guest[]>(
`SELECT * FROM guests
WHERE name LIKE $1 OR phone LIKE $1
ORDER BY name LIMIT 8`,
[`%${query}%`]
);
}
export async function listGuests(): Promise<GuestListRow[]> {
const d = await getDb();
return d.select<GuestListRow[]>(
`SELECT g.*, COUNT(r.id) AS stay_count, MAX(r.departure) AS last_departure
FROM guests g
LEFT JOIN reservations r ON r.guest_id = g.id
GROUP BY g.id
ORDER BY g.name`
);
}
export async function getGuestReservations(guestId: number): Promise<Reservation[]> {
const d = await getDb();
const rows = await d.select<ReservationRow[]>(
"SELECT * FROM reservations WHERE guest_id = $1 ORDER BY arrival DESC",
[guestId]
);
return rows.map(toReservation);
}
export async function updateGuest(g: Guest): Promise<void> {
const d = await getDb();
await d.execute("UPDATE guests SET name = $1, address = $2, phone = $3, notes = $4 WHERE id = $5", [
g.name,
g.address,
g.phone,
g.notes,
g.id
]);
}
/** Usuwa gościa; rezerwacje zostają (guest_id -> NULL przez ON DELETE SET NULL). */
export async function deleteGuest(id: number): Promise<void> {
const d = await getDb();
await d.execute("DELETE FROM guests WHERE id = $1", [id]);
}
/**
* Dopasowuje gościa po (nazwisko, telefon); aktualizuje dane albo zakłada nowego.
* Zwraca guest_id do podpięcia w rezerwacji.
*/
export async function upsertGuestForReservation(
name: string,
address: string | null,
phone: string | null
): Promise<number> {
const d = await getDb();
const rows = await d.select<Guest[]>(
"SELECT * FROM guests WHERE name = $1 AND COALESCE(phone, '') = COALESCE($2, '') LIMIT 1",
[name, phone]
);
const existing = rows[0];
if (existing) {
if (address && address !== existing.address) {
await d.execute("UPDATE guests SET address = $1 WHERE id = $2", [address, existing.id]);
}
return existing.id;
}
const res = await d.execute("INSERT INTO guests (name, address, phone) VALUES ($1, $2, $3)", [
name,
address,
phone
]);
return res.lastInsertId as number;
}
// --- Cennik sezonowy ---
export async function listPriceSeasons(): Promise<PriceSeason[]> {
const d = await getDb();
return d.select<PriceSeason[]>("SELECT * FROM price_seasons ORDER BY date_from, cabin_id");
}
export async function createPriceSeason(s: PriceSeasonInput): Promise<void> {
const d = await getDb();
await d.execute(
"INSERT INTO price_seasons (cabin_id, date_from, date_to, price) VALUES ($1, $2, $3, $4)",
[s.cabin_id, s.date_from, s.date_to, s.price]
);
}
export async function updatePriceSeason(s: PriceSeason): Promise<void> {
const d = await getDb();
await d.execute(
"UPDATE price_seasons SET cabin_id = $1, date_from = $2, date_to = $3, price = $4 WHERE id = $5",
[s.cabin_id, s.date_from, s.date_to, s.price, s.id]
);
}
export async function deletePriceSeason(id: number): Promise<void> {
const d = await getDb();
await d.execute("DELETE FROM price_seasons WHERE id = $1", [id]);
}
/** Sugerowana cena za dobę dla pobytu wg cennika sezonowego. */
export async function suggestPrice(
cabinId: number,
arrival: string,
departure: string
): Promise<number | null> {
const d = await getDb();
const seasons = await d.select<PriceSeason[]>(
"SELECT * FROM price_seasons WHERE date_from < $1 AND date_to > $2 AND (cabin_id = $3 OR cabin_id IS NULL)",
[departure, arrival, cabinId]
);
return suggestPriceFromSeasons(seasons, cabinId, arrival, departure);
}
// --- Kopia zapasowa ---
/** Bezpieczna kopia działającej bazy do wskazanego pliku. */
export async function backupTo(path: string): Promise<void> {
const d = await getDb();
await d.execute(`VACUUM INTO $1`, [path]);
}
// --- Ustawienia ---
export async function getSettings(): Promise<Settings> {

37
src/lib/pricing.ts Normal file
View File

@@ -0,0 +1,37 @@
import type { PriceSeason } from "./types";
import { addDays } from "./dates";
import { round2 } from "./money";
/** Cena za noc z cennika: sezon konkretnego domku wygrywa z globalnym (cabin_id = null). */
export function priceForNight(
seasons: PriceSeason[],
cabinId: number,
night: string
): number | null {
let global: number | null = null;
for (const s of seasons) {
if (night < s.date_from || night >= s.date_to) continue;
if (s.cabin_id === cabinId) return s.price;
if (s.cabin_id == null) global = s.price;
}
return global;
}
/**
* Sugerowana cena za dobę pobytu: średnia ważona cen nocy pokrytych cennikiem,
* zaokrąglona do 2 miejsc. Null, gdy żadna noc nie jest pokryta.
*/
export function suggestPriceFromSeasons(
seasons: PriceSeason[],
cabinId: number,
arrival: string,
departure: string
): number | null {
const prices: number[] = [];
for (let night = arrival; night < departure; night = addDays(night, 1)) {
const price = priceForNight(seasons, cabinId, night);
if (price != null) prices.push(price);
}
if (prices.length === 0) return null;
return round2(prices.reduce((sum, p) => sum + p, 0) / prices.length);
}

View File

@@ -28,6 +28,9 @@ export interface Reservation {
meter_start: number | null;
/** Stan licznika energii na końcu pobytu (kWh) */
meter_end: number | null;
guest_id: number | null;
/** Czy pobyt został rozliczony z gościem */
settled: boolean;
created_at: string;
}
@@ -109,3 +112,40 @@ export interface RevenueStats {
nights_confirmed: number;
revenue_confirmed: number;
}
// --- Goście ---
export interface Guest {
id: number;
name: string;
address: string | null;
phone: string | null;
notes: string | null;
created_at: string;
}
/** Wiersz listy gości z agregacją pobytów. */
export interface GuestListRow extends Guest {
stay_count: number;
last_departure: string | null;
}
// --- Cennik sezonowy ---
export interface PriceSeason {
id: number;
/** null = obowiązuje wszystkie domki */
cabin_id: number | null;
/** Zakres [date_from, date_to) */
date_from: string;
date_to: string;
price: number;
}
export type PriceSeasonInput = Omit<PriceSeason, "id">;
// --- Raporty ---
export interface CabinReport extends RevenueStats {
cabin_id: number;
}

View File

@@ -7,6 +7,8 @@
const links = [
{ href: "/", label: "Grafik" },
{ href: "/faktury", label: "Faktury" },
{ href: "/goscie", label: "Goście" },
{ href: "/raporty", label: "Raporty" },
{ href: "/ustawienia", label: "Ustawienia" }
];
</script>
@@ -33,6 +35,7 @@
--muted: #6b6a66;
--accent: #2f6fb3;
--danger: #a32d2d;
--ok: #3b6d11;
--warn-text: #854f0b;
--weekend: #f1efe8;
--today: #e6f1fb;
@@ -51,6 +54,7 @@
--muted: #a3a29c;
--accent: #6aa5e0;
--danger: #f09595;
--ok: #97c459;
--warn-text: #fac775;
--weekend: #222220;
--today: #253647;

View File

@@ -1,7 +1,7 @@
<script lang="ts">
import { onMount } from "svelte";
import { ScheduleState } from "$lib/schedule.svelte";
import { MONTH_NAMES, todayISO } from "$lib/dates";
import { formatShort, MONTH_NAMES, todayISO } from "$lib/dates";
import ScheduleGrid from "$lib/components/ScheduleGrid.svelte";
import ReservationDialog from "$lib/components/ReservationDialog.svelte";
import CabinsDialog from "$lib/components/CabinsDialog.svelte";
@@ -50,6 +50,30 @@
function editReservation(r: Reservation) {
dialogParams = { reservation: r };
}
let moveError = $state<string | null>(null);
async function moveReservation(
r: Reservation,
cabinId: number,
arrival: string,
departure: string
) {
moveError = null;
try {
const conflicts = await db.findConflicts(cabinId, arrival, departure, r.id);
if (conflicts.length > 0) {
const c = conflicts[0];
moveError = `Nie można przenieść — termin koliduje z rezerwacją: ${c.guest_name} (${formatShort(c.arrival)}${formatShort(c.departure)}).`;
setTimeout(() => (moveError = null), 5000);
return;
}
await db.updateReservation(r.id, { ...r, cabin_id: cabinId, arrival, departure });
await schedule.reload();
} catch (e) {
moveError = `Nie udało się przenieść rezerwacji: ${e}`;
}
}
</script>
<svelte:head>
@@ -84,6 +108,10 @@
</div>
{/if}
{#if moveError}
<p class="error">{moveError}</p>
{/if}
{#if schedule.error}
<p class="error">{schedule.error}</p>
{:else if !schedule.loading && schedule.cabins.length === 0}
@@ -98,11 +126,13 @@
reservations={schedule.reservations}
onCellClick={(cabinId, date) => newReservation(cabinId, date)}
onReservationClick={editReservation}
onReservationMove={moveReservation}
/>
<footer class="legend">
<span><span class="sw confirmed"></span>Potwierdzona</span>
<span><span class="sw option"></span>Opcja (niepotwierdzona)</span>
<span class="hint">✓ = zaliczka wpłacona · kliknij pustą komórkę, aby dodać rezerwację</span>
<span><span class="sw ok"></span>Rozliczony</span>
<span class="hint">✓ = zaliczka · klik w pustą komórkę dodaje · przeciąganie paska przenosi pobyt</span>
</footer>
{/if}
</main>
@@ -248,6 +278,9 @@
.legend .sw.option {
background: var(--bar-option-bg);
}
.legend .sw.ok {
background: var(--ok);
}
.legend .hint {
margin-left: auto;
}

View File

@@ -0,0 +1,289 @@
<script lang="ts">
import { onMount } from "svelte";
import * as db from "$lib/db";
import { formatShort, nightsBetween } from "$lib/dates";
import { formatMoney } from "$lib/money";
import type { Cabin, GuestListRow, Reservation } from "$lib/types";
let guests = $state<GuestListRow[]>([]);
let cabins = $state<Cabin[]>([]);
let filter = $state("");
let loading = $state(true);
let error = $state<string | null>(null);
let expandedId = $state<number | null>(null);
let history = $state<Reservation[]>([]);
let filtered = $derived(
filter.trim()
? guests.filter((g) => {
const q = filter.trim().toLowerCase();
return (
g.name.toLowerCase().includes(q) ||
(g.phone ?? "").toLowerCase().includes(q) ||
(g.address ?? "").toLowerCase().includes(q)
);
})
: guests
);
async function reload() {
loading = true;
error = null;
try {
[guests, cabins] = await Promise.all([db.listGuests(), db.listCabins()]);
} catch (e) {
error = `Nie udało się wczytać gości: ${e}`;
} finally {
loading = false;
}
}
onMount(() => {
void reload();
});
function cabinName(id: number): string {
return cabins.find((c) => c.id === id)?.name ?? `Domek #${id}`;
}
async function toggleHistory(guest: GuestListRow) {
if (expandedId === guest.id) {
expandedId = null;
return;
}
try {
history = await db.getGuestReservations(guest.id);
expandedId = guest.id;
} catch (e) {
error = `Nie udało się wczytać historii: ${e}`;
}
}
async function persist(guest: GuestListRow) {
if (!guest.name.trim()) return;
error = null;
try {
await db.updateGuest({ ...guest, name: guest.name.trim() });
} catch (e) {
error = `Nie udało się zapisać gościa: ${e}`;
}
}
async function remove(guest: GuestListRow) {
if (
!confirm(
`Usunąć gościa ${guest.name}? Rezerwacje zostaną zachowane, ale stracą powiązanie z gościem.`
)
) {
return;
}
try {
await db.deleteGuest(guest.id);
if (expandedId === guest.id) expandedId = null;
await reload();
} catch (e) {
error = `Nie udało się usunąć gościa: ${e}`;
}
}
</script>
<svelte:head>
<title>Słoneczko — goście</title>
</svelte:head>
<main>
<header class="toolbar">
<h1>Goście</h1>
<input type="search" bind:value={filter} placeholder="Szukaj po nazwisku, telefonie, adresie…" />
</header>
{#if error}
<p class="error">{error}</p>
{/if}
{#if !loading && guests.length === 0}
<div class="empty">
<p>Baza gości jest pusta — wypełni się automatycznie przy dodawaniu rezerwacji.</p>
</div>
{:else if filtered.length > 0}
<table>
<thead>
<tr>
<th>Imię i nazwisko</th>
<th>Telefon</th>
<th>Adres</th>
<th class="num">Pobyty</th>
<th>Ostatni pobyt</th>
<th></th>
</tr>
</thead>
<tbody>
{#each filtered as guest (guest.id)}
<tr>
<td>
<input type="text" bind:value={guest.name} onchange={() => persist(guest)} aria-label="Imię i nazwisko" />
</td>
<td>
<input type="text" bind:value={guest.phone} onchange={() => persist(guest)} aria-label="Telefon" placeholder="—" />
</td>
<td>
<input type="text" bind:value={guest.address} onchange={() => persist(guest)} aria-label="Adres" placeholder="—" />
</td>
<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}>
{expandedId === guest.id ? "Zwiń" : "Historia"}
</button>
<button type="button" class="danger" onclick={() => remove(guest)}>Usuń</button>
</td>
</tr>
{#if expandedId === guest.id}
<tr class="history-row">
<td colspan="6">
<div class="history">
{#each history as r (r.id)}
<div class="stay">
<span class="strong">{cabinName(r.cabin_id)}</span>
<span>{formatShort(r.arrival)}{formatShort(r.departure)}.{r.departure.slice(0, 4)}</span>
<span>{nightsBetween(r.arrival, r.departure)} dób</span>
{#if r.price_per_night != null}
<span>{formatMoney(nightsBetween(r.arrival, r.departure) * r.price_per_night)}</span>
{/if}
<span class="muted">{r.status === "option" ? "opcja" : r.settled ? "rozliczony" : "potwierdzona"}</span>
</div>
{/each}
</div>
</td>
</tr>
{/if}
{/each}
</tbody>
</table>
{:else}
<p class="hint">Brak wyników dla „{filter}".</p>
{/if}
</main>
<style>
main {
padding: 14px 16px;
}
.toolbar {
display: flex;
align-items: center;
gap: 14px;
margin-bottom: 14px;
}
h1 {
margin: 0;
font-size: 18px;
}
.toolbar input[type="search"] {
flex: 1;
max-width: 360px;
font: inherit;
font-size: 14px;
color: var(--text);
background: var(--surface);
border: 1px solid var(--border);
border-radius: 7px;
padding: 7px 11px;
}
.error {
color: var(--danger);
}
.hint {
color: var(--muted);
font-size: 13px;
}
.empty {
padding: 60px 20px;
text-align: center;
border: 1px dashed var(--border);
border-radius: 10px;
color: var(--muted);
}
table {
width: 100%;
border-collapse: collapse;
background: var(--surface);
border: 1px solid var(--border);
border-radius: 10px;
overflow: hidden;
}
th,
td {
padding: 7px 10px;
text-align: left;
font-size: 13px;
border-top: 1px solid var(--border);
}
thead th {
border-top: none;
font-size: 11px;
color: var(--muted);
font-weight: 600;
}
td input {
width: 100%;
font: inherit;
font-size: 13px;
color: var(--text);
background: transparent;
border: 1px solid transparent;
border-radius: 5px;
padding: 4px 6px;
}
td input:hover,
td input:focus {
background: var(--bg);
border-color: var(--border);
}
.num {
text-align: right;
}
.strong {
font-weight: 600;
}
.muted {
color: var(--muted);
}
.row-actions {
display: flex;
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);
}
.history {
display: flex;
flex-direction: column;
gap: 4px;
padding: 4px 6px;
}
.stay {
display: flex;
gap: 16px;
font-size: 12px;
}
</style>

View File

@@ -0,0 +1,329 @@
<script lang="ts">
import { onMount } from "svelte";
import * as db from "$lib/db";
import { daysInMonth, isoDate, MONTH_NAMES } from "$lib/dates";
import { formatMoney } from "$lib/money";
import type { Cabin, CabinReport, RevenueStats } from "$lib/types";
let year = $state(new Date().getFullYear());
let cabins = $state<Cabin[]>([]);
let yearStats = $state<RevenueStats | null>(null);
let cabinRows = $state<CabinReport[]>([]);
let monthRows = $state<RevenueStats[]>([]);
let loading = $state(true);
let error = $state<string | null>(null);
let isLeap = $derived(new Date(year, 1, 29).getDate() === 29);
let daysInYear = $derived(isLeap ? 366 : 365);
async function reload() {
loading = true;
error = null;
try {
const from = isoDate(year, 0, 1);
const to = isoDate(year + 1, 0, 1);
const monthRanges = Array.from({ length: 12 }, (_, m) => ({
from: isoDate(year, m, 1),
to: m === 11 ? isoDate(year + 1, 0, 1) : isoDate(year, m + 1, 1)
}));
const [c, ys, cr, ...months] = await Promise.all([
db.listCabins(),
db.revenueStats(from, to),
db.cabinReports(from, to),
...monthRanges.map((r) => db.revenueStats(r.from, r.to))
]);
cabins = c;
yearStats = ys;
cabinRows = cr;
monthRows = months;
} catch (e) {
error = `Nie udało się wczytać raportu: ${e}`;
} finally {
loading = false;
}
}
onMount(() => {
void reload();
});
function changeYear(delta: number) {
year += delta;
void reload();
}
function cabinName(id: number): string {
return cabins.find((c) => c.id === id)?.name ?? `Domek #${id}`;
}
function occupancy(nights: number, availableNights: number): string {
if (availableNights <= 0) return "—";
return `${Math.round((nights / availableNights) * 100)}%`;
}
let maxMonthRevenue = $derived(Math.max(1, ...monthRows.map((m) => m.revenue_total)));
</script>
<svelte:head>
<title>Słoneczko — raporty</title>
</svelte:head>
<main>
<header class="toolbar">
<h1>Raporty</h1>
<span class="spacer"></span>
<button type="button" class="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>
</header>
{#if error}
<p class="error">{error}</p>
{:else if yearStats}
<div class="cards">
<div class="card">
<span class="label">Doby (wszystkie)</span>
<span class="value">{Math.round(yearStats.nights_total)}</span>
</div>
<div class="card">
<span class="label">Obłożenie</span>
<span class="value">{occupancy(yearStats.nights_total, cabins.length * daysInYear)}</span>
</div>
<div class="card">
<span class="label">Przychód potencjalny</span>
<span class="value">{formatMoney(yearStats.revenue_total)}</span>
</div>
<div class="card">
<span class="label">Przychód potwierdzony</span>
<span class="value">{formatMoney(yearStats.revenue_confirmed)}</span>
</div>
</div>
<section>
<h2>Przychód miesięczny</h2>
<div class="chart">
{#each monthRows as m, i (i)}
<div class="col">
<span class="amount">{m.revenue_total > 0 ? formatMoney(m.revenue_total) : ""}</span>
<div class="barwrap">
<div
class="bar"
style="height: {(m.revenue_total / maxMonthRevenue) * 100}%"
title="{MONTH_NAMES[i]}: {formatMoney(m.revenue_total)} zł (potwierdzone {formatMoney(m.revenue_confirmed)} zł)"
>
<div
class="bar confirmed-part"
style="height: {m.revenue_total > 0 ? (m.revenue_confirmed / m.revenue_total) * 100 : 0}%"
></div>
</div>
</div>
<span class="m">{MONTH_NAMES[i].slice(0, 3)}</span>
</div>
{/each}
</div>
<p class="hint">Ciemniejsza część słupka to przychód z rezerwacji potwierdzonych.</p>
</section>
<div class="two-cols">
<section>
<h2>Wg domków</h2>
<table>
<thead>
<tr>
<th>Domek</th>
<th class="num">Doby</th>
<th class="num">Obłożenie</th>
<th class="num">Przychód</th>
</tr>
</thead>
<tbody>
{#each cabins as cabin (cabin.id)}
{@const row = cabinRows.find((r) => r.cabin_id === cabin.id)}
<tr>
<td>{cabin.name}</td>
<td class="num">{row ? Math.round(row.nights_total) : 0}</td>
<td class="num">{occupancy(row?.nights_total ?? 0, daysInYear)}</td>
<td class="num">{formatMoney(row?.revenue_total ?? 0)}</td>
</tr>
{/each}
</tbody>
</table>
</section>
<section>
<h2>Wg miesięcy</h2>
<table>
<thead>
<tr>
<th>Miesiąc</th>
<th class="num">Doby</th>
<th class="num">Obłożenie</th>
<th class="num">Przychód</th>
</tr>
</thead>
<tbody>
{#each monthRows as m, i (i)}
<tr>
<td>{MONTH_NAMES[i]}</td>
<td class="num">{Math.round(m.nights_total)}</td>
<td class="num">{occupancy(m.nights_total, cabins.length * daysInMonth(year, i))}</td>
<td class="num">{formatMoney(m.revenue_total)}</td>
</tr>
{/each}
</tbody>
</table>
</section>
</div>
{:else if loading}
<p class="hint">Wczytywanie…</p>
{/if}
</main>
<style>
main {
padding: 14px 16px;
max-width: 980px;
}
.toolbar {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 14px;
}
h1 {
margin: 0;
font-size: 18px;
}
.spacer {
flex: 1;
}
.year {
min-width: 60px;
text-align: center;
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);
}
.hint {
color: var(--muted);
font-size: 12px;
margin: 8px 0 0;
}
.cards {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 10px;
margin-bottom: 14px;
}
.card {
display: flex;
flex-direction: column;
gap: 4px;
background: var(--surface);
border: 1px solid var(--border);
border-radius: 10px;
padding: 12px 14px;
}
.card .label {
font-size: 11px;
color: var(--muted);
}
.card .value {
font-size: 20px;
font-weight: 600;
}
section {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 10px;
padding: 14px 16px;
margin-bottom: 14px;
}
h2 {
margin: 0 0 12px;
font-size: 14px;
}
.chart {
display: flex;
gap: 8px;
align-items: stretch;
height: 180px;
}
.col {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
min-width: 0;
}
.amount {
font-size: 10px;
color: var(--muted);
white-space: nowrap;
overflow: hidden;
max-width: 100%;
}
.barwrap {
flex: 1;
width: 100%;
display: flex;
align-items: flex-end;
}
.chart .bar {
width: 100%;
background: var(--bar-confirmed-bg);
border-radius: 4px 4px 0 0;
position: relative;
display: flex;
align-items: flex-end;
}
.chart .bar .confirmed-part {
background: var(--accent);
border-radius: 0;
}
.m {
font-size: 11px;
color: var(--muted);
}
.two-cols {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 14px;
align-items: start;
}
table {
width: 100%;
border-collapse: collapse;
}
th,
td {
padding: 6px 8px;
text-align: left;
font-size: 13px;
border-top: 1px solid var(--border);
}
thead th {
border-top: none;
font-size: 11px;
color: var(--muted);
font-weight: 600;
}
.num {
text-align: right;
}
</style>

View File

@@ -1,20 +1,110 @@
<script lang="ts">
import { onMount } from "svelte";
import { save as saveDialog } from "@tauri-apps/plugin-dialog";
import * as db from "$lib/db";
import type { Settings } from "$lib/types";
import { todayISO } from "$lib/dates";
import type { Cabin, PriceSeason, Settings } from "$lib/types";
let settings = $state<Settings | null>(null);
let cabins = $state<Cabin[]>([]);
let seasons = $state<PriceSeason[]>([]);
let error = $state<string | null>(null);
let savedFlash = $state(false);
let backupInfo = $state<string | null>(null);
let newSeason = $state<{ cabin_id: number | null; date_from: string; date_to: string; price: number | null }>({
cabin_id: null,
date_from: "",
date_to: "",
price: null
});
onMount(async () => {
try {
settings = await db.getSettings();
[settings, cabins, seasons] = await Promise.all([
db.getSettings(),
db.listCabins(),
db.listPriceSeasons()
]);
} catch (e) {
error = `Nie udało się wczytać ustawień: ${e}`;
}
});
function cabinName(id: number | null): string {
if (id == null) return "Wszystkie domki";
return cabins.find((c) => c.id === id)?.name ?? `Domek #${id}`;
}
async function backup() {
error = null;
backupInfo = null;
try {
const path = await saveDialog({
title: "Zapisz kopię zapasową bazy",
defaultPath: `sloneczko-${todayISO()}.db`,
filters: [{ name: "Baza SQLite", extensions: ["db"] }]
});
if (!path) return;
await db.backupTo(path);
backupInfo = `Kopia zapisana: ${path}`;
} catch (e) {
error = `Nie udało się zapisać kopii: ${e}`;
}
}
async function reloadSeasons() {
seasons = await db.listPriceSeasons();
}
async function addSeason(event: SubmitEvent) {
event.preventDefault();
error = null;
if (!newSeason.date_from || !newSeason.date_to || newSeason.price == null) return;
if (newSeason.date_to <= newSeason.date_from) {
error = "Koniec okresu cennika musi być późniejszy niż początek.";
return;
}
try {
await db.createPriceSeason({
cabin_id: newSeason.cabin_id,
date_from: newSeason.date_from,
date_to: newSeason.date_to,
price: newSeason.price
});
newSeason = { cabin_id: null, date_from: "", date_to: "", price: null };
await reloadSeasons();
} catch (e) {
error = `Nie udało się dodać okresu: ${e}`;
}
}
async function persistSeason(season: PriceSeason) {
error = null;
if (season.date_to <= season.date_from) {
error = "Koniec okresu cennika musi być późniejszy niż początek.";
return;
}
try {
await db.updatePriceSeason(season);
await reloadSeasons();
} catch (e) {
error = `Nie udało się zapisać okresu: ${e}`;
}
}
async function removeSeason(season: PriceSeason) {
if (!confirm(`Usunąć okres ${season.date_from} ${season.date_to} (${cabinName(season.cabin_id)})?`)) {
return;
}
try {
await db.deletePriceSeason(season.id);
await reloadSeasons();
} catch (e) {
error = `Nie udało się usunąć okresu: ${e}`;
}
}
async function save(event: SubmitEvent) {
event.preventDefault();
if (!settings) return;
@@ -114,6 +204,61 @@
<button type="submit" class="primary">Zapisz ustawienia</button>
</div>
</form>
<section>
<h2>Cennik sezonowy</h2>
{#if seasons.length > 0}
<div class="seasons">
<span class="sh">Domek</span>
<span class="sh">Od</span>
<span class="sh">Do (wyłącznie)</span>
<span class="sh">Cena/doba</span>
<span class="sh"></span>
{#each seasons as season (season.id)}
<select bind:value={season.cabin_id} onchange={() => persistSeason(season)} aria-label="Domek">
<option value={null}>Wszystkie domki</option>
{#each cabins as c (c.id)}
<option value={c.id}>{c.name}</option>
{/each}
</select>
<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>
{/each}
</div>
{:else}
<p class="hint">Brak okresów — dodaj pierwszy poniżej, a cena za dobę będzie podpowiadana przy rezerwacji.</p>
{/if}
<form class="seasons add" onsubmit={addSeason}>
<select bind:value={newSeason.cabin_id} aria-label="Domek">
<option value={null}>Wszystkie domki</option>
{#each cabins as c (c.id)}
<option value={c.id}>{c.name}</option>
{/each}
</select>
<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>
</form>
<p class="hint">
Cennik konkretnego domku ma pierwszeństwo przed cennikiem „Wszystkie domki". Przy pobycie na
przełomie okresów podpowiadana jest średnia z nocy.
</p>
</section>
<section>
<h2>Kopia zapasowa</h2>
<p class="hint">
Przy każdym uruchomieniu aplikacja zapisuje automatyczną kopię bazy (katalog „backups" obok
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>
{#if backupInfo}<span class="saved">{backupInfo}</span>{/if}
</div>
</section>
{/if}
</main>
@@ -152,7 +297,8 @@
label.wide {
grid-column: 1 / -1;
}
input {
input,
select {
font: inherit;
font-size: 14px;
color: var(--text);
@@ -160,6 +306,36 @@
border: 1px solid var(--border);
border-radius: 6px;
padding: 7px 9px;
min-width: 0;
}
.seasons {
display: grid;
grid-template-columns: 1.2fr 1fr 1fr 90px auto;
gap: 6px 8px;
align-items: center;
}
.seasons.add {
margin-top: 10px;
padding-top: 10px;
border-top: 1px solid var(--border);
}
.sh {
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;