add some features

This commit is contained in:
Kazimierz Ciołek
2026-07-07 15:25:57 +02:00
parent b38aa268ef
commit 3175ea81fe
20 changed files with 2859 additions and 156 deletions

1
.gitignore vendored
View File

@@ -9,3 +9,4 @@ node_modules
vite.config.js.timestamp-* vite.config.js.timestamp-*
vite.config.ts.timestamp-* vite.config.ts.timestamp-*
/faktury /faktury
/trainhub-flutter

10
package-lock.json generated
View File

@@ -11,6 +11,7 @@
"dependencies": { "dependencies": {
"@tauri-apps/api": "^2", "@tauri-apps/api": "^2",
"@tauri-apps/plugin-dialog": "^2.7.1", "@tauri-apps/plugin-dialog": "^2.7.1",
"@tauri-apps/plugin-http": "^2.5.9",
"@tauri-apps/plugin-opener": "^2", "@tauri-apps/plugin-opener": "^2",
"@tauri-apps/plugin-sql": "^2.4.0" "@tauri-apps/plugin-sql": "^2.4.0"
}, },
@@ -1229,6 +1230,15 @@
"@tauri-apps/api": "^2.11.0" "@tauri-apps/api": "^2.11.0"
} }
}, },
"node_modules/@tauri-apps/plugin-http": {
"version": "2.5.9",
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-http/-/plugin-http-2.5.9.tgz",
"integrity": "sha512-lCiY0+vs4HvIUSvZrBs8TC3TiCB0MOPRmiUjTq4prW7SlcJE2jdLeT6KBsJrT9Tlplufl7W1pY6SFAO3gCWxDA==",
"license": "MIT OR Apache-2.0",
"dependencies": {
"@tauri-apps/api": "^2.11.0"
}
},
"node_modules/@tauri-apps/plugin-opener": { "node_modules/@tauri-apps/plugin-opener": {
"version": "2.5.4", "version": "2.5.4",
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-opener/-/plugin-opener-2.5.4.tgz", "resolved": "https://registry.npmjs.org/@tauri-apps/plugin-opener/-/plugin-opener-2.5.4.tgz",

View File

@@ -15,6 +15,7 @@
"dependencies": { "dependencies": {
"@tauri-apps/api": "^2", "@tauri-apps/api": "^2",
"@tauri-apps/plugin-dialog": "^2.7.1", "@tauri-apps/plugin-dialog": "^2.7.1",
"@tauri-apps/plugin-http": "^2.5.9",
"@tauri-apps/plugin-opener": "^2", "@tauri-apps/plugin-opener": "^2",
"@tauri-apps/plugin-sql": "^2.4.0" "@tauri-apps/plugin-sql": "^2.4.0"
}, },

967
src-tauri/Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -25,4 +25,9 @@ serde_json = "1"
tauri-plugin-sql = { version = "2.4.0", features = ["sqlite"] } tauri-plugin-sql = { version = "2.4.0", features = ["sqlite"] }
tauri-plugin-dialog = "2.7.1" tauri-plugin-dialog = "2.7.1"
chrono = "0.4.45" chrono = "0.4.45"
tauri-plugin-http = "2.5.9"
reqwest = { version = "0.13.4", features = ["stream"] }
zip = "8.6.0"
futures-util = "0.3.32"
tokio = { version = "1.52.3", features = ["time"] }

View File

@@ -8,6 +8,15 @@
"opener:default", "opener:default",
"sql:default", "sql:default",
"sql:allow-execute", "sql:allow-execute",
"dialog:default" "dialog:default",
{
"identifier": "http:default",
"allow": [
{ "url": "https://api.anthropic.com/*" },
{ "url": "https://api.openai.com/*" },
{ "url": "http://localhost:*/*" },
{ "url": "http://127.0.0.1:*/*" }
]
}
] ]
} }

View File

@@ -1,3 +1,8 @@
mod llama;
use std::sync::Mutex;
use tauri::Manager;
use tauri_plugin_sql::{Migration, MigrationKind}; use tauri_plugin_sql::{Migration, MigrationKind};
fn migrations() -> Vec<Migration> { fn migrations() -> Vec<Migration> {
@@ -148,14 +153,35 @@ fn migrations() -> Vec<Migration> {
UPDATE reservations SET prepared = 1 WHERE arrival < date('now'); UPDATE reservations SET prepared = 1 WHERE arrival < date('now');
", ",
kind: MigrationKind::Up, kind: MigrationKind::Up,
},
Migration {
version: 6,
description: "add_season_costs_ai",
sql: "
ALTER TABLE settings ADD COLUMN season_start_month INTEGER NOT NULL DEFAULT 5;
ALTER TABLE settings ADD COLUMN season_end_month INTEGER NOT NULL DEFAULT 9;
ALTER TABLE settings ADD COLUMN linen_cost REAL;
ALTER TABLE settings ADD COLUMN ai_provider TEXT NOT NULL DEFAULT 'anthropic';
ALTER TABLE settings ADD COLUMN ai_api_key TEXT;
ALTER TABLE settings ADD COLUMN ai_model TEXT;
ALTER TABLE settings ADD COLUMN ai_base_url TEXT;
CREATE TABLE costs (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL,
amount REAL NOT NULL,
period TEXT NOT NULL DEFAULT 'year',
year INTEGER,
notes TEXT
);
",
kind: MigrationKind::Up,
}] }]
} }
/// Kopia bazy przy starcie (zanim plugin SQL otworzy połączenia): /// Kopia bazy przy starcie (zanim plugin SQL otworzy połączenia):
/// backups/sloneczko-RRRR-MM-DD.db obok pliku bazy, retencja 14 ostatnich. /// backups/sloneczko-RRRR-MM-DD.db obok pliku bazy, retencja 14 ostatnich.
fn auto_backup(app: &tauri::AppHandle) -> std::io::Result<()> { fn auto_backup(app: &tauri::AppHandle) -> std::io::Result<()> {
use tauri::Manager;
let candidates = [app.path().app_config_dir(), app.path().app_data_dir()]; let candidates = [app.path().app_config_dir(), app.path().app_data_dir()];
for dir in candidates.into_iter().flatten() { for dir in candidates.into_iter().flatten() {
let db = dir.join("sloneczko.db"); let db = dir.join("sloneczko.db");
@@ -195,7 +221,14 @@ fn save_text_file(path: String, contents: String) -> Result<(), String> {
#[cfg_attr(mobile, tauri::mobile_entry_point)] #[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() { pub fn run() {
tauri::Builder::default() tauri::Builder::default()
.invoke_handler(tauri::generate_handler![save_text_file]) .manage(llama::LlamaState(Mutex::new(None)))
.invoke_handler(tauri::generate_handler![
save_text_file,
llama::llama_status,
llama::llama_download,
llama::llama_start,
llama::llama_stop
])
.setup(|app| { .setup(|app| {
if let Err(e) = auto_backup(app.handle()) { if let Err(e) = auto_backup(app.handle()) {
eprintln!("Nie udało się wykonać kopii zapasowej: {e}"); eprintln!("Nie udało się wykonać kopii zapasowej: {e}");
@@ -208,7 +241,16 @@ pub fn run() {
.build(), .build(),
) )
.plugin(tauri_plugin_dialog::init()) .plugin(tauri_plugin_dialog::init())
.plugin(tauri_plugin_http::init())
.plugin(tauri_plugin_opener::init()) .plugin(tauri_plugin_opener::init())
.run(tauri::generate_context!()) .build(tauri::generate_context!())
.expect("error while running tauri application"); .expect("error while running tauri application")
.run(|app, event| {
// llama-server nie może przeżyć aplikacji — zombie zjadałby RAM
if let tauri::RunEvent::Exit = event {
if let Some(state) = app.try_state::<llama::LlamaState>() {
llama::kill(&state);
}
}
});
} }

328
src-tauri/src/llama.rs Normal file
View File

@@ -0,0 +1,328 @@
//! Wbudowany serwer AI: zarządzanie procesem llama.cpp (llama-server)
//! na wzór AiProcessManager z trainhub-flutter — pobieranie binarki i modelu,
//! start z fallbackiem GPU -> CPU, health-poll, ubijanie przy zamknięciu.
use std::fs;
use std::io::Write;
use std::path::{Path, PathBuf};
use std::process::{Child, Command, Stdio};
use std::sync::Mutex;
use std::time::{Duration, Instant};
use futures_util::StreamExt;
use serde::Serialize;
use tauri::{AppHandle, Emitter, Manager, State};
pub const PORT: u16 = 8123;
const LLAMA_BUILD: &str = "b8130";
const SERVER_BINARY: &str = "llama-server.exe";
const MODEL_FILE: &str = "qwen3-4b-instruct-2507-q4_k_m.gguf";
const MODEL_URL: &str = "https://huggingface.co/bartowski/Qwen_Qwen3-4B-Instruct-2507-GGUF/resolve/main/Qwen_Qwen3-4B-Instruct-2507-Q4_K_M.gguf";
// Plik mniejszy niż to minimum to pozostałość przerwanego pobierania.
const MODEL_MIN_BYTES: u64 = 2_000_000_000;
const BINARY_MIN_BYTES: u64 = 1_000_000;
const CONTEXT_SIZE: u32 = 8192;
const GPU_LAYERS: u32 = 99;
const GPU_TIMEOUT_SECS: u64 = 300;
const CPU_TIMEOUT_SECS: u64 = 180;
pub struct LlamaState(pub Mutex<Option<Child>>);
#[derive(Serialize, Clone)]
pub struct LlamaStatus {
pub installed: bool,
pub running: bool,
}
#[derive(Serialize, Clone)]
struct LlamaProgress {
task: String,
progress: Option<f64>,
}
fn emit_progress(app: &AppHandle, task: &str, progress: Option<f64>) {
let _ = app.emit(
"llama-progress",
LlamaProgress {
task: task.to_string(),
progress,
},
);
}
fn llama_dir(app: &AppHandle) -> Result<PathBuf, String> {
let dir = app
.path()
.app_data_dir()
.map_err(|e| e.to_string())?
.join("llama");
fs::create_dir_all(&dir).map_err(|e| e.to_string())?;
Ok(dir)
}
fn file_complete(path: &Path, min_bytes: u64) -> bool {
fs::metadata(path).map(|m| m.len() >= min_bytes).unwrap_or(false)
}
fn is_installed(dir: &Path) -> bool {
file_complete(&dir.join(SERVER_BINARY), BINARY_MIN_BYTES)
&& file_complete(&dir.join(MODEL_FILE), MODEL_MIN_BYTES)
}
async fn is_healthy() -> bool {
let url = format!("http://127.0.0.1:{PORT}/health");
match reqwest::Client::new()
.get(&url)
.timeout(Duration::from_secs(2))
.send()
.await
{
Ok(res) => res.status().is_success(),
Err(_) => false,
}
}
/// Ubija proces serwera, jeśli działa. Bezpieczne przy braku procesu.
pub fn kill(state: &LlamaState) {
if let Ok(mut guard) = state.0.lock() {
if let Some(mut child) = guard.take() {
let _ = child.kill();
let _ = child.wait();
}
}
}
/// Czy proces trzymany w stanie nadal żyje (sprząta uchwyt po padzie).
fn child_alive(state: &LlamaState) -> bool {
let mut guard = match state.0.lock() {
Ok(g) => g,
Err(_) => return false,
};
match guard.as_mut() {
Some(child) => match child.try_wait() {
Ok(None) => true,
_ => {
*guard = None;
false
}
},
None => false,
}
}
#[tauri::command]
pub async fn llama_status(
app: AppHandle,
state: State<'_, LlamaState>,
) -> Result<LlamaStatus, String> {
let dir = llama_dir(&app)?;
let installed = is_installed(&dir);
let running = child_alive(&state) && is_healthy().await;
Ok(LlamaStatus { installed, running })
}
async fn download_file(
app: &AppHandle,
url: &str,
dest: &Path,
label: &str,
overall_start: f64,
overall_end: f64,
min_bytes: u64,
) -> Result<(), String> {
if min_bytes > 0 && file_complete(dest, min_bytes) {
emit_progress(app, label, Some(overall_end));
return Ok(());
}
emit_progress(app, label, Some(overall_start));
// Pobieranie do pliku .part i podmiana na końcu — przerwane pobieranie
// nigdy nie udaje kompletnego pliku.
let part = dest.with_extension("part");
let response = reqwest::Client::new()
.get(url)
.send()
.await
.map_err(|e| format!("Błąd sieci: {e}"))?;
if !response.status().is_success() {
return Err(format!("Błąd pobierania (HTTP {}): {url}", response.status()));
}
let total = response.content_length().unwrap_or(0);
let mut file = fs::File::create(&part).map_err(|e| e.to_string())?;
let mut stream = response.bytes_stream();
let mut received: u64 = 0;
let mut last_permille: u64 = 0;
while let Some(chunk) = stream.next().await {
let chunk = chunk.map_err(|e| format!("Błąd sieci: {e}"))?;
file.write_all(&chunk).map_err(|e| e.to_string())?;
received += chunk.len() as u64;
if total > 0 {
let permille = received * 1000 / total;
if permille != last_permille {
last_permille = permille;
let progress =
overall_start + (received as f64 / total as f64) * (overall_end - overall_start);
emit_progress(app, label, Some(progress));
}
}
}
drop(file);
if dest.exists() {
let _ = fs::remove_file(dest);
}
fs::rename(&part, dest).map_err(|e| e.to_string())?;
Ok(())
}
/// Wyciąga llama-server.exe i biblioteki .dll z archiwum wydania llama.cpp.
fn extract_binary(archive: &Path, dest: &Path) -> Result<(), String> {
let file = fs::File::open(archive).map_err(|e| e.to_string())?;
let mut zip = zip::ZipArchive::new(file).map_err(|e| e.to_string())?;
let mut found_server = false;
for i in 0..zip.len() {
let mut entry = zip.by_index(i).map_err(|e| e.to_string())?;
if entry.is_dir() {
continue;
}
let name = entry.name().to_string();
let base = name
.rsplit(['/', '\\'])
.next()
.unwrap_or(&name)
.to_string();
let lower = base.to_lowercase();
if lower == SERVER_BINARY || lower.ends_with(".dll") {
let out_path = dest.join(&base);
let mut out = fs::File::create(&out_path).map_err(|e| e.to_string())?;
std::io::copy(&mut entry, &mut out).map_err(|e| e.to_string())?;
if lower == SERVER_BINARY {
found_server = true;
}
}
}
if found_server {
Ok(())
} else {
Err("Nie znaleziono llama-server.exe w archiwum.".to_string())
}
}
#[tauri::command]
pub async fn llama_download(app: AppHandle, state: State<'_, LlamaState>) -> Result<(), String> {
kill(&state);
let dir = llama_dir(&app)?;
if !file_complete(&dir.join(SERVER_BINARY), BINARY_MIN_BYTES) {
let zip_url = format!(
"https://github.com/ggml-org/llama.cpp/releases/download/{LLAMA_BUILD}/llama-{LLAMA_BUILD}-bin-win-vulkan-x64.zip"
);
let archive = dir.join("llama.zip");
download_file(&app, &zip_url, &archive, "Pobieranie llama.cpp…", 0.0, 0.06, 0).await?;
emit_progress(&app, "Rozpakowywanie llama.cpp…", Some(0.06));
let archive_clone = archive.clone();
let dir_clone = dir.clone();
tauri::async_runtime::spawn_blocking(move || extract_binary(&archive_clone, &dir_clone))
.await
.map_err(|e| e.to_string())??;
let _ = fs::remove_file(&archive);
}
download_file(
&app,
MODEL_URL,
&dir.join(MODEL_FILE),
"Pobieranie modelu Qwen3 4B (~2,4 GB)…",
0.06,
1.0,
MODEL_MIN_BYTES,
)
.await?;
emit_progress(&app, "Pobieranie zakończone.", Some(1.0));
Ok(())
}
fn spawn_server(dir: &Path, gpu_layers: u32) -> Result<Child, String> {
let log = fs::File::create(dir.join("llama.log")).map_err(|e| e.to_string())?;
let mut cmd = Command::new(dir.join(SERVER_BINARY));
cmd.args([
"-m",
&dir.join(MODEL_FILE).to_string_lossy(),
"--port",
&PORT.to_string(),
"--ctx-size",
&CONTEXT_SIZE.to_string(),
"-ngl",
&gpu_layers.to_string(),
// szablon czatu wbudowany w GGUF — wymagany dla poprawnego formatu Qwen3
"--jinja",
])
.current_dir(dir)
.stdout(Stdio::null())
.stderr(Stdio::from(log));
// bez migającego okna konsoli na Windowsie
#[cfg(windows)]
{
use std::os::windows::process::CommandExt;
const CREATE_NO_WINDOW: u32 = 0x0800_0000;
cmd.creation_flags(CREATE_NO_WINDOW);
}
cmd.spawn().map_err(|e| format!("Nie udało się uruchomić llama-server: {e}"))
}
#[tauri::command]
pub async fn llama_start(app: AppHandle, state: State<'_, LlamaState>) -> Result<(), String> {
if child_alive(&state) && is_healthy().await {
return Ok(());
}
let dir = llama_dir(&app)?;
if !is_installed(&dir) {
return Err("Model nie jest zainstalowany — najpierw pobierz pliki.".to_string());
}
// Próba 1: pełny offload na GPU (Vulkan). Pierwsze uruchomienie kompiluje
// pipeline'y i może trwać minuty. Próba 2: sam CPU — ładuje przez mmap.
for (gpu_layers, timeout_secs, label) in
[(GPU_LAYERS, GPU_TIMEOUT_SECS, "GPU"), (0, CPU_TIMEOUT_SECS, "CPU")]
{
kill(&state);
let child = spawn_server(&dir, gpu_layers)?;
if let Ok(mut guard) = state.0.lock() {
*guard = Some(child);
}
let started = Instant::now();
while started.elapsed() < Duration::from_secs(timeout_secs) {
if !child_alive(&state) {
// proces padł — od razu przechodzimy do kolejnej próby
break;
}
if is_healthy().await {
emit_progress(&app, "Model gotowy.", None);
return Ok(());
}
emit_progress(
&app,
&format!(
"Ładowanie modelu ({label})… {}s — pierwsze uruchomienie może potrwać kilka minut",
started.elapsed().as_secs()
),
None,
);
tokio::time::sleep(Duration::from_millis(750)).await;
}
}
kill(&state);
let log_hint = dir.join("llama.log").to_string_lossy().to_string();
Err(format!(
"Serwer AI nie wystartował (próbowano GPU i CPU). Szczegóły w pliku: {log_hint}"
))
}
#[tauri::command]
pub async fn llama_stop(state: State<'_, LlamaState>) -> Result<(), String> {
kill(&state);
Ok(())
}

257
src/lib/ai.ts Normal file
View File

@@ -0,0 +1,257 @@
import { fetch } from "@tauri-apps/plugin-http";
import { LLAMA_PORT } from "./llama";
import * as db from "./db";
import type { AiProvider, Settings } from "./types";
import { daysInSeason, isoDate, MONTH_NAMES, seasonMonths, todayISO } from "./dates";
import { formatMoney, round2 } from "./money";
import { COST_PERIOD_LABELS, costPerYear, totalCostsForYear } from "./costs";
export interface ChatMessage {
role: "user" | "assistant";
content: string;
}
export const AI_PROVIDER_LABELS: Record<AiProvider, string> = {
local: "Wbudowany (llama.cpp, offline)",
anthropic: "Anthropic (Claude)",
openai: "OpenAI",
ollama: "Ollama (lokalnie)",
lmstudio: "LM Studio (lokalnie)"
};
export const DEFAULT_MODELS: Record<AiProvider, string> = {
local: "Qwen3 4B Instruct",
anthropic: "claude-sonnet-5",
openai: "gpt-4o-mini",
ollama: "llama3.1",
lmstudio: "local-model"
};
export const DEFAULT_BASE_URLS: Partial<Record<AiProvider, string>> = {
ollama: "http://localhost:11434",
lmstudio: "http://localhost:1234"
};
export function providerNeedsKey(p: AiProvider): boolean {
return p === "anthropic" || p === "openai";
}
export function isAiConfigured(s: Settings): boolean {
return !providerNeedsKey(s.ai_provider) || !!s.ai_api_key?.trim();
}
const SYSTEM_PROMPT = `Jesteś asystentem menedżera małego ośrodka domków letniskowych „Słoneczko" w Polsce.
Pomagasz w: planowaniu obłożenia i cen, znajdowaniu wolnych luk w sezonie, analizie przychodów i kosztów,
oraz podpowiadaniu, jak zwiększyć zarobki możliwie małym nakładem pracy i pieniędzy.
Zasady:
- Odpowiadaj po polsku, zwięźle i konkretnie; opieraj się na danych z sekcji DANE poniżej i podawaj liczby.
- Gdy proponujesz zmiany cen lub promocje, szacuj ich wpływ na przychód.
- Pisz zwykłym tekstem bez formatowania markdown (bez gwiazdek, krzyżyków i tabel); listy zapisuj od myślników.
- Jeśli danych brakuje, powiedz czego brakuje, zamiast zgadywać.`;
/** Zwięzły obraz biznesu z bazy — doklejany do promptu systemowego przy każdej rozmowie. */
export async function buildBusinessContext(): Promise<string> {
const year = new Date().getFullYear();
const from = isoDate(year, 0, 1);
const to = isoDate(year + 1, 0, 1);
const monthRanges = Array.from({ length: 12 }, (_, m) => ({
from: isoDate(year, m, 1),
to: m === 11 ? isoDate(year + 1, 0, 1) : isoDate(year, m + 1, 1)
}));
const [settings, cabins, priceSeasons, costs, yearStats, cabinStats, linenSets, ...months] =
await Promise.all([
db.getSettings(),
db.listCabins(),
db.listPriceSeasons(),
db.listCosts(),
db.revenueStats(from, to),
db.cabinReports(from, to),
db.linenSetsForYear(year),
...monthRanges.map((r) => db.revenueStats(r.from, r.to))
]);
const sMonths = seasonMonths(settings.season_start_month, settings.season_end_month);
const seasonDays = daysInSeason(year, settings.season_start_month, settings.season_end_month);
const availableNights = cabins.length * seasonDays;
const lines: string[] = [];
lines.push(`DANE OŚRODKA (stan na ${todayISO()}, rok ${year}):`);
lines.push(
`Sezon: ${MONTH_NAMES[settings.season_start_month - 1]}${MONTH_NAMES[settings.season_end_month - 1]}` +
` (${seasonDays} dni × ${cabins.length} domków = ${availableNights} dostępnych dób).`
);
lines.push(
`Domki: ${cabins.map((c) => `${c.name}${c.capacity ? ` (${c.capacity} os.)` : ""}`).join(", ")}.`
);
if (priceSeasons.length > 0) {
lines.push("Cennik sezonowy (za dobę):");
for (const p of priceSeasons) {
const cabinName = p.cabin_id == null
? "wszystkie domki"
: (cabins.find((c) => c.id === p.cabin_id)?.name ?? `domek #${p.cabin_id}`);
lines.push(`- ${p.date_from} do ${p.date_to}: ${formatMoney(p.price)} zł (${cabinName})`);
}
} else {
lines.push("Cennik sezonowy: brak zdefiniowanych okresów.");
}
lines.push(`Obłożenie i przychód ${year} wg miesięcy (doby / % obłożenia / przychód, w nawiasie potwierdzony):`);
months.forEach((m, i) => {
if (m.nights_total <= 0 && !sMonths.includes(i + 1)) return;
const daysM = new Date(year, i + 1, 0).getDate();
const occ = cabins.length > 0 ? Math.round((m.nights_total / (cabins.length * daysM)) * 100) : 0;
const seasonTag = sMonths.includes(i + 1) ? "" : " [poza sezonem]";
lines.push(
`- ${MONTH_NAMES[i]}: ${Math.round(m.nights_total)} dób, ${occ}%, ${formatMoney(m.revenue_total)} zł (${formatMoney(m.revenue_confirmed)} zł)${seasonTag}`
);
});
lines.push("Wg domków (doby / przychód):");
for (const c of cabins) {
const r = cabinStats.find((x) => x.cabin_id === c.id);
lines.push(
`- ${c.name}: ${Math.round(r?.nights_total ?? 0)} dób, ${formatMoney(r?.revenue_total ?? 0)}`
);
}
const costsTotal = totalCostsForYear(costs, year, sMonths.length);
const linenCost = round2(linenSets * (settings.linen_cost ?? 0));
lines.push(`Koszty roczne (${year}):`);
for (const c of costs) {
const yearly = costPerYear(c, year, sMonths.length);
if (yearly > 0) {
lines.push(`- ${c.name}: ${formatMoney(yearly)} zł (${COST_PERIOD_LABELS[c.period]} ${formatMoney(c.amount)} zł)`);
}
}
if (settings.linen_cost != null) {
lines.push(`- Pościel: ${linenSets} kompletów × ${formatMoney(settings.linen_cost)} zł = ${formatMoney(linenCost)}`);
}
const totalAllCosts = round2(costsTotal + linenCost);
lines.push(`Koszty razem: ${formatMoney(totalAllCosts)} zł.`);
lines.push(
`Wynik ${year}: przychód potencjalny ${formatMoney(yearStats.revenue_total)} zł, potwierdzony ${formatMoney(yearStats.revenue_confirmed)} zł; ` +
`przewidywany zysk (potwierdzony koszty): ${formatMoney(round2(yearStats.revenue_confirmed - totalAllCosts))} zł.`
);
return lines.join("\n");
}
// --- Streaming ---
function handleSseLine(line: string, onData: (data: unknown) => void): void {
if (!line.startsWith("data: ")) return;
const dataStr = line.slice(6).trim();
if (!dataStr || dataStr === "[DONE]") return;
try {
onData(JSON.parse(dataStr));
} catch {
// uszkodzona linia — pomijamy
}
}
/** Czyta SSE strumieniowo; gdy strumień niedostępny, parsuje całą odpowiedź na raz. */
async function readSse(res: Response, onData: (data: unknown) => void): Promise<void> {
const reader = res.body?.getReader();
if (!reader) {
for (const line of (await res.text()).split("\n")) handleSseLine(line, onData);
return;
}
const decoder = new TextDecoder();
let pending = "";
for (;;) {
const { done, value } = await reader.read();
if (done) break;
pending += decoder.decode(value, { stream: true });
const lines = pending.split("\n");
pending = lines.pop() ?? "";
for (const line of lines) handleSseLine(line, onData);
}
if (pending) handleSseLine(pending, onData);
}
interface AnthropicEvent {
type?: string;
delta?: { type?: string; text?: string };
error?: { message?: string };
}
interface OpenAiChunk {
choices?: { delta?: { content?: string } }[];
}
/**
* Wysyła rozmowę do skonfigurowanego dostawcy i przekazuje fragmenty odpowiedzi do onDelta.
* Kontekst biznesowy jest budowany z bazy przy każdym wywołaniu.
*/
export async function chat(
history: ChatMessage[],
settings: Settings,
onDelta: (text: string) => void,
signal?: AbortSignal
): Promise<void> {
const system = `${SYSTEM_PROMPT}\n\n${await buildBusinessContext()}`;
const model = settings.ai_model?.trim() || DEFAULT_MODELS[settings.ai_provider];
const key = settings.ai_api_key?.trim() || null;
if (settings.ai_provider === "anthropic") {
if (!key) throw new Error("Brak klucza API Anthropic — uzupełnij w Ustawieniach.");
const res = await fetch("https://api.anthropic.com/v1/messages", {
method: "POST",
signal,
headers: {
"content-type": "application/json",
"x-api-key": key,
"anthropic-version": "2023-06-01"
},
body: JSON.stringify({ model, max_tokens: 2048, system, messages: history, stream: true })
});
if (!res.ok) {
throw new Error(`Błąd API (${res.status}): ${(await res.text()).slice(0, 300)}`);
}
await readSse(res, (data) => {
const ev = data as AnthropicEvent;
if (ev.type === "content_block_delta" && ev.delta?.type === "text_delta") {
onDelta(ev.delta.text ?? "");
} else if (ev.type === "error") {
throw new Error(ev.error?.message ?? "nieznany błąd API");
}
});
return;
}
if (settings.ai_provider === "openai" && !key) {
throw new Error("Brak klucza API OpenAI — uzupełnij w Ustawieniach.");
}
const isLocal = settings.ai_provider === "local";
const base = settings.ai_base_url?.trim() || DEFAULT_BASE_URLS[settings.ai_provider] || "";
const url = isLocal
? `http://127.0.0.1:${LLAMA_PORT}/v1/chat/completions`
: settings.ai_provider === "openai"
? "https://api.openai.com/v1/chat/completions"
: `${base}/v1/chat/completions`;
const res = await fetch(url, {
method: "POST",
signal,
headers: {
"content-type": "application/json",
...(key && !isLocal ? { authorization: `Bearer ${key}` } : {})
},
body: JSON.stringify({
...(isLocal ? {} : { model }),
messages: [{ role: "system", content: system }, ...history],
// parametry jak w trainhub: llama.cpp reuse'uje KV-cache wspólnego
// prefiksu między turami — wielotury odpowiadają dużo szybciej
...(isLocal ? { temperature: 0.7, max_tokens: 1536, cache_prompt: true } : {}),
stream: true
})
});
if (!res.ok) {
throw new Error(`Błąd API (${res.status}): ${(await res.text()).slice(0, 300)}`);
}
await readSse(res, (data) => {
const delta = (data as OpenAiChunk).choices?.[0]?.delta?.content;
if (delta) onDelta(delta);
});
}

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

@@ -0,0 +1,28 @@
import type { Cost, CostPeriod } from "./types";
import { round2 } from "./money";
export const COST_PERIOD_LABELS: Record<CostPeriod, string> = {
year: "rocznie",
month: "miesięcznie",
season_month: "miesięcznie w sezonie",
once: "jednorazowo"
};
/** Roczny wymiar pojedynczego kosztu dla danego roku. */
export function costPerYear(cost: Cost, year: number, seasonMonthCount: number): number {
switch (cost.period) {
case "year":
return cost.amount;
case "month":
return cost.amount * 12;
case "season_month":
return cost.amount * seasonMonthCount;
case "once":
return cost.year === year ? cost.amount : 0;
}
}
/** Suma kosztów stałych w roku (bez pościeli). */
export function totalCostsForYear(costs: Cost[], year: number, seasonMonthCount: number): number {
return round2(costs.reduce((sum, c) => sum + costPerYear(c, year, seasonMonthCount), 0));
}

View File

@@ -58,6 +58,23 @@ export function formatShort(iso: string): string {
return `${Number(iso.slice(8))}.${iso.slice(5, 7)}`; return `${Number(iso.slice(8))}.${iso.slice(5, 7)}`;
} }
/** Miesiące sezonu (112) od start do end włącznie; obsługuje sezon przez przełom roku. */
export function seasonMonths(start: number, end: number): number[] {
const months: number[] = [];
let m = start;
for (let i = 0; i < 12; i++) {
months.push(m);
if (m === end) break;
m = m === 12 ? 1 : m + 1;
}
return months;
}
/** Liczba dni sezonu w danym roku. */
export function daysInSeason(year: number, start: number, end: number): number {
return seasonMonths(start, end).reduce((sum, m) => sum + daysInMonth(year, m - 1), 0);
}
/** Liczba nocy między dwiema datami ISO (departure - arrival). */ /** Liczba nocy między dwiema datami ISO (departure - arrival). */
export function nightsBetween(arrival: string, departure: string): number { export function nightsBetween(arrival: string, departure: string): number {
const a = new Date(arrival + "T00:00:00"); const a = new Date(arrival + "T00:00:00");

View File

@@ -2,6 +2,8 @@ import Database from "@tauri-apps/plugin-sql";
import type { import type {
Cabin, Cabin,
CabinReport, CabinReport,
Cost,
CostInput,
Guest, Guest,
GuestListRow, GuestListRow,
Invoice, Invoice,
@@ -452,7 +454,9 @@ export async function updateSettings(s: Settings): Promise<void> {
`UPDATE settings SET `UPDATE settings SET
company_name = $1, first_name = $2, last_name = $3, address = $4, nip = $5, 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, bank_account = $6, bank_name = $7, issue_place = $8, default_pkwiu = $9,
default_vat_rate = $10, energy_price = $11 default_vat_rate = $10, energy_price = $11, season_start_month = $12,
season_end_month = $13, linen_cost = $14, ai_provider = $15, ai_api_key = $16,
ai_model = $17, ai_base_url = $18
WHERE id = 1`, WHERE id = 1`,
[ [
s.company_name, s.company_name,
@@ -465,11 +469,56 @@ export async function updateSettings(s: Settings): Promise<void> {
s.issue_place, s.issue_place,
s.default_pkwiu, s.default_pkwiu,
s.default_vat_rate, s.default_vat_rate,
s.energy_price s.energy_price,
s.season_start_month,
s.season_end_month,
s.linen_cost,
s.ai_provider,
s.ai_api_key,
s.ai_model,
s.ai_base_url
] ]
); );
} }
// --- Koszty ---
export async function listCosts(): Promise<Cost[]> {
const d = await getDb();
return d.select<Cost[]>("SELECT * FROM costs ORDER BY name");
}
export async function createCost(c: CostInput): Promise<void> {
const d = await getDb();
await d.execute(
"INSERT INTO costs (name, amount, period, year, notes) VALUES ($1, $2, $3, $4, $5)",
[c.name, c.amount, c.period, c.year, c.notes]
);
}
export async function updateCost(c: Cost): Promise<void> {
const d = await getDb();
await d.execute(
"UPDATE costs SET name = $1, amount = $2, period = $3, year = $4, notes = $5 WHERE id = $6",
[c.name, c.amount, c.period, c.year, c.notes, c.id]
);
}
export async function deleteCost(id: number): Promise<void> {
const d = await getDb();
await d.execute("DELETE FROM costs WHERE id = $1", [id]);
}
/** Suma kompletów pościeli (osób × pobyt) dla pobytów rozpoczętych w danym roku. */
export async function linenSetsForYear(year: number): Promise<number> {
const d = await getDb();
const rows = await d.select<{ sets: number | null }[]>(
"SELECT SUM(COALESCE(num_guests, 1)) AS sets FROM reservations WHERE arrival >= $1 AND arrival < $2",
[`${year}-01-01`, `${year + 1}-01-01`]
);
return rows[0]?.sets ?? 0;
}
// --- Faktury --- // --- Faktury ---
export async function listInvoices(): Promise<(Invoice & { gross: number })[]> { export async function listInvoices(): Promise<(Invoice & { gross: number })[]> {

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

@@ -0,0 +1,37 @@
import { invoke } from "@tauri-apps/api/core";
import { listen, type UnlistenFn } from "@tauri-apps/api/event";
/** Port wbudowanego llama-server — musi zgadzać się z PORT w src-tauri/src/llama.rs. */
export const LLAMA_PORT = 8123;
export interface LlamaStatus {
installed: boolean;
running: boolean;
}
export interface LlamaProgress {
task: string;
progress: number | null;
}
export function llamaStatus(): Promise<LlamaStatus> {
return invoke<LlamaStatus>("llama_status");
}
/** Pobiera binarkę llama.cpp i model (~2,5 GB); postęp przez onLlamaProgress. */
export function llamaDownload(): Promise<void> {
return invoke("llama_download");
}
/** Startuje serwer (GPU, potem fallback CPU) i czeka aż będzie gotowy. */
export function llamaStart(): Promise<void> {
return invoke("llama_start");
}
export function llamaStop(): Promise<void> {
return invoke("llama_stop");
}
export function onLlamaProgress(handler: (p: LlamaProgress) => void): Promise<UnlistenFn> {
return listen<LlamaProgress>("llama-progress", (event) => handler(event.payload));
}

View File

@@ -47,6 +47,8 @@ export type ReservationDialogParams =
// --- Faktury --- // --- Faktury ---
export type AiProvider = "anthropic" | "openai" | "ollama" | "lmstudio" | "local";
export interface Settings { export interface Settings {
id: number; id: number;
company_name: string; company_name: string;
@@ -61,8 +63,33 @@ export interface Settings {
default_vat_rate: number; default_vat_rate: number;
/** Cena energii zł/kWh do rozliczania liczników */ /** Cena energii zł/kWh do rozliczania liczników */
energy_price: number | null; energy_price: number | null;
/** Sezon działalności: miesiące 112 włącznie */
season_start_month: number;
season_end_month: number;
/** Koszt prania/wymiany pościeli zł za komplet na osobę (na pobyt) */
linen_cost: number | null;
ai_provider: AiProvider;
ai_api_key: string | null;
ai_model: string | null;
ai_base_url: string | null;
} }
// --- Koszty ---
export type CostPeriod = "year" | "month" | "season_month" | "once";
export interface Cost {
id: number;
name: string;
amount: number;
period: CostPeriod;
/** Dla period = 'once': rok, którego koszt dotyczy */
year: number | null;
notes: string | null;
}
export type CostInput = Omit<Cost, "id">;
export type PaymentMethod = "CASH" | "CARD" | "CARD_CASH" | "SEND"; export type PaymentMethod = "CASH" | "CARD" | "CARD_CASH" | "SEND";
export interface Invoice { export interface Invoice {

View File

@@ -9,7 +9,9 @@
{ href: "/faktury", label: "Faktury" }, { href: "/faktury", label: "Faktury" },
{ href: "/goscie", label: "Goście" }, { href: "/goscie", label: "Goście" },
{ href: "/sprzatanie", label: "Sprzątanie" }, { href: "/sprzatanie", label: "Sprzątanie" },
{ href: "/koszty", label: "Koszty" },
{ href: "/raporty", label: "Raporty" }, { href: "/raporty", label: "Raporty" },
{ href: "/asystent", label: "Asystent" },
{ href: "/ustawienia", label: "Ustawienia" } { href: "/ustawienia", label: "Ustawienia" }
]; ];
</script> </script>
@@ -95,6 +97,9 @@
:global(select:hover:not(:focus)) { :global(select:hover:not(:focus)) {
border-color: var(--muted); border-color: var(--muted);
} }
:global(input[type="checkbox"]) {
accent-color: var(--accent);
}
/* --- Wspólny system przycisków --- */ /* --- Wspólny system przycisków --- */
:global(.btn) { :global(.btn) {

View File

@@ -0,0 +1,457 @@
<script lang="ts">
import { onMount } from "svelte";
import * as db from "$lib/db";
import { chat, isAiConfigured, AI_PROVIDER_LABELS, type ChatMessage } from "$lib/ai";
import {
llamaDownload,
llamaStart,
llamaStatus,
onLlamaProgress,
type LlamaStatus
} from "$lib/llama";
import type { Settings } from "$lib/types";
const HISTORY_LIMIT = 12;
const SUGGESTIONS = [
"Jak zwiększyć przychód w tym sezonie minimalnym nakładem?",
"Przeanalizuj obłożenie i wskaż największe luki w grafiku.",
"Czy moje ceny za dobę są dobrze ustawione? Co bym zyskał podnosząc je o 10%?",
"Które koszty najbardziej zjadają zysk i co z tym zrobić?"
];
let settings = $state<Settings | null>(null);
let messages = $state<ChatMessage[]>([]);
let input = $state("");
let streaming = $state(false);
let error = $state<string | null>(null);
let abortController: AbortController | null = null;
let scrollEl: HTMLDivElement | undefined = $state();
// Stan wbudowanego modelu (llama.cpp)
let llama = $state<LlamaStatus | null>(null);
let llamaTask = $state<string | null>(null);
let llamaProgress = $state<number | null>(null);
let llamaBusy = $state(false);
onMount(() => {
let unlisten: (() => void) | undefined;
void (async () => {
try {
settings = await db.getSettings();
if (settings.ai_provider === "local") {
llama = await llamaStatus();
}
unlisten = await onLlamaProgress((p) => {
llamaTask = p.task;
llamaProgress = p.progress;
});
} catch (e) {
error = `Nie udało się wczytać ustawień: ${e}`;
}
})();
return () => unlisten?.();
});
let configured = $derived(settings != null && isAiConfigured(settings));
let isLocal = $derived(settings?.ai_provider === "local");
async function downloadModel() {
if (llamaBusy) return;
llamaBusy = true;
error = null;
try {
await llamaDownload();
llama = await llamaStatus();
// od razu ładujemy model — użytkownik nie musi klikać drugi raz
await llamaStart();
llama = await llamaStatus();
llamaTask = null;
} catch (e) {
error = String(e instanceof Error ? e.message : e);
} finally {
llamaBusy = false;
llamaProgress = null;
}
}
async function startModel() {
if (llamaBusy) return;
llamaBusy = true;
error = null;
try {
await llamaStart();
llama = await llamaStatus();
llamaTask = null;
} catch (e) {
error = String(e instanceof Error ? e.message : e);
} finally {
llamaBusy = false;
}
}
/** Dla wbudowanego modelu: dopilnuj, żeby serwer działał; rzuca przy porażce. */
async function ensureLocalReady(): Promise<void> {
llama = await llamaStatus();
if (!llama.installed) {
throw new Error("Najpierw pobierz wbudowany model (panel powyżej).");
}
if (!llama.running) {
await llamaStart();
llama = await llamaStatus();
llamaTask = null;
}
}
function scrollDown() {
requestAnimationFrame(() => {
scrollEl?.scrollTo({ top: scrollEl.scrollHeight });
});
}
async function send(text?: string) {
const content = (text ?? input).trim();
if (!content || streaming || !settings) return;
input = "";
error = null;
messages.push({ role: "user", content });
messages.push({ role: "assistant", content: "" });
const assistantIndex = messages.length - 1;
streaming = true;
abortController = new AbortController();
scrollDown();
try {
if (isLocal) {
await ensureLocalReady();
}
const history = messages
.slice(0, -1)
.slice(-HISTORY_LIMIT)
.map((m) => ({ role: m.role, content: m.content }));
await chat(
history,
settings,
(delta) => {
messages[assistantIndex].content += delta;
scrollDown();
},
abortController.signal
);
if (!messages[assistantIndex].content.trim()) {
messages[assistantIndex].content = "(pusta odpowiedź — spróbuj ponownie)";
}
} catch (e) {
const aborted = abortController?.signal.aborted;
if (aborted) {
if (!messages[assistantIndex].content.trim()) {
messages.splice(assistantIndex - 1, 2);
}
} else if (messages[assistantIndex].content.trim()) {
error = `Odpowiedź przerwana: ${e}`;
} else {
messages.splice(assistantIndex - 1, 2);
error = String(e instanceof Error ? e.message : e);
}
} finally {
streaming = false;
abortController = null;
}
}
function stop() {
abortController?.abort();
}
function reset() {
if (streaming) stop();
messages = [];
error = null;
}
function onkeydown(event: KeyboardEvent) {
if (event.key === "Enter" && !event.shiftKey) {
event.preventDefault();
void send();
}
}
</script>
<svelte:head>
<title>Słoneczko — asystent</title>
</svelte:head>
<main>
<header class="toolbar">
<h1>Asystent</h1>
{#if settings}
<span class="provider">{AI_PROVIDER_LABELS[settings.ai_provider]}</span>
{/if}
<span class="spacer"></span>
{#if messages.length > 0}
<button type="button" class="btn" onclick={reset}>Nowa rozmowa</button>
{/if}
</header>
{#if settings && !configured}
<div class="empty">
<p>Asystent nie jest jeszcze skonfigurowany.</p>
<p class="hint">
Wybierz dostawcę AI i podaj klucz API (albo wskaż lokalny serwer Ollama/LM Studio).
</p>
<a class="btn primary" href="/ustawienia">Przejdź do ustawień</a>
</div>
{:else if settings}
{#if isLocal && llama}
<div class="local-panel">
{#if !llama.installed}
<span>Wbudowany model nie jest jeszcze pobrany (Qwen3 4B, działa offline).</span>
<button type="button" class="btn primary" onclick={downloadModel} disabled={llamaBusy}>
Pobierz model (~2,5 GB)
</button>
{:else if llama.running}
<span class="ok-chip">● Model działa</span>
<span class="muted">Qwen3 4B — lokalnie, bez wysyłania danych</span>
{:else}
<span>Model zainstalowany.</span>
<button type="button" class="btn" onclick={startModel} disabled={llamaBusy}>
Uruchom teraz
</button>
<span class="muted">uruchomi się też sam przy pierwszej wiadomości</span>
{/if}
{#if llamaTask}
<div class="task">
<span>{llamaTask}</span>
{#if llamaProgress != null}
<div class="bar">
<div class="fill" style="width: {Math.round(llamaProgress * 100)}%"></div>
</div>
{/if}
</div>
{/if}
</div>
{/if}
<div class="chat" bind:this={scrollEl}>
{#if messages.length === 0}
<div class="welcome">
<p>
Znam Twój grafik, cennik, obłożenie i koszty — przy każdym pytaniu dostaję ich aktualny
obraz. Zapytaj np.:
</p>
<div class="suggestions">
{#each SUGGESTIONS as s (s)}
<button type="button" class="suggestion" onclick={() => void send(s)}>{s}</button>
{/each}
</div>
</div>
{/if}
{#each messages as message, i (i)}
<div class="bubble {message.role}">
{#if message.content}
{message.content}
{:else if streaming && i === messages.length - 1}
<span class="typing">Myślę…</span>
{/if}
</div>
{/each}
</div>
{#if error}
<p class="error">{error}</p>
{/if}
<form
class="composer"
onsubmit={(e) => {
e.preventDefault();
void send();
}}
>
<textarea
rows="2"
bind:value={input}
{onkeydown}
placeholder="Zapytaj o obłożenie, ceny, koszty (Enter wysyła, Shift+Enter to nowa linia)"
disabled={streaming}
></textarea>
{#if streaming}
<button type="button" class="btn danger" onclick={stop}>Stop</button>
{:else}
<button type="submit" class="btn primary" disabled={!input.trim()}>Wyślij</button>
{/if}
</form>
{/if}
</main>
<style>
main {
display: flex;
flex-direction: column;
height: calc(100vh - 46px);
max-width: 860px;
margin: 0 auto;
padding: 14px 16px;
}
.toolbar {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 12px;
}
h1 {
margin: 0;
font-size: 18px;
}
.provider {
font-size: 12px;
color: var(--muted);
background: var(--surface);
border: 1px solid var(--border);
border-radius: 12px;
padding: 2px 10px;
}
.spacer {
flex: 1;
}
.empty {
display: flex;
flex-direction: column;
align-items: center;
gap: 10px;
padding: 60px 20px;
border: 1px dashed var(--border);
border-radius: 10px;
color: var(--muted);
}
.empty .btn {
text-decoration: none;
}
.local-panel {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
padding: 10px 14px;
margin-bottom: 10px;
background: var(--surface);
border: 1px solid var(--border);
border-radius: 10px;
font-size: 13px;
}
.ok-chip {
color: var(--ok);
font-weight: 600;
}
.muted {
color: var(--muted);
font-size: 12px;
}
.task {
flex-basis: 100%;
display: flex;
flex-direction: column;
gap: 6px;
font-size: 12px;
color: var(--muted);
}
.bar {
height: 6px;
border-radius: 3px;
background: var(--bg);
overflow: hidden;
}
.fill {
height: 100%;
background: var(--accent);
border-radius: 3px;
transition: width 0.2s ease;
}
.chat {
flex: 1;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 10px;
padding: 14px;
background: var(--surface);
border: 1px solid var(--border);
border-radius: 10px;
margin-bottom: 10px;
}
.welcome {
color: var(--muted);
font-size: 13px;
}
.suggestions {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 8px;
margin-top: 10px;
}
.suggestion {
font: inherit;
font-size: 13px;
text-align: left;
padding: 8px 12px;
border-radius: 9px;
border: 1px solid var(--border);
background: var(--bg);
color: var(--text);
cursor: pointer;
}
.suggestion:hover {
border-color: var(--accent);
color: var(--accent);
}
.bubble {
max-width: 85%;
padding: 9px 13px;
border-radius: 12px;
font-size: 14px;
line-height: 1.55;
white-space: pre-wrap;
overflow-wrap: break-word;
}
.bubble.user {
align-self: flex-end;
background: var(--accent);
color: #fff;
border-bottom-right-radius: 4px;
}
.bubble.assistant {
align-self: flex-start;
background: var(--bg);
border: 1px solid var(--border);
border-bottom-left-radius: 4px;
}
.typing {
color: var(--muted);
font-style: italic;
}
.error {
margin: 0 0 8px;
font-size: 13px;
color: var(--danger);
}
.composer {
display: flex;
gap: 8px;
align-items: flex-end;
}
textarea {
flex: 1;
font: inherit;
font-size: 14px;
color: var(--text);
background: var(--surface);
border: 1px solid var(--border);
border-radius: 9px;
padding: 9px 12px;
resize: none;
}
textarea:disabled {
opacity: 0.6;
}
.hint {
font-size: 12px;
}
</style>

View File

@@ -0,0 +1,371 @@
<script lang="ts">
import { onMount } from "svelte";
import * as db from "$lib/db";
import { isoDate, seasonMonths } from "$lib/dates";
import { formatMoney, round2 } from "$lib/money";
import { COST_PERIOD_LABELS, costPerYear, totalCostsForYear } from "$lib/costs";
import type { Cost, CostPeriod, RevenueStats, Settings } from "$lib/types";
let year = $state(new Date().getFullYear());
let settings = $state<Settings | null>(null);
let costs = $state<Cost[]>([]);
let yearStats = $state<RevenueStats | null>(null);
let linenSets = $state(0);
let error = $state<string | null>(null);
let newCost = $state<{ name: string; amount: number | null; period: CostPeriod; year: number | null }>({
name: "",
amount: null,
period: "year",
year: null
});
let seasonMonthCount = $derived(
settings ? seasonMonths(settings.season_start_month, settings.season_end_month).length : 12
);
let fixedCosts = $derived(totalCostsForYear(costs, year, seasonMonthCount));
let linenTotal = $derived(round2(linenSets * (settings?.linen_cost ?? 0)));
let totalCosts = $derived(round2(fixedCosts + linenTotal));
let profitPotential = $derived(
yearStats ? round2(yearStats.revenue_total - totalCosts) : null
);
let profitConfirmed = $derived(
yearStats ? round2(yearStats.revenue_confirmed - totalCosts) : null
);
async function reload() {
error = null;
try {
[settings, costs, yearStats, linenSets] = await Promise.all([
db.getSettings(),
db.listCosts(),
db.revenueStats(isoDate(year, 0, 1), isoDate(year + 1, 0, 1)),
db.linenSetsForYear(year)
]);
} catch (e) {
error = `Nie udało się wczytać danych: ${e}`;
}
}
onMount(() => {
void reload();
});
function changeYear(delta: number) {
year += delta;
void reload();
}
async function saveLinen() {
if (!settings) return;
try {
await db.updateSettings({
...settings,
linen_cost:
settings.linen_cost == null || Number.isNaN(settings.linen_cost)
? null
: settings.linen_cost
});
} catch (e) {
error = `Nie udało się zapisać: ${e}`;
}
}
async function addCost(event: SubmitEvent) {
event.preventDefault();
error = null;
if (!newCost.name.trim() || newCost.amount == null || Number.isNaN(newCost.amount)) return;
try {
await db.createCost({
name: newCost.name.trim(),
amount: newCost.amount,
period: newCost.period,
year: newCost.period === "once" ? (newCost.year ?? year) : null,
notes: null
});
newCost = { name: "", amount: null, period: "year", year: null };
await reload();
} catch (e) {
error = `Nie udało się dodać kosztu: ${e}`;
}
}
async function persistCost(cost: Cost) {
if (!cost.name.trim() || Number.isNaN(cost.amount)) return;
error = null;
try {
await db.updateCost({
...cost,
name: cost.name.trim(),
year: cost.period === "once" ? (cost.year ?? year) : null
});
await reload();
} catch (e) {
error = `Nie udało się zapisać kosztu: ${e}`;
}
}
async function removeCost(cost: Cost) {
if (!confirm(`Usunąć koszt „${cost.name}"?`)) return;
try {
await db.deleteCost(cost.id);
await reload();
} catch (e) {
error = `Nie udało się usunąć kosztu: ${e}`;
}
}
</script>
<svelte:head>
<title>Słoneczko — koszty</title>
</svelte:head>
<main>
<header class="toolbar">
<h1>Koszty</h1>
<span class="spacer"></span>
<button type="button" class="btn icon" onclick={() => changeYear(-1)} aria-label="Poprzedni rok"></button>
<span class="year">{year}</span>
<button type="button" class="btn icon" onclick={() => changeYear(1)} aria-label="Następny rok"></button>
</header>
{#if error}
<p class="error">{error}</p>
{/if}
{#if settings && yearStats}
<div class="cards">
<div class="card">
<span class="label">Przychód potwierdzony</span>
<span class="value">{formatMoney(yearStats.revenue_confirmed)}</span>
</div>
<div class="card">
<span class="label">Koszty razem</span>
<span class="value">{formatMoney(totalCosts)}</span>
</div>
<div class="card" class:neg={profitConfirmed != null && profitConfirmed < 0}>
<span class="label">Zysk (potwierdzony koszty)</span>
<span class="value">{formatMoney(profitConfirmed ?? 0)}</span>
</div>
<div class="card">
<span class="label">Zysk przy pełnym potencjale</span>
<span class="value">{formatMoney(profitPotential ?? 0)}</span>
</div>
</div>
<section>
<h2>Pościel</h2>
<div class="linen">
<label>
Koszt kompletu na osobę (zł)
<input
type="number"
min="0"
step="0.01"
bind:value={settings.linen_cost}
onchange={saveLinen}
/>
</label>
<p class="calc">
W {year}: {linenSets} kompletów (osoby × pobyty)
{#if settings.linen_cost != null}
× {formatMoney(settings.linen_cost)} zł = <strong>{formatMoney(linenTotal)}</strong>
{/if}
</p>
</div>
</section>
<section>
<h2>Koszty stałe</h2>
{#if costs.length > 0}
<div class="list">
<span class="lh">Nazwa</span>
<span class="lh">Kwota (zł)</span>
<span class="lh">Okres</span>
<span class="lh">Rok</span>
<span class="lh num">W {year}</span>
<span class="lh"></span>
{#each costs as cost (cost.id)}
<input type="text" bind:value={cost.name} onchange={() => persistCost(cost)} aria-label="Nazwa kosztu" />
<input type="number" min="0" step="0.01" bind:value={cost.amount} onchange={() => persistCost(cost)} aria-label="Kwota" />
<select bind:value={cost.period} onchange={() => persistCost(cost)} aria-label="Okres">
{#each Object.entries(COST_PERIOD_LABELS) as [value, label] (value)}
<option {value}>{label}</option>
{/each}
</select>
{#if cost.period === "once"}
<input type="number" bind:value={cost.year} onchange={() => persistCost(cost)} aria-label="Rok kosztu" />
{:else}
<span class="muted"></span>
{/if}
<span class="num">{formatMoney(costPerYear(cost, year, seasonMonthCount))}</span>
<button type="button" class="btn sm danger" onclick={() => removeCost(cost)}>Usuń</button>
{/each}
</div>
<p class="sum">Koszty stałe razem: <strong>{formatMoney(fixedCosts)}</strong></p>
{:else}
<p class="hint">
Nie ma jeszcze żadnych kosztów — dodaj np. wodę, śmieci, ubezpieczenie, podatek od
nieruchomości czy naprawy.
</p>
{/if}
<form class="add" onsubmit={addCost}>
<input type="text" bind:value={newCost.name} placeholder="np. Ubezpieczenie" required />
<input type="number" min="0" step="0.01" bind:value={newCost.amount} placeholder="zł" required />
<select bind:value={newCost.period}>
{#each Object.entries(COST_PERIOD_LABELS) as [value, label] (value)}
<option {value}>{label}</option>
{/each}
</select>
{#if newCost.period === "once"}
<input type="number" bind:value={newCost.year} placeholder={String(year)} aria-label="Rok kosztu" />
{:else}
<span class="muted"></span>
{/if}
<button type="submit" class="btn">Dodaj</button>
</form>
<p class="hint">
„Miesięcznie w sezonie" mnoży kwotę przez {seasonMonthCount}
{seasonMonthCount === 1 ? "miesiąc" : seasonMonthCount < 5 ? "miesiące" : "miesięcy"} sezonu.
Jednorazowe koszty (np. naprawa dachu) przypisz do konkretnego roku.
</p>
</section>
{/if}
</main>
<style>
main {
padding: 14px 16px;
max-width: 860px;
}
.toolbar {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 14px;
}
h1 {
margin: 0;
font-size: 18px;
}
.spacer {
flex: 1;
}
.year {
min-width: 60px;
text-align: center;
font-size: 16px;
font-weight: 600;
}
.error {
color: var(--danger);
}
.cards {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 10px;
margin-bottom: 14px;
}
.card {
display: flex;
flex-direction: column;
gap: 4px;
background: var(--surface);
border: 1px solid var(--border);
border-radius: 10px;
padding: 12px 14px;
}
.card .label {
font-size: 11px;
color: var(--muted);
}
.card .value {
font-size: 20px;
font-weight: 600;
}
.card.neg .value {
color: var(--danger);
}
section {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 10px;
padding: 14px 16px;
margin-bottom: 14px;
}
h2 {
margin: 0 0 12px;
font-size: 14px;
}
.linen {
display: flex;
align-items: flex-end;
gap: 18px;
flex-wrap: wrap;
}
.linen label {
display: flex;
flex-direction: column;
gap: 4px;
font-size: 12px;
color: var(--muted);
}
.calc {
margin: 0 0 6px;
font-size: 13px;
color: var(--muted);
}
.calc strong {
color: var(--text);
}
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;
}
.list,
.add {
display: grid;
grid-template-columns: 1.4fr 100px 170px 80px 110px auto;
gap: 6px 8px;
align-items: center;
}
.add {
margin-top: 12px;
padding-top: 12px;
border-top: 1px solid var(--border);
}
.lh {
font-size: 11px;
color: var(--muted);
}
.num {
text-align: right;
font-size: 13px;
}
.muted {
color: var(--muted);
text-align: center;
}
.sum {
margin: 10px 0 0;
font-size: 13px;
color: var(--muted);
text-align: right;
}
.sum strong {
color: var(--text);
}
.hint {
margin: 10px 0 0;
font-size: 12px;
color: var(--muted);
}
</style>

View File

@@ -1,21 +1,36 @@
<script lang="ts"> <script lang="ts">
import { onMount } from "svelte"; import { onMount } from "svelte";
import * as db from "$lib/db"; import * as db from "$lib/db";
import { daysInMonth, isoDate, MONTH_NAMES } from "$lib/dates"; import { daysInMonth, daysInSeason, isoDate, MONTH_NAMES, seasonMonths } from "$lib/dates";
import { formatMoney } from "$lib/money"; import { formatMoney } from "$lib/money";
import { csvNumber, saveCsv, toCsv } from "$lib/csv"; import { csvNumber, saveCsv, toCsv } from "$lib/csv";
import type { Cabin, CabinReport, RevenueStats } from "$lib/types"; import type { Cabin, CabinReport, RevenueStats, Settings } from "$lib/types";
let year = $state(new Date().getFullYear()); let year = $state(new Date().getFullYear());
let cabins = $state<Cabin[]>([]); let cabins = $state<Cabin[]>([]);
let settings = $state<Settings | null>(null);
let yearStats = $state<RevenueStats | null>(null); let yearStats = $state<RevenueStats | null>(null);
let cabinRows = $state<CabinReport[]>([]); let cabinRows = $state<CabinReport[]>([]);
let monthRows = $state<RevenueStats[]>([]); let monthRows = $state<RevenueStats[]>([]);
let loading = $state(true); let loading = $state(true);
let error = $state<string | null>(null); let error = $state<string | null>(null);
let isLeap = $derived(new Date(year, 1, 29).getDate() === 29); let seasonMonthList = $derived(
let daysInYear = $derived(isLeap ? 366 : 365); settings ? seasonMonths(settings.season_start_month, settings.season_end_month) : []
);
/** Mianownik obłożenia: dni sezonu (nie całego roku). */
let seasonDays = $derived(
settings ? daysInSeason(year, settings.season_start_month, settings.season_end_month) : 365
);
let seasonLabel = $derived(
settings
? `${MONTH_NAMES[settings.season_start_month - 1].toLowerCase()}${MONTH_NAMES[settings.season_end_month - 1].toLowerCase()}`
: ""
);
function inSeason(monthIndex: number): boolean {
return seasonMonthList.includes(monthIndex + 1);
}
async function reload() { async function reload() {
loading = true; loading = true;
@@ -27,12 +42,14 @@
from: isoDate(year, m, 1), from: isoDate(year, m, 1),
to: m === 11 ? isoDate(year + 1, 0, 1) : isoDate(year, m + 1, 1) to: m === 11 ? isoDate(year + 1, 0, 1) : isoDate(year, m + 1, 1)
})); }));
const [c, ys, cr, ...months] = await Promise.all([ const [s, c, ys, cr, ...months] = await Promise.all([
db.getSettings(),
db.listCabins(), db.listCabins(),
db.revenueStats(from, to), db.revenueStats(from, to),
db.cabinReports(from, to), db.cabinReports(from, to),
...monthRanges.map((r) => db.revenueStats(r.from, r.to)) ...monthRanges.map((r) => db.revenueStats(r.from, r.to))
]); ]);
settings = s;
cabins = c; cabins = c;
yearStats = ys; yearStats = ys;
cabinRows = cr; cabinRows = cr;
@@ -70,15 +87,18 @@
error = null; error = null;
exportInfo = null; exportInfo = null;
try { try {
const rows: (string | number | null)[][] = [[`Raport ${year}`], []]; const rows: (string | number | null)[][] = [
[`Raport ${year} (sezon ${seasonLabel}, ${seasonDays} dni)`],
[]
];
rows.push(["Wg domków"]); rows.push(["Wg domków"]);
rows.push(["Domek", "Doby", "Obłożenie %", "Przychód", "Przychód potwierdzony"]); rows.push(["Domek", "Doby", "Obłożenie % (sezon)", "Przychód", "Przychód potwierdzony"]);
for (const cabin of cabins) { for (const cabin of cabins) {
const row = cabinRows.find((r) => r.cabin_id === cabin.id); const row = cabinRows.find((r) => r.cabin_id === cabin.id);
rows.push([ rows.push([
cabin.name, cabin.name,
Math.round(row?.nights_total ?? 0), Math.round(row?.nights_total ?? 0),
Math.round(((row?.nights_total ?? 0) / daysInYear) * 100), seasonDays > 0 ? Math.round(((row?.nights_total ?? 0) / seasonDays) * 100) : 0,
csvNumber(row?.revenue_total ?? 0), csvNumber(row?.revenue_total ?? 0),
csvNumber(row?.revenue_confirmed ?? 0) csvNumber(row?.revenue_confirmed ?? 0)
]); ]);
@@ -134,8 +154,8 @@
<span class="value">{Math.round(yearStats.nights_total)}</span> <span class="value">{Math.round(yearStats.nights_total)}</span>
</div> </div>
<div class="card"> <div class="card">
<span class="label">Obłożenie</span> <span class="label">Obłożenie (sezon {seasonLabel})</span>
<span class="value">{occupancy(yearStats.nights_total, cabins.length * daysInYear)}</span> <span class="value">{occupancy(yearStats.nights_total, cabins.length * seasonDays)}</span>
</div> </div>
<div class="card"> <div class="card">
<span class="label">Przychód potencjalny</span> <span class="label">Przychód potencjalny</span>
@@ -190,7 +210,7 @@
<tr> <tr>
<td>{cabin.name}</td> <td>{cabin.name}</td>
<td class="num">{row ? Math.round(row.nights_total) : 0}</td> <td class="num">{row ? Math.round(row.nights_total) : 0}</td>
<td class="num">{occupancy(row?.nights_total ?? 0, daysInYear)}</td> <td class="num">{occupancy(row?.nights_total ?? 0, seasonDays)}</td>
<td class="num">{formatMoney(row?.revenue_total ?? 0)}</td> <td class="num">{formatMoney(row?.revenue_total ?? 0)}</td>
</tr> </tr>
{/each} {/each}
@@ -211,8 +231,8 @@
</thead> </thead>
<tbody> <tbody>
{#each monthRows as m, i (i)} {#each monthRows as m, i (i)}
<tr> <tr class:off-season={!inSeason(i)}>
<td>{MONTH_NAMES[i]}</td> <td>{MONTH_NAMES[i]}{#if !inSeason(i)}<span class="off"> · poza sezonem</span>{/if}</td>
<td class="num">{Math.round(m.nights_total)}</td> <td class="num">{Math.round(m.nights_total)}</td>
<td class="num">{occupancy(m.nights_total, cabins.length * daysInMonth(year, i))}</td> <td class="num">{occupancy(m.nights_total, cabins.length * daysInMonth(year, i))}</td>
<td class="num">{formatMoney(m.revenue_total)}</td> <td class="num">{formatMoney(m.revenue_total)}</td>
@@ -220,6 +240,7 @@
{/each} {/each}
</tbody> </tbody>
</table> </table>
<p class="hint">Obłożenie roczne i per domek liczone dla sezonu ({seasonLabel}, {seasonDays} dni).</p>
</section> </section>
</div> </div>
{:else if loading} {:else if loading}
@@ -367,4 +388,11 @@
.num { .num {
text-align: right; text-align: right;
} }
tr.off-season td {
color: var(--muted);
}
.off {
font-size: 11px;
color: var(--muted);
}
</style> </style>

View File

@@ -1,7 +1,7 @@
<script lang="ts"> <script lang="ts">
import { onMount } from "svelte"; import { onMount } from "svelte";
import * as db from "$lib/db"; import * as db from "$lib/db";
import { addDays, formatShort, todayISO } from "$lib/dates"; import { addDays, formatShort, todayISO, weekdayShort } from "$lib/dates";
import type { Cabin, Reservation } from "$lib/types"; import type { Cabin, Reservation } from "$lib/types";
const DAYS_AHEAD = 14; const DAYS_AHEAD = 14;
@@ -20,6 +20,7 @@
let error = $state<string | null>(null); let error = $state<string | null>(null);
const today = todayISO(); const today = todayISO();
const tomorrow = addDays(today, 1);
async function reload() { async function reload() {
loading = true; loading = true;
@@ -68,10 +69,10 @@
return cabins.find((c) => c.id === id)?.name ?? `Domek #${id}`; return cabins.find((c) => c.id === id)?.name ?? `Domek #${id}`;
} }
function dayLabel(iso: string): string { function dayTag(iso: string): string {
const weekday = new Date(iso + "T00:00:00").toLocaleDateString("pl-PL", { weekday: "long" }); if (iso === today) return "dziś";
const prefix = iso === today ? "Dziś — " : iso === addDays(today, 1) ? "Jutro — " : ""; if (iso === tomorrow) return "jutro";
return `${prefix}${weekday}, ${formatShort(iso)}`; return weekdayShort(iso);
} }
function hasSameDayArrival(r: Reservation): boolean { function hasSameDayArrival(r: Reservation): boolean {
@@ -110,7 +111,7 @@
<main> <main>
<header class="toolbar"> <header class="toolbar">
<h1>Sprzątanie</h1> <h1>Sprzątanie</h1>
<span class="hint">wyjazdy z najbliższych {DAYS_AHEAD} dni</span> <span class="hint">wyjazdy i przygotowania z najbliższych {DAYS_AHEAD} dni</span>
</header> </header>
{#if error} {#if error}
@@ -118,8 +119,12 @@
{/if} {/if}
{#if overdue.length > 0} {#if overdue.length > 0}
<section class="overdue"> <div class="day overdue">
<h2>Zaległe</h2> <div class="datebox">
<span class="num">!</span>
<span class="wd">zaległe</span>
</div>
<div class="card">
{#each overdue as r (r.id)} {#each overdue as r (r.id)}
<label class="task"> <label class="task">
<input <input
@@ -127,11 +132,15 @@
checked={r.cleaned} checked={r.cleaned}
onchange={(e) => toggleCleaned(r, e.currentTarget.checked)} onchange={(e) => toggleCleaned(r, e.currentTarget.checked)}
/> />
<span class="info">
<span class="cabin">{cabinName(r.cabin_id)}</span> <span class="cabin">{cabinName(r.cabin_id)}</span>
<span class="muted">wyjazd {formatShort(r.departure)} · {r.guest_name}</span> <span class="sub">wyjazd {formatShort(r.departure)} · {r.guest_name}</span>
</span>
<span class="badge overdue-badge">do nadrobienia</span>
</label> </label>
{/each} {/each}
</section> </div>
</div>
{/if} {/if}
{#if !loading && byDay.length === 0 && overdue.length === 0} {#if !loading && byDay.length === 0 && overdue.length === 0}
@@ -141,49 +150,58 @@
{/if} {/if}
{#each byDay as [day, tasks] (day)} {#each byDay as [day, tasks] (day)}
<section class:today={day === today}> <div class="day" class:today={day === today}>
<h2>{dayLabel(day)}</h2> <div class="datebox" title={day}>
<span class="num">{Number(day.slice(8))}</span>
<span class="wd">{dayTag(day)}</span>
</div>
<div class="card">
{#each tasks as task (`${task.kind}-${task.r.id}`)} {#each tasks as task (`${task.kind}-${task.r.id}`)}
{#if task.kind === "cleaning"} {#if task.kind === "cleaning"}
<label class="task"> <label class="task" class:done={task.r.cleaned}>
<input <input
type="checkbox" type="checkbox"
checked={task.r.cleaned} checked={task.r.cleaned}
onchange={(e) => toggleCleaned(task.r, e.currentTarget.checked)} onchange={(e) => toggleCleaned(task.r, e.currentTarget.checked)}
/> />
<span class="cabin" class:done={task.r.cleaned}>{cabinName(task.r.cabin_id)}</span> <span class="info">
<span class="muted">po pobycie: {task.r.guest_name}</span> <span class="cabin">{cabinName(task.r.cabin_id)}</span>
<span class="sub">posprzątać po pobycie: {task.r.guest_name}</span>
</span>
{#if hasSameDayArrival(task.r)} {#if hasSameDayArrival(task.r)}
<span class="urgent">przyjazd tego samego dnia</span> <span class="badge urgent">przyjazd tego samego dnia</span>
{/if} {/if}
</label> </label>
{:else} {:else}
<label class="task"> <label class="task" class:done={task.r.prepared}>
<input <input
type="checkbox" type="checkbox"
checked={task.r.prepared} checked={task.r.prepared}
onchange={(e) => togglePrepared(task.r, e.currentTarget.checked)} onchange={(e) => togglePrepared(task.r, e.currentTarget.checked)}
/> />
<span class="cabin" class:done={task.r.prepared}>{cabinName(task.r.cabin_id)}</span> <span class="info">
<span class="muted">przygotować — przyjeżdża: {task.r.guest_name}</span> <span class="cabin">{cabinName(task.r.cabin_id)}</span>
<span class="prep">pierwszy gość w domku</span> <span class="sub">przygotować — przyjeżdża: {task.r.guest_name}</span>
</span>
<span class="badge prep">pierwszy gość</span>
</label> </label>
{/if} {/if}
{/each} {/each}
</section> </div>
</div>
{/each} {/each}
</main> </main>
<style> <style>
main { main {
padding: 14px 16px; padding: 14px 16px;
max-width: 640px; max-width: 680px;
} }
.toolbar { .toolbar {
display: flex; display: flex;
align-items: baseline; align-items: baseline;
gap: 12px; gap: 12px;
margin-bottom: 14px; margin-bottom: 16px;
} }
h1 { h1 {
margin: 0; margin: 0;
@@ -203,79 +221,128 @@
border-radius: 10px; border-radius: 10px;
color: var(--muted); color: var(--muted);
} }
section {
background: var(--surface); .day {
border: 1px solid var(--border); display: grid;
border-radius: 10px; grid-template-columns: 56px minmax(0, 1fr);
padding: 12px 14px; gap: 12px;
align-items: start;
margin-bottom: 12px; margin-bottom: 12px;
} }
section.today { .datebox {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 56px;
background: var(--surface);
border: 1px solid var(--border);
border-radius: 12px;
box-shadow: 0 1px 3px rgb(0 0 0 / 0.05);
}
.datebox .num {
font-size: 20px;
font-weight: 700;
line-height: 1.1;
}
.datebox .wd {
font-size: 11px;
color: var(--muted);
}
.day.today .datebox {
background: var(--today);
border-color: var(--accent); border-color: var(--accent);
} }
section.overdue { .day.today .datebox .num,
.day.today .datebox .wd {
color: var(--accent);
}
.day.overdue .datebox {
border-color: var(--danger); border-color: var(--danger);
} }
section.overdue h2 { .day.overdue .datebox .num,
.day.overdue .datebox .wd {
color: var(--danger); color: var(--danger);
} }
h2 { .day.overdue .card {
margin: 0 0 8px; border-color: var(--danger);
font-size: 13px;
font-weight: 600;
} }
h2::first-letter {
text-transform: uppercase; .card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 12px;
overflow: hidden;
box-shadow: 0 1px 3px rgb(0 0 0 / 0.05);
} }
.day.today .card {
border-color: var(--accent);
}
.task { .task {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 10px; gap: 12px;
padding: 6px 4px; padding: 10px 14px;
border-top: 1px solid var(--border);
font-size: 14px;
cursor: pointer; cursor: pointer;
transition: background 0.12s ease;
}
.task + .task {
border-top: 1px solid var(--border);
}
.task:hover {
background: var(--bg);
} }
.task input { .task input {
width: 17px; width: 18px;
height: 17px; height: 18px;
flex: none; flex: none;
margin: 0;
cursor: pointer; cursor: pointer;
} }
.info {
display: flex;
flex-direction: column;
gap: 1px;
min-width: 0;
flex: 1;
}
.cabin { .cabin {
font-weight: 600; font-weight: 600;
min-width: 110px; font-size: 14px;
} }
.cabin.done { .sub {
text-decoration: line-through;
color: var(--muted);
font-weight: 400;
}
.muted {
color: var(--muted);
font-size: 12px; font-size: 12px;
color: var(--muted);
overflow: hidden; overflow: hidden;
white-space: nowrap; white-space: nowrap;
text-overflow: ellipsis; text-overflow: ellipsis;
} }
.urgent { .task.done {
margin-left: auto; opacity: 0.55;
}
.task.done .cabin {
text-decoration: line-through;
font-weight: 400;
}
.badge {
flex: none; flex: none;
font-size: 11px; font-size: 11px;
font-weight: 600; font-weight: 600;
border-radius: 20px;
padding: 3px 10px;
}
.badge.urgent {
color: var(--warn-text); color: var(--warn-text);
background: var(--bar-option-bg); background: var(--bar-option-bg);
border-radius: 5px;
padding: 2px 8px;
} }
.prep { .badge.prep {
margin-left: auto;
flex: none;
font-size: 11px;
font-weight: 600;
color: var(--bar-confirmed-text); color: var(--bar-confirmed-text);
background: var(--bar-confirmed-bg); background: var(--bar-confirmed-bg);
border-radius: 5px; }
padding: 2px 8px; .badge.overdue-badge {
color: var(--danger);
background: color-mix(in srgb, var(--danger) 12%, var(--surface));
} }
</style> </style>

View File

@@ -2,8 +2,9 @@
import { onMount } from "svelte"; import { onMount } from "svelte";
import { save as saveDialog } from "@tauri-apps/plugin-dialog"; import { save as saveDialog } from "@tauri-apps/plugin-dialog";
import * as db from "$lib/db"; import * as db from "$lib/db";
import { todayISO } from "$lib/dates"; import { MONTH_NAMES, todayISO } from "$lib/dates";
import type { Cabin, PriceSeason, Settings } from "$lib/types"; import { AI_PROVIDER_LABELS, DEFAULT_BASE_URLS, DEFAULT_MODELS, providerNeedsKey } from "$lib/ai";
import type { AiProvider, Cabin, PriceSeason, Settings } from "$lib/types";
let settings = $state<Settings | null>(null); let settings = $state<Settings | null>(null);
let cabins = $state<Cabin[]>([]); let cabins = $state<Cabin[]>([]);
@@ -116,7 +117,10 @@
energy_price: energy_price:
settings.energy_price == null || Number.isNaN(settings.energy_price) settings.energy_price == null || Number.isNaN(settings.energy_price)
? null ? null
: settings.energy_price : settings.energy_price,
ai_api_key: settings.ai_api_key?.trim() || null,
ai_model: settings.ai_model?.trim() || null,
ai_base_url: settings.ai_base_url?.trim() || null
}); });
savedFlash = true; savedFlash = true;
setTimeout(() => (savedFlash = false), 2500); setTimeout(() => (savedFlash = false), 2500);
@@ -201,6 +205,81 @@
</p> </p>
</section> </section>
<section>
<h2>Sezon działalności</h2>
<div class="grid2">
<label>
Od miesiąca
<select bind:value={settings.season_start_month}>
{#each MONTH_NAMES as name, i (i)}
<option value={i + 1}>{name}</option>
{/each}
</select>
</label>
<label>
Do miesiąca (włącznie)
<select bind:value={settings.season_end_month}>
{#each MONTH_NAMES as name, i (i)}
<option value={i + 1}>{name}</option>
{/each}
</select>
</label>
</div>
<p class="hint">
Obłożenie w raportach i podpowiedzi asystenta liczone są tylko dla miesięcy sezonu.
</p>
</section>
<section>
<h2>Asystent AI</h2>
<div class="grid2">
<label>
Dostawca
<select bind:value={settings.ai_provider}>
{#each Object.entries(AI_PROVIDER_LABELS) as [value, label] (value)}
<option {value}>{label}</option>
{/each}
</select>
</label>
{#if settings.ai_provider !== "local"}
<label>
Model
<input
type="text"
bind:value={settings.ai_model}
placeholder={DEFAULT_MODELS[settings.ai_provider]}
/>
</label>
{/if}
{#if providerNeedsKey(settings.ai_provider)}
<label class="wide">
Klucz API
<input type="password" bind:value={settings.ai_api_key} placeholder="sk-…" />
</label>
{:else if settings.ai_provider !== "local"}
<label class="wide">
Adres serwera
<input
type="text"
bind:value={settings.ai_base_url}
placeholder={DEFAULT_BASE_URLS[settings.ai_provider as AiProvider] ?? ""}
/>
</label>
{/if}
</div>
{#if settings.ai_provider === "local"}
<p class="hint">
Wbudowany model ({DEFAULT_MODELS.local}) działa w całości na tym komputerze — bez
klucza API i internetu. Pliki (~2,5 GB) pobierzesz w zakładce „Asystent".
</p>
{:else}
<p class="hint">
Asystent w zakładce „Asystent" otrzymuje przy każdej rozmowie aktualne dane o obłożeniu,
cenniku i kosztach. Klucz API jest przechowywany lokalnie w bazie aplikacji.
</p>
{/if}
</section>
<div class="actions"> <div class="actions">
{#if savedFlash}<span class="saved">Zapisano</span>{/if} {#if savedFlash}<span class="saved">Zapisano</span>{/if}
<button type="submit" class="btn primary">Zapisz ustawienia</button> <button type="submit" class="btn primary">Zapisz ustawienia</button>