ShadCN Implementation
This commit is contained in:
1
frontend/.env.example
Normal file
1
frontend/.env.example
Normal file
@@ -0,0 +1 @@
|
||||
VITE_API_BASE=http://localhost:8000/api
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
9
frontend/src/lib/components/ui/card-content.svelte
Normal file
9
frontend/src/lib/components/ui/card-content.svelte
Normal 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>
|
||||
9
frontend/src/lib/components/ui/card-description.svelte
Normal file
9
frontend/src/lib/components/ui/card-description.svelte
Normal 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>
|
||||
9
frontend/src/lib/components/ui/card-footer.svelte
Normal file
9
frontend/src/lib/components/ui/card-footer.svelte
Normal 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>
|
||||
9
frontend/src/lib/components/ui/card-header.svelte
Normal file
9
frontend/src/lib/components/ui/card-header.svelte
Normal 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>
|
||||
9
frontend/src/lib/components/ui/card-title.svelte
Normal file
9
frontend/src/lib/components/ui/card-title.svelte
Normal 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>
|
||||
@@ -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>
|
||||
|
||||
17
frontend/src/lib/components/ui/input.svelte
Normal file
17
frontend/src/lib/components/ui/input.svelte
Normal 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}
|
||||
/>
|
||||
10
frontend/src/lib/components/ui/label.svelte
Normal file
10
frontend/src/lib/components/ui/label.svelte
Normal 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>
|
||||
17
frontend/src/lib/components/ui/select.svelte
Normal file
17
frontend/src/lib/components/ui/select.svelte
Normal 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>
|
||||
16
frontend/src/lib/components/ui/textarea.svelte
Normal file
16
frontend/src/lib/components/ui/textarea.svelte
Normal 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>
|
||||
6
frontend/src/lib/utils.ts
Normal file
6
frontend/src/lib/utils.ts
Normal 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));
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
55
frontend/src/routes/app/vendors/+page.svelte
vendored
55
frontend/src/routes/app/vendors/+page.svelte
vendored
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user