ShadCN Implementation

This commit is contained in:
sandy
2026-04-01 03:57:04 +02:00
parent d68d476482
commit 3546239396
37 changed files with 1073 additions and 320 deletions

1
frontend/.env.example Normal file
View File

@@ -0,0 +1 @@
VITE_API_BASE=http://localhost:8000/api

View File

@@ -1,38 +1,50 @@
@import "tailwindcss";
:root {
--background: #f5f1e8;
--foreground: #201916;
--card: #fffcf6;
--card-foreground: #201916;
--popover: #fff9ef;
--popover-foreground: #201916;
--primary: #16302b;
--primary-foreground: #f6efe4;
--secondary: #d8c3a8;
--secondary-foreground: #2d241e;
--muted: #ece2d3;
--muted-foreground: #615246;
--accent: #ba6c46;
--accent-foreground: #fff7ef;
--border: #cdbca5;
--input: #e7dbca;
--ring: #16302b;
--background: #09090b;
--foreground: #fafafa;
--card: #09090b;
--card-foreground: #fafafa;
--popover: #09090b;
--popover-foreground: #fafafa;
--primary: #fafafa;
--primary-foreground: #09090b;
--secondary: #18181b;
--secondary-foreground: #fafafa;
--muted: #18181b;
--muted-foreground: #a1a1aa;
--accent: #27272a;
--accent-foreground: #fafafa;
--border: #27272a;
--input: #27272a;
--ring: #d4d4d8;
}
body {
background:
radial-gradient(circle at top left, rgba(186, 108, 70, 0.18), transparent 30%),
radial-gradient(circle at bottom right, rgba(22, 48, 43, 0.18), transparent 35%),
var(--background);
background: var(--background);
color: var(--foreground);
font-family: "IBM Plex Sans", "Segoe UI", sans-serif;
font-family: Arial, Helvetica, sans-serif;
min-height: 100vh;
}
.panel {
background: color-mix(in srgb, var(--card) 92%, white);
border: 1px solid var(--border);
border-radius: 1rem;
box-shadow: 0 18px 60px rgba(32, 25, 22, 0.08);
* {
border-color: var(--border);
box-sizing: border-box;
}
::selection {
background: #3f3f46;
color: var(--foreground);
}
button,
input,
select,
textarea {
font: inherit;
}
a {
color: inherit;
text-decoration: none;
}

View File

@@ -1,7 +1,9 @@
<script lang="ts">
import { page } from "$app/state";
import { goto } from "$app/navigation";
import { api } from "$lib/api/client";
import Button from "$lib/components/ui/button.svelte";
import Card from "$lib/components/ui/card.svelte";
import { authUser, clearAuth, hasPermission } from "$lib/stores/auth";
const items = [
@@ -28,14 +30,22 @@
$: visibleItems = items.filter((item) => !$authUser || hasPermission(item.permission));
</script>
<aside class="panel flex w-full max-w-64 flex-col gap-3 p-4">
<Card className="flex w-full max-w-72 flex-col gap-4 bg-[var(--secondary)] p-4 lg:sticky lg:top-4 lg:h-[calc(100vh-2rem)]">
<div class="border-b border-[var(--border)] pb-4">
<p class="text-xs uppercase tracking-[0.25em] text-[var(--muted-foreground)]">Central Hub</p>
<h1 class="mt-2 text-xl font-semibold">Hospitality Ops</h1>
<p class="mt-1 text-sm text-[var(--muted-foreground)]">Django control surface</p>
</div>
<nav class="flex flex-col gap-2">
{#each visibleItems as item}
<a class="rounded-xl px-3 py-2 text-sm text-[var(--foreground)] transition hover:bg-black/5" href={item.href}>
<a
class={`rounded-lg px-3 py-2 text-sm transition ${
page.url.pathname === item.href
? "bg-[var(--foreground)] text-[var(--background)]"
: "text-[var(--muted-foreground)] hover:bg-[var(--accent)] hover:text-[var(--foreground)]"
}`}
href={item.href}
>
{item.label}
</a>
{/each}
@@ -49,4 +59,4 @@
<Button type="button" variant="ghost" on:click={signOut}>Sign out</Button>
</div>
</div>
</aside>
</Card>

View File

@@ -1,19 +1,44 @@
<script lang="ts">
import { createEventDispatcher } from "svelte";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "$lib/utils";
export let href: string | undefined = undefined;
export let type: "button" | "submit" | "reset" = "button";
export let variant: "primary" | "secondary" | "ghost" | "destructive" = "primary";
export let variant: ButtonVariant = "default";
export let size: ButtonSize = "default";
export let disabled = false;
export let className = "";
const dispatch = createEventDispatcher<{ click: MouseEvent }>();
const variants = {
primary: "bg-[var(--primary)] text-[var(--primary-foreground)] hover:opacity-95",
secondary: "bg-[var(--secondary)] text-[var(--secondary-foreground)] hover:opacity-95",
ghost: "bg-transparent text-[var(--foreground)] hover:bg-black/5",
destructive: "bg-red-700 text-white hover:bg-red-800"
};
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors ring-offset-[var(--background)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--ring)] focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
{
variants: {
variant: {
default: "bg-[var(--primary)] text-[var(--primary-foreground)] shadow-sm hover:opacity-95",
secondary: "bg-[var(--secondary)] text-[var(--secondary-foreground)] shadow-sm hover:opacity-95",
ghost: "hover:bg-[var(--accent)] text-[var(--foreground)]",
destructive: "bg-red-700 text-white shadow-sm hover:bg-red-800",
outline: "border border-[var(--border)] bg-[var(--background)] shadow-sm hover:bg-[var(--accent)]"
},
size: {
default: "h-10 px-4 py-2",
sm: "h-9 rounded-md px-3",
lg: "h-11 rounded-md px-8",
icon: "h-10 w-10"
}
},
defaultVariants: {
variant: "default",
size: "default"
}
}
);
type ButtonVariant = VariantProps<typeof buttonVariants>["variant"];
type ButtonSize = VariantProps<typeof buttonVariants>["size"];
function handleClick(event: MouseEvent) {
dispatch("click", event);
@@ -22,7 +47,7 @@
{#if href}
<a
class={`inline-flex items-center justify-center rounded-xl px-4 py-2 text-sm font-medium transition ${variants[variant]}`}
class={cn(buttonVariants({ variant, size }), className)}
{href}
{...$$restProps}
on:click={handleClick}
@@ -31,7 +56,7 @@
</a>
{:else}
<button
class={`inline-flex items-center justify-center rounded-xl px-4 py-2 text-sm font-medium transition disabled:opacity-50 ${variants[variant]}`}
class={cn(buttonVariants({ variant, size }), className)}
{type}
{disabled}
{...$$restProps}

View File

@@ -0,0 +1,9 @@
<script lang="ts">
import { cn } from "$lib/utils";
export let className = "";
</script>
<div class={cn("p-6 pt-0", className)}>
<slot />
</div>

View File

@@ -0,0 +1,9 @@
<script lang="ts">
import { cn } from "$lib/utils";
export let className = "";
</script>
<p class={cn("text-sm text-[var(--muted-foreground)]", className)}>
<slot />
</p>

View File

@@ -0,0 +1,9 @@
<script lang="ts">
import { cn } from "$lib/utils";
export let className = "";
</script>
<div class={cn("flex items-center p-6 pt-0", className)}>
<slot />
</div>

View File

@@ -0,0 +1,9 @@
<script lang="ts">
import { cn } from "$lib/utils";
export let className = "";
</script>
<div class={cn("flex flex-col space-y-1.5 p-6", className)}>
<slot />
</div>

View File

@@ -0,0 +1,9 @@
<script lang="ts">
import { cn } from "$lib/utils";
export let className = "";
</script>
<h3 class={cn("leading-none font-semibold tracking-tight", className)}>
<slot />
</h3>

View File

@@ -1,3 +1,14 @@
<div class="panel p-5">
<script lang="ts">
import { cn } from "$lib/utils";
export let className = "";
</script>
<div
class={cn(
"rounded-xl border border-[var(--border)] bg-[var(--card)] text-[var(--card-foreground)] shadow-sm",
className
)}
>
<slot />
</div>

View File

@@ -0,0 +1,17 @@
<script lang="ts">
import { cn } from "$lib/utils";
export let type: string = "text";
export let value: string | number = "";
export let className = "";
</script>
<input
{type}
bind:value
class={cn(
"flex h-10 w-full rounded-md border border-[var(--input)] bg-[var(--background)] px-3 py-2 text-sm ring-offset-[var(--background)] file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-[var(--muted-foreground)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--ring)] focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...$$restProps}
/>

View File

@@ -0,0 +1,10 @@
<script lang="ts">
import { cn } from "$lib/utils";
export let forId: string | undefined = undefined;
export let className = "";
</script>
<label for={forId} class={cn("text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70", className)}>
<slot />
</label>

View File

@@ -0,0 +1,17 @@
<script lang="ts">
import { cn } from "$lib/utils";
export let value: string | number | null = "";
export let className = "";
</script>
<select
bind:value
class={cn(
"flex h-10 w-full rounded-md border border-[var(--input)] bg-[var(--background)] px-3 py-2 text-sm ring-offset-[var(--background)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--ring)] focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...$$restProps}
>
<slot />
</select>

View File

@@ -0,0 +1,16 @@
<script lang="ts">
import { cn } from "$lib/utils";
export let value: string = "";
export let className = "";
</script>
<textarea
bind:value
class={cn(
"flex min-h-20 w-full rounded-md border border-[var(--input)] bg-[var(--background)] px-3 py-2 text-sm ring-offset-[var(--background)] placeholder:text-[var(--muted-foreground)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--ring)] focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...$$restProps}
>
</textarea>

View File

@@ -0,0 +1,6 @@
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}

View File

@@ -2,6 +2,7 @@
import { onMount } from "svelte";
import { goto } from "$app/navigation";
import Sidebar from "$lib/components/app-shell/sidebar.svelte";
import Card from "$lib/components/ui/card.svelte";
import { authReady, bootstrapAuth } from "$lib/stores/auth";
let loading = true;
@@ -24,21 +25,21 @@
{#if loading || !$authReady}
<div class="mx-auto flex min-h-screen max-w-7xl items-center justify-center px-6 py-10">
<div class="panel p-6 text-center">
<Card className="p-6 text-center">
<p class="text-sm uppercase tracking-[0.3em] text-[var(--muted-foreground)]">Session</p>
<p class="mt-2 text-lg font-medium">Checking authentication…</p>
</div>
</Card>
</div>
{:else if error}
<div class="mx-auto flex min-h-screen max-w-7xl items-center justify-center px-6 py-10">
<div class="panel p-6 text-center">
<p class="text-sm text-red-700">{error}</p>
</div>
<Card className="p-6 text-center">
<p class="text-sm text-red-300">{error}</p>
</Card>
</div>
{:else}
<div class="mx-auto flex min-h-screen max-w-7xl gap-6 px-4 py-4 lg:px-6">
<div class="mx-auto flex min-h-screen max-w-[1600px] gap-6 px-4 py-4 lg:px-6">
<Sidebar />
<main class="min-w-0 flex-1">
<main class="min-w-0 flex-1 rounded-2xl border border-[var(--border)] bg-[var(--secondary)]/40 p-4 lg:p-6">
<slot />
</main>
</div>

View File

@@ -2,6 +2,10 @@
import { onMount } from "svelte";
import { page } from "$app/state";
import Card from "$lib/components/ui/card.svelte";
import CardContent from "$lib/components/ui/card-content.svelte";
import CardDescription from "$lib/components/ui/card-description.svelte";
import CardHeader from "$lib/components/ui/card-header.svelte";
import CardTitle from "$lib/components/ui/card-title.svelte";
import { api } from "$lib/api/client";
import type { BusinessSummaryPayload } from "$lib/types/domain";
@@ -19,7 +23,7 @@
<div class="space-y-6">
{#if error}
<Card><p class="text-sm text-red-700">{error}</p></Card>
<Card><CardContent className="p-5"><p class="text-sm text-red-300">{error}</p></CardContent></Card>
{:else if summary}
<div>
<p class="text-sm uppercase tracking-[0.3em] text-[var(--muted-foreground)]">{summary.business.short_code}</p>
@@ -27,21 +31,21 @@
</div>
<div class="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
<Card><p class="text-sm text-[var(--muted-foreground)]">Revenue</p><p class="mt-2 text-3xl font-semibold">{summary.stats.total_revenue.toFixed(2)} {summary.business.currency}</p></Card>
<Card><p class="text-sm text-[var(--muted-foreground)]">Expenses</p><p class="mt-2 text-3xl font-semibold">{summary.stats.total_expenses.toFixed(2)} {summary.business.currency}</p></Card>
<Card><p class="text-sm text-[var(--muted-foreground)]">Outstanding invoices</p><p class="mt-2 text-3xl font-semibold">{summary.stats.outstanding_invoices}</p></Card>
<Card><p class="text-sm text-[var(--muted-foreground)]">Invoices</p><p class="mt-2 text-3xl font-semibold">{summary.stats.invoice_count}</p></Card>
<Card><p class="text-sm text-[var(--muted-foreground)]">VAT tracked</p><p class="mt-2 text-3xl font-semibold">{summary.stats.total_vat.toFixed(2)} {summary.business.currency}</p></Card>
<Card><p class="text-sm text-[var(--muted-foreground)]">Inventory items</p><p class="mt-2 text-3xl font-semibold">{summary.stats.inventory_items}</p></Card>
<Card><CardContent className="p-5"><p class="text-sm text-[var(--muted-foreground)]">Revenue</p><p class="mt-2 text-3xl font-semibold">{summary.stats.total_revenue.toFixed(2)} {summary.business.currency}</p></CardContent></Card>
<Card><CardContent className="p-5"><p class="text-sm text-[var(--muted-foreground)]">Expenses</p><p class="mt-2 text-3xl font-semibold">{summary.stats.total_expenses.toFixed(2)} {summary.business.currency}</p></CardContent></Card>
<Card><CardContent className="p-5"><p class="text-sm text-[var(--muted-foreground)]">Outstanding invoices</p><p class="mt-2 text-3xl font-semibold">{summary.stats.outstanding_invoices}</p></CardContent></Card>
<Card><CardContent className="p-5"><p class="text-sm text-[var(--muted-foreground)]">Invoices</p><p class="mt-2 text-3xl font-semibold">{summary.stats.invoice_count}</p></CardContent></Card>
<Card><CardContent className="p-5"><p class="text-sm text-[var(--muted-foreground)]">VAT tracked</p><p class="mt-2 text-3xl font-semibold">{summary.stats.total_vat.toFixed(2)} {summary.business.currency}</p></CardContent></Card>
<Card><CardContent className="p-5"><p class="text-sm text-[var(--muted-foreground)]">Inventory items</p><p class="mt-2 text-3xl font-semibold">{summary.stats.inventory_items}</p></CardContent></Card>
</div>
<div class="grid gap-6 xl:grid-cols-[1fr_1fr]">
<Card>
<div class="mb-4">
<h3 class="text-xl font-semibold">Recent revenue</h3>
<p class="text-sm text-[var(--muted-foreground)]">Latest imported revenue summary rows for this business.</p>
</div>
<div class="space-y-3">
<CardHeader>
<CardTitle className="text-xl">Recent revenue</CardTitle>
<CardDescription>Latest imported revenue summary rows for this business.</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
{#each summary.recent_revenue as row}
<div class="flex items-center justify-between border-b border-[var(--border)] py-2 last:border-0">
<div>
@@ -54,15 +58,15 @@
</div>
</div>
{/each}
</div>
</CardContent>
</Card>
<Card>
<div class="mb-4">
<h3 class="text-xl font-semibold">Recent invoices</h3>
<p class="text-sm text-[var(--muted-foreground)]">Most recent invoice activity linked to this business.</p>
</div>
<div class="space-y-3">
<CardHeader>
<CardTitle className="text-xl">Recent invoices</CardTitle>
<CardDescription>Most recent invoice activity linked to this business.</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
{#each summary.recent_invoices as invoice}
<div class="flex items-center justify-between border-b border-[var(--border)] py-2 last:border-0">
<div>
@@ -75,10 +79,10 @@
</div>
</div>
{/each}
</div>
</CardContent>
</Card>
</div>
{:else}
<Card>Loading business summary…</Card>
<Card><CardContent className="p-5">Loading business summary…</CardContent></Card>
{/if}
</div>

View File

@@ -2,6 +2,10 @@
import { onMount } from "svelte";
import Button from "$lib/components/ui/button.svelte";
import Card from "$lib/components/ui/card.svelte";
import CardContent from "$lib/components/ui/card-content.svelte";
import CardDescription from "$lib/components/ui/card-description.svelte";
import CardHeader from "$lib/components/ui/card-header.svelte";
import CardTitle from "$lib/components/ui/card-title.svelte";
import { api } from "$lib/api/client";
import type { DashboardBusinessSummary, DashboardOverview } from "$lib/types/domain";
@@ -30,27 +34,25 @@
</div>
{#if error}
<Card><p class="text-sm text-red-700">{error}</p></Card>
<Card><CardContent className="p-5"><p class="text-sm text-red-300">{error}</p></CardContent></Card>
{:else if overview}
<div class="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
<Card><p class="text-sm text-[var(--muted-foreground)]">Revenue</p><p class="mt-2 text-3xl font-semibold">{overview.total_revenue.toFixed(2)}</p></Card>
<Card><p class="text-sm text-[var(--muted-foreground)]">Expenses</p><p class="mt-2 text-3xl font-semibold">{overview.total_expenses.toFixed(2)}</p></Card>
<Card><p class="text-sm text-[var(--muted-foreground)]">Outstanding invoices</p><p class="mt-2 text-3xl font-semibold">{overview.outstanding_invoices}</p></Card>
<Card><p class="text-sm text-[var(--muted-foreground)]">VAT tracked</p><p class="mt-2 text-3xl font-semibold">{overview.total_vat.toFixed(2)}</p></Card>
<Card><p class="text-sm text-[var(--muted-foreground)]">Vendors</p><p class="mt-2 text-3xl font-semibold">{overview.vendor_count}</p></Card>
<Card><p class="text-sm text-[var(--muted-foreground)]">Unread notifications</p><p class="mt-2 text-3xl font-semibold">{overview.unread_notifications}</p></Card>
<Card><CardContent className="p-5"><p class="text-sm text-[var(--muted-foreground)]">Revenue</p><p class="mt-2 text-3xl font-semibold">{overview.total_revenue.toFixed(2)}</p></CardContent></Card>
<Card><CardContent className="p-5"><p class="text-sm text-[var(--muted-foreground)]">Expenses</p><p class="mt-2 text-3xl font-semibold">{overview.total_expenses.toFixed(2)}</p></CardContent></Card>
<Card><CardContent className="p-5"><p class="text-sm text-[var(--muted-foreground)]">Outstanding invoices</p><p class="mt-2 text-3xl font-semibold">{overview.outstanding_invoices}</p></CardContent></Card>
<Card><CardContent className="p-5"><p class="text-sm text-[var(--muted-foreground)]">VAT tracked</p><p class="mt-2 text-3xl font-semibold">{overview.total_vat.toFixed(2)}</p></CardContent></Card>
<Card><CardContent className="p-5"><p class="text-sm text-[var(--muted-foreground)]">Vendors</p><p class="mt-2 text-3xl font-semibold">{overview.vendor_count}</p></CardContent></Card>
<Card><CardContent className="p-5"><p class="text-sm text-[var(--muted-foreground)]">Unread notifications</p><p class="mt-2 text-3xl font-semibold">{overview.unread_notifications}</p></CardContent></Card>
</div>
<Card>
<div class="mb-4 flex items-center justify-between">
<div>
<h3 class="text-xl font-semibold">Business roll-up</h3>
<p class="text-sm text-[var(--muted-foreground)]">Per-business revenue, expenses, and invoice pressure.</p>
</div>
</div>
<div class="space-y-3">
<CardHeader>
<CardTitle className="text-xl">Business roll-up</CardTitle>
<CardDescription>Per-business revenue, expenses, and invoice pressure.</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
{#each businesses as business}
<div class="rounded-2xl border border-[var(--border)] bg-white/60 p-4">
<div class="rounded-2xl border border-[var(--border)] bg-[var(--muted)] p-4">
<div class="flex items-start justify-between gap-4">
<div>
<p class="font-medium">{business.business_name}</p>
@@ -66,9 +68,9 @@
</div>
</div>
{/each}
</div>
</CardContent>
</Card>
{:else}
<Card>Loading dashboard…</Card>
<Card><CardContent className="p-5">Loading dashboard…</CardContent></Card>
{/if}
</div>

View File

@@ -2,6 +2,11 @@
import { onMount } from "svelte";
import Button from "$lib/components/ui/button.svelte";
import Card from "$lib/components/ui/card.svelte";
import CardContent from "$lib/components/ui/card-content.svelte";
import CardDescription from "$lib/components/ui/card-description.svelte";
import CardHeader from "$lib/components/ui/card-header.svelte";
import CardTitle from "$lib/components/ui/card-title.svelte";
import Input from "$lib/components/ui/input.svelte";
import { api } from "$lib/api/client";
import type { DeviceItem, DeviceRegistrationTokenItem } from "$lib/types/domain";
@@ -112,28 +117,28 @@
</div>
{#if error}
<Card><p class="text-sm text-red-700">{error}</p></Card>
<Card><CardContent className="p-5"><p class="text-sm text-red-300">{error}</p></CardContent></Card>
{/if}
{#if loading}
<Card>Loading devices…</Card>
<Card><CardContent className="p-5">Loading devices…</CardContent></Card>
{:else}
<div class="grid gap-4 md:grid-cols-3">
<Card><p class="text-sm text-[var(--muted-foreground)]">Allowed devices</p><p class="mt-2 text-3xl font-semibold">{devices.length}</p></Card>
<Card><p class="text-sm text-[var(--muted-foreground)]">Active devices</p><p class="mt-2 text-3xl font-semibold">{devices.filter((device) => device.is_active).length}</p></Card>
<Card><p class="text-sm text-[var(--muted-foreground)]">Registration tokens</p><p class="mt-2 text-3xl font-semibold">{tokens.length}</p></Card>
<Card><CardContent className="p-5"><p class="text-sm text-[var(--muted-foreground)]">Allowed devices</p><p class="mt-2 text-3xl font-semibold">{devices.length}</p></CardContent></Card>
<Card><CardContent className="p-5"><p class="text-sm text-[var(--muted-foreground)]">Active devices</p><p class="mt-2 text-3xl font-semibold">{devices.filter((device) => device.is_active).length}</p></CardContent></Card>
<Card><CardContent className="p-5"><p class="text-sm text-[var(--muted-foreground)]">Registration tokens</p><p class="mt-2 text-3xl font-semibold">{tokens.length}</p></CardContent></Card>
</div>
<div class="grid gap-6 xl:grid-cols-[1.1fr_0.9fr]">
<div class="space-y-6">
<Card>
<div class="mb-4">
<h3 class="text-xl font-semibold">Whitelisted devices</h3>
<p class="text-sm text-[var(--muted-foreground)]">Imported device/IP allowlist plus new Django-managed entries.</p>
</div>
<div class="space-y-3">
<CardHeader>
<CardTitle className="text-xl">Whitelisted devices</CardTitle>
<CardDescription>Imported device/IP allowlist plus new Django-managed entries.</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
{#each devices as device}
<div class="rounded-2xl border border-[var(--border)] bg-white/60 p-4">
<div class="rounded-2xl border border-[var(--border)] bg-[var(--muted)] p-4">
<div class="flex items-start justify-between gap-4">
<div>
<p class="font-medium">{device.label || device.ip_address}</p>
@@ -143,65 +148,69 @@
</p>
</div>
<div class="text-right">
<p class={`text-sm ${device.is_active ? "text-green-700" : "text-red-700"}`}>{device.is_active ? "Active" : "Inactive"}</p>
<button class="mt-2 text-sm text-red-700 hover:opacity-80" on:click={() => removeDevice(device.id)}>Delete</button>
<p class="text-sm text-[var(--muted-foreground)]">{device.is_active ? "Active" : "Inactive"}</p>
<button class="mt-2 text-sm text-red-300 hover:text-red-200" on:click={() => removeDevice(device.id)}>Delete</button>
</div>
</div>
</div>
{/each}
</div>
</CardContent>
</Card>
<Card>
<div class="mb-4">
<h3 class="text-xl font-semibold">Registration tokens</h3>
<p class="text-sm text-[var(--muted-foreground)]">Manual tokens for controlled device onboarding.</p>
</div>
<div class="space-y-3">
<CardHeader>
<CardTitle className="text-xl">Registration tokens</CardTitle>
<CardDescription>Manual tokens for controlled device onboarding.</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
{#each tokens as token}
<div class="rounded-2xl border border-[var(--border)] bg-white/60 p-4">
<div class="rounded-2xl border border-[var(--border)] bg-[var(--muted)] p-4">
<p class="font-medium">{token.label || "Untitled token"}</p>
<p class="mt-1 break-all text-sm text-[var(--muted-foreground)]">{token.token}</p>
<p class="mt-1 text-xs uppercase tracking-[0.2em] text-[var(--muted-foreground)]">
Expires {new Date(token.expires_at).toLocaleString()} {token.used_at ? ` used ${new Date(token.used_at).toLocaleString()}` : "• unused"}
</p>
<button class="mt-3 text-sm text-red-700 hover:opacity-80" on:click={() => removeToken(token.id)}>Delete</button>
<button class="mt-3 text-sm text-red-300 hover:text-red-200" on:click={() => removeToken(token.id)}>Delete</button>
</div>
{/each}
</div>
</CardContent>
</Card>
</div>
<div class="space-y-6">
<Card>
<div class="mb-4">
<h3 class="text-xl font-semibold">Add device</h3>
<p class="text-sm text-[var(--muted-foreground)]">Create a whitelist entry directly in the new backend.</p>
</div>
<CardHeader>
<CardTitle className="text-xl">Add device</CardTitle>
<CardDescription>Create a whitelist entry directly in the new backend.</CardDescription>
</CardHeader>
<CardContent>
<form class="space-y-4" on:submit|preventDefault={saveDevice}>
<input class="w-full rounded-xl border border-[var(--input)] bg-white/70 px-3 py-2" bind:value={deviceForm.ip_address} placeholder="IP address" required />
<input class="w-full rounded-xl border border-[var(--input)] bg-white/70 px-3 py-2" bind:value={deviceForm.label} placeholder="Label" />
<input class="w-full rounded-xl border border-[var(--input)] bg-white/70 px-3 py-2" bind:value={deviceForm.user_agent} placeholder="User agent" />
<input class="w-full rounded-xl border border-[var(--input)] bg-white/70 px-3 py-2" bind:value={deviceForm.ipv6_prefix} placeholder="IPv6 prefix" />
<input class="w-full rounded-xl border border-[var(--input)] bg-white/70 px-3 py-2" bind:value={deviceForm.known_ips} placeholder="Known IPs, comma separated" />
<Input bind:value={deviceForm.ip_address} placeholder="IP address" required />
<Input bind:value={deviceForm.label} placeholder="Label" />
<Input bind:value={deviceForm.user_agent} placeholder="User agent" />
<Input bind:value={deviceForm.ipv6_prefix} placeholder="IPv6 prefix" />
<Input bind:value={deviceForm.known_ips} placeholder="Known IPs, comma separated" />
<label class="flex items-center gap-2 text-sm">
<input type="checkbox" bind:checked={deviceForm.is_active} />
Active device
</label>
<Button type="submit" disabled={savingDevice}>{savingDevice ? "Saving…" : "Add device"}</Button>
</form>
</CardContent>
</Card>
<Card>
<div class="mb-4">
<h3 class="text-xl font-semibold">Create registration token</h3>
<p class="text-sm text-[var(--muted-foreground)]">Generate a fresh onboarding token with an expiry window.</p>
</div>
<CardHeader>
<CardTitle className="text-xl">Create registration token</CardTitle>
<CardDescription>Generate a fresh onboarding token with an expiry window.</CardDescription>
</CardHeader>
<CardContent>
<form class="space-y-4" on:submit|preventDefault={saveToken}>
<input class="w-full rounded-xl border border-[var(--input)] bg-white/70 px-3 py-2" bind:value={tokenForm.label} placeholder="Label" />
<input class="w-full rounded-xl border border-[var(--input)] bg-white/70 px-3 py-2" type="number" min="1" bind:value={tokenForm.expires_in_days} />
<Input bind:value={tokenForm.label} placeholder="Label" />
<Input type="number" min="1" bind:value={tokenForm.expires_in_days} />
<Button type="submit" disabled={savingToken}>{savingToken ? "Saving…" : "Create token"}</Button>
</form>
</CardContent>
</Card>
</div>
</div>

View File

@@ -2,6 +2,13 @@
import { onMount } from "svelte";
import Button from "$lib/components/ui/button.svelte";
import Card from "$lib/components/ui/card.svelte";
import CardContent from "$lib/components/ui/card-content.svelte";
import CardDescription from "$lib/components/ui/card-description.svelte";
import CardHeader from "$lib/components/ui/card-header.svelte";
import CardTitle from "$lib/components/ui/card-title.svelte";
import Input from "$lib/components/ui/input.svelte";
import Select from "$lib/components/ui/select.svelte";
import Textarea from "$lib/components/ui/textarea.svelte";
import { api } from "$lib/api/client";
import type { Business, EventItem } from "$lib/types/domain";
@@ -22,7 +29,7 @@
end_datetime: "",
all_day: false,
location: "",
color: "#ba6c46",
color: "#09090b",
recurrence_type: "none",
recurrence_end_date: ""
};
@@ -70,7 +77,7 @@
end_datetime: "",
all_day: false,
location: "",
color: "#ba6c46",
color: "#09090b",
recurrence_type: "none",
recurrence_end_date: ""
};
@@ -94,7 +101,7 @@
end_datetime: event.end_datetime.slice(0, 16),
all_day: event.all_day,
location: event.location,
color: event.color || "#ba6c46",
color: event.color || "#09090b",
recurrence_type: event.recurrence_type,
recurrence_end_date: event.recurrence_end_date || ""
};
@@ -119,32 +126,33 @@
<h2 class="mt-2 text-3xl font-semibold">Event planner</h2>
</div>
<div class="flex gap-2">
<select class="rounded-xl border border-[var(--input)] bg-white/70 px-3 py-2" bind:value={selectedBusiness}>
<Select bind:value={selectedBusiness}>
<option value="">All businesses</option>
{#each businesses as business}
<option value={business.id}>{business.name}</option>
{/each}
</select>
</Select>
<Button type="button" on:click={loadData}>Filter</Button>
</div>
</div>
{#if error}
<Card><p class="text-sm text-red-700">{error}</p></Card>
<Card><CardContent className="p-5"><p class="text-sm text-red-300">{error}</p></CardContent></Card>
{/if}
<div class="grid gap-6 xl:grid-cols-[1.05fr_0.95fr]">
<Card>
<div class="mb-4">
<h3 class="text-xl font-semibold">Upcoming events</h3>
<p class="text-sm text-[var(--muted-foreground)]">Imported events plus newly created Django-side events.</p>
</div>
<CardHeader>
<CardTitle className="text-xl">Upcoming events</CardTitle>
<CardDescription>Imported events plus newly created Django-side events.</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
{#if loading}
<p>Loading events…</p>
{:else}
<div class="space-y-3">
{#each events as event}
<div class="rounded-2xl border border-[var(--border)] bg-white/60 p-4">
<div class="rounded-2xl border border-[var(--border)] bg-[var(--muted)] p-4">
<div class="flex items-start justify-between gap-4">
<div>
<p class="font-medium">{event.title}</p>
@@ -154,49 +162,51 @@
</div>
<div class="flex items-center gap-2">
<button class="text-sm text-[var(--muted-foreground)] hover:text-[var(--foreground)]" on:click={() => editEvent(event)}>Edit</button>
<button class="text-sm text-red-700 hover:opacity-80" on:click={() => removeEvent(event.id)}>Delete</button>
<div class="h-4 w-4 rounded-full border border-black/10" style={`background:${event.color || "#ba6c46"}`}></div>
<button class="text-sm text-red-300 hover:text-red-200" on:click={() => removeEvent(event.id)}>Delete</button>
<div class="h-4 w-4 rounded-full border border-white/10" style={`background:${event.color || "#09090b"}`}></div>
</div>
</div>
</div>
{/each}
</div>
{/if}
</CardContent>
</Card>
<Card>
<div class="mb-4">
<h3 class="text-xl font-semibold">Create event</h3>
<p class="text-sm text-[var(--muted-foreground)]">{editingEventId ? "Update an existing event." : "A lean replacement for the old calendar modal stack."}</p>
</div>
<CardHeader>
<CardTitle className="text-xl">{editingEventId ? "Edit event" : "Create event"}</CardTitle>
<CardDescription>{editingEventId ? "Update an existing event." : "A lean replacement for the old calendar modal stack."}</CardDescription>
</CardHeader>
<CardContent>
<form class="space-y-4" on:submit|preventDefault={saveEvent}>
<select class="w-full rounded-xl border border-[var(--input)] bg-white/70 px-3 py-2" bind:value={form.business_id}>
<Select bind:value={form.business_id}>
{#each businesses as business}
<option value={business.id}>{business.name}</option>
{/each}
</select>
<input class="w-full rounded-xl border border-[var(--input)] bg-white/70 px-3 py-2" bind:value={form.title} placeholder="Event title" required />
<textarea class="min-h-24 w-full rounded-xl border border-[var(--input)] bg-white/70 px-3 py-2" bind:value={form.description} placeholder="Description"></textarea>
</Select>
<Input bind:value={form.title} placeholder="Event title" required />
<Textarea className="min-h-24" bind:value={form.description} placeholder="Description"></Textarea>
<div class="grid gap-4 md:grid-cols-2">
<select class="rounded-xl border border-[var(--input)] bg-white/70 px-3 py-2" bind:value={form.event_type}>
<Select bind:value={form.event_type}>
<option value="other">Other</option>
<option value="service">Service</option>
<option value="private">Private</option>
<option value="maintenance">Maintenance</option>
</select>
<input class="rounded-xl border border-[var(--input)] bg-white/70 px-3 py-2" bind:value={form.location} placeholder="Location" />
<input class="rounded-xl border border-[var(--input)] bg-white/70 px-3 py-2" type="datetime-local" bind:value={form.start_datetime} required />
<input class="rounded-xl border border-[var(--input)] bg-white/70 px-3 py-2" type="datetime-local" bind:value={form.end_datetime} required />
<select class="rounded-xl border border-[var(--input)] bg-white/70 px-3 py-2" bind:value={form.recurrence_type}>
</Select>
<Input bind:value={form.location} placeholder="Location" />
<Input type="datetime-local" bind:value={form.start_datetime} required />
<Input type="datetime-local" bind:value={form.end_datetime} required />
<Select bind:value={form.recurrence_type}>
<option value="none">No recurrence</option>
<option value="daily">Daily</option>
<option value="weekly">Weekly</option>
<option value="monthly">Monthly</option>
</select>
<input class="rounded-xl border border-[var(--input)] bg-white/70 px-3 py-2" type="date" bind:value={form.recurrence_end_date} />
</Select>
<Input type="date" bind:value={form.recurrence_end_date} />
</div>
<div class="flex items-center gap-3">
<input class="h-10 w-16 rounded-xl border border-[var(--input)] bg-white/70 px-2 py-1" type="color" bind:value={form.color} />
<Input className="h-10 w-16 px-2 py-1" type="color" bind:value={form.color} />
<label class="flex items-center gap-2 text-sm">
<input type="checkbox" bind:checked={form.all_day} />
All day
@@ -204,6 +214,7 @@
</div>
<Button type="submit" disabled={saving}>{saving ? "Saving…" : editingEventId ? "Update event" : "Create event"}</Button>
</form>
</CardContent>
</Card>
</div>
</div>

View File

@@ -2,6 +2,9 @@
import { onMount } from "svelte";
import Button from "$lib/components/ui/button.svelte";
import Card from "$lib/components/ui/card.svelte";
import CardContent from "$lib/components/ui/card-content.svelte";
import Input from "$lib/components/ui/input.svelte";
import Select from "$lib/components/ui/select.svelte";
import { api } from "$lib/api/client";
import type { Category, InventoryItem } from "$lib/types/domain";
@@ -42,52 +45,52 @@
<h2 class="mt-2 text-3xl font-semibold">Inventory health</h2>
</div>
<div class="flex gap-2">
<input class="rounded-xl border border-[var(--input)] bg-white/70 px-3 py-2" bind:value={query} placeholder="Search product or GTIN" />
<select class="rounded-xl border border-[var(--input)] bg-white/70 px-3 py-2" bind:value={selectedCategory}>
<Input bind:value={query} placeholder="Search product or GTIN" />
<Select bind:value={selectedCategory}>
<option value="">All categories</option>
{#each categories as category}
<option value={category.id}>{category.name}</option>
{/each}
</select>
</Select>
<Button type="button" on:click={loadData}>Filter</Button>
</div>
</div>
{#if error}
<Card><p class="text-sm text-red-700">{error}</p></Card>
<Card><CardContent className="p-5"><p class="text-sm text-red-300">{error}</p></CardContent></Card>
{/if}
<div class="grid gap-4 md:grid-cols-3">
<Card><p class="text-sm text-[var(--muted-foreground)]">Tracked products</p><p class="mt-2 text-3xl font-semibold">{rows.length}</p></Card>
<Card><p class="text-sm text-[var(--muted-foreground)]">Zero stock</p><p class="mt-2 text-3xl font-semibold">{rows.filter((row) => row.quantity_on_hand <= 0).length}</p></Card>
<Card><p class="text-sm text-[var(--muted-foreground)]">Low stock</p><p class="mt-2 text-3xl font-semibold">{rows.filter((row) => row.quantity_on_hand > 0 && row.quantity_on_hand < 5).length}</p></Card>
<Card><CardContent className="p-5"><p class="text-sm text-[var(--muted-foreground)]">Tracked products</p><p class="mt-2 text-3xl font-semibold">{rows.length}</p></CardContent></Card>
<Card><CardContent className="p-5"><p class="text-sm text-[var(--muted-foreground)]">Zero stock</p><p class="mt-2 text-3xl font-semibold">{rows.filter((row) => row.quantity_on_hand <= 0).length}</p></CardContent></Card>
<Card><CardContent className="p-5"><p class="text-sm text-[var(--muted-foreground)]">Low stock</p><p class="mt-2 text-3xl font-semibold">{rows.filter((row) => row.quantity_on_hand > 0 && row.quantity_on_hand < 5).length}</p></CardContent></Card>
</div>
<Card>
<CardContent className="space-y-3 p-5">
{#if loading}
<p>Loading inventory…</p>
{:else}
<div class="space-y-3">
{#each rows as row}
<div class="rounded-2xl border border-[var(--border)] bg-white/60 p-4">
<div class="flex items-start justify-between gap-4">
<div>
<p class="font-medium">{row.product_name}</p>
<p class="text-sm text-[var(--muted-foreground)]">{row.gtin || "No GTIN"}{row.category_names.join(", ") || "Uncategorized"}</p>
<p class="mt-1 text-xs uppercase tracking-[0.2em] text-[var(--muted-foreground)]">
VAT {row.vat_rate.toFixed(2)} • Purchase {row.net_purchase_price.toFixed(2)} • Sales {row.display_sales_price.toFixed(2)}
</p>
</div>
<div class="text-right">
<p class={`text-2xl font-semibold ${row.quantity_on_hand <= 0 ? "text-red-700" : row.quantity_on_hand < 5 ? "text-amber-700" : ""}`}>
{row.quantity_on_hand.toFixed(3)}
</p>
<p class="text-sm text-[var(--muted-foreground)]">{row.uom}</p>
</div>
{#each rows as row}
<div class="rounded-2xl border border-[var(--border)] bg-[var(--muted)] p-4">
<div class="flex items-start justify-between gap-4">
<div>
<p class="font-medium">{row.product_name}</p>
<p class="text-sm text-[var(--muted-foreground)]">{row.gtin || "No GTIN"}{row.category_names.join(", ") || "Uncategorized"}</p>
<p class="mt-1 text-xs uppercase tracking-[0.2em] text-[var(--muted-foreground)]">
VAT {row.vat_rate.toFixed(2)} • Purchase {row.net_purchase_price.toFixed(2)} • Sales {row.display_sales_price.toFixed(2)}
</p>
</div>
<div class="text-right">
<p class={`text-2xl font-semibold ${row.quantity_on_hand <= 0 ? "text-red-300" : row.quantity_on_hand < 5 ? "text-zinc-300" : ""}`}>
{row.quantity_on_hand.toFixed(3)}
</p>
<p class="text-sm text-[var(--muted-foreground)]">{row.uom}</p>
</div>
</div>
{/each}
</div>
</div>
{/each}
{/if}
</CardContent>
</Card>
</div>

View File

@@ -2,6 +2,10 @@
import { onMount } from "svelte";
import Card from "$lib/components/ui/card.svelte";
import Button from "$lib/components/ui/button.svelte";
import Input from "$lib/components/ui/input.svelte";
import Label from "$lib/components/ui/label.svelte";
import Select from "$lib/components/ui/select.svelte";
import Textarea from "$lib/components/ui/textarea.svelte";
import { api } from "$lib/api/client";
import type { Business, Category, Invoice, Product, Vendor } from "$lib/types/domain";
@@ -173,17 +177,12 @@
<h2 class="mt-2 text-3xl font-semibold">Invoice tracker</h2>
</div>
<div class="w-full max-w-sm">
<input
class="w-full rounded-xl border border-[var(--input)] bg-white/70 px-3 py-2"
bind:value={query}
on:change={() => loadData(query)}
placeholder="Search vendor, note, or invoice number"
/>
<Input bind:value={query} on:change={() => loadData(query)} placeholder="Search vendor, note, or invoice number" />
</div>
</div>
{#if error}
<Card><p class="text-sm text-red-700">{error}</p></Card>
<Card><div class="p-5"><p class="text-sm text-red-300">{error}</p></div></Card>
{/if}
<div class="grid gap-6 xl:grid-cols-[1.1fr_0.9fr]">
@@ -203,7 +202,7 @@
<div class="space-y-3">
{#each invoices as invoice}
<button
class="w-full rounded-2xl border border-[var(--border)] bg-white/60 p-4 text-left transition hover:bg-white"
class="w-full rounded-2xl border border-[var(--border)] bg-[var(--muted)] p-4 text-left transition hover:bg-[var(--accent)]"
on:click={() => startInvoiceDraft(invoice)}
>
<div class="flex items-start justify-between gap-3">
@@ -239,9 +238,9 @@
</div>
<div class="grid gap-3 md:grid-cols-3">
<div class="rounded-xl bg-black/5 p-3"><p class="text-xs uppercase tracking-[0.2em] text-[var(--muted-foreground)]">Business</p><p class="mt-1">{selectedInvoice.business_name ?? "Unassigned"}</p></div>
<div class="rounded-xl bg-black/5 p-3"><p class="text-xs uppercase tracking-[0.2em] text-[var(--muted-foreground)]">Status</p><p class="mt-1">{selectedInvoice.payment_status}</p></div>
<div class="rounded-xl bg-black/5 p-3"><p class="text-xs uppercase tracking-[0.2em] text-[var(--muted-foreground)]">Due</p><p class="mt-1">{selectedInvoice.due_date ?? "Not set"}</p></div>
<div class="rounded-xl bg-[var(--muted)] p-3"><p class="text-xs uppercase tracking-[0.2em] text-[var(--muted-foreground)]">Business</p><p class="mt-1">{selectedInvoice.business_name ?? "Unassigned"}</p></div>
<div class="rounded-xl bg-[var(--muted)] p-3"><p class="text-xs uppercase tracking-[0.2em] text-[var(--muted-foreground)]">Status</p><p class="mt-1">{selectedInvoice.payment_status}</p></div>
<div class="rounded-xl bg-[var(--muted)] p-3"><p class="text-xs uppercase tracking-[0.2em] text-[var(--muted-foreground)]">Due</p><p class="mt-1">{selectedInvoice.due_date ?? "Not set"}</p></div>
</div>
<div class="mt-4 space-y-2">
@@ -276,61 +275,61 @@
<form class="space-y-4" on:submit|preventDefault={saveInvoice}>
<div class="grid gap-4 md:grid-cols-2">
<div>
<label class="mb-2 block text-sm font-medium">Business</label>
<select class="w-full rounded-xl border border-[var(--input)] bg-white/70 px-3 py-2" bind:value={form.business_id}>
<Label forId="invoice-business" className="mb-2 block">Business</Label>
<Select id="invoice-business" bind:value={form.business_id}>
<option value="">Unassigned</option>
{#each businesses as business}
<option value={business.id}>{business.name}</option>
{/each}
</select>
</Select>
</div>
<div>
<label class="mb-2 block text-sm font-medium">Vendor</label>
<select class="w-full rounded-xl border border-[var(--input)] bg-white/70 px-3 py-2" bind:value={form.vendor_id} required>
<Label forId="invoice-vendor" className="mb-2 block">Vendor</Label>
<Select id="invoice-vendor" bind:value={form.vendor_id} required>
<option value={0}>Select vendor</option>
{#each vendors as vendor}
<option value={vendor.id}>{vendor.name}</option>
{/each}
</select>
</Select>
</div>
<div>
<label class="mb-2 block text-sm font-medium">Invoice number</label>
<input class="w-full rounded-xl border border-[var(--input)] bg-white/70 px-3 py-2" bind:value={form.invoice_number} />
<Label forId="invoice-number" className="mb-2 block">Invoice number</Label>
<Input id="invoice-number" bind:value={form.invoice_number} />
</div>
<div>
<label class="mb-2 block text-sm font-medium">Invoice date</label>
<input class="w-full rounded-xl border border-[var(--input)] bg-white/70 px-3 py-2" type="date" bind:value={form.invoice_date} />
<Label forId="invoice-date" className="mb-2 block">Invoice date</Label>
<Input id="invoice-date" type="date" bind:value={form.invoice_date} />
</div>
<div>
<label class="mb-2 block text-sm font-medium">Due date</label>
<input class="w-full rounded-xl border border-[var(--input)] bg-white/70 px-3 py-2" type="date" bind:value={form.due_date} />
<Label forId="invoice-due-date" className="mb-2 block">Due date</Label>
<Input id="invoice-due-date" type="date" bind:value={form.due_date} />
</div>
<div>
<label class="mb-2 block text-sm font-medium">Currency</label>
<input class="w-full rounded-xl border border-[var(--input)] bg-white/70 px-3 py-2" bind:value={form.currency} />
<Label forId="invoice-currency" className="mb-2 block">Currency</Label>
<Input id="invoice-currency" bind:value={form.currency} />
</div>
<div>
<label class="mb-2 block text-sm font-medium">Payment status</label>
<select class="w-full rounded-xl border border-[var(--input)] bg-white/70 px-3 py-2" bind:value={form.payment_status}>
<Label forId="invoice-payment-status" className="mb-2 block">Payment status</Label>
<Select id="invoice-payment-status" bind:value={form.payment_status}>
<option value="unpaid">Unpaid</option>
<option value="paid">Paid</option>
</select>
</Select>
</div>
<div>
<label class="mb-2 block text-sm font-medium">Goods received</label>
<select class="w-full rounded-xl border border-[var(--input)] bg-white/70 px-3 py-2" bind:value={form.goods_received_status}>
<Label forId="invoice-goods-status" className="mb-2 block">Goods received</Label>
<Select id="invoice-goods-status" bind:value={form.goods_received_status}>
<option value="not_received">Not received</option>
<option value="received">Received</option>
</select>
</Select>
</div>
</div>
<div>
<label class="mb-2 block text-sm font-medium">Categories</label>
<p class="mb-2 text-sm font-medium">Categories</p>
<div class="flex flex-wrap gap-2">
{#each categories as category}
<button
class={`rounded-full border px-3 py-1 text-sm ${form.category_ids.includes(category.id) ? "border-[var(--primary)] bg-[var(--primary)] text-[var(--primary-foreground)]" : "border-[var(--border)] bg-white/70"}`}
class={`rounded-full border px-3 py-1 text-sm ${form.category_ids.includes(category.id) ? "border-[var(--primary)] bg-[var(--primary)] text-[var(--primary-foreground)]" : "border-[var(--border)] bg-[var(--secondary)] text-[var(--muted-foreground)]"}`}
type="button"
on:click={() => toggleCategory(category.id)}
>
@@ -342,19 +341,19 @@
<div class="space-y-3">
<div class="flex items-center justify-between">
<label class="block text-sm font-medium">Line items</label>
<p class="text-sm font-medium">Line items</p>
<Button type="button" variant="secondary" on:click={addLine}>Add line</Button>
</div>
{#each form.line_items as line, index}
<div class="grid gap-3 md:grid-cols-[1.5fr_0.7fr_0.8fr_auto]">
<select class="rounded-xl border border-[var(--input)] bg-white/70 px-3 py-2" bind:value={line.product_id}>
<Select bind:value={line.product_id}>
<option value={0}>Select product</option>
{#each products as product}
<option value={product.id}>{product.name}</option>
{/each}
</select>
<input class="rounded-xl border border-[var(--input)] bg-white/70 px-3 py-2" type="number" min="0" step="0.001" bind:value={line.quantity} />
<input class="rounded-xl border border-[var(--input)] bg-white/70 px-3 py-2" type="number" min="0" step="0.01" bind:value={line.unit_price} />
</Select>
<Input type="number" min="0" step="0.001" bind:value={line.quantity} />
<Input type="number" min="0" step="0.01" bind:value={line.unit_price} />
<div class="flex items-center justify-end text-sm text-[var(--muted-foreground)]">
{(Number(line.quantity || 0) * Number(line.unit_price || 0)).toFixed(2)}
</div>
@@ -363,11 +362,11 @@
</div>
<div>
<label class="mb-2 block text-sm font-medium">Notes</label>
<textarea class="min-h-28 w-full rounded-xl border border-[var(--input)] bg-white/70 px-3 py-2" bind:value={form.notes}></textarea>
<Label forId="invoice-notes" className="mb-2 block">Notes</Label>
<Textarea id="invoice-notes" className="min-h-28" bind:value={form.notes}></Textarea>
</div>
<div class="flex items-center justify-between rounded-2xl bg-black/5 p-4">
<div class="flex items-center justify-between rounded-2xl bg-[var(--muted)] p-4">
<div>
<p class="text-sm text-[var(--muted-foreground)]">Draft subtotal</p>
<p class="text-2xl font-semibold">{subtotal.toFixed(2)} {form.currency}</p>

View File

@@ -2,6 +2,11 @@
import { onMount } from "svelte";
import Button from "$lib/components/ui/button.svelte";
import Card from "$lib/components/ui/card.svelte";
import CardContent from "$lib/components/ui/card-content.svelte";
import CardDescription from "$lib/components/ui/card-description.svelte";
import CardHeader from "$lib/components/ui/card-header.svelte";
import CardTitle from "$lib/components/ui/card-title.svelte";
import Select from "$lib/components/ui/select.svelte";
import { api } from "$lib/api/client";
import type { Business, ScheduleOverview } from "$lib/types/domain";
@@ -38,54 +43,54 @@
<h2 class="mt-2 text-3xl font-semibold">Schedule overview</h2>
</div>
<div class="flex gap-2">
<select class="rounded-xl border border-[var(--input)] bg-white/70 px-3 py-2" bind:value={selectedBusiness}>
<Select bind:value={selectedBusiness}>
<option value="">All businesses</option>
{#each businesses as business}
<option value={business.id}>{business.name}</option>
{/each}
</select>
</Select>
<Button type="button" on:click={loadData}>Filter</Button>
</div>
</div>
{#if error}
<Card><p class="text-sm text-red-700">{error}</p></Card>
<Card><CardContent className="p-5"><p class="text-sm text-red-300">{error}</p></CardContent></Card>
{:else if loading || !overview}
<Card>Loading schedule…</Card>
<Card><CardContent className="p-5">Loading schedule…</CardContent></Card>
{:else}
<div class="grid gap-4 md:grid-cols-3">
<Card><p class="text-sm text-[var(--muted-foreground)]">Roles</p><p class="mt-2 text-3xl font-semibold">{overview.roles.length}</p></Card>
<Card><p class="text-sm text-[var(--muted-foreground)]">Templates</p><p class="mt-2 text-3xl font-semibold">{overview.templates.length}</p></Card>
<Card><p class="text-sm text-[var(--muted-foreground)]">Assignments</p><p class="mt-2 text-3xl font-semibold">{overview.assignments.length}</p></Card>
<Card><CardContent className="p-5"><p class="text-sm text-[var(--muted-foreground)]">Roles</p><p class="mt-2 text-3xl font-semibold">{overview.roles.length}</p></CardContent></Card>
<Card><CardContent className="p-5"><p class="text-sm text-[var(--muted-foreground)]">Templates</p><p class="mt-2 text-3xl font-semibold">{overview.templates.length}</p></CardContent></Card>
<Card><CardContent className="p-5"><p class="text-sm text-[var(--muted-foreground)]">Assignments</p><p class="mt-2 text-3xl font-semibold">{overview.assignments.length}</p></CardContent></Card>
</div>
<div class="grid gap-6 xl:grid-cols-[0.7fr_1.2fr_1.1fr]">
<Card>
<div class="mb-4">
<h3 class="text-xl font-semibold">Roles</h3>
<p class="text-sm text-[var(--muted-foreground)]">Shift role catalogue by business.</p>
</div>
<div class="space-y-3">
<CardHeader>
<CardTitle className="text-xl">Roles</CardTitle>
<CardDescription>Shift role catalogue by business.</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
{#each overview.roles as role}
<div class="flex items-center justify-between border-b border-[var(--border)] py-2 last:border-0">
<div>
<p class="font-medium">{role.name}</p>
<p class="text-sm text-[var(--muted-foreground)]">{role.business_name}</p>
</div>
<div class="h-4 w-4 rounded-full border border-black/10" style={`background:${role.color || "#16302b"}`}></div>
<div class="h-4 w-4 rounded-full border border-white/10" style={`background:${role.color || "#09090b"}`}></div>
</div>
{/each}
</div>
</CardContent>
</Card>
<Card>
<div class="mb-4">
<h3 class="text-xl font-semibold">Upcoming templates</h3>
<p class="text-sm text-[var(--muted-foreground)]">Recurring shift definitions and staffing targets.</p>
</div>
<div class="space-y-3">
<CardHeader>
<CardTitle className="text-xl">Upcoming templates</CardTitle>
<CardDescription>Recurring shift definitions and staffing targets.</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
{#each overview.templates as template}
<div class="rounded-2xl border border-[var(--border)] bg-white/60 p-4">
<div class="rounded-2xl border border-[var(--border)] bg-[var(--muted)] p-4">
<div class="flex items-start justify-between gap-3">
<div>
<p class="font-medium">{template.name}</p>
@@ -102,15 +107,15 @@
</div>
</div>
{/each}
</div>
</CardContent>
</Card>
<Card>
<div class="mb-4">
<h3 class="text-xl font-semibold">Assignments</h3>
<p class="text-sm text-[var(--muted-foreground)]">Upcoming user allocations against templates.</p>
</div>
<div class="space-y-3">
<CardHeader>
<CardTitle className="text-xl">Assignments</CardTitle>
<CardDescription>Upcoming user allocations against templates.</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
{#each overview.assignments as assignment}
<div class="flex items-start justify-between border-b border-[var(--border)] py-2 last:border-0">
<div>
@@ -124,7 +129,7 @@
</div>
</div>
{/each}
</div>
</CardContent>
</Card>
</div>
{/if}

View File

@@ -2,6 +2,8 @@
import { onMount } from "svelte";
import Button from "$lib/components/ui/button.svelte";
import Card from "$lib/components/ui/card.svelte";
import Input from "$lib/components/ui/input.svelte";
import Select from "$lib/components/ui/select.svelte";
import { api } from "$lib/api/client";
import type { Business, SettingsOverview, SettingsUser } from "$lib/types/domain";
@@ -132,7 +134,7 @@
</div>
{#if error}
<Card><p class="text-sm text-red-700">{error}</p></Card>
<Card><div class="p-5"><p class="text-sm text-red-300">{error}</p></div></Card>
{/if}
{#if loading || !overview}
@@ -153,7 +155,7 @@
</div>
<div class="space-y-3">
{#each overview.users as user}
<button class="w-full rounded-2xl border border-[var(--border)] bg-white/60 p-4 text-left transition hover:bg-white" on:click={() => startEditUser(user)}>
<button class="w-full rounded-2xl border border-[var(--border)] bg-[var(--muted)] p-4 text-left transition hover:bg-[var(--accent)]" on:click={() => startEditUser(user)}>
<div class="flex items-start justify-between gap-4">
<div>
<p class="font-medium">{user.display_name || user.username}</p>
@@ -162,7 +164,7 @@
{user.role_name || "No role"} • {user.business_ids.length} businesses
</p>
</div>
<p class={`text-sm ${user.is_active ? "text-green-700" : "text-red-700"}`}>{user.is_active ? "Active" : "Inactive"}</p>
<p class="text-sm text-[var(--muted-foreground)]">{user.is_active ? "Active" : "Inactive"}</p>
</div>
</button>
{/each}
@@ -176,7 +178,7 @@
</div>
<div class="space-y-3">
{#each overview.roles as role}
<div class="rounded-2xl border border-[var(--border)] bg-white/60 p-4">
<div class="rounded-2xl border border-[var(--border)] bg-[var(--muted)] p-4">
<div class="flex items-start justify-between gap-4">
<div>
<p class="font-medium">{role.name}</p>
@@ -208,17 +210,17 @@
</div>
<form class="space-y-4" on:submit|preventDefault={saveUser}>
<div class="grid gap-4 md:grid-cols-2">
<input class="rounded-xl border border-[var(--input)] bg-white/70 px-3 py-2" bind:value={form.username} placeholder="Username" required />
<input class="rounded-xl border border-[var(--input)] bg-white/70 px-3 py-2" bind:value={form.password} type="password" placeholder={editingUserId ? "Leave blank to keep password" : "Password"} required={!editingUserId} />
<input class="rounded-xl border border-[var(--input)] bg-white/70 px-3 py-2" bind:value={form.display_name} placeholder="Display name" />
<input class="rounded-xl border border-[var(--input)] bg-white/70 px-3 py-2" bind:value={form.email} type="email" placeholder="Email" />
<Input bind:value={form.username} placeholder="Username" required />
<Input bind:value={form.password} type="password" placeholder={editingUserId ? "Leave blank to keep password" : "Password"} required={!editingUserId} />
<Input bind:value={form.display_name} placeholder="Display name" />
<Input bind:value={form.email} type="email" placeholder="Email" />
</div>
<select class="w-full rounded-xl border border-[var(--input)] bg-white/70 px-3 py-2" bind:value={form.role_id}>
<Select bind:value={form.role_id}>
<option value="">No role</option>
{#each overview.roles as role}
<option value={role.id}>{role.name}</option>
{/each}
</select>
</Select>
<div>
<p class="mb-2 text-sm font-medium">Business access</p>
@@ -226,7 +228,7 @@
{#each businesses as business}
<button
type="button"
class={`rounded-full border px-3 py-1 text-sm ${form.business_ids.includes(business.id) ? "border-[var(--primary)] bg-[var(--primary)] text-[var(--primary-foreground)]" : "border-[var(--border)] bg-white/70"}`}
class={`rounded-full border px-3 py-1 text-sm ${form.business_ids.includes(business.id) ? "border-[var(--primary)] bg-[var(--primary)] text-[var(--primary-foreground)]" : "border-[var(--border)] bg-[var(--secondary)] text-[var(--muted-foreground)]"}`}
on:click={() => toggleBusiness(business.id)}
>
{business.name}

View File

@@ -2,6 +2,13 @@
import { onMount } from "svelte";
import Button from "$lib/components/ui/button.svelte";
import Card from "$lib/components/ui/card.svelte";
import CardContent from "$lib/components/ui/card-content.svelte";
import CardDescription from "$lib/components/ui/card-description.svelte";
import CardHeader from "$lib/components/ui/card-header.svelte";
import CardTitle from "$lib/components/ui/card-title.svelte";
import Input from "$lib/components/ui/input.svelte";
import Select from "$lib/components/ui/select.svelte";
import Textarea from "$lib/components/ui/textarea.svelte";
import { api } from "$lib/api/client";
import type { Business, Category, Vendor } from "$lib/types/domain";
@@ -116,30 +123,31 @@
<h2 class="mt-2 text-3xl font-semibold">Vendor management</h2>
</div>
<div class="flex gap-2">
<input class="rounded-xl border border-[var(--input)] bg-white/70 px-3 py-2" bind:value={query} placeholder="Search vendors" />
<Input bind:value={query} placeholder="Search vendors" />
<Button type="button" on:click={loadData}>Filter</Button>
</div>
</div>
{#if error}
<Card><p class="text-sm text-red-700">{error}</p></Card>
<Card><CardContent className="p-5"><p class="text-sm text-red-300">{error}</p></CardContent></Card>
{/if}
<div class="grid gap-6 xl:grid-cols-[1.1fr_0.9fr]">
<Card>
<div class="mb-4 grid gap-3 md:grid-cols-2">
<select class="rounded-xl border border-[var(--input)] bg-white/70 px-3 py-2" bind:value={selectedBusiness}>
<CardContent className="space-y-4 p-5">
<div class="grid gap-3 md:grid-cols-2">
<Select bind:value={selectedBusiness}>
<option value="">All businesses</option>
{#each businesses as business}
<option value={business.id}>{business.name}</option>
{/each}
</select>
<select class="rounded-xl border border-[var(--input)] bg-white/70 px-3 py-2" bind:value={selectedCategory}>
</Select>
<Select bind:value={selectedCategory}>
<option value="">All categories</option>
{#each categories as category}
<option value={category.id}>{category.name}</option>
{/each}
</select>
</Select>
</div>
{#if loading}
@@ -147,7 +155,7 @@
{:else}
<div class="space-y-3">
{#each vendors as vendor}
<button class="w-full rounded-2xl border border-[var(--border)] bg-white/60 p-4 text-left transition hover:bg-white" on:click={() => startEdit(vendor)}>
<button class="w-full rounded-2xl border border-[var(--border)] bg-[var(--muted)] p-4 text-left transition hover:bg-[var(--accent)]" on:click={() => startEdit(vendor)}>
<div class="flex items-start justify-between gap-3">
<div>
<p class="font-medium">{vendor.name}</p>
@@ -156,35 +164,37 @@
{vendor.business_ids.length} businesses • {vendor.category_ids.length} categories
</p>
</div>
<p class={`text-sm ${vendor.is_active ? "text-green-700" : "text-red-700"}`}>{vendor.is_active ? "Active" : "Inactive"}</p>
<p class="text-sm text-[var(--muted-foreground)]">{vendor.is_active ? "Active" : "Inactive"}</p>
</div>
</button>
{/each}
</div>
{/if}
</CardContent>
</Card>
<Card>
<div class="mb-4 flex items-center justify-between">
<CardHeader className="flex-row items-start justify-between space-y-0">
<div>
<h3 class="text-xl font-semibold">{editingVendorId ? "Edit vendor" : "Create vendor"}</h3>
<p class="text-sm text-[var(--muted-foreground)]">Manage linkage to businesses and invoice categories.</p>
<CardTitle className="text-xl">{editingVendorId ? "Edit vendor" : "Create vendor"}</CardTitle>
<CardDescription>Manage linkage to businesses and invoice categories.</CardDescription>
</div>
{#if editingVendorId}
<Button type="button" variant="ghost" on:click={resetForm}>Reset</Button>
{/if}
</div>
</CardHeader>
<CardContent>
<form class="space-y-4" on:submit|preventDefault={saveVendor}>
<div class="grid gap-4 md:grid-cols-2">
<input class="rounded-xl border border-[var(--input)] bg-white/70 px-3 py-2" bind:value={form.name} placeholder="Vendor name" required />
<input class="rounded-xl border border-[var(--input)] bg-white/70 px-3 py-2" bind:value={form.vat_number} placeholder="VAT number" />
<input class="rounded-xl border border-[var(--input)] bg-white/70 px-3 py-2" bind:value={form.registration_id} placeholder="Registration ID" />
<input class="rounded-xl border border-[var(--input)] bg-white/70 px-3 py-2" bind:value={form.contact_email} placeholder="Email" />
<input class="rounded-xl border border-[var(--input)] bg-white/70 px-3 py-2 md:col-span-2" bind:value={form.contact_phone} placeholder="Phone" />
<Input bind:value={form.name} placeholder="Vendor name" required />
<Input bind:value={form.vat_number} placeholder="VAT number" />
<Input bind:value={form.registration_id} placeholder="Registration ID" />
<Input bind:value={form.contact_email} placeholder="Email" />
<Input className="md:col-span-2" bind:value={form.contact_phone} placeholder="Phone" />
</div>
<textarea class="min-h-20 w-full rounded-xl border border-[var(--input)] bg-white/70 px-3 py-2" bind:value={form.address} placeholder="Address"></textarea>
<textarea class="min-h-24 w-full rounded-xl border border-[var(--input)] bg-white/70 px-3 py-2" bind:value={form.notes} placeholder="Notes"></textarea>
<Textarea className="min-h-20" bind:value={form.address} placeholder="Address"></Textarea>
<Textarea className="min-h-24" bind:value={form.notes} placeholder="Notes"></Textarea>
<div>
<p class="mb-2 text-sm font-medium">Businesses</p>
@@ -192,7 +202,7 @@
{#each businesses as business}
<button
type="button"
class={`rounded-full border px-3 py-1 text-sm ${form.business_ids.includes(business.id) ? "border-[var(--primary)] bg-[var(--primary)] text-[var(--primary-foreground)]" : "border-[var(--border)] bg-white/70"}`}
class={`rounded-full border px-3 py-1 text-sm ${form.business_ids.includes(business.id) ? "border-[var(--primary)] bg-[var(--primary)] text-[var(--primary-foreground)]" : "border-[var(--border)] bg-[var(--secondary)] text-[var(--muted-foreground)]"}`}
on:click={() => (form.business_ids = toggleId(form.business_ids, business.id))}
>
{business.name}
@@ -207,7 +217,7 @@
{#each categories as category}
<button
type="button"
class={`rounded-full border px-3 py-1 text-sm ${form.category_ids.includes(category.id) ? "border-[var(--primary)] bg-[var(--primary)] text-[var(--primary-foreground)]" : "border-[var(--border)] bg-white/70"}`}
class={`rounded-full border px-3 py-1 text-sm ${form.category_ids.includes(category.id) ? "border-[var(--primary)] bg-[var(--primary)] text-[var(--primary-foreground)]" : "border-[var(--border)] bg-[var(--secondary)] text-[var(--muted-foreground)]"}`}
on:click={() => (form.category_ids = toggleId(form.category_ids, category.id))}
>
{category.name}
@@ -225,6 +235,7 @@
{saving ? "Saving…" : editingVendorId ? "Update vendor" : "Create vendor"}
</Button>
</form>
</CardContent>
</Card>
</div>
</div>

View File

@@ -3,6 +3,12 @@
import { goto } from "$app/navigation";
import Button from "$lib/components/ui/button.svelte";
import Card from "$lib/components/ui/card.svelte";
import CardContent from "$lib/components/ui/card-content.svelte";
import CardDescription from "$lib/components/ui/card-description.svelte";
import CardHeader from "$lib/components/ui/card-header.svelte";
import CardTitle from "$lib/components/ui/card-title.svelte";
import Input from "$lib/components/ui/input.svelte";
import Label from "$lib/components/ui/label.svelte";
import { api } from "$lib/api/client";
import { authUser, authReady, bootstrapAuth } from "$lib/stores/auth";
@@ -43,41 +49,70 @@
{#if checkingSession}
<div class="mx-auto flex min-h-screen max-w-6xl items-center justify-center px-6 py-10">
<div class="panel p-6 text-center">
<Card className="w-full max-w-md bg-[var(--secondary)] p-6 text-center">
<p class="text-sm uppercase tracking-[0.3em] text-[var(--muted-foreground)]">Session</p>
<p class="mt-2 text-lg font-medium">Checking authentication…</p>
</div>
</Card>
</div>
{:else}
<div class="mx-auto flex min-h-screen max-w-6xl items-center px-6 py-10">
<div class="grid w-full gap-8 lg:grid-cols-[1.2fr_0.8fr]">
<div class="space-y-6">
<p class="text-sm uppercase tracking-[0.35em] text-[var(--muted-foreground)]">Django + Svelte port</p>
<h1 class="max-w-2xl text-5xl font-semibold leading-tight">
Replace the brittle FastAPI tangle with a session-based operations platform.
</h1>
<p class="max-w-xl text-lg text-[var(--muted-foreground)]">
The new frontend is organized around stable domains, smaller route modules, and a backend that uses Django ORM and admin instead of runtime schema patching.
</p>
<div class="mx-auto flex min-h-screen max-w-7xl items-center px-6 py-10">
<div class="grid w-full gap-10 lg:grid-cols-[1.3fr_0.7fr]">
<div class="flex flex-col justify-center space-y-8">
<div class="space-y-4">
<p class="text-sm uppercase tracking-[0.35em] text-[var(--muted-foreground)]">Django + Svelte port</p>
<h1 class="max-w-3xl text-5xl font-semibold leading-tight lg:text-6xl">
Hospitality operations, rebuilt as a clean black-and-white control surface.
</h1>
<p class="max-w-2xl text-lg text-[var(--muted-foreground)]">
Session auth, Django ORM, scoped business access, and domain-focused screens without the legacy FastAPI sprawl.
</p>
</div>
<div class="grid gap-4 sm:grid-cols-3">
<Card className="bg-[var(--secondary)]">
<CardContent className="p-5">
<p class="text-xs uppercase tracking-[0.2em] text-[var(--muted-foreground)]">Backend</p>
<p class="mt-2 text-lg font-semibold">Django</p>
</CardContent>
</Card>
<Card className="bg-[var(--secondary)]">
<CardContent className="p-5">
<p class="text-xs uppercase tracking-[0.2em] text-[var(--muted-foreground)]">Frontend</p>
<p class="mt-2 text-lg font-semibold">SvelteKit</p>
</CardContent>
</Card>
<Card className="bg-[var(--secondary)]">
<CardContent className="p-5">
<p class="text-xs uppercase tracking-[0.2em] text-[var(--muted-foreground)]">UI</p>
<p class="mt-2 text-lg font-semibold">Vanilla shadcn style</p>
</CardContent>
</Card>
</div>
</div>
<Card>
<form class="space-y-4" on:submit|preventDefault={submit}>
<div>
<label class="mb-2 block text-sm font-medium">Username</label>
<input class="w-full rounded-xl border border-[var(--input)] bg-white/70 px-3 py-2" bind:value={username} />
</div>
<div>
<label class="mb-2 block text-sm font-medium">Password</label>
<input class="w-full rounded-xl border border-[var(--input)] bg-white/70 px-3 py-2" type="password" bind:value={password} />
</div>
{#if error}
<p class="text-sm text-red-700">{error}</p>
{/if}
<Button type="submit" disabled={submitting}>
{submitting ? "Signing in..." : "Sign in"}
</Button>
</form>
<Card className="border-[var(--foreground)]/10 bg-[var(--secondary)]">
<CardHeader>
<CardTitle className="text-2xl">Sign in</CardTitle>
<CardDescription>Use the Django session auth flow for the new operations app.</CardDescription>
</CardHeader>
<CardContent>
<form class="space-y-4" on:submit|preventDefault={submit}>
<div class="space-y-2">
<Label forId="login-username">Username</Label>
<Input id="login-username" bind:value={username} autocomplete="username" />
</div>
<div class="space-y-2">
<Label forId="login-password">Password</Label>
<Input id="login-password" type="password" bind:value={password} autocomplete="current-password" />
</div>
{#if error}
<p class="rounded-md border border-red-900 bg-red-950/40 px-3 py-2 text-sm text-red-300">{error}</p>
{/if}
<Button type="submit" disabled={submitting} className="w-full">
{submitting ? "Signing in..." : "Sign in"}
</Button>
</form>
</CardContent>
</Card>
</div>
</div>