Files
Sloneczko-manager/src/routes/raporty/+page.svelte
2026-07-05 15:53:52 +02:00

330 lines
8.3 KiB
Svelte
Raw Blame History

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