Dodanie raportów, dnd, listy gości i innych.
This commit is contained in:
10
package-lock.json
generated
10
package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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
147
src-tauri/Cargo.lock
generated
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
"core:default",
|
||||
"opener:default",
|
||||
"sql:default",
|
||||
"sql:allow-execute"
|
||||
"sql:allow-execute",
|
||||
"dialog:default"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
|
||||
157
src/lib/components/GuestField.svelte
Normal file
157
src/lib/components/GuestField.svelte
Normal 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>
|
||||
@@ -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} zł ${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;
|
||||
|
||||
@@ -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)} zł</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)} zł</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;
|
||||
|
||||
@@ -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}
|
||||
|
||||
221
src/lib/db.ts
221
src/lib/db.ts
@@ -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
37
src/lib/pricing.ts
Normal 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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
289
src/routes/goscie/+page.svelte
Normal file
289
src/routes/goscie/+page.svelte
Normal 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)} zł</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>
|
||||
329
src/routes/raporty/+page.svelte
Normal file
329
src/routes/raporty/+page.svelte
Normal 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)} zł</span>
|
||||
</div>
|
||||
<div class="card">
|
||||
<span class="label">Przychód potwierdzony</span>
|
||||
<span class="value">{formatMoney(yearStats.revenue_confirmed)} zł</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)} zł</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)} zł</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>
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user