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