Init
11
.gitignore
vendored
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
.DS_Store
|
||||||
|
node_modules
|
||||||
|
/build
|
||||||
|
/.svelte-kit
|
||||||
|
/package
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
|
vite.config.js.timestamp-*
|
||||||
|
vite.config.ts.timestamp-*
|
||||||
|
/faktury
|
||||||
7
.vscode/extensions.json
vendored
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"recommendations": [
|
||||||
|
"svelte.svelte-vscode",
|
||||||
|
"tauri-apps.tauri-vscode",
|
||||||
|
"rust-lang.rust-analyzer"
|
||||||
|
]
|
||||||
|
}
|
||||||
3
.vscode/settings.json
vendored
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"svelte.enable-ts-plugin": true
|
||||||
|
}
|
||||||
7
README.md
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
# Tauri + SvelteKit + TypeScript
|
||||||
|
|
||||||
|
This template should help get you started developing with Tauri, SvelteKit and TypeScript in Vite.
|
||||||
|
|
||||||
|
## Recommended IDE Setup
|
||||||
|
|
||||||
|
[VS Code](https://code.visualstudio.com/) + [Svelte](https://marketplace.visualstudio.com/items?itemName=svelte.svelte-vscode) + [Tauri](https://marketplace.visualstudio.com/items?itemName=tauri-apps.tauri-vscode) + [rust-analyzer](https://marketplace.visualstudio.com/items?itemName=rust-lang.rust-analyzer).
|
||||||
1898
package-lock.json
generated
Normal file
30
package.json
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
{
|
||||||
|
"name": "sloneczko-menager",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"description": "",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite dev",
|
||||||
|
"build": "vite build",
|
||||||
|
"preview": "vite preview",
|
||||||
|
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
||||||
|
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
|
||||||
|
"tauri": "tauri"
|
||||||
|
},
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@tauri-apps/api": "^2",
|
||||||
|
"@tauri-apps/plugin-opener": "^2",
|
||||||
|
"@tauri-apps/plugin-sql": "^2.4.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@sveltejs/adapter-static": "^3.0.6",
|
||||||
|
"@sveltejs/kit": "^2.9.0",
|
||||||
|
"@sveltejs/vite-plugin-svelte": "^5.0.0",
|
||||||
|
"@tauri-apps/cli": "^2",
|
||||||
|
"svelte": "^5.0.0",
|
||||||
|
"svelte-check": "^4.0.0",
|
||||||
|
"typescript": "~5.6.2",
|
||||||
|
"vite": "^6.0.3"
|
||||||
|
}
|
||||||
|
}
|
||||||
7
src-tauri/.gitignore
vendored
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
# Generated by Cargo
|
||||||
|
# will have compiled files and executables
|
||||||
|
/target/
|
||||||
|
|
||||||
|
# Generated by Tauri
|
||||||
|
# will have schema files for capabilities auto-completion
|
||||||
|
/gen/schemas
|
||||||
5888
src-tauri/Cargo.lock
generated
Normal file
26
src-tauri/Cargo.toml
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
[package]
|
||||||
|
name = "sloneczko-menager"
|
||||||
|
version = "0.1.0"
|
||||||
|
description = "A Tauri App"
|
||||||
|
authors = ["you"]
|
||||||
|
edition = "2021"
|
||||||
|
|
||||||
|
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
||||||
|
|
||||||
|
[lib]
|
||||||
|
# The `_lib` suffix may seem redundant but it is necessary
|
||||||
|
# to make the lib name unique and wouldn't conflict with the bin name.
|
||||||
|
# This seems to be only an issue on Windows, see https://github.com/rust-lang/cargo/issues/8519
|
||||||
|
name = "sloneczko_menager_lib"
|
||||||
|
crate-type = ["staticlib", "cdylib", "rlib"]
|
||||||
|
|
||||||
|
[build-dependencies]
|
||||||
|
tauri-build = { version = "2", features = [] }
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
tauri = { version = "2", features = [] }
|
||||||
|
tauri-plugin-opener = "2"
|
||||||
|
serde = { version = "1", features = ["derive"] }
|
||||||
|
serde_json = "1"
|
||||||
|
tauri-plugin-sql = { version = "2.4.0", features = ["sqlite"] }
|
||||||
|
|
||||||
3
src-tauri/build.rs
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
fn main() {
|
||||||
|
tauri_build::build()
|
||||||
|
}
|
||||||
12
src-tauri/capabilities/default.json
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"$schema": "../gen/schemas/desktop-schema.json",
|
||||||
|
"identifier": "default",
|
||||||
|
"description": "Capability for the main window",
|
||||||
|
"windows": ["main"],
|
||||||
|
"permissions": [
|
||||||
|
"core:default",
|
||||||
|
"opener:default",
|
||||||
|
"sql:default",
|
||||||
|
"sql:allow-execute"
|
||||||
|
]
|
||||||
|
}
|
||||||
BIN
src-tauri/icons/128x128.png
Normal file
|
After Width: | Height: | Size: 3.4 KiB |
BIN
src-tauri/icons/128x128@2x.png
Normal file
|
After Width: | Height: | Size: 6.8 KiB |
BIN
src-tauri/icons/32x32.png
Normal file
|
After Width: | Height: | Size: 974 B |
BIN
src-tauri/icons/Square107x107Logo.png
Normal file
|
After Width: | Height: | Size: 2.8 KiB |
BIN
src-tauri/icons/Square142x142Logo.png
Normal file
|
After Width: | Height: | Size: 3.8 KiB |
BIN
src-tauri/icons/Square150x150Logo.png
Normal file
|
After Width: | Height: | Size: 3.9 KiB |
BIN
src-tauri/icons/Square284x284Logo.png
Normal file
|
After Width: | Height: | Size: 7.6 KiB |
BIN
src-tauri/icons/Square30x30Logo.png
Normal file
|
After Width: | Height: | Size: 903 B |
BIN
src-tauri/icons/Square310x310Logo.png
Normal file
|
After Width: | Height: | Size: 8.4 KiB |
BIN
src-tauri/icons/Square44x44Logo.png
Normal file
|
After Width: | Height: | Size: 1.3 KiB |
BIN
src-tauri/icons/Square71x71Logo.png
Normal file
|
After Width: | Height: | Size: 2.0 KiB |
BIN
src-tauri/icons/Square89x89Logo.png
Normal file
|
After Width: | Height: | Size: 2.4 KiB |
BIN
src-tauri/icons/StoreLogo.png
Normal file
|
After Width: | Height: | Size: 1.5 KiB |
BIN
src-tauri/icons/icon.icns
Normal file
BIN
src-tauri/icons/icon.ico
Normal file
|
After Width: | Height: | Size: 85 KiB |
BIN
src-tauri/icons/icon.png
Normal file
|
After Width: | Height: | Size: 14 KiB |
109
src-tauri/src/lib.rs
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
use tauri_plugin_sql::{Migration, MigrationKind};
|
||||||
|
|
||||||
|
fn migrations() -> Vec<Migration> {
|
||||||
|
vec![Migration {
|
||||||
|
version: 1,
|
||||||
|
description: "create_cabins_and_reservations",
|
||||||
|
sql: "
|
||||||
|
CREATE TABLE cabins (
|
||||||
|
id INTEGER PRIMARY KEY,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
capacity INTEGER,
|
||||||
|
sort_order INTEGER NOT NULL DEFAULT 0
|
||||||
|
);
|
||||||
|
CREATE TABLE reservations (
|
||||||
|
id INTEGER PRIMARY KEY,
|
||||||
|
cabin_id INTEGER NOT NULL REFERENCES cabins(id) ON DELETE CASCADE,
|
||||||
|
guest_name TEXT NOT NULL,
|
||||||
|
address TEXT,
|
||||||
|
phone TEXT,
|
||||||
|
num_guests INTEGER,
|
||||||
|
deposit_amount REAL,
|
||||||
|
deposit_paid INTEGER NOT NULL DEFAULT 0,
|
||||||
|
status TEXT NOT NULL DEFAULT 'confirmed',
|
||||||
|
arrival TEXT NOT NULL,
|
||||||
|
departure TEXT NOT NULL,
|
||||||
|
notes TEXT,
|
||||||
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
CHECK (departure > arrival)
|
||||||
|
);
|
||||||
|
CREATE INDEX idx_reservations_cabin ON reservations (cabin_id, arrival, departure);
|
||||||
|
",
|
||||||
|
kind: MigrationKind::Up,
|
||||||
|
},
|
||||||
|
Migration {
|
||||||
|
version: 2,
|
||||||
|
description: "add_pricing_meters_settings_invoices",
|
||||||
|
sql: "
|
||||||
|
ALTER TABLE reservations ADD COLUMN price_per_night REAL;
|
||||||
|
ALTER TABLE reservations ADD COLUMN meter_start REAL;
|
||||||
|
ALTER TABLE reservations ADD COLUMN meter_end REAL;
|
||||||
|
|
||||||
|
CREATE TABLE settings (
|
||||||
|
id INTEGER PRIMARY KEY CHECK (id = 1),
|
||||||
|
company_name TEXT NOT NULL DEFAULT '',
|
||||||
|
first_name TEXT NOT NULL DEFAULT '',
|
||||||
|
last_name TEXT NOT NULL DEFAULT '',
|
||||||
|
address TEXT NOT NULL DEFAULT '',
|
||||||
|
nip TEXT,
|
||||||
|
bank_account TEXT NOT NULL DEFAULT '',
|
||||||
|
bank_name TEXT NOT NULL DEFAULT '',
|
||||||
|
issue_place TEXT NOT NULL DEFAULT '',
|
||||||
|
default_pkwiu TEXT NOT NULL DEFAULT '55.20',
|
||||||
|
default_vat_rate REAL NOT NULL DEFAULT 8,
|
||||||
|
energy_price REAL
|
||||||
|
);
|
||||||
|
INSERT INTO settings (id) VALUES (1);
|
||||||
|
|
||||||
|
CREATE TABLE invoices (
|
||||||
|
id INTEGER PRIMARY KEY,
|
||||||
|
invoice_number TEXT NOT NULL UNIQUE,
|
||||||
|
reservation_id INTEGER REFERENCES reservations(id) ON DELETE SET NULL,
|
||||||
|
issue_date TEXT NOT NULL,
|
||||||
|
sale_date TEXT NOT NULL,
|
||||||
|
payment_method TEXT NOT NULL DEFAULT 'CASH',
|
||||||
|
payment_card REAL,
|
||||||
|
payment_cash REAL,
|
||||||
|
payment_due TEXT NOT NULL,
|
||||||
|
buyer_name TEXT NOT NULL,
|
||||||
|
buyer_address TEXT,
|
||||||
|
buyer_nip TEXT,
|
||||||
|
seller_company TEXT NOT NULL DEFAULT '',
|
||||||
|
seller_person TEXT NOT NULL DEFAULT '',
|
||||||
|
seller_address TEXT NOT NULL DEFAULT '',
|
||||||
|
seller_nip TEXT,
|
||||||
|
seller_bank_account TEXT NOT NULL DEFAULT '',
|
||||||
|
seller_bank_name TEXT NOT NULL DEFAULT '',
|
||||||
|
issue_place TEXT NOT NULL DEFAULT '',
|
||||||
|
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||||
|
);
|
||||||
|
CREATE INDEX idx_invoices_reservation ON invoices (reservation_id);
|
||||||
|
|
||||||
|
CREATE TABLE invoice_items (
|
||||||
|
id INTEGER PRIMARY KEY,
|
||||||
|
invoice_id INTEGER NOT NULL REFERENCES invoices(id) ON DELETE CASCADE,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
pkwiu TEXT,
|
||||||
|
quantity REAL NOT NULL,
|
||||||
|
unit_price REAL NOT NULL,
|
||||||
|
vat_rate REAL NOT NULL,
|
||||||
|
position INTEGER NOT NULL DEFAULT 0
|
||||||
|
);
|
||||||
|
CREATE INDEX idx_invoice_items_invoice ON invoice_items (invoice_id);
|
||||||
|
",
|
||||||
|
kind: MigrationKind::Up,
|
||||||
|
}]
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||||
|
pub fn run() {
|
||||||
|
tauri::Builder::default()
|
||||||
|
.plugin(
|
||||||
|
tauri_plugin_sql::Builder::default()
|
||||||
|
.add_migrations("sqlite:sloneczko.db", migrations())
|
||||||
|
.build(),
|
||||||
|
)
|
||||||
|
.plugin(tauri_plugin_opener::init())
|
||||||
|
.run(tauri::generate_context!())
|
||||||
|
.expect("error while running tauri application");
|
||||||
|
}
|
||||||
6
src-tauri/src/main.rs
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
// Prevents additional console window on Windows in release, DO NOT REMOVE!!
|
||||||
|
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||||
|
|
||||||
|
fn main() {
|
||||||
|
sloneczko_menager_lib::run()
|
||||||
|
}
|
||||||
35
src-tauri/tauri.conf.json
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://schema.tauri.app/config/2",
|
||||||
|
"productName": "sloneczko-menager",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"identifier": "pl.kciolek",
|
||||||
|
"build": {
|
||||||
|
"beforeDevCommand": "npm run dev",
|
||||||
|
"devUrl": "http://localhost:1420",
|
||||||
|
"beforeBuildCommand": "npm run build",
|
||||||
|
"frontendDist": "../build"
|
||||||
|
},
|
||||||
|
"app": {
|
||||||
|
"windows": [
|
||||||
|
{
|
||||||
|
"title": "sloneczko-menager",
|
||||||
|
"width": 800,
|
||||||
|
"height": 600
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"security": {
|
||||||
|
"csp": null
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"bundle": {
|
||||||
|
"active": true,
|
||||||
|
"targets": "all",
|
||||||
|
"icon": [
|
||||||
|
"icons/32x32.png",
|
||||||
|
"icons/128x128.png",
|
||||||
|
"icons/128x128@2x.png",
|
||||||
|
"icons/icon.icns",
|
||||||
|
"icons/icon.ico"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
13
src/app.html
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<link rel="icon" href="%sveltekit.assets%/favicon.png" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<title>Tauri + SvelteKit + Typescript App</title>
|
||||||
|
%sveltekit.head%
|
||||||
|
</head>
|
||||||
|
<body data-sveltekit-preload-data="hover">
|
||||||
|
<div style="display: contents">%sveltekit.body%</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
214
src/lib/components/CabinsDialog.svelte
Normal file
@@ -0,0 +1,214 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { Cabin } from "$lib/types";
|
||||||
|
import * as db from "$lib/db";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
cabins: Cabin[];
|
||||||
|
onclose: () => void;
|
||||||
|
onchanged: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { cabins, onclose, onchanged }: Props = $props();
|
||||||
|
|
||||||
|
let newName = $state("");
|
||||||
|
let newCapacity = $state<number | null>(null);
|
||||||
|
let error = $state<string | null>(null);
|
||||||
|
|
||||||
|
function showModal(el: HTMLDialogElement) {
|
||||||
|
el.showModal();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function add(event: SubmitEvent) {
|
||||||
|
event.preventDefault();
|
||||||
|
error = null;
|
||||||
|
const name = newName.trim();
|
||||||
|
if (!name) return;
|
||||||
|
try {
|
||||||
|
await db.createCabin(
|
||||||
|
name,
|
||||||
|
newCapacity == null || Number.isNaN(newCapacity) ? null : newCapacity
|
||||||
|
);
|
||||||
|
newName = "";
|
||||||
|
newCapacity = null;
|
||||||
|
onchanged();
|
||||||
|
} catch (e) {
|
||||||
|
error = `Nie udało się dodać domku: ${e}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function persist(cabin: Cabin) {
|
||||||
|
error = null;
|
||||||
|
if (!cabin.name.trim()) return;
|
||||||
|
try {
|
||||||
|
await db.updateCabin({ ...cabin, name: cabin.name.trim() });
|
||||||
|
onchanged();
|
||||||
|
} catch (e) {
|
||||||
|
error = `Nie udało się zapisać zmian: ${e}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function move(index: number, dir: -1 | 1) {
|
||||||
|
const other = index + dir;
|
||||||
|
if (other < 0 || other >= cabins.length) return;
|
||||||
|
const a = cabins[index];
|
||||||
|
const b = cabins[other];
|
||||||
|
// sort_order może się powtarzać — nadaj jednoznaczne wartości wg pozycji na liście
|
||||||
|
try {
|
||||||
|
await db.updateCabin({ ...a, sort_order: other + 1 });
|
||||||
|
await db.updateCabin({ ...b, sort_order: index + 1 });
|
||||||
|
onchanged();
|
||||||
|
} catch (e) {
|
||||||
|
error = `Nie udało się zmienić kolejności: ${e}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function remove(cabin: Cabin) {
|
||||||
|
if (!confirm(`Usunąć domek „${cabin.name}"? Usunie to również wszystkie jego rezerwacje.`)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await db.deleteCabin(cabin.id);
|
||||||
|
onchanged();
|
||||||
|
} catch (e) {
|
||||||
|
error = `Nie udało się usunąć domku: ${e}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<dialog {@attach showModal} {onclose}>
|
||||||
|
<h2>Domki</h2>
|
||||||
|
|
||||||
|
{#if cabins.length === 0}
|
||||||
|
<p class="hint">Nie ma jeszcze żadnych domków — dodaj pierwszy poniżej.</p>
|
||||||
|
{:else}
|
||||||
|
<div class="list">
|
||||||
|
<span class="head">Nazwa</span>
|
||||||
|
<span class="head">Pojemność</span>
|
||||||
|
<span class="head"></span>
|
||||||
|
{#each cabins as cabin, i (cabin.id)}
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
bind:value={cabin.name}
|
||||||
|
onchange={() => persist(cabin)}
|
||||||
|
aria-label="Nazwa domku"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
bind:value={cabin.capacity}
|
||||||
|
onchange={() => persist(cabin)}
|
||||||
|
aria-label="Pojemność domku"
|
||||||
|
placeholder="—"
|
||||||
|
/>
|
||||||
|
<span class="row-actions">
|
||||||
|
<button type="button" onclick={() => move(i, -1)} disabled={i === 0} aria-label="Przesuń wyżej">↑</button>
|
||||||
|
<button type="button" onclick={() => move(i, 1)} disabled={i === cabins.length - 1} aria-label="Przesuń niżej">↓</button>
|
||||||
|
<button type="button" class="danger" onclick={() => remove(cabin)}>Usuń</button>
|
||||||
|
</span>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<form class="add" onsubmit={add}>
|
||||||
|
<input type="text" bind:value={newName} placeholder="Nazwa nowego domku" required />
|
||||||
|
<input type="number" min="1" bind:value={newCapacity} placeholder="Osób" />
|
||||||
|
<button type="submit" class="primary">Dodaj</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{#if error}
|
||||||
|
<p class="error">{error}</p>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="actions">
|
||||||
|
<button type="button" onclick={onclose}>Zamknij</button>
|
||||||
|
</div>
|
||||||
|
</dialog>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
dialog {
|
||||||
|
width: min(480px, calc(100vw - 32px));
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 20px;
|
||||||
|
background: var(--surface);
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
dialog::backdrop {
|
||||||
|
background: rgb(0 0 0 / 0.35);
|
||||||
|
}
|
||||||
|
h2 {
|
||||||
|
margin: 0 0 14px;
|
||||||
|
font-size: 17px;
|
||||||
|
}
|
||||||
|
.hint {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
.list {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 80px auto;
|
||||||
|
gap: 6px 8px;
|
||||||
|
align-items: center;
|
||||||
|
max-height: 320px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
.head {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
input {
|
||||||
|
font: inherit;
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--text);
|
||||||
|
background: var(--bg);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 6px 8px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.row-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
button {
|
||||||
|
font: inherit;
|
||||||
|
font-size: 13px;
|
||||||
|
padding: 5px 9px;
|
||||||
|
border-radius: 6px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
background: var(--surface);
|
||||||
|
color: var(--text);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
button:disabled {
|
||||||
|
opacity: 0.4;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
button.danger {
|
||||||
|
color: var(--danger);
|
||||||
|
border-color: var(--danger);
|
||||||
|
}
|
||||||
|
button.primary {
|
||||||
|
background: var(--accent);
|
||||||
|
border-color: var(--accent);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
.add {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 80px auto;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 14px;
|
||||||
|
padding-top: 14px;
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
.error {
|
||||||
|
margin: 10px 0 0;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--danger);
|
||||||
|
}
|
||||||
|
.actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
margin-top: 16px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
503
src/lib/components/InvoiceDialog.svelte
Normal file
@@ -0,0 +1,503 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type {
|
||||||
|
InvoiceDialogParams,
|
||||||
|
InvoiceInput,
|
||||||
|
InvoiceItemInput,
|
||||||
|
PaymentMethod,
|
||||||
|
Settings
|
||||||
|
} from "$lib/types";
|
||||||
|
import { formatShort, nightsBetween, todayISO } from "$lib/dates";
|
||||||
|
import { computeTotals, formatMoney, PAYMENT_METHOD_LABELS, round2 } from "$lib/money";
|
||||||
|
import * as db from "$lib/db";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
params: InvoiceDialogParams;
|
||||||
|
onclose: () => void;
|
||||||
|
onsaved: (invoiceId: number) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { params, onclose, onsaved }: Props = $props();
|
||||||
|
|
||||||
|
// Dialog jest montowany od nowa przy każdym otwarciu — params to wartości początkowe.
|
||||||
|
// svelte-ignore state_referenced_locally
|
||||||
|
const p = params;
|
||||||
|
|
||||||
|
let loading = $state(true);
|
||||||
|
let editingId = $state<number | null>(null);
|
||||||
|
let reservationId = $state<number | null>(null);
|
||||||
|
|
||||||
|
let invoiceNumber = $state("");
|
||||||
|
let issueDate = $state(todayISO());
|
||||||
|
let saleDate = $state(todayISO());
|
||||||
|
let paymentMethod = $state<PaymentMethod>("CASH");
|
||||||
|
let paymentCard = $state<number | null>(null);
|
||||||
|
let paymentCash = $state<number | null>(null);
|
||||||
|
let paymentDue = $state(todayISO());
|
||||||
|
let buyerName = $state("");
|
||||||
|
let buyerAddress = $state("");
|
||||||
|
let buyerNip = $state("");
|
||||||
|
let seller = $state({
|
||||||
|
company: "",
|
||||||
|
person: "",
|
||||||
|
address: "",
|
||||||
|
nip: "" as string | null,
|
||||||
|
bank_account: "",
|
||||||
|
bank_name: "",
|
||||||
|
issue_place: ""
|
||||||
|
});
|
||||||
|
let items = $state<InvoiceItemInput[]>([]);
|
||||||
|
let error = $state<string | null>(null);
|
||||||
|
let saving = $state(false);
|
||||||
|
|
||||||
|
let totals = $derived(computeTotals(items));
|
||||||
|
|
||||||
|
function sellerFromSettings(s: Settings) {
|
||||||
|
return {
|
||||||
|
company: s.company_name,
|
||||||
|
person: `${s.first_name} ${s.last_name}`.trim(),
|
||||||
|
address: s.address,
|
||||||
|
nip: s.nip,
|
||||||
|
bank_account: s.bank_account,
|
||||||
|
bank_name: s.bank_name,
|
||||||
|
issue_place: s.issue_place
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function initialize() {
|
||||||
|
try {
|
||||||
|
if ("invoice" in p) {
|
||||||
|
loadInvoice();
|
||||||
|
} else if ("reservation" in p) {
|
||||||
|
// Jeśli do pobytu już wystawiono fakturę, edytujemy ją zamiast tworzyć duplikat.
|
||||||
|
const existing = await db.getInvoiceForReservation(p.reservation.id);
|
||||||
|
if (existing) {
|
||||||
|
const existingItems = await db.getInvoiceItems(existing.id);
|
||||||
|
loadInvoice({ invoice: existing, items: existingItems });
|
||||||
|
} else {
|
||||||
|
await prefillFromReservation();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
await prefillBlank();
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
error = `Nie udało się przygotować faktury: ${e}`;
|
||||||
|
} finally {
|
||||||
|
loading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadInvoice(data?: Extract<InvoiceDialogParams, { invoice: unknown }>) {
|
||||||
|
const src = data ?? (p as Extract<InvoiceDialogParams, { invoice: unknown }>);
|
||||||
|
const inv = src.invoice;
|
||||||
|
editingId = inv.id;
|
||||||
|
reservationId = inv.reservation_id;
|
||||||
|
invoiceNumber = inv.invoice_number;
|
||||||
|
issueDate = inv.issue_date;
|
||||||
|
saleDate = inv.sale_date;
|
||||||
|
paymentMethod = inv.payment_method;
|
||||||
|
paymentCard = inv.payment_card;
|
||||||
|
paymentCash = inv.payment_cash;
|
||||||
|
paymentDue = inv.payment_due;
|
||||||
|
buyerName = inv.buyer_name;
|
||||||
|
buyerAddress = inv.buyer_address ?? "";
|
||||||
|
buyerNip = inv.buyer_nip ?? "";
|
||||||
|
seller = {
|
||||||
|
company: inv.seller_company,
|
||||||
|
person: inv.seller_person,
|
||||||
|
address: inv.seller_address,
|
||||||
|
nip: inv.seller_nip,
|
||||||
|
bank_account: inv.seller_bank_account,
|
||||||
|
bank_name: inv.seller_bank_name,
|
||||||
|
issue_place: inv.issue_place
|
||||||
|
};
|
||||||
|
items = src.items.map((it) => ({
|
||||||
|
name: it.name,
|
||||||
|
pkwiu: it.pkwiu,
|
||||||
|
quantity: it.quantity,
|
||||||
|
unit_price: it.unit_price,
|
||||||
|
vat_rate: it.vat_rate
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function prefillFromReservation() {
|
||||||
|
if (!("reservation" in p)) return;
|
||||||
|
const r = p.reservation;
|
||||||
|
const settings = await db.getSettings();
|
||||||
|
seller = sellerFromSettings(settings);
|
||||||
|
reservationId = r.id;
|
||||||
|
invoiceNumber = await db.nextInvoiceNumber(new Date().getFullYear());
|
||||||
|
saleDate = r.departure;
|
||||||
|
buyerName = r.guest_name;
|
||||||
|
buyerAddress = r.address ?? "";
|
||||||
|
const nights = nightsBetween(r.arrival, r.departure);
|
||||||
|
const list: InvoiceItemInput[] = [
|
||||||
|
{
|
||||||
|
name: `Usługa noclegowa ${formatShort(r.arrival)}–${formatShort(r.departure)}`,
|
||||||
|
pkwiu: settings.default_pkwiu,
|
||||||
|
quantity: nights,
|
||||||
|
unit_price: r.price_per_night ?? 0,
|
||||||
|
vat_rate: settings.default_vat_rate
|
||||||
|
}
|
||||||
|
];
|
||||||
|
if (r.meter_start != null && r.meter_end != null && r.meter_end > r.meter_start) {
|
||||||
|
const kwh = round2(r.meter_end - r.meter_start);
|
||||||
|
list.push({
|
||||||
|
name: `Energia elektryczna (${kwh} kWh)`,
|
||||||
|
pkwiu: null,
|
||||||
|
quantity: kwh,
|
||||||
|
unit_price: settings.energy_price ?? 0,
|
||||||
|
vat_rate: 23
|
||||||
|
});
|
||||||
|
}
|
||||||
|
items = list;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function prefillBlank() {
|
||||||
|
const settings = await db.getSettings();
|
||||||
|
seller = sellerFromSettings(settings);
|
||||||
|
invoiceNumber = await db.nextInvoiceNumber(new Date().getFullYear());
|
||||||
|
items = [
|
||||||
|
{
|
||||||
|
name: "",
|
||||||
|
pkwiu: settings.default_pkwiu,
|
||||||
|
quantity: 1,
|
||||||
|
unit_price: 0,
|
||||||
|
vat_rate: settings.default_vat_rate
|
||||||
|
}
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
void initialize();
|
||||||
|
|
||||||
|
function showModal(el: HTMLDialogElement) {
|
||||||
|
el.showModal();
|
||||||
|
}
|
||||||
|
|
||||||
|
function addItem() {
|
||||||
|
items.push({
|
||||||
|
name: "",
|
||||||
|
pkwiu: items[0]?.pkwiu ?? null,
|
||||||
|
quantity: 1,
|
||||||
|
unit_price: 0,
|
||||||
|
vat_rate: items[0]?.vat_rate ?? 23
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeItem(index: number) {
|
||||||
|
items.splice(index, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildInput(): InvoiceInput {
|
||||||
|
return {
|
||||||
|
invoice_number: invoiceNumber.trim(),
|
||||||
|
reservation_id: reservationId,
|
||||||
|
issue_date: issueDate,
|
||||||
|
sale_date: saleDate,
|
||||||
|
payment_method: paymentMethod,
|
||||||
|
payment_card: paymentMethod === "CARD_CASH" ? (paymentCard ?? 0) : null,
|
||||||
|
payment_cash: paymentMethod === "CARD_CASH" ? (paymentCash ?? 0) : null,
|
||||||
|
payment_due: paymentDue,
|
||||||
|
buyer_name: buyerName.trim(),
|
||||||
|
buyer_address: buyerAddress.trim() || null,
|
||||||
|
buyer_nip: buyerNip.trim() || null,
|
||||||
|
seller_company: seller.company,
|
||||||
|
seller_person: seller.person,
|
||||||
|
seller_address: seller.address,
|
||||||
|
seller_nip: seller.nip,
|
||||||
|
seller_bank_account: seller.bank_account,
|
||||||
|
seller_bank_name: seller.bank_name,
|
||||||
|
issue_place: seller.issue_place
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function save(event: SubmitEvent) {
|
||||||
|
event.preventDefault();
|
||||||
|
error = null;
|
||||||
|
if (!invoiceNumber.trim()) {
|
||||||
|
error = "Podaj numer faktury.";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!buyerName.trim()) {
|
||||||
|
error = "Podaj nazwę nabywcy.";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const validItems = items.filter((it) => it.name.trim());
|
||||||
|
if (validItems.length === 0) {
|
||||||
|
error = "Dodaj co najmniej jedną pozycję z nazwą.";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
saving = true;
|
||||||
|
try {
|
||||||
|
let id: number;
|
||||||
|
if (editingId == null) {
|
||||||
|
id = await db.createInvoice(buildInput(), validItems);
|
||||||
|
} else {
|
||||||
|
id = editingId;
|
||||||
|
await db.updateInvoice(editingId, buildInput(), validItems);
|
||||||
|
}
|
||||||
|
onsaved(id);
|
||||||
|
} catch (e) {
|
||||||
|
const msg = String(e);
|
||||||
|
error = msg.includes("UNIQUE")
|
||||||
|
? `Faktura o numerze ${invoiceNumber.trim()} już istnieje.`
|
||||||
|
: `Nie udało się zapisać faktury: ${msg}`;
|
||||||
|
} finally {
|
||||||
|
saving = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<dialog {@attach showModal} {onclose}>
|
||||||
|
{#if loading}
|
||||||
|
<p class="hint">Przygotowywanie faktury…</p>
|
||||||
|
{:else}
|
||||||
|
<form onsubmit={save}>
|
||||||
|
<h2>{editingId == null ? "Nowa faktura" : `Faktura ${invoiceNumber}`}</h2>
|
||||||
|
<p class="seller-info">
|
||||||
|
Sprzedawca: {seller.company || "(uzupełnij dane w Ustawieniach)"}
|
||||||
|
{#if seller.nip}· NIP {seller.nip}{/if}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="grid3">
|
||||||
|
<label>
|
||||||
|
Numer faktury
|
||||||
|
<input type="text" bind:value={invoiceNumber} required />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Data wystawienia
|
||||||
|
<input type="date" bind:value={issueDate} required />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Data sprzedaży
|
||||||
|
<input type="date" bind:value={saleDate} required />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Sposób zapłaty
|
||||||
|
<select bind:value={paymentMethod}>
|
||||||
|
{#each Object.entries(PAYMENT_METHOD_LABELS) as [value, label] (value)}
|
||||||
|
<option {value}>{label}</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Termin zapłaty
|
||||||
|
<input type="date" bind:value={paymentDue} required />
|
||||||
|
</label>
|
||||||
|
{#if paymentMethod === "CARD_CASH"}
|
||||||
|
<label>
|
||||||
|
Karta (zł)
|
||||||
|
<input type="number" min="0" step="0.01" bind:value={paymentCard} />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Gotówka (zł)
|
||||||
|
<input type="number" min="0" step="0.01" bind:value={paymentCash} />
|
||||||
|
</label>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h3>Nabywca</h3>
|
||||||
|
<div class="grid3">
|
||||||
|
<label class="span2">
|
||||||
|
Nazwa / imię i nazwisko
|
||||||
|
<input type="text" bind:value={buyerName} required />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
NIP (opcjonalnie)
|
||||||
|
<input type="text" bind:value={buyerNip} />
|
||||||
|
</label>
|
||||||
|
<label class="span3">
|
||||||
|
Adres
|
||||||
|
<input type="text" bind:value={buyerAddress} />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h3>Pozycje</h3>
|
||||||
|
<div class="items">
|
||||||
|
<span class="ih">Nazwa towaru lub usługi</span>
|
||||||
|
<span class="ih">PKWiU</span>
|
||||||
|
<span class="ih num">Ilość</span>
|
||||||
|
<span class="ih num">Cena brutto</span>
|
||||||
|
<span class="ih num">VAT %</span>
|
||||||
|
<span class="ih"></span>
|
||||||
|
{#each items as item, i (i)}
|
||||||
|
<input type="text" bind:value={item.name} aria-label="Nazwa pozycji" />
|
||||||
|
<input type="text" bind:value={item.pkwiu} aria-label="PKWiU" />
|
||||||
|
<input type="number" min="0" step="0.1" bind:value={item.quantity} aria-label="Ilość" />
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
step="0.01"
|
||||||
|
bind:value={item.unit_price}
|
||||||
|
aria-label="Cena jednostkowa brutto"
|
||||||
|
/>
|
||||||
|
<input type="number" min="0" step="1" bind:value={item.vat_rate} aria-label="Stawka VAT" />
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="rm"
|
||||||
|
onclick={() => removeItem(i)}
|
||||||
|
disabled={items.length === 1}
|
||||||
|
aria-label="Usuń pozycję"
|
||||||
|
>✕</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
<button type="button" class="add-item" onclick={addItem}>+ Dodaj pozycję</button>
|
||||||
|
|
||||||
|
<div class="totals">
|
||||||
|
<span>Netto: {formatMoney(totals.net)} zł</span>
|
||||||
|
<span>VAT: {formatMoney(totals.tax)} zł</span>
|
||||||
|
<span class="gross">Do zapłaty: <strong>{formatMoney(totals.gross)} zł</strong></span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if error}
|
||||||
|
<p class="error">{error}</p>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="actions">
|
||||||
|
<button type="button" onclick={onclose} disabled={saving}>Anuluj</button>
|
||||||
|
<button type="submit" class="primary" disabled={saving}>Zapisz i pokaż</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
{/if}
|
||||||
|
</dialog>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
dialog {
|
||||||
|
width: min(720px, calc(100vw - 32px));
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 20px;
|
||||||
|
background: var(--surface);
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
dialog::backdrop {
|
||||||
|
background: rgb(0 0 0 / 0.35);
|
||||||
|
}
|
||||||
|
h2 {
|
||||||
|
margin: 0 0 4px;
|
||||||
|
font-size: 17px;
|
||||||
|
}
|
||||||
|
h3 {
|
||||||
|
margin: 16px 0 8px;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
.seller-info {
|
||||||
|
margin: 0 0 14px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
.hint {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
.grid3 {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr 1fr;
|
||||||
|
gap: 10px 14px;
|
||||||
|
}
|
||||||
|
.span2 {
|
||||||
|
grid-column: span 2;
|
||||||
|
}
|
||||||
|
.span3 {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
}
|
||||||
|
label {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
input,
|
||||||
|
select {
|
||||||
|
font: inherit;
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--text);
|
||||||
|
background: var(--bg);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 7px 9px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.items {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 90px 70px 90px 60px 30px;
|
||||||
|
gap: 6px 8px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
.ih {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
.ih.num {
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
.rm {
|
||||||
|
font: inherit;
|
||||||
|
padding: 5px 0;
|
||||||
|
border-radius: 6px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
background: var(--surface);
|
||||||
|
color: var(--danger);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.rm:disabled {
|
||||||
|
opacity: 0.35;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
.add-item {
|
||||||
|
margin-top: 8px;
|
||||||
|
font: inherit;
|
||||||
|
font-size: 13px;
|
||||||
|
padding: 5px 10px;
|
||||||
|
border-radius: 6px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
background: var(--surface);
|
||||||
|
color: var(--text);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.totals {
|
||||||
|
display: flex;
|
||||||
|
gap: 20px;
|
||||||
|
justify-content: flex-end;
|
||||||
|
align-items: baseline;
|
||||||
|
margin-top: 14px;
|
||||||
|
padding-top: 10px;
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
.totals .gross {
|
||||||
|
font-size: 15px;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
.error {
|
||||||
|
margin: 12px 0 0;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--danger);
|
||||||
|
}
|
||||||
|
.actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
justify-content: flex-end;
|
||||||
|
margin-top: 18px;
|
||||||
|
}
|
||||||
|
.actions button {
|
||||||
|
font: inherit;
|
||||||
|
font-size: 14px;
|
||||||
|
padding: 8px 16px;
|
||||||
|
border-radius: 7px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
background: var(--surface);
|
||||||
|
color: var(--text);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.actions button.primary {
|
||||||
|
background: var(--accent);
|
||||||
|
border-color: var(--accent);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
.actions button:disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
300
src/lib/components/InvoicePreview.svelte
Normal file
@@ -0,0 +1,300 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { Invoice, InvoiceItem } from "$lib/types";
|
||||||
|
import { formatLong } from "$lib/dates";
|
||||||
|
import { amountInWordsPl, computeTotals, formatMoney, PAYMENT_METHOD_LABELS, round2 } from "$lib/money";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
invoice: Invoice;
|
||||||
|
items: InvoiceItem[];
|
||||||
|
onclose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { invoice, items, onclose }: Props = $props();
|
||||||
|
|
||||||
|
let totals = $derived(computeTotals(items));
|
||||||
|
|
||||||
|
let paymentText = $derived(
|
||||||
|
invoice.payment_method === "CARD_CASH"
|
||||||
|
? `Karta: ${formatMoney(invoice.payment_card ?? 0)} PLN, Gotówka: ${formatMoney(invoice.payment_cash ?? 0)} PLN`
|
||||||
|
: PAYMENT_METHOD_LABELS[invoice.payment_method]
|
||||||
|
);
|
||||||
|
|
||||||
|
function print() {
|
||||||
|
window.print();
|
||||||
|
}
|
||||||
|
|
||||||
|
function onkeydown(event: KeyboardEvent) {
|
||||||
|
if (event.key === "Escape") onclose();
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:window {onkeydown} />
|
||||||
|
|
||||||
|
<div class="overlay" role="dialog" aria-modal="true" aria-label="Podgląd faktury {invoice.invoice_number}">
|
||||||
|
<div class="toolbar">
|
||||||
|
<span class="title">Faktura {invoice.invoice_number}</span>
|
||||||
|
<button type="button" class="primary" onclick={print}>Drukuj / zapisz PDF</button>
|
||||||
|
<button type="button" onclick={onclose}>Zamknij</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="sheet print-sheet">
|
||||||
|
<header>
|
||||||
|
<h1>Faktura VAT</h1>
|
||||||
|
<p class="nr">Nr faktury: {invoice.invoice_number}</p>
|
||||||
|
<p>Oryginał/Kopia</p>
|
||||||
|
<p class="small">(Podatek VAT liczony od wartości brutto)</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<p class="issue">
|
||||||
|
Data i miejsce wystawienia: {formatLong(invoice.issue_date)}{invoice.issue_place
|
||||||
|
? `, ${invoice.issue_place}`
|
||||||
|
: ""}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="parties">
|
||||||
|
<div class="box">
|
||||||
|
<h2>Sprzedawca</h2>
|
||||||
|
<p class="strong">{invoice.seller_company}</p>
|
||||||
|
<p>{invoice.seller_person}</p>
|
||||||
|
<p>{invoice.seller_address}</p>
|
||||||
|
{#if invoice.seller_nip}<p>NIP: {invoice.seller_nip}</p>{/if}
|
||||||
|
<p>Data sprzedaży: {formatLong(invoice.sale_date)}</p>
|
||||||
|
{#if invoice.seller_bank_name}<p>Nazwa banku: {invoice.seller_bank_name}</p>{/if}
|
||||||
|
{#if invoice.seller_bank_account}<p>Rachunek bankowy: {invoice.seller_bank_account}</p>{/if}
|
||||||
|
</div>
|
||||||
|
<div class="box">
|
||||||
|
<h2>Nabywca</h2>
|
||||||
|
<p class="strong">{invoice.buyer_name}</p>
|
||||||
|
{#if invoice.buyer_address}<p>{invoice.buyer_address}</p>{/if}
|
||||||
|
{#if invoice.buyer_nip}<p>NIP: {invoice.buyer_nip}</p>{/if}
|
||||||
|
<p>Sposób zapłaty: {PAYMENT_METHOD_LABELS[invoice.payment_method]}</p>
|
||||||
|
<p>Termin zapłaty: {formatLong(invoice.payment_due)}</p>
|
||||||
|
<p>Zamówienie/oferta: rezerwacja</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<table class="items">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Nazwa towaru lub usługi</th>
|
||||||
|
<th>Symbol PKWiU</th>
|
||||||
|
<th class="num">Ilość</th>
|
||||||
|
<th class="num">Cena jedn. z podatkiem</th>
|
||||||
|
<th class="num">Stawka podatku VAT</th>
|
||||||
|
<th class="num">Wartość z podatkiem</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{#each items as item (item.id)}
|
||||||
|
<tr>
|
||||||
|
<td>{item.name}</td>
|
||||||
|
<td>{item.pkwiu ?? ""}</td>
|
||||||
|
<td class="num">{item.quantity}</td>
|
||||||
|
<td class="num">{formatMoney(item.unit_price)}</td>
|
||||||
|
<td class="num">{item.vat_rate}%</td>
|
||||||
|
<td class="num">{formatMoney(round2(item.quantity * item.unit_price))}</td>
|
||||||
|
</tr>
|
||||||
|
{/each}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<div class="summary">
|
||||||
|
<div class="pay">
|
||||||
|
<p class="strong">Do zapłaty:</p>
|
||||||
|
<p class="amount">{formatMoney(totals.gross)} PLN</p>
|
||||||
|
<p class="small">Słownie: {amountInWordsPl(totals.gross)}</p>
|
||||||
|
<div class="box method">
|
||||||
|
<h2>Sposób płatności</h2>
|
||||||
|
<p>{paymentText}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<table class="vat">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th class="num">Wartość bez podatku</th>
|
||||||
|
<th class="num">Podatek</th>
|
||||||
|
<th>Stawka</th>
|
||||||
|
<th class="num">Wartość z podatkiem</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{#each totals.byRate as row (row.rate)}
|
||||||
|
<tr>
|
||||||
|
<td class="num">{formatMoney(row.net)}</td>
|
||||||
|
<td class="num">{formatMoney(row.tax)}</td>
|
||||||
|
<td>{row.rate}%</td>
|
||||||
|
<td class="num">{formatMoney(row.gross)}</td>
|
||||||
|
</tr>
|
||||||
|
{/each}
|
||||||
|
<tr class="strong">
|
||||||
|
<td class="num">{formatMoney(totals.net)}</td>
|
||||||
|
<td class="num">{formatMoney(totals.tax)}</td>
|
||||||
|
<td>X</td>
|
||||||
|
<td class="num">{formatMoney(totals.gross)}</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="signature">
|
||||||
|
<p>{invoice.seller_person}</p>
|
||||||
|
<p class="small">Imię i nazwisko oraz podpis osoby uprawnionej</p>
|
||||||
|
<p class="small">(upoważnionej) do wystawienia faktury VAT</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.overlay {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 50;
|
||||||
|
overflow: auto;
|
||||||
|
background: rgb(0 0 0 / 0.5);
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
.toolbar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
max-width: 210mm;
|
||||||
|
margin: 0 auto 10px;
|
||||||
|
}
|
||||||
|
.toolbar .title {
|
||||||
|
flex: 1;
|
||||||
|
color: #fff;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.toolbar button {
|
||||||
|
font: inherit;
|
||||||
|
font-size: 14px;
|
||||||
|
padding: 7px 14px;
|
||||||
|
border-radius: 7px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
background: var(--surface);
|
||||||
|
color: var(--text);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.toolbar button.primary {
|
||||||
|
background: var(--accent);
|
||||||
|
border-color: var(--accent);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Arkusz A4 — zawsze czarno na białym, niezależnie od motywu aplikacji */
|
||||||
|
.sheet {
|
||||||
|
width: 210mm;
|
||||||
|
min-height: 297mm;
|
||||||
|
max-width: 100%;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 10mm;
|
||||||
|
background: #fff;
|
||||||
|
color: #000;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.35;
|
||||||
|
}
|
||||||
|
.sheet p {
|
||||||
|
margin: 0 0 2px;
|
||||||
|
}
|
||||||
|
header {
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: 14px;
|
||||||
|
}
|
||||||
|
h1 {
|
||||||
|
margin: 0 0 6px;
|
||||||
|
font-size: 26px;
|
||||||
|
}
|
||||||
|
.nr {
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.small {
|
||||||
|
font-size: 10px;
|
||||||
|
}
|
||||||
|
.strong {
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.issue {
|
||||||
|
text-align: right;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
.parties {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
.box {
|
||||||
|
flex: 1;
|
||||||
|
border: 1px solid #999;
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 10px;
|
||||||
|
}
|
||||||
|
.box h2 {
|
||||||
|
margin: 0 0 6px;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
margin-bottom: 14px;
|
||||||
|
}
|
||||||
|
th,
|
||||||
|
td {
|
||||||
|
border: 1px solid #999;
|
||||||
|
padding: 4px 6px;
|
||||||
|
text-align: left;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
th {
|
||||||
|
background: #f0f0f0;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.num {
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
.summary {
|
||||||
|
display: flex;
|
||||||
|
gap: 16px;
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
.pay {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
.amount {
|
||||||
|
font-size: 22px;
|
||||||
|
font-weight: 700;
|
||||||
|
margin: 2px 0 4px;
|
||||||
|
}
|
||||||
|
.method {
|
||||||
|
margin-top: 10px;
|
||||||
|
}
|
||||||
|
.vat {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
tr.strong td {
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
.signature {
|
||||||
|
margin-top: 40px;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media print {
|
||||||
|
:global(body *) {
|
||||||
|
visibility: hidden !important;
|
||||||
|
}
|
||||||
|
:global(.print-sheet),
|
||||||
|
:global(.print-sheet *) {
|
||||||
|
visibility: visible !important;
|
||||||
|
}
|
||||||
|
:global(.print-sheet) {
|
||||||
|
position: absolute !important;
|
||||||
|
left: 0;
|
||||||
|
top: 0;
|
||||||
|
width: 100% !important;
|
||||||
|
min-height: 0 !important;
|
||||||
|
margin: 0 !important;
|
||||||
|
padding: 0 !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
91
src/lib/components/ReservationBar.svelte
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { Reservation } from "$lib/types";
|
||||||
|
import { formatShort } from "$lib/dates";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
reservation: Reservation;
|
||||||
|
days: string[];
|
||||||
|
onclick: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { reservation: r, days, onclick }: Props = $props();
|
||||||
|
|
||||||
|
let n = $derived(days.length);
|
||||||
|
let clippedStart = $derived(r.arrival < days[0]);
|
||||||
|
let clippedEnd = $derived(r.departure > days[n - 1]);
|
||||||
|
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);
|
||||||
|
|
||||||
|
let tooltip = $derived(
|
||||||
|
[
|
||||||
|
`${r.guest_name} · ${formatShort(r.arrival)} → ${formatShort(r.departure)}`,
|
||||||
|
r.status === "option" ? "Opcja (niepotwierdzona)" : "Potwierdzona",
|
||||||
|
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 ?? ""
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join("\n")
|
||||||
|
);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="bar {r.status}"
|
||||||
|
class:clip-start={clippedStart}
|
||||||
|
class:clip-end={clippedEnd}
|
||||||
|
style="left: {startPos * 100}%; width: {(endPos - startPos) * 100}%;"
|
||||||
|
title={tooltip}
|
||||||
|
{onclick}
|
||||||
|
>
|
||||||
|
{#if r.deposit_paid}<span class="paid" aria-label="Zaliczka wpłacona">✓</span>{/if}
|
||||||
|
<span class="name">{r.guest_name}</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.bar {
|
||||||
|
position: absolute;
|
||||||
|
top: 7px;
|
||||||
|
height: calc(100% - 14px);
|
||||||
|
z-index: 2;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 0 8px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
font: inherit;
|
||||||
|
font-size: 12px;
|
||||||
|
text-align: left;
|
||||||
|
cursor: pointer;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.name {
|
||||||
|
overflow: hidden;
|
||||||
|
white-space: nowrap;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
.paid {
|
||||||
|
flex: none;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
.confirmed {
|
||||||
|
background: var(--bar-confirmed-bg);
|
||||||
|
color: var(--bar-confirmed-text);
|
||||||
|
}
|
||||||
|
.option {
|
||||||
|
background: var(--bar-option-bg);
|
||||||
|
color: var(--bar-option-text);
|
||||||
|
}
|
||||||
|
.clip-start {
|
||||||
|
border-top-left-radius: 0;
|
||||||
|
border-bottom-left-radius: 0;
|
||||||
|
}
|
||||||
|
.clip-end {
|
||||||
|
border-top-right-radius: 0;
|
||||||
|
border-bottom-right-radius: 0;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
392
src/lib/components/ReservationDialog.svelte
Normal file
@@ -0,0 +1,392 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type {
|
||||||
|
Cabin,
|
||||||
|
Reservation,
|
||||||
|
ReservationDialogParams,
|
||||||
|
ReservationInput,
|
||||||
|
ReservationStatus
|
||||||
|
} from "$lib/types";
|
||||||
|
import { addDays, formatShort, nightsBetween } from "$lib/dates";
|
||||||
|
import { formatMoney } from "$lib/money";
|
||||||
|
import * as db from "$lib/db";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
params: ReservationDialogParams;
|
||||||
|
cabins: Cabin[];
|
||||||
|
onclose: () => void;
|
||||||
|
onsaved: () => void;
|
||||||
|
/** Wywoływane po zapisie, gdy użytkownik chce wystawić/otworzyć fakturę do pobytu. */
|
||||||
|
oninvoice: (reservation: Reservation) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { params, cabins, onclose, onsaved, oninvoice }: Props = $props();
|
||||||
|
|
||||||
|
// Dialog jest montowany od nowa przy każdym otwarciu ({#if} u rodzica),
|
||||||
|
// więc z params celowo pobieramy tylko wartości początkowe formularza.
|
||||||
|
// svelte-ignore state_referenced_locally
|
||||||
|
const p = params;
|
||||||
|
const existingReservation = "reservation" in p ? p.reservation : null;
|
||||||
|
const editingId = existingReservation?.id ?? null;
|
||||||
|
const init: ReservationInput =
|
||||||
|
"reservation" in p
|
||||||
|
? p.reservation
|
||||||
|
: {
|
||||||
|
cabin_id: p.cabin_id,
|
||||||
|
guest_name: "",
|
||||||
|
address: null,
|
||||||
|
phone: null,
|
||||||
|
num_guests: null,
|
||||||
|
deposit_amount: null,
|
||||||
|
deposit_paid: false,
|
||||||
|
status: "confirmed",
|
||||||
|
arrival: p.arrival,
|
||||||
|
departure: addDays(p.arrival, 7),
|
||||||
|
notes: null,
|
||||||
|
price_per_night: null,
|
||||||
|
meter_start: null,
|
||||||
|
meter_end: null
|
||||||
|
};
|
||||||
|
|
||||||
|
let cabinId = $state(init.cabin_id);
|
||||||
|
let guestName = $state(init.guest_name);
|
||||||
|
let address = $state(init.address ?? "");
|
||||||
|
let phone = $state(init.phone ?? "");
|
||||||
|
let numGuests = $state<number | null>(init.num_guests);
|
||||||
|
let depositAmount = $state<number | null>(init.deposit_amount);
|
||||||
|
let depositPaid = $state(init.deposit_paid);
|
||||||
|
let status = $state<ReservationStatus>(init.status);
|
||||||
|
let arrival = $state(init.arrival);
|
||||||
|
let departure = $state(init.departure);
|
||||||
|
let notes = $state(init.notes ?? "");
|
||||||
|
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 error = $state<string | null>(null);
|
||||||
|
let saving = $state(false);
|
||||||
|
|
||||||
|
let selectedCabin = $derived(cabins.find((c) => c.id === cabinId));
|
||||||
|
let capacityWarning = $derived(
|
||||||
|
selectedCabin?.capacity != null && numGuests != null && numGuests > selectedCabin.capacity
|
||||||
|
? `Uwaga: ${numGuests} osób przekracza pojemność domku (${selectedCabin.capacity} os.).`
|
||||||
|
: null
|
||||||
|
);
|
||||||
|
let nights = $derived(
|
||||||
|
arrival && departure && departure > arrival ? nightsBetween(arrival, departure) : 0
|
||||||
|
);
|
||||||
|
let stayTotal = $derived(
|
||||||
|
nights > 0 && pricePerNight != null && !Number.isNaN(pricePerNight)
|
||||||
|
? nights * pricePerNight
|
||||||
|
: null
|
||||||
|
);
|
||||||
|
let energyUsage = $derived(
|
||||||
|
meterStart != null && meterEnd != null && !Number.isNaN(meterStart) && !Number.isNaN(meterEnd)
|
||||||
|
? meterEnd - meterStart
|
||||||
|
: null
|
||||||
|
);
|
||||||
|
|
||||||
|
function showModal(el: HTMLDialogElement) {
|
||||||
|
el.showModal();
|
||||||
|
}
|
||||||
|
|
||||||
|
function normNumber(v: number | null | undefined): number | null {
|
||||||
|
return v == null || Number.isNaN(v) ? null : v;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildInput(): ReservationInput {
|
||||||
|
return {
|
||||||
|
cabin_id: cabinId,
|
||||||
|
guest_name: guestName.trim(),
|
||||||
|
address: address.trim() || null,
|
||||||
|
phone: phone.trim() || null,
|
||||||
|
num_guests: normNumber(numGuests),
|
||||||
|
deposit_amount: normNumber(depositAmount),
|
||||||
|
deposit_paid: depositPaid,
|
||||||
|
status,
|
||||||
|
arrival,
|
||||||
|
departure,
|
||||||
|
notes: notes.trim() || null,
|
||||||
|
price_per_night: normNumber(pricePerNight),
|
||||||
|
meter_start: normNumber(meterStart),
|
||||||
|
meter_end: normNumber(meterEnd)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function persist(): Promise<boolean> {
|
||||||
|
error = null;
|
||||||
|
if (!guestName.trim()) {
|
||||||
|
error = "Podaj imię i nazwisko gościa.";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (!arrival || !departure || departure <= arrival) {
|
||||||
|
error = "Data wyjazdu musi być późniejsza niż data przyjazdu.";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
saving = true;
|
||||||
|
try {
|
||||||
|
const conflicts = await db.findConflicts(cabinId, arrival, departure, editingId ?? undefined);
|
||||||
|
if (conflicts.length > 0) {
|
||||||
|
const c = conflicts[0];
|
||||||
|
error = `Termin koliduje z rezerwacją: ${c.guest_name} (${formatShort(c.arrival)} → ${formatShort(c.departure)}).`;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (editingId == null) {
|
||||||
|
await db.createReservation(buildInput());
|
||||||
|
} else {
|
||||||
|
await db.updateReservation(editingId, buildInput());
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
} catch (e) {
|
||||||
|
error = `Nie udało się zapisać: ${e}`;
|
||||||
|
return false;
|
||||||
|
} finally {
|
||||||
|
saving = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function save(event: SubmitEvent) {
|
||||||
|
event.preventDefault();
|
||||||
|
if (await persist()) {
|
||||||
|
onsaved();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function issueInvoice() {
|
||||||
|
if (!existingReservation) return;
|
||||||
|
if (await persist()) {
|
||||||
|
oninvoice({ ...existingReservation, ...buildInput() });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function remove() {
|
||||||
|
if (editingId == null) return;
|
||||||
|
if (!confirm(`Usunąć rezerwację gościa ${guestName}?`)) return;
|
||||||
|
saving = true;
|
||||||
|
try {
|
||||||
|
await db.deleteReservation(editingId);
|
||||||
|
onsaved();
|
||||||
|
} catch (e) {
|
||||||
|
error = `Nie udało się usunąć: ${e}`;
|
||||||
|
} finally {
|
||||||
|
saving = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<dialog {@attach showModal} {onclose}>
|
||||||
|
<form onsubmit={save}>
|
||||||
|
<h2>{editingId == null ? "Nowa rezerwacja" : "Edycja rezerwacji"}</h2>
|
||||||
|
|
||||||
|
<div class="grid2">
|
||||||
|
<label>
|
||||||
|
Domek
|
||||||
|
<select bind:value={cabinId} required>
|
||||||
|
{#each cabins as c (c.id)}
|
||||||
|
<option value={c.id}>{c.name}{c.capacity ? ` (${c.capacity} os.)` : ""}</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Status
|
||||||
|
<select bind:value={status}>
|
||||||
|
<option value="confirmed">Potwierdzona</option>
|
||||||
|
<option value="option">Opcja (niepotwierdzona)</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Przyjazd
|
||||||
|
<input type="date" bind:value={arrival} required />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Wyjazd
|
||||||
|
<input type="date" bind:value={departure} required />
|
||||||
|
</label>
|
||||||
|
<label class="wide">
|
||||||
|
Imię i nazwisko
|
||||||
|
<input type="text" bind:value={guestName} required placeholder="Jan Kowalski" />
|
||||||
|
</label>
|
||||||
|
<label class="wide">
|
||||||
|
Adres
|
||||||
|
<input type="text" bind:value={address} placeholder="ul. Letnia 5, 21-500 Biała Podlaska" />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Telefon
|
||||||
|
<input type="tel" bind:value={phone} placeholder="600 100 200" />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Liczba osób
|
||||||
|
<input type="number" min="1" bind:value={numGuests} />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Zaliczka (zł)
|
||||||
|
<input type="number" min="0" step="0.01" bind:value={depositAmount} />
|
||||||
|
</label>
|
||||||
|
<label class="check">
|
||||||
|
<input type="checkbox" bind:checked={depositPaid} />
|
||||||
|
Zaliczka wpłacona
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Cena za dobę (zł)
|
||||||
|
<input type="number" min="0" step="0.01" bind:value={pricePerNight} />
|
||||||
|
</label>
|
||||||
|
<div class="calc">
|
||||||
|
{#if stayTotal != null}
|
||||||
|
{nights} {nights === 1 ? "doba" : nights < 5 ? "doby" : "dób"} ×
|
||||||
|
{formatMoney(pricePerNight ?? 0)} zł = <strong>{formatMoney(stayTotal)} zł</strong>
|
||||||
|
{:else if nights > 0}
|
||||||
|
{nights} {nights === 1 ? "doba" : nights < 5 ? "doby" : "dób"}
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<label>
|
||||||
|
Licznik energii — przed (kWh)
|
||||||
|
<input type="number" min="0" step="0.1" bind:value={meterStart} />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Licznik energii — po (kWh)
|
||||||
|
<input type="number" min="0" step="0.1" bind:value={meterEnd} />
|
||||||
|
</label>
|
||||||
|
{#if energyUsage != null}
|
||||||
|
<div class="calc wide" class:bad={energyUsage < 0}>
|
||||||
|
{#if energyUsage < 0}
|
||||||
|
Stan „po" jest mniejszy niż „przed" — sprawdź odczyty.
|
||||||
|
{:else}
|
||||||
|
Zużycie energii: <strong>{Math.round(energyUsage * 10) / 10} kWh</strong>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
<label class="wide">
|
||||||
|
Notatki
|
||||||
|
<textarea rows="2" bind:value={notes}></textarea>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if capacityWarning}
|
||||||
|
<p class="warning">{capacityWarning}</p>
|
||||||
|
{/if}
|
||||||
|
{#if error}
|
||||||
|
<p class="error">{error}</p>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="actions">
|
||||||
|
{#if editingId != null}
|
||||||
|
<button type="button" class="danger" onclick={remove} disabled={saving}>Usuń</button>
|
||||||
|
<button type="button" onclick={issueInvoice} disabled={saving}>Wystaw fakturę</button>
|
||||||
|
{/if}
|
||||||
|
<span class="spacer"></span>
|
||||||
|
<button type="button" onclick={onclose} disabled={saving}>Anuluj</button>
|
||||||
|
<button type="submit" class="primary" disabled={saving}>Zapisz</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</dialog>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
dialog {
|
||||||
|
width: min(560px, calc(100vw - 32px));
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 20px;
|
||||||
|
background: var(--surface);
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
dialog::backdrop {
|
||||||
|
background: rgb(0 0 0 / 0.35);
|
||||||
|
}
|
||||||
|
h2 {
|
||||||
|
margin: 0 0 16px;
|
||||||
|
font-size: 17px;
|
||||||
|
}
|
||||||
|
.grid2 {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 10px 14px;
|
||||||
|
}
|
||||||
|
label {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
label.wide {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
}
|
||||||
|
label.check {
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
align-self: end;
|
||||||
|
padding-bottom: 8px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
input,
|
||||||
|
select,
|
||||||
|
textarea {
|
||||||
|
font: inherit;
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--text);
|
||||||
|
background: var(--bg);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 7px 9px;
|
||||||
|
}
|
||||||
|
input[type="checkbox"] {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
.calc {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-end;
|
||||||
|
gap: 4px;
|
||||||
|
padding-bottom: 8px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
.calc strong {
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
.calc.bad {
|
||||||
|
color: var(--danger);
|
||||||
|
}
|
||||||
|
.warning {
|
||||||
|
margin: 12px 0 0;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--warn-text);
|
||||||
|
}
|
||||||
|
.error {
|
||||||
|
margin: 12px 0 0;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--danger);
|
||||||
|
}
|
||||||
|
.actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 18px;
|
||||||
|
}
|
||||||
|
.spacer {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
.actions button {
|
||||||
|
font: inherit;
|
||||||
|
font-size: 14px;
|
||||||
|
padding: 8px 16px;
|
||||||
|
border-radius: 7px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
background: var(--surface);
|
||||||
|
color: var(--text);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.actions button.primary {
|
||||||
|
background: var(--accent);
|
||||||
|
border-color: var(--accent);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
.actions button.danger {
|
||||||
|
color: var(--danger);
|
||||||
|
border-color: var(--danger);
|
||||||
|
}
|
||||||
|
.actions button:disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
165
src/lib/components/ScheduleGrid.svelte
Normal file
@@ -0,0 +1,165 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { Cabin, Reservation } from "$lib/types";
|
||||||
|
import { isWeekend, todayISO, weekdayShort } from "$lib/dates";
|
||||||
|
import ReservationBar from "./ReservationBar.svelte";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
cabins: Cabin[];
|
||||||
|
days: string[];
|
||||||
|
reservations: Reservation[];
|
||||||
|
onCellClick: (cabinId: number, date: string) => void;
|
||||||
|
onReservationClick: (r: Reservation) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { cabins, days, reservations, onCellClick, onReservationClick }: Props = $props();
|
||||||
|
|
||||||
|
const today = todayISO();
|
||||||
|
|
||||||
|
let byCabin = $derived.by(() => {
|
||||||
|
const map = new Map<number, Reservation[]>();
|
||||||
|
for (const r of reservations) {
|
||||||
|
const list = map.get(r.cabin_id);
|
||||||
|
if (list) {
|
||||||
|
list.push(r);
|
||||||
|
} else {
|
||||||
|
map.set(r.cabin_id, [r]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="scroll">
|
||||||
|
<div class="grid" style="grid-template-columns: 140px repeat({days.length}, minmax(34px, 1fr));">
|
||||||
|
<div class="corner"></div>
|
||||||
|
{#each days as d (d)}
|
||||||
|
<div class="day-head" class:weekend={isWeekend(d)} class:today={d === today}>
|
||||||
|
<span class="num">{Number(d.slice(8))}</span>
|
||||||
|
<span class="wd">{weekdayShort(d)}</span>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
|
||||||
|
{#each cabins as cabin (cabin.id)}
|
||||||
|
<div class="cabin-name">
|
||||||
|
<span class="cn">{cabin.name}</span>
|
||||||
|
{#if cabin.capacity}<span class="cap">{cabin.capacity} os.</span>{/if}
|
||||||
|
</div>
|
||||||
|
<div class="track">
|
||||||
|
{#each days as d (d)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="cell"
|
||||||
|
class:weekend={isWeekend(d)}
|
||||||
|
class:today={d === today}
|
||||||
|
aria-label="Nowa rezerwacja: {cabin.name}, {d}"
|
||||||
|
onclick={() => onCellClick(cabin.id, d)}
|
||||||
|
></button>
|
||||||
|
{/each}
|
||||||
|
{#each byCabin.get(cabin.id) ?? [] as r (r.id)}
|
||||||
|
<ReservationBar reservation={r} {days} onclick={() => onReservationClick(r)} />
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.scroll {
|
||||||
|
overflow: auto;
|
||||||
|
max-height: calc(100vh - 230px);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 10px;
|
||||||
|
background: var(--surface);
|
||||||
|
}
|
||||||
|
.grid {
|
||||||
|
display: grid;
|
||||||
|
min-width: 900px;
|
||||||
|
}
|
||||||
|
.corner {
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
z-index: 5;
|
||||||
|
background: var(--surface);
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
.day-head {
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 3;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
padding: 6px 0 4px;
|
||||||
|
background: var(--surface);
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
border-left: 1px solid var(--border);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
.day-head .num {
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.day-head .wd {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
.day-head.weekend {
|
||||||
|
background: var(--weekend);
|
||||||
|
}
|
||||||
|
.day-head.today {
|
||||||
|
background: var(--today);
|
||||||
|
}
|
||||||
|
.day-head.today .num {
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
.cabin-name {
|
||||||
|
position: sticky;
|
||||||
|
left: 0;
|
||||||
|
z-index: 4;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
height: 44px;
|
||||||
|
padding: 0 10px;
|
||||||
|
background: var(--surface);
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
.cabin-name .cn {
|
||||||
|
font-weight: 600;
|
||||||
|
overflow: hidden;
|
||||||
|
white-space: nowrap;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
.cabin-name .cap {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 11px;
|
||||||
|
flex: none;
|
||||||
|
}
|
||||||
|
.track {
|
||||||
|
grid-column: 2 / -1;
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
height: 44px;
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
.cell {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
border: none;
|
||||||
|
border-left: 1px solid var(--border);
|
||||||
|
background: transparent;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.cell.weekend {
|
||||||
|
background: var(--weekend);
|
||||||
|
}
|
||||||
|
.cell.today {
|
||||||
|
background: var(--today);
|
||||||
|
}
|
||||||
|
.cell:hover {
|
||||||
|
background: var(--cell-hover);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
75
src/lib/dates.ts
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
export const MONTH_NAMES = [
|
||||||
|
"Styczeń",
|
||||||
|
"Luty",
|
||||||
|
"Marzec",
|
||||||
|
"Kwiecień",
|
||||||
|
"Maj",
|
||||||
|
"Czerwiec",
|
||||||
|
"Lipiec",
|
||||||
|
"Sierpień",
|
||||||
|
"Wrzesień",
|
||||||
|
"Październik",
|
||||||
|
"Listopad",
|
||||||
|
"Grudzień"
|
||||||
|
];
|
||||||
|
|
||||||
|
const WEEKDAYS_SHORT = ["nd", "pn", "wt", "śr", "cz", "pt", "so"];
|
||||||
|
|
||||||
|
function pad2(n: number): string {
|
||||||
|
return String(n).padStart(2, "0");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Data ISO YYYY-MM-DD; month0 liczony od zera. */
|
||||||
|
export function isoDate(year: number, month0: number, day: number): string {
|
||||||
|
return `${year}-${pad2(month0 + 1)}-${pad2(day)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function todayISO(): string {
|
||||||
|
const t = new Date();
|
||||||
|
return isoDate(t.getFullYear(), t.getMonth(), t.getDate());
|
||||||
|
}
|
||||||
|
|
||||||
|
export function daysInMonth(year: number, month0: number): number {
|
||||||
|
return new Date(year, month0 + 1, 0).getDate();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Wszystkie dni miesiąca jako daty ISO. */
|
||||||
|
export function monthDays(year: number, month0: number): string[] {
|
||||||
|
return Array.from({ length: daysInMonth(year, month0) }, (_, i) => isoDate(year, month0, i + 1));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function addDays(iso: string, n: number): string {
|
||||||
|
const d = new Date(iso + "T00:00:00");
|
||||||
|
d.setDate(d.getDate() + n);
|
||||||
|
return isoDate(d.getFullYear(), d.getMonth(), d.getDate());
|
||||||
|
}
|
||||||
|
|
||||||
|
export function weekdayShort(iso: string): string {
|
||||||
|
return WEEKDAYS_SHORT[new Date(iso + "T00:00:00").getDay()];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isWeekend(iso: string): boolean {
|
||||||
|
const wd = new Date(iso + "T00:00:00").getDay();
|
||||||
|
return wd === 0 || wd === 6;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Format "4.07" do zwięzłego wyświetlania. */
|
||||||
|
export function formatShort(iso: string): string {
|
||||||
|
return `${Number(iso.slice(8))}.${iso.slice(5, 7)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Liczba nocy między dwiema datami ISO (departure - arrival). */
|
||||||
|
export function nightsBetween(arrival: string, departure: string): number {
|
||||||
|
const a = new Date(arrival + "T00:00:00");
|
||||||
|
const b = new Date(departure + "T00:00:00");
|
||||||
|
return Math.round((b.getTime() - a.getTime()) / 86_400_000);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Format "4 lipca 2026" do wydruku faktury. */
|
||||||
|
export function formatLong(iso: string): string {
|
||||||
|
return new Date(iso + "T00:00:00").toLocaleDateString("pl-PL", {
|
||||||
|
day: "numeric",
|
||||||
|
month: "long",
|
||||||
|
year: "numeric"
|
||||||
|
});
|
||||||
|
}
|
||||||
337
src/lib/db.ts
Normal file
@@ -0,0 +1,337 @@
|
|||||||
|
import Database from "@tauri-apps/plugin-sql";
|
||||||
|
import type {
|
||||||
|
Cabin,
|
||||||
|
Invoice,
|
||||||
|
InvoiceInput,
|
||||||
|
InvoiceItem,
|
||||||
|
InvoiceItemInput,
|
||||||
|
Reservation,
|
||||||
|
ReservationInput,
|
||||||
|
RevenueStats,
|
||||||
|
Settings
|
||||||
|
} from "./types";
|
||||||
|
|
||||||
|
let db: Database | null = null;
|
||||||
|
|
||||||
|
async function getDb(): Promise<Database> {
|
||||||
|
if (!db) {
|
||||||
|
db = await Database.load("sqlite:sloneczko.db");
|
||||||
|
}
|
||||||
|
return db;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ReservationRow extends Omit<Reservation, "deposit_paid"> {
|
||||||
|
deposit_paid: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toReservation(row: ReservationRow): Reservation {
|
||||||
|
return { ...row, deposit_paid: !!row.deposit_paid };
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Domki ---
|
||||||
|
|
||||||
|
export async function listCabins(): Promise<Cabin[]> {
|
||||||
|
const d = await getDb();
|
||||||
|
return d.select<Cabin[]>("SELECT * FROM cabins ORDER BY sort_order, name");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createCabin(name: string, capacity: number | null): Promise<void> {
|
||||||
|
const d = await getDb();
|
||||||
|
await d.execute(
|
||||||
|
"INSERT INTO cabins (name, capacity, sort_order) VALUES ($1, $2, (SELECT COALESCE(MAX(sort_order), 0) + 1 FROM cabins))",
|
||||||
|
[name, capacity]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateCabin(cabin: Cabin): Promise<void> {
|
||||||
|
const d = await getDb();
|
||||||
|
await d.execute("UPDATE cabins SET name = $1, capacity = $2, sort_order = $3 WHERE id = $4", [
|
||||||
|
cabin.name,
|
||||||
|
cabin.capacity,
|
||||||
|
cabin.sort_order,
|
||||||
|
cabin.id
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteCabin(id: number): Promise<void> {
|
||||||
|
const d = await getDb();
|
||||||
|
await d.execute("DELETE FROM cabins WHERE id = $1", [id]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Rezerwacje ---
|
||||||
|
|
||||||
|
/** Rezerwacje nachodzące na zakres dat [from, to) — daty jako YYYY-MM-DD. */
|
||||||
|
export async function listReservations(from: string, to: string): Promise<Reservation[]> {
|
||||||
|
const d = await getDb();
|
||||||
|
const rows = await d.select<ReservationRow[]>(
|
||||||
|
"SELECT * FROM reservations WHERE arrival < $1 AND departure >= $2 ORDER BY arrival",
|
||||||
|
[to, from]
|
||||||
|
);
|
||||||
|
return rows.map(toReservation);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rezerwacje tego samego domku kolidujące z terminem [arrival, departure).
|
||||||
|
* Styk terminów (wyjazd jednej = przyjazd drugiej) nie jest kolizją.
|
||||||
|
*/
|
||||||
|
export async function findConflicts(
|
||||||
|
cabinId: number,
|
||||||
|
arrival: string,
|
||||||
|
departure: string,
|
||||||
|
excludeId?: number
|
||||||
|
): Promise<Reservation[]> {
|
||||||
|
const d = await getDb();
|
||||||
|
const rows = await d.select<ReservationRow[]>(
|
||||||
|
"SELECT * FROM reservations WHERE cabin_id = $1 AND arrival < $2 AND departure > $3 AND id != $4",
|
||||||
|
[cabinId, departure, arrival, excludeId ?? -1]
|
||||||
|
);
|
||||||
|
return rows.map(toReservation);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createReservation(r: ReservationInput): Promise<void> {
|
||||||
|
const d = await getDb();
|
||||||
|
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`,
|
||||||
|
[
|
||||||
|
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,
|
||||||
|
id
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteReservation(id: number): Promise<void> {
|
||||||
|
const d = await getDb();
|
||||||
|
await d.execute("DELETE FROM reservations WHERE id = $1", [id]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Statystyki przychodu dla zakresu [from, to) — liczone po nocach mieszczących się
|
||||||
|
* w zakresie (pobyt na przełomie okresów wlicza się proporcjonalnie).
|
||||||
|
*/
|
||||||
|
export async function revenueStats(from: string, to: string): Promise<RevenueStats> {
|
||||||
|
const d = await getDb();
|
||||||
|
const rows = await d.select<
|
||||||
|
{ nights_total: number | null; revenue_total: number | null; nights_confirmed: number | null; revenue_confirmed: number | null }[]
|
||||||
|
>(
|
||||||
|
`SELECT
|
||||||
|
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`,
|
||||||
|
[to, from]
|
||||||
|
);
|
||||||
|
const r = rows[0];
|
||||||
|
return {
|
||||||
|
nights_total: r?.nights_total ?? 0,
|
||||||
|
revenue_total: r?.revenue_total ?? 0,
|
||||||
|
nights_confirmed: r?.nights_confirmed ?? 0,
|
||||||
|
revenue_confirmed: r?.revenue_confirmed ?? 0
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Ustawienia ---
|
||||||
|
|
||||||
|
export async function getSettings(): Promise<Settings> {
|
||||||
|
const d = await getDb();
|
||||||
|
const rows = await d.select<Settings[]>("SELECT * FROM settings WHERE id = 1");
|
||||||
|
return rows[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateSettings(s: Settings): Promise<void> {
|
||||||
|
const d = await getDb();
|
||||||
|
await d.execute(
|
||||||
|
`UPDATE settings SET
|
||||||
|
company_name = $1, first_name = $2, last_name = $3, address = $4, nip = $5,
|
||||||
|
bank_account = $6, bank_name = $7, issue_place = $8, default_pkwiu = $9,
|
||||||
|
default_vat_rate = $10, energy_price = $11
|
||||||
|
WHERE id = 1`,
|
||||||
|
[
|
||||||
|
s.company_name,
|
||||||
|
s.first_name,
|
||||||
|
s.last_name,
|
||||||
|
s.address,
|
||||||
|
s.nip,
|
||||||
|
s.bank_account,
|
||||||
|
s.bank_name,
|
||||||
|
s.issue_place,
|
||||||
|
s.default_pkwiu,
|
||||||
|
s.default_vat_rate,
|
||||||
|
s.energy_price
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Faktury ---
|
||||||
|
|
||||||
|
export async function listInvoices(): Promise<(Invoice & { gross: number })[]> {
|
||||||
|
const d = await getDb();
|
||||||
|
return d.select<(Invoice & { gross: number })[]>(
|
||||||
|
`SELECT i.*,
|
||||||
|
COALESCE((SELECT SUM(it.quantity * it.unit_price)
|
||||||
|
FROM invoice_items it WHERE it.invoice_id = i.id), 0) AS gross
|
||||||
|
FROM invoices i
|
||||||
|
ORDER BY i.issue_date DESC, i.id DESC`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getInvoice(id: number): Promise<Invoice | null> {
|
||||||
|
const d = await getDb();
|
||||||
|
const rows = await d.select<Invoice[]>("SELECT * FROM invoices WHERE id = $1", [id]);
|
||||||
|
return rows[0] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getInvoiceItems(invoiceId: number): Promise<InvoiceItem[]> {
|
||||||
|
const d = await getDb();
|
||||||
|
return d.select<InvoiceItem[]>(
|
||||||
|
"SELECT * FROM invoice_items WHERE invoice_id = $1 ORDER BY position",
|
||||||
|
[invoiceId]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getInvoiceForReservation(reservationId: number): Promise<Invoice | null> {
|
||||||
|
const d = await getDb();
|
||||||
|
const rows = await d.select<Invoice[]>(
|
||||||
|
"SELECT * FROM invoices WHERE reservation_id = $1 ORDER BY id DESC LIMIT 1",
|
||||||
|
[reservationId]
|
||||||
|
);
|
||||||
|
return rows[0] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Kolejny wolny numer w formacie N/RRRR dla danego roku. */
|
||||||
|
export async function nextInvoiceNumber(year: number): Promise<string> {
|
||||||
|
const d = await getDb();
|
||||||
|
const rows = await d.select<{ invoice_number: string }[]>(
|
||||||
|
"SELECT invoice_number FROM invoices WHERE invoice_number LIKE $1",
|
||||||
|
[`%/${year}`]
|
||||||
|
);
|
||||||
|
let max = 0;
|
||||||
|
for (const row of rows) {
|
||||||
|
const seq = parseInt(row.invoice_number, 10);
|
||||||
|
if (!Number.isNaN(seq) && seq > max) max = seq;
|
||||||
|
}
|
||||||
|
return `${max + 1}/${year}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function insertInvoiceItems(
|
||||||
|
d: Database,
|
||||||
|
invoiceId: number,
|
||||||
|
items: InvoiceItemInput[]
|
||||||
|
): Promise<void> {
|
||||||
|
for (let i = 0; i < items.length; i++) {
|
||||||
|
const it = items[i];
|
||||||
|
await d.execute(
|
||||||
|
`INSERT INTO invoice_items (invoice_id, name, pkwiu, quantity, unit_price, vat_rate, position)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7)`,
|
||||||
|
[invoiceId, it.name, it.pkwiu, it.quantity, it.unit_price, it.vat_rate, i]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const INVOICE_COLUMNS = [
|
||||||
|
"invoice_number",
|
||||||
|
"reservation_id",
|
||||||
|
"issue_date",
|
||||||
|
"sale_date",
|
||||||
|
"payment_method",
|
||||||
|
"payment_card",
|
||||||
|
"payment_cash",
|
||||||
|
"payment_due",
|
||||||
|
"buyer_name",
|
||||||
|
"buyer_address",
|
||||||
|
"buyer_nip",
|
||||||
|
"seller_company",
|
||||||
|
"seller_person",
|
||||||
|
"seller_address",
|
||||||
|
"seller_nip",
|
||||||
|
"seller_bank_account",
|
||||||
|
"seller_bank_name",
|
||||||
|
"issue_place"
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
function invoiceValues(inv: InvoiceInput): unknown[] {
|
||||||
|
return INVOICE_COLUMNS.map((c) => inv[c]);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createInvoice(inv: InvoiceInput, items: InvoiceItemInput[]): Promise<number> {
|
||||||
|
const d = await getDb();
|
||||||
|
const placeholders = INVOICE_COLUMNS.map((_, i) => `$${i + 1}`).join(", ");
|
||||||
|
const res = await d.execute(
|
||||||
|
`INSERT INTO invoices (${INVOICE_COLUMNS.join(", ")}) VALUES (${placeholders})`,
|
||||||
|
invoiceValues(inv)
|
||||||
|
);
|
||||||
|
const invoiceId = res.lastInsertId as number;
|
||||||
|
try {
|
||||||
|
await insertInvoiceItems(d, invoiceId, items);
|
||||||
|
} catch (e) {
|
||||||
|
await d.execute("DELETE FROM invoices WHERE id = $1", [invoiceId]);
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
return invoiceId;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateInvoice(
|
||||||
|
id: number,
|
||||||
|
inv: InvoiceInput,
|
||||||
|
items: InvoiceItemInput[]
|
||||||
|
): Promise<void> {
|
||||||
|
const d = await getDb();
|
||||||
|
const assignments = INVOICE_COLUMNS.map((c, i) => `${c} = $${i + 1}`).join(", ");
|
||||||
|
await d.execute(`UPDATE invoices SET ${assignments} WHERE id = $${INVOICE_COLUMNS.length + 1}`, [
|
||||||
|
...invoiceValues(inv),
|
||||||
|
id
|
||||||
|
]);
|
||||||
|
await d.execute("DELETE FROM invoice_items WHERE invoice_id = $1", [id]);
|
||||||
|
await insertInvoiceItems(d, id, items);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteInvoice(id: number): Promise<void> {
|
||||||
|
const d = await getDb();
|
||||||
|
await d.execute("DELETE FROM invoices WHERE id = $1", [id]);
|
||||||
|
}
|
||||||
159
src/lib/money.ts
Normal file
@@ -0,0 +1,159 @@
|
|||||||
|
import type { InvoiceItemInput, PaymentMethod } from "./types";
|
||||||
|
|
||||||
|
export function round2(n: number): number {
|
||||||
|
return Math.round(n * 100) / 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
const plnFormat = new Intl.NumberFormat("pl-PL", {
|
||||||
|
minimumFractionDigits: 2,
|
||||||
|
maximumFractionDigits: 2
|
||||||
|
});
|
||||||
|
|
||||||
|
export function formatMoney(n: number): string {
|
||||||
|
return plnFormat.format(round2(n));
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface VatSummaryRow {
|
||||||
|
rate: number;
|
||||||
|
net: number;
|
||||||
|
tax: number;
|
||||||
|
gross: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface InvoiceTotals {
|
||||||
|
net: number;
|
||||||
|
tax: number;
|
||||||
|
gross: number;
|
||||||
|
byRate: VatSummaryRow[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** VAT liczony od wartości brutto (metoda "w stu"), jak w dotychczasowych fakturach. */
|
||||||
|
export function computeTotals(items: InvoiceItemInput[]): InvoiceTotals {
|
||||||
|
const byRate = new Map<number, VatSummaryRow>();
|
||||||
|
for (const it of items) {
|
||||||
|
const gross = round2(it.quantity * it.unit_price);
|
||||||
|
const net = round2(gross / (1 + it.vat_rate / 100));
|
||||||
|
const tax = round2(gross - net);
|
||||||
|
const row = byRate.get(it.vat_rate) ?? { rate: it.vat_rate, net: 0, tax: 0, gross: 0 };
|
||||||
|
row.net = round2(row.net + net);
|
||||||
|
row.tax = round2(row.tax + tax);
|
||||||
|
row.gross = round2(row.gross + gross);
|
||||||
|
byRate.set(it.vat_rate, row);
|
||||||
|
}
|
||||||
|
const rows = [...byRate.values()].sort((a, b) => b.rate - a.rate);
|
||||||
|
return {
|
||||||
|
net: round2(rows.reduce((s, r) => s + r.net, 0)),
|
||||||
|
tax: round2(rows.reduce((s, r) => s + r.tax, 0)),
|
||||||
|
gross: round2(rows.reduce((s, r) => s + r.gross, 0)),
|
||||||
|
byRate: rows
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export const PAYMENT_METHOD_LABELS: Record<PaymentMethod, string> = {
|
||||||
|
CASH: "Gotówka",
|
||||||
|
CARD: "Karta",
|
||||||
|
CARD_CASH: "Karta + gotówka",
|
||||||
|
SEND: "Przelew"
|
||||||
|
};
|
||||||
|
|
||||||
|
// --- Kwota słownie ---
|
||||||
|
|
||||||
|
const ONES = ["", "jeden", "dwa", "trzy", "cztery", "pięć", "sześć", "siedem", "osiem", "dziewięć"];
|
||||||
|
const TEENS = [
|
||||||
|
"dziesięć",
|
||||||
|
"jedenaście",
|
||||||
|
"dwanaście",
|
||||||
|
"trzynaście",
|
||||||
|
"czternaście",
|
||||||
|
"piętnaście",
|
||||||
|
"szesnaście",
|
||||||
|
"siedemnaście",
|
||||||
|
"osiemnaście",
|
||||||
|
"dziewiętnaście"
|
||||||
|
];
|
||||||
|
const TENS = [
|
||||||
|
"",
|
||||||
|
"",
|
||||||
|
"dwadzieścia",
|
||||||
|
"trzydzieści",
|
||||||
|
"czterdzieści",
|
||||||
|
"pięćdziesiąt",
|
||||||
|
"sześćdziesiąt",
|
||||||
|
"siedemdziesiąt",
|
||||||
|
"osiemdziesiąt",
|
||||||
|
"dziewięćdziesiąt"
|
||||||
|
];
|
||||||
|
const HUNDREDS = [
|
||||||
|
"",
|
||||||
|
"sto",
|
||||||
|
"dwieście",
|
||||||
|
"trzysta",
|
||||||
|
"czterysta",
|
||||||
|
"pięćset",
|
||||||
|
"sześćset",
|
||||||
|
"siedemset",
|
||||||
|
"osiemset",
|
||||||
|
"dziewięćset"
|
||||||
|
];
|
||||||
|
const GROUPS: [string, string, string][] = [
|
||||||
|
["", "", ""],
|
||||||
|
["tysiąc", "tysiące", "tysięcy"],
|
||||||
|
["milion", "miliony", "milionów"],
|
||||||
|
["miliard", "miliardy", "miliardów"]
|
||||||
|
];
|
||||||
|
|
||||||
|
/** 0 = forma "jeden", 1 = "dwa-cztery", 2 = "pięć i więcej" */
|
||||||
|
function pluralForm(n: number): 0 | 1 | 2 {
|
||||||
|
if (n === 1) return 0;
|
||||||
|
const d = n % 10;
|
||||||
|
const t = n % 100;
|
||||||
|
if (d >= 2 && d <= 4 && (t < 12 || t > 14)) return 1;
|
||||||
|
return 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
function tripletToWords(n: number): string {
|
||||||
|
const parts: string[] = [];
|
||||||
|
const h = Math.floor(n / 100);
|
||||||
|
const rest = n % 100;
|
||||||
|
if (h > 0) parts.push(HUNDREDS[h]);
|
||||||
|
if (rest >= 10 && rest < 20) {
|
||||||
|
parts.push(TEENS[rest - 10]);
|
||||||
|
} else {
|
||||||
|
const t = Math.floor(rest / 10);
|
||||||
|
const o = rest % 10;
|
||||||
|
if (t >= 2) parts.push(TENS[t]);
|
||||||
|
if (o > 0) parts.push(ONES[o]);
|
||||||
|
}
|
||||||
|
return parts.join(" ");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function numberToWordsPl(n: number): string {
|
||||||
|
const value = Math.floor(Math.abs(n));
|
||||||
|
if (value === 0) return "zero";
|
||||||
|
const parts: string[] = [];
|
||||||
|
let rest = value;
|
||||||
|
let group = 0;
|
||||||
|
const groupWords: string[] = [];
|
||||||
|
while (rest > 0) {
|
||||||
|
const triplet = rest % 1000;
|
||||||
|
if (triplet > 0) {
|
||||||
|
const groupName = GROUPS[group]?.[pluralForm(triplet)] ?? "";
|
||||||
|
// zwyczajowo "tysiąc", nie "jeden tysiąc"
|
||||||
|
const words = triplet === 1 && group > 0 ? "" : tripletToWords(triplet);
|
||||||
|
groupWords.unshift([words, groupName].filter(Boolean).join(" "));
|
||||||
|
}
|
||||||
|
rest = Math.floor(rest / 1000);
|
||||||
|
group++;
|
||||||
|
}
|
||||||
|
parts.push(...groupWords);
|
||||||
|
return parts.join(" ");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Kwota słownie do faktury, np. "sto dwadzieścia trzy złote 45/100". */
|
||||||
|
export function amountInWordsPl(amount: number): string {
|
||||||
|
const zl = Math.floor(round2(Math.abs(amount)));
|
||||||
|
const gr = Math.round((round2(Math.abs(amount)) - zl) * 100);
|
||||||
|
const zlForms: [string, string, string] = ["złoty", "złote", "złotych"];
|
||||||
|
const zlWord = zl === 0 ? zlForms[2] : zlForms[pluralForm(zl)];
|
||||||
|
return `${numberToWordsPl(zl)} ${zlWord} ${String(gr).padStart(2, "0")}/100`;
|
||||||
|
}
|
||||||
67
src/lib/schedule.svelte.ts
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
import * as db from "./db";
|
||||||
|
import type { Cabin, Reservation, RevenueStats } from "./types";
|
||||||
|
import { isoDate, monthDays } from "./dates";
|
||||||
|
|
||||||
|
export class ScheduleState {
|
||||||
|
year = $state(new Date().getFullYear());
|
||||||
|
month = $state(new Date().getMonth());
|
||||||
|
cabins = $state<Cabin[]>([]);
|
||||||
|
reservations = $state<Reservation[]>([]);
|
||||||
|
monthStats = $state<RevenueStats | null>(null);
|
||||||
|
yearStats = $state<RevenueStats | null>(null);
|
||||||
|
loading = $state(true);
|
||||||
|
error = $state<string | null>(null);
|
||||||
|
|
||||||
|
days = $derived(monthDays(this.year, this.month));
|
||||||
|
|
||||||
|
async reload(): Promise<void> {
|
||||||
|
this.loading = true;
|
||||||
|
this.error = null;
|
||||||
|
try {
|
||||||
|
const from = isoDate(this.year, this.month, 1);
|
||||||
|
const to =
|
||||||
|
this.month === 11 ? isoDate(this.year + 1, 0, 1) : isoDate(this.year, this.month + 1, 1);
|
||||||
|
const [cabins, reservations, monthStats, yearStats] = await Promise.all([
|
||||||
|
db.listCabins(),
|
||||||
|
db.listReservations(from, to),
|
||||||
|
db.revenueStats(from, to),
|
||||||
|
db.revenueStats(isoDate(this.year, 0, 1), isoDate(this.year + 1, 0, 1))
|
||||||
|
]);
|
||||||
|
this.cabins = cabins;
|
||||||
|
this.reservations = reservations;
|
||||||
|
this.monthStats = monthStats;
|
||||||
|
this.yearStats = yearStats;
|
||||||
|
} catch (e) {
|
||||||
|
this.error = `Nie udało się wczytać danych: ${e}`;
|
||||||
|
} finally {
|
||||||
|
this.loading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
prevMonth(): void {
|
||||||
|
if (this.month === 0) {
|
||||||
|
this.month = 11;
|
||||||
|
this.year -= 1;
|
||||||
|
} else {
|
||||||
|
this.month -= 1;
|
||||||
|
}
|
||||||
|
void this.reload();
|
||||||
|
}
|
||||||
|
|
||||||
|
nextMonth(): void {
|
||||||
|
if (this.month === 11) {
|
||||||
|
this.month = 0;
|
||||||
|
this.year += 1;
|
||||||
|
} else {
|
||||||
|
this.month += 1;
|
||||||
|
}
|
||||||
|
void this.reload();
|
||||||
|
}
|
||||||
|
|
||||||
|
goToday(): void {
|
||||||
|
const t = new Date();
|
||||||
|
this.year = t.getFullYear();
|
||||||
|
this.month = t.getMonth();
|
||||||
|
void this.reload();
|
||||||
|
}
|
||||||
|
}
|
||||||
111
src/lib/types.ts
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
export interface Cabin {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
capacity: number | null;
|
||||||
|
sort_order: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ReservationStatus = "confirmed" | "option";
|
||||||
|
|
||||||
|
export interface Reservation {
|
||||||
|
id: number;
|
||||||
|
cabin_id: number;
|
||||||
|
guest_name: string;
|
||||||
|
address: string | null;
|
||||||
|
phone: string | null;
|
||||||
|
num_guests: number | null;
|
||||||
|
deposit_amount: number | null;
|
||||||
|
deposit_paid: boolean;
|
||||||
|
status: ReservationStatus;
|
||||||
|
/** Data przyjazdu w formacie YYYY-MM-DD */
|
||||||
|
arrival: string;
|
||||||
|
/** Data wyjazdu w formacie YYYY-MM-DD */
|
||||||
|
departure: string;
|
||||||
|
notes: string | null;
|
||||||
|
/** Cena za dobę w zł */
|
||||||
|
price_per_night: number | null;
|
||||||
|
/** Stan licznika energii na początku pobytu (kWh) */
|
||||||
|
meter_start: number | null;
|
||||||
|
/** Stan licznika energii na końcu pobytu (kWh) */
|
||||||
|
meter_end: number | null;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ReservationInput = Omit<Reservation, "id" | "created_at">;
|
||||||
|
|
||||||
|
/** Parametry otwarcia dialogu rezerwacji: edycja istniejącej albo nowa w danym domku i terminie. */
|
||||||
|
export type ReservationDialogParams =
|
||||||
|
| { reservation: Reservation }
|
||||||
|
| { cabin_id: number; arrival: string };
|
||||||
|
|
||||||
|
// --- Faktury ---
|
||||||
|
|
||||||
|
export interface Settings {
|
||||||
|
id: number;
|
||||||
|
company_name: string;
|
||||||
|
first_name: string;
|
||||||
|
last_name: string;
|
||||||
|
address: string;
|
||||||
|
nip: string | null;
|
||||||
|
bank_account: string;
|
||||||
|
bank_name: string;
|
||||||
|
issue_place: string;
|
||||||
|
default_pkwiu: string;
|
||||||
|
default_vat_rate: number;
|
||||||
|
/** Cena energii zł/kWh do rozliczania liczników */
|
||||||
|
energy_price: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type PaymentMethod = "CASH" | "CARD" | "CARD_CASH" | "SEND";
|
||||||
|
|
||||||
|
export interface Invoice {
|
||||||
|
id: number;
|
||||||
|
invoice_number: string;
|
||||||
|
reservation_id: number | null;
|
||||||
|
issue_date: string;
|
||||||
|
sale_date: string;
|
||||||
|
payment_method: PaymentMethod;
|
||||||
|
payment_card: number | null;
|
||||||
|
payment_cash: number | null;
|
||||||
|
payment_due: string;
|
||||||
|
buyer_name: string;
|
||||||
|
buyer_address: string | null;
|
||||||
|
buyer_nip: string | null;
|
||||||
|
seller_company: string;
|
||||||
|
seller_person: string;
|
||||||
|
seller_address: string;
|
||||||
|
seller_nip: string | null;
|
||||||
|
seller_bank_account: string;
|
||||||
|
seller_bank_name: string;
|
||||||
|
issue_place: string;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface InvoiceItem {
|
||||||
|
id: number;
|
||||||
|
invoice_id: number;
|
||||||
|
name: string;
|
||||||
|
pkwiu: string | null;
|
||||||
|
quantity: number;
|
||||||
|
/** Cena jednostkowa brutto (VAT liczony od brutto, jak w starym systemie) */
|
||||||
|
unit_price: number;
|
||||||
|
vat_rate: number;
|
||||||
|
position: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type InvoiceItemInput = Omit<InvoiceItem, "id" | "invoice_id" | "position">;
|
||||||
|
export type InvoiceInput = Omit<Invoice, "id" | "created_at">;
|
||||||
|
|
||||||
|
/** Parametry dialogu faktury: edycja istniejącej, nowa z pobytu albo pusta. */
|
||||||
|
export type InvoiceDialogParams =
|
||||||
|
| { invoice: Invoice; items: InvoiceItem[] }
|
||||||
|
| { reservation: Reservation }
|
||||||
|
| { blank: true };
|
||||||
|
|
||||||
|
/** Statystyki przychodu w zakresie dat (noclegi liczone tylko w zakresie). */
|
||||||
|
export interface RevenueStats {
|
||||||
|
nights_total: number;
|
||||||
|
revenue_total: number;
|
||||||
|
nights_confirmed: number;
|
||||||
|
revenue_confirmed: number;
|
||||||
|
}
|
||||||
101
src/routes/+layout.svelte
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { Snippet } from "svelte";
|
||||||
|
import { page } from "$app/state";
|
||||||
|
|
||||||
|
let { children }: { children: Snippet } = $props();
|
||||||
|
|
||||||
|
const links = [
|
||||||
|
{ href: "/", label: "Grafik" },
|
||||||
|
{ href: "/faktury", label: "Faktury" },
|
||||||
|
{ href: "/ustawienia", label: "Ustawienia" }
|
||||||
|
];
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<nav class="topnav">
|
||||||
|
<span class="brand">Słoneczko</span>
|
||||||
|
{#each links as link (link.href)}
|
||||||
|
<a href={link.href} class:active={page.url.pathname === link.href}>{link.label}</a>
|
||||||
|
{/each}
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
{@render children()}
|
||||||
|
|
||||||
|
<style>
|
||||||
|
:global(*) {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
:global(:root) {
|
||||||
|
color-scheme: light dark;
|
||||||
|
--bg: #f7f6f3;
|
||||||
|
--surface: #ffffff;
|
||||||
|
--border: #e2e0da;
|
||||||
|
--text: #1f1f1e;
|
||||||
|
--muted: #6b6a66;
|
||||||
|
--accent: #2f6fb3;
|
||||||
|
--danger: #a32d2d;
|
||||||
|
--warn-text: #854f0b;
|
||||||
|
--weekend: #f1efe8;
|
||||||
|
--today: #e6f1fb;
|
||||||
|
--cell-hover: #ecf3fa;
|
||||||
|
--bar-confirmed-bg: #cfe3f7;
|
||||||
|
--bar-confirmed-text: #0c447c;
|
||||||
|
--bar-option-bg: #fae3b2;
|
||||||
|
--bar-option-text: #633806;
|
||||||
|
}
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
:global(:root) {
|
||||||
|
--bg: #1d1d1b;
|
||||||
|
--surface: #272724;
|
||||||
|
--border: #3d3d39;
|
||||||
|
--text: #ececea;
|
||||||
|
--muted: #a3a29c;
|
||||||
|
--accent: #6aa5e0;
|
||||||
|
--danger: #f09595;
|
||||||
|
--warn-text: #fac775;
|
||||||
|
--weekend: #222220;
|
||||||
|
--today: #253647;
|
||||||
|
--cell-hover: #2e3d4d;
|
||||||
|
--bar-confirmed-bg: #185fa5;
|
||||||
|
--bar-confirmed-text: #e6f1fb;
|
||||||
|
--bar-option-bg: #854f0b;
|
||||||
|
--bar-option-text: #faeeda;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
:global(body) {
|
||||||
|
margin: 0;
|
||||||
|
font-family: "Segoe UI", system-ui, sans-serif;
|
||||||
|
font-size: 14px;
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--text);
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
}
|
||||||
|
.topnav {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 8px 16px;
|
||||||
|
background: var(--surface);
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
.brand {
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 15px;
|
||||||
|
margin-right: 14px;
|
||||||
|
}
|
||||||
|
.topnav a {
|
||||||
|
padding: 6px 12px;
|
||||||
|
border-radius: 7px;
|
||||||
|
color: var(--muted);
|
||||||
|
text-decoration: none;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
.topnav a:hover {
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
.topnav a.active {
|
||||||
|
background: var(--today);
|
||||||
|
color: var(--accent);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
5
src/routes/+layout.ts
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
// Tauri doesn't have a Node.js server to do proper SSR
|
||||||
|
// so we use adapter-static with a fallback to index.html to put the site in SPA mode
|
||||||
|
// See: https://svelte.dev/docs/kit/single-page-apps
|
||||||
|
// See: https://v2.tauri.app/start/frontend/sveltekit/ for more info
|
||||||
|
export const ssr = false;
|
||||||
254
src/routes/+page.svelte
Normal file
@@ -0,0 +1,254 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { onMount } from "svelte";
|
||||||
|
import { ScheduleState } from "$lib/schedule.svelte";
|
||||||
|
import { MONTH_NAMES, todayISO } from "$lib/dates";
|
||||||
|
import ScheduleGrid from "$lib/components/ScheduleGrid.svelte";
|
||||||
|
import ReservationDialog from "$lib/components/ReservationDialog.svelte";
|
||||||
|
import CabinsDialog from "$lib/components/CabinsDialog.svelte";
|
||||||
|
import InvoiceDialog from "$lib/components/InvoiceDialog.svelte";
|
||||||
|
import InvoicePreview from "$lib/components/InvoicePreview.svelte";
|
||||||
|
import { formatMoney } from "$lib/money";
|
||||||
|
import * as db from "$lib/db";
|
||||||
|
import type {
|
||||||
|
Invoice,
|
||||||
|
InvoiceDialogParams,
|
||||||
|
InvoiceItem,
|
||||||
|
Reservation,
|
||||||
|
ReservationDialogParams
|
||||||
|
} from "$lib/types";
|
||||||
|
|
||||||
|
const schedule = new ScheduleState();
|
||||||
|
|
||||||
|
let dialogParams = $state<ReservationDialogParams | null>(null);
|
||||||
|
let cabinsOpen = $state(false);
|
||||||
|
let invoiceParams = $state<InvoiceDialogParams | null>(null);
|
||||||
|
let preview = $state<{ invoice: Invoice; items: InvoiceItem[] } | null>(null);
|
||||||
|
|
||||||
|
async function openPreview(invoiceId: number) {
|
||||||
|
const [invoice, items] = await Promise.all([
|
||||||
|
db.getInvoice(invoiceId),
|
||||||
|
db.getInvoiceItems(invoiceId)
|
||||||
|
]);
|
||||||
|
if (invoice) {
|
||||||
|
preview = { invoice, items };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
void schedule.reload();
|
||||||
|
});
|
||||||
|
|
||||||
|
function newReservation(cabinId?: number, date?: string) {
|
||||||
|
const cid = cabinId ?? schedule.cabins[0]?.id;
|
||||||
|
if (cid == null) {
|
||||||
|
cabinsOpen = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
dialogParams = { cabin_id: cid, arrival: date ?? todayISO() };
|
||||||
|
}
|
||||||
|
|
||||||
|
function editReservation(r: Reservation) {
|
||||||
|
dialogParams = { reservation: r };
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<title>Słoneczko — grafik rezerwacji</title>
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
<main>
|
||||||
|
<header class="toolbar">
|
||||||
|
<button type="button" class="icon" onclick={() => schedule.prevMonth()} aria-label="Poprzedni miesiąc">‹</button>
|
||||||
|
<span class="month">{MONTH_NAMES[schedule.month]} {schedule.year}</span>
|
||||||
|
<button type="button" class="icon" onclick={() => schedule.nextMonth()} aria-label="Następny miesiąc">›</button>
|
||||||
|
<button type="button" onclick={() => schedule.goToday()}>Dziś</button>
|
||||||
|
<span class="spacer"></span>
|
||||||
|
<button type="button" onclick={() => (cabinsOpen = true)}>Domki</button>
|
||||||
|
<button type="button" class="primary" onclick={() => newReservation()}>+ Nowa rezerwacja</button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{#if schedule.monthStats && schedule.yearStats}
|
||||||
|
<div class="stats">
|
||||||
|
<span>
|
||||||
|
<strong>{MONTH_NAMES[schedule.month]}:</strong>
|
||||||
|
{Math.round(schedule.monthStats.nights_total)} dób ·
|
||||||
|
{formatMoney(schedule.monthStats.revenue_total)} zł
|
||||||
|
<span class="confirmed">(potwierdzone: {formatMoney(schedule.monthStats.revenue_confirmed)} zł)</span>
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
<strong>Rok {schedule.year}:</strong>
|
||||||
|
{Math.round(schedule.yearStats.nights_total)} dób ·
|
||||||
|
{formatMoney(schedule.yearStats.revenue_total)} zł
|
||||||
|
<span class="confirmed">(potwierdzone: {formatMoney(schedule.yearStats.revenue_confirmed)} zł)</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if schedule.error}
|
||||||
|
<p class="error">{schedule.error}</p>
|
||||||
|
{:else if !schedule.loading && schedule.cabins.length === 0}
|
||||||
|
<div class="empty">
|
||||||
|
<p>Nie ma jeszcze żadnych domków. Dodaj je, aby zacząć planować rezerwacje.</p>
|
||||||
|
<button type="button" class="primary" onclick={() => (cabinsOpen = true)}>Dodaj domki</button>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<ScheduleGrid
|
||||||
|
cabins={schedule.cabins}
|
||||||
|
days={schedule.days}
|
||||||
|
reservations={schedule.reservations}
|
||||||
|
onCellClick={(cabinId, date) => newReservation(cabinId, date)}
|
||||||
|
onReservationClick={editReservation}
|
||||||
|
/>
|
||||||
|
<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>
|
||||||
|
</footer>
|
||||||
|
{/if}
|
||||||
|
</main>
|
||||||
|
|
||||||
|
{#if dialogParams}
|
||||||
|
<ReservationDialog
|
||||||
|
params={dialogParams}
|
||||||
|
cabins={schedule.cabins}
|
||||||
|
onclose={() => (dialogParams = null)}
|
||||||
|
onsaved={() => {
|
||||||
|
dialogParams = null;
|
||||||
|
void schedule.reload();
|
||||||
|
}}
|
||||||
|
oninvoice={(reservation) => {
|
||||||
|
dialogParams = null;
|
||||||
|
invoiceParams = { reservation };
|
||||||
|
void schedule.reload();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if invoiceParams}
|
||||||
|
<InvoiceDialog
|
||||||
|
params={invoiceParams}
|
||||||
|
onclose={() => (invoiceParams = null)}
|
||||||
|
onsaved={(id) => {
|
||||||
|
invoiceParams = null;
|
||||||
|
void openPreview(id);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if preview}
|
||||||
|
<InvoicePreview invoice={preview.invoice} items={preview.items} onclose={() => (preview = null)} />
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if cabinsOpen}
|
||||||
|
<CabinsDialog
|
||||||
|
cabins={schedule.cabins}
|
||||||
|
onclose={() => (cabinsOpen = false)}
|
||||||
|
onchanged={() => void schedule.reload()}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<style>
|
||||||
|
main {
|
||||||
|
padding: 14px 16px;
|
||||||
|
}
|
||||||
|
.toolbar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
.toolbar .month {
|
||||||
|
min-width: 150px;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.toolbar .spacer {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
.toolbar button {
|
||||||
|
font: inherit;
|
||||||
|
font-size: 14px;
|
||||||
|
padding: 7px 14px;
|
||||||
|
border-radius: 7px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
background: var(--surface);
|
||||||
|
color: var(--text);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.toolbar button.icon {
|
||||||
|
width: 34px;
|
||||||
|
padding: 7px 0;
|
||||||
|
font-size: 16px;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
.toolbar button.primary {
|
||||||
|
background: var(--accent);
|
||||||
|
border-color: var(--accent);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
.toolbar button:hover:not(.primary) {
|
||||||
|
background: var(--bg);
|
||||||
|
}
|
||||||
|
.error {
|
||||||
|
color: var(--danger);
|
||||||
|
}
|
||||||
|
.stats {
|
||||||
|
display: flex;
|
||||||
|
gap: 24px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
.stats strong {
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
.stats .confirmed {
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
.empty {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 60px 20px;
|
||||||
|
border: 1px dashed var(--border);
|
||||||
|
border-radius: 10px;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
.empty button.primary {
|
||||||
|
font: inherit;
|
||||||
|
padding: 8px 16px;
|
||||||
|
border-radius: 7px;
|
||||||
|
border: 1px solid var(--accent);
|
||||||
|
background: var(--accent);
|
||||||
|
color: #fff;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.legend {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 18px;
|
||||||
|
margin-top: 10px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
.legend .sw {
|
||||||
|
display: inline-block;
|
||||||
|
width: 12px;
|
||||||
|
height: 12px;
|
||||||
|
border-radius: 3px;
|
||||||
|
vertical-align: -2px;
|
||||||
|
margin-right: 6px;
|
||||||
|
}
|
||||||
|
.legend .sw.confirmed {
|
||||||
|
background: var(--bar-confirmed-bg);
|
||||||
|
}
|
||||||
|
.legend .sw.option {
|
||||||
|
background: var(--bar-option-bg);
|
||||||
|
}
|
||||||
|
.legend .hint {
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
215
src/routes/faktury/+page.svelte
Normal file
@@ -0,0 +1,215 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { onMount } from "svelte";
|
||||||
|
import * as db from "$lib/db";
|
||||||
|
import { formatShort } from "$lib/dates";
|
||||||
|
import { formatMoney, PAYMENT_METHOD_LABELS } from "$lib/money";
|
||||||
|
import InvoiceDialog from "$lib/components/InvoiceDialog.svelte";
|
||||||
|
import InvoicePreview from "$lib/components/InvoicePreview.svelte";
|
||||||
|
import type { Invoice, InvoiceDialogParams, InvoiceItem } from "$lib/types";
|
||||||
|
|
||||||
|
let invoices = $state<(Invoice & { gross: number })[]>([]);
|
||||||
|
let loading = $state(true);
|
||||||
|
let error = $state<string | null>(null);
|
||||||
|
let dialogParams = $state<InvoiceDialogParams | null>(null);
|
||||||
|
let preview = $state<{ invoice: Invoice; items: InvoiceItem[] } | null>(null);
|
||||||
|
|
||||||
|
async function reload() {
|
||||||
|
loading = true;
|
||||||
|
error = null;
|
||||||
|
try {
|
||||||
|
invoices = await db.listInvoices();
|
||||||
|
} catch (e) {
|
||||||
|
error = `Nie udało się wczytać faktur: ${e}`;
|
||||||
|
} finally {
|
||||||
|
loading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
void reload();
|
||||||
|
});
|
||||||
|
|
||||||
|
async function openPreview(invoice: Invoice) {
|
||||||
|
preview = { invoice, items: await db.getInvoiceItems(invoice.id) };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function edit(invoice: Invoice) {
|
||||||
|
dialogParams = { invoice, items: await db.getInvoiceItems(invoice.id) };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function remove(invoice: Invoice) {
|
||||||
|
if (!confirm(`Usunąć fakturę ${invoice.invoice_number} (${invoice.buyer_name})?`)) return;
|
||||||
|
try {
|
||||||
|
await db.deleteInvoice(invoice.id);
|
||||||
|
await reload();
|
||||||
|
} catch (e) {
|
||||||
|
error = `Nie udało się usunąć faktury: ${e}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<title>Słoneczko — faktury</title>
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
<main>
|
||||||
|
<header class="toolbar">
|
||||||
|
<h1>Faktury</h1>
|
||||||
|
<span class="spacer"></span>
|
||||||
|
<button type="button" class="primary" onclick={() => (dialogParams = { blank: true })}>
|
||||||
|
+ Nowa faktura
|
||||||
|
</button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{#if error}
|
||||||
|
<p class="error">{error}</p>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if !loading && invoices.length === 0}
|
||||||
|
<div class="empty">
|
||||||
|
<p>Nie wystawiono jeszcze żadnych faktur.</p>
|
||||||
|
<p class="hint">
|
||||||
|
Fakturę wystawisz z poziomu pobytu na grafiku (przycisk „Wystaw fakturę") albo przyciskiem powyżej.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{:else if invoices.length > 0}
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Numer</th>
|
||||||
|
<th>Data wystawienia</th>
|
||||||
|
<th>Sprzedaż</th>
|
||||||
|
<th>Nabywca</th>
|
||||||
|
<th class="num">Brutto</th>
|
||||||
|
<th>Płatność</th>
|
||||||
|
<th></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{#each invoices as invoice (invoice.id)}
|
||||||
|
<tr>
|
||||||
|
<td class="strong">{invoice.invoice_number}</td>
|
||||||
|
<td>{formatShort(invoice.issue_date)}.{invoice.issue_date.slice(0, 4)}</td>
|
||||||
|
<td>{formatShort(invoice.sale_date)}.{invoice.sale_date.slice(0, 4)}</td>
|
||||||
|
<td>{invoice.buyer_name}</td>
|
||||||
|
<td class="num strong">{formatMoney(invoice.gross)} zł</td>
|
||||||
|
<td>{PAYMENT_METHOD_LABELS[invoice.payment_method]}</td>
|
||||||
|
<td class="row-actions">
|
||||||
|
<button type="button" onclick={() => openPreview(invoice)}>Podgląd</button>
|
||||||
|
<button type="button" onclick={() => edit(invoice)}>Edytuj</button>
|
||||||
|
<button type="button" class="danger" onclick={() => remove(invoice)}>Usuń</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{/each}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{/if}
|
||||||
|
</main>
|
||||||
|
|
||||||
|
{#if dialogParams}
|
||||||
|
<InvoiceDialog
|
||||||
|
params={dialogParams}
|
||||||
|
onclose={() => (dialogParams = null)}
|
||||||
|
onsaved={async (id) => {
|
||||||
|
dialogParams = null;
|
||||||
|
await reload();
|
||||||
|
const invoice = invoices.find((i) => i.id === id);
|
||||||
|
if (invoice) void openPreview(invoice);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if preview}
|
||||||
|
<InvoicePreview invoice={preview.invoice} items={preview.items} onclose={() => (preview = null)} />
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<style>
|
||||||
|
main {
|
||||||
|
padding: 14px 16px;
|
||||||
|
}
|
||||||
|
.toolbar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 14px;
|
||||||
|
}
|
||||||
|
h1 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 18px;
|
||||||
|
}
|
||||||
|
.spacer {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
.toolbar button.primary {
|
||||||
|
font: inherit;
|
||||||
|
font-size: 14px;
|
||||||
|
padding: 7px 14px;
|
||||||
|
border-radius: 7px;
|
||||||
|
border: 1px solid var(--accent);
|
||||||
|
background: var(--accent);
|
||||||
|
color: #fff;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.error {
|
||||||
|
color: var(--danger);
|
||||||
|
}
|
||||||
|
.empty {
|
||||||
|
padding: 60px 20px;
|
||||||
|
text-align: center;
|
||||||
|
border: 1px dashed var(--border);
|
||||||
|
border-radius: 10px;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
.empty .hint {
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 10px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
th,
|
||||||
|
td {
|
||||||
|
padding: 9px 12px;
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
.strong {
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.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:hover {
|
||||||
|
background: var(--bg);
|
||||||
|
}
|
||||||
|
.row-actions button.danger {
|
||||||
|
color: var(--danger);
|
||||||
|
border-color: var(--danger);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
192
src/routes/ustawienia/+page.svelte
Normal file
@@ -0,0 +1,192 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { onMount } from "svelte";
|
||||||
|
import * as db from "$lib/db";
|
||||||
|
import type { Settings } from "$lib/types";
|
||||||
|
|
||||||
|
let settings = $state<Settings | null>(null);
|
||||||
|
let error = $state<string | null>(null);
|
||||||
|
let savedFlash = $state(false);
|
||||||
|
|
||||||
|
onMount(async () => {
|
||||||
|
try {
|
||||||
|
settings = await db.getSettings();
|
||||||
|
} catch (e) {
|
||||||
|
error = `Nie udało się wczytać ustawień: ${e}`;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
async function save(event: SubmitEvent) {
|
||||||
|
event.preventDefault();
|
||||||
|
if (!settings) return;
|
||||||
|
error = null;
|
||||||
|
try {
|
||||||
|
await db.updateSettings({
|
||||||
|
...settings,
|
||||||
|
nip: settings.nip?.trim() || null,
|
||||||
|
energy_price:
|
||||||
|
settings.energy_price == null || Number.isNaN(settings.energy_price)
|
||||||
|
? null
|
||||||
|
: settings.energy_price
|
||||||
|
});
|
||||||
|
savedFlash = true;
|
||||||
|
setTimeout(() => (savedFlash = false), 2500);
|
||||||
|
} catch (e) {
|
||||||
|
error = `Nie udało się zapisać ustawień: ${e}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<title>Słoneczko — ustawienia</title>
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
<main>
|
||||||
|
<h1>Ustawienia</h1>
|
||||||
|
|
||||||
|
{#if error}
|
||||||
|
<p class="error">{error}</p>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if settings}
|
||||||
|
<form onsubmit={save}>
|
||||||
|
<section>
|
||||||
|
<h2>Dane sprzedawcy (do faktur)</h2>
|
||||||
|
<div class="grid2">
|
||||||
|
<label class="wide">
|
||||||
|
Nazwa firmy
|
||||||
|
<input type="text" bind:value={settings.company_name} />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Imię
|
||||||
|
<input type="text" bind:value={settings.first_name} />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Nazwisko
|
||||||
|
<input type="text" bind:value={settings.last_name} />
|
||||||
|
</label>
|
||||||
|
<label class="wide">
|
||||||
|
Adres
|
||||||
|
<input type="text" bind:value={settings.address} />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
NIP
|
||||||
|
<input type="text" bind:value={settings.nip} />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Miejsce wystawienia faktur
|
||||||
|
<input type="text" bind:value={settings.issue_place} placeholder="np. Dźwirzyno" />
|
||||||
|
</label>
|
||||||
|
<label class="wide">
|
||||||
|
Nazwa banku
|
||||||
|
<input type="text" bind:value={settings.bank_name} />
|
||||||
|
</label>
|
||||||
|
<label class="wide">
|
||||||
|
Rachunek bankowy
|
||||||
|
<input type="text" bind:value={settings.bank_account} />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h2>Domyślne wartości faktury</h2>
|
||||||
|
<div class="grid2">
|
||||||
|
<label>
|
||||||
|
Symbol PKWiU
|
||||||
|
<input type="text" bind:value={settings.default_pkwiu} />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Stawka VAT (%)
|
||||||
|
<input type="number" min="0" max="100" step="1" bind:value={settings.default_vat_rate} />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Cena energii (zł/kWh)
|
||||||
|
<input type="number" min="0" step="0.01" bind:value={settings.energy_price} />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<p class="hint">
|
||||||
|
Cena energii służy do automatycznego wyliczenia pozycji „Energia elektryczna" ze stanów
|
||||||
|
licznika przy wystawianiu faktury do pobytu.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div class="actions">
|
||||||
|
{#if savedFlash}<span class="saved">Zapisano</span>{/if}
|
||||||
|
<button type="submit" class="primary">Zapisz ustawienia</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
{/if}
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
main {
|
||||||
|
padding: 14px 16px;
|
||||||
|
max-width: 640px;
|
||||||
|
}
|
||||||
|
h1 {
|
||||||
|
margin: 0 0 16px;
|
||||||
|
font-size: 18px;
|
||||||
|
}
|
||||||
|
section {
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 16px;
|
||||||
|
margin-bottom: 14px;
|
||||||
|
}
|
||||||
|
h2 {
|
||||||
|
margin: 0 0 12px;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
.grid2 {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 10px 14px;
|
||||||
|
}
|
||||||
|
label {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
label.wide {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
}
|
||||||
|
input {
|
||||||
|
font: inherit;
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--text);
|
||||||
|
background: var(--bg);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 7px 9px;
|
||||||
|
}
|
||||||
|
.hint {
|
||||||
|
margin: 10px 0 0;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
.error {
|
||||||
|
color: var(--danger);
|
||||||
|
}
|
||||||
|
.actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
.saved {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
.actions button.primary {
|
||||||
|
font: inherit;
|
||||||
|
font-size: 14px;
|
||||||
|
padding: 8px 16px;
|
||||||
|
border-radius: 7px;
|
||||||
|
border: 1px solid var(--accent);
|
||||||
|
background: var(--accent);
|
||||||
|
color: #fff;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
BIN
static/favicon.png
Normal file
|
After Width: | Height: | Size: 1.5 KiB |
1
static/svelte.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="26.6" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 308"><path fill="#FF3E00" d="M239.682 40.707C211.113-.182 154.69-12.301 113.895 13.69L42.247 59.356a82.198 82.198 0 0 0-37.135 55.056a86.566 86.566 0 0 0 8.536 55.576a82.425 82.425 0 0 0-12.296 30.719a87.596 87.596 0 0 0 14.964 66.244c28.574 40.893 84.997 53.007 125.787 27.016l71.648-45.664a82.182 82.182 0 0 0 37.135-55.057a86.601 86.601 0 0 0-8.53-55.577a82.409 82.409 0 0 0 12.29-30.718a87.573 87.573 0 0 0-14.963-66.244"></path><path fill="#FFF" d="M106.889 270.841c-23.102 6.007-47.497-3.036-61.103-22.648a52.685 52.685 0 0 1-9.003-39.85a49.978 49.978 0 0 1 1.713-6.693l1.35-4.115l3.671 2.697a92.447 92.447 0 0 0 28.036 14.007l2.663.808l-.245 2.659a16.067 16.067 0 0 0 2.89 10.656a17.143 17.143 0 0 0 18.397 6.828a15.786 15.786 0 0 0 4.403-1.935l71.67-45.672a14.922 14.922 0 0 0 6.734-9.977a15.923 15.923 0 0 0-2.713-12.011a17.156 17.156 0 0 0-18.404-6.832a15.78 15.78 0 0 0-4.396 1.933l-27.35 17.434a52.298 52.298 0 0 1-14.553 6.391c-23.101 6.007-47.497-3.036-61.101-22.649a52.681 52.681 0 0 1-9.004-39.849a49.428 49.428 0 0 1 22.34-33.114l71.664-45.677a52.218 52.218 0 0 1 14.563-6.398c23.101-6.007 47.497 3.036 61.101 22.648a52.685 52.685 0 0 1 9.004 39.85a50.559 50.559 0 0 1-1.713 6.692l-1.35 4.116l-3.67-2.693a92.373 92.373 0 0 0-28.037-14.013l-2.664-.809l.246-2.658a16.099 16.099 0 0 0-2.89-10.656a17.143 17.143 0 0 0-18.398-6.828a15.786 15.786 0 0 0-4.402 1.935l-71.67 45.674a14.898 14.898 0 0 0-6.73 9.975a15.9 15.9 0 0 0 2.709 12.012a17.156 17.156 0 0 0 18.404 6.832a15.841 15.841 0 0 0 4.402-1.935l27.345-17.427a52.147 52.147 0 0 1 14.552-6.397c23.101-6.006 47.497 3.037 61.102 22.65a52.681 52.681 0 0 1 9.003 39.848a49.453 49.453 0 0 1-22.34 33.12l-71.664 45.673a52.218 52.218 0 0 1-14.563 6.398"></path></svg>
|
||||||
|
After Width: | Height: | Size: 1.9 KiB |
6
static/tauri.svg
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
<svg width="206" height="231" viewBox="0 0 206 231" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M143.143 84C143.143 96.1503 133.293 106 121.143 106C108.992 106 99.1426 96.1503 99.1426 84C99.1426 71.8497 108.992 62 121.143 62C133.293 62 143.143 71.8497 143.143 84Z" fill="#FFC131"/>
|
||||||
|
<ellipse cx="84.1426" cy="147" rx="22" ry="22" transform="rotate(180 84.1426 147)" fill="#24C8DB"/>
|
||||||
|
<path fill-rule="evenodd" clip-rule="evenodd" d="M166.738 154.548C157.86 160.286 148.023 164.269 137.757 166.341C139.858 160.282 141 153.774 141 147C141 144.543 140.85 142.121 140.558 139.743C144.975 138.204 149.215 136.139 153.183 133.575C162.73 127.404 170.292 118.608 174.961 108.244C179.63 97.8797 181.207 86.3876 179.502 75.1487C177.798 63.9098 172.884 53.4021 165.352 44.8883C157.82 36.3744 147.99 30.2165 137.042 27.1546C126.095 24.0926 114.496 24.2568 103.64 27.6274C92.7839 30.998 83.1319 37.4317 75.8437 46.1553C74.9102 47.2727 74.0206 48.4216 73.176 49.5993C61.9292 50.8488 51.0363 54.0318 40.9629 58.9556C44.2417 48.4586 49.5653 38.6591 56.679 30.1442C67.0505 17.7298 80.7861 8.57426 96.2354 3.77762C111.685 -1.01901 128.19 -1.25267 143.769 3.10474C159.348 7.46215 173.337 16.2252 184.056 28.3411C194.775 40.457 201.767 55.4101 204.193 71.404C206.619 87.3978 204.374 103.752 197.73 118.501C191.086 133.25 180.324 145.767 166.738 154.548ZM41.9631 74.275L62.5557 76.8042C63.0459 72.813 63.9401 68.9018 65.2138 65.1274C57.0465 67.0016 49.2088 70.087 41.9631 74.275Z" fill="#FFC131"/>
|
||||||
|
<path fill-rule="evenodd" clip-rule="evenodd" d="M38.4045 76.4519C47.3493 70.6709 57.2677 66.6712 67.6171 64.6132C65.2774 70.9669 64 77.8343 64 85.0001C64 87.1434 64.1143 89.26 64.3371 91.3442C60.0093 92.8732 55.8533 94.9092 51.9599 97.4256C42.4128 103.596 34.8505 112.392 30.1816 122.756C25.5126 133.12 23.9357 144.612 25.6403 155.851C27.3449 167.09 32.2584 177.598 39.7906 186.112C47.3227 194.626 57.153 200.784 68.1003 203.846C79.0476 206.907 90.6462 206.743 101.502 203.373C112.359 200.002 122.011 193.568 129.299 184.845C130.237 183.722 131.131 182.567 131.979 181.383C143.235 180.114 154.132 176.91 164.205 171.962C160.929 182.49 155.596 192.319 148.464 200.856C138.092 213.27 124.357 222.426 108.907 227.222C93.458 232.019 76.9524 232.253 61.3736 227.895C45.7948 223.538 31.8055 214.775 21.0867 202.659C10.3679 190.543 3.37557 175.59 0.949823 159.596C-1.47592 143.602 0.768139 127.248 7.41237 112.499C14.0566 97.7497 24.8183 85.2327 38.4045 76.4519ZM163.062 156.711L163.062 156.711C162.954 156.773 162.846 156.835 162.738 156.897C162.846 156.835 162.954 156.773 163.062 156.711Z" fill="#24C8DB"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 2.5 KiB |
1
static/vite.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>
|
||||||
|
After Width: | Height: | Size: 1.5 KiB |
18
svelte.config.js
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
// Tauri doesn't have a Node.js server to do proper SSR
|
||||||
|
// so we use adapter-static with a fallback to index.html to put the site in SPA mode
|
||||||
|
// See: https://svelte.dev/docs/kit/single-page-apps
|
||||||
|
// See: https://v2.tauri.app/start/frontend/sveltekit/ for more info
|
||||||
|
import adapter from "@sveltejs/adapter-static";
|
||||||
|
import { vitePreprocess } from "@sveltejs/vite-plugin-svelte";
|
||||||
|
|
||||||
|
/** @type {import('@sveltejs/kit').Config} */
|
||||||
|
const config = {
|
||||||
|
preprocess: vitePreprocess(),
|
||||||
|
kit: {
|
||||||
|
adapter: adapter({
|
||||||
|
fallback: "index.html",
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default config;
|
||||||
19
tsconfig.json
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
{
|
||||||
|
"extends": "./.svelte-kit/tsconfig.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"allowJs": true,
|
||||||
|
"checkJs": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"sourceMap": true,
|
||||||
|
"strict": true,
|
||||||
|
"moduleResolution": "bundler"
|
||||||
|
}
|
||||||
|
// Path aliases are handled by https://svelte.dev/docs/kit/configuration#alias
|
||||||
|
// except $lib which is handled by https://svelte.dev/docs/kit/configuration#files
|
||||||
|
//
|
||||||
|
// If you want to overwrite includes/excludes, make sure to copy over the relevant includes/excludes
|
||||||
|
// from the referenced tsconfig.json - TypeScript does not merge them in
|
||||||
|
}
|
||||||
32
vite.config.js
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
import { defineConfig } from "vite";
|
||||||
|
import { sveltekit } from "@sveltejs/kit/vite";
|
||||||
|
|
||||||
|
// @ts-expect-error process is a nodejs global
|
||||||
|
const host = process.env.TAURI_DEV_HOST;
|
||||||
|
|
||||||
|
// https://vite.dev/config/
|
||||||
|
export default defineConfig(async () => ({
|
||||||
|
plugins: [sveltekit()],
|
||||||
|
|
||||||
|
// Vite options tailored for Tauri development and only applied in `tauri dev` or `tauri build`
|
||||||
|
//
|
||||||
|
// 1. prevent Vite from obscuring rust errors
|
||||||
|
clearScreen: false,
|
||||||
|
// 2. tauri expects a fixed port, fail if that port is not available
|
||||||
|
server: {
|
||||||
|
port: 1420,
|
||||||
|
strictPort: true,
|
||||||
|
host: host || false,
|
||||||
|
hmr: host
|
||||||
|
? {
|
||||||
|
protocol: "ws",
|
||||||
|
host,
|
||||||
|
port: 1421,
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
|
watch: {
|
||||||
|
// 3. tell Vite to ignore watching `src-tauri`
|
||||||
|
ignored: ["**/src-tauri/**"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}));
|
||||||