Initial commit
This commit is contained in:
15
frontend/components.json
Normal file
15
frontend/components.json
Normal file
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"$schema": "https://shadcn-svelte.com/schema.json",
|
||||
"style": "default",
|
||||
"typescript": true,
|
||||
"tailwind": {
|
||||
"config": "",
|
||||
"css": "src/app.css",
|
||||
"baseColor": "neutral"
|
||||
},
|
||||
"aliases": {
|
||||
"components": "$lib/components",
|
||||
"utils": "$lib/utils",
|
||||
"ui": "$lib/components/ui"
|
||||
}
|
||||
}
|
||||
2568
frontend/package-lock.json
generated
Normal file
2568
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
31
frontend/package.json
Normal file
31
frontend/package.json
Normal file
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"name": "hub-svelte-port",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite dev",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"check": "svelte-check --tsconfig ./tsconfig.json"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@sveltejs/adapter-auto": "^4.0.0",
|
||||
"@sveltejs/kit": "^2.15.0",
|
||||
"@sveltejs/vite-plugin-svelte": "^5.0.0",
|
||||
"@tailwindcss/vite": "^4.1.0",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"postcss": "^8.4.49",
|
||||
"svelte": "^5.0.0",
|
||||
"svelte-check": "^4.0.0",
|
||||
"tailwindcss": "^4.1.0",
|
||||
"typescript": "^5.6.0",
|
||||
"vite": "^6.0.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"bits-ui": "^0.22.0",
|
||||
"clsx": "^2.1.1",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"tailwind-merge": "^2.5.5"
|
||||
}
|
||||
}
|
||||
38
frontend/src/app.css
Normal file
38
frontend/src/app.css
Normal file
@@ -0,0 +1,38 @@
|
||||
@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;
|
||||
}
|
||||
|
||||
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);
|
||||
color: var(--foreground);
|
||||
font-family: "IBM Plex Sans", "Segoe UI", 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);
|
||||
}
|
||||
11
frontend/src/app.html
Normal file
11
frontend/src/app.html
Normal file
@@ -0,0 +1,11 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
%sveltekit.head%
|
||||
</head>
|
||||
<body data-sveltekit-preload-data="hover">
|
||||
<div style="display: contents">%sveltekit.body%</div>
|
||||
</body>
|
||||
</html>
|
||||
197
frontend/src/lib/api/client.ts
Normal file
197
frontend/src/lib/api/client.ts
Normal file
@@ -0,0 +1,197 @@
|
||||
const RAW_API_BASE = import.meta.env.VITE_API_BASE ?? "http://localhost:8000/api";
|
||||
const API_BASE = RAW_API_BASE.replace(/\/+$/, "");
|
||||
|
||||
export class ApiError extends Error {
|
||||
status: number;
|
||||
|
||||
constructor(status: number, message: string) {
|
||||
super(message);
|
||||
this.status = status;
|
||||
}
|
||||
}
|
||||
|
||||
function getCookie(name: string): string | null {
|
||||
const cookie = document.cookie
|
||||
.split("; ")
|
||||
.find((row) => row.startsWith(`${name}=`));
|
||||
return cookie ? decodeURIComponent(cookie.split("=")[1]) : null;
|
||||
}
|
||||
|
||||
async function request<T>(path: string, init: RequestInit = {}): Promise<T> {
|
||||
const method = init.method ?? "GET";
|
||||
const headers = new Headers(init.headers ?? {});
|
||||
if (method !== "GET" && method !== "HEAD") {
|
||||
const csrf = getCookie("hub_csrftoken");
|
||||
if (csrf) headers.set("X-CSRFToken", csrf);
|
||||
}
|
||||
if (!headers.has("Content-Type")) {
|
||||
headers.set("Content-Type", "application/json");
|
||||
}
|
||||
const normalizedPath = `/${path.replace(/^\/+/, "")}`;
|
||||
const response = await fetch(`${API_BASE}${normalizedPath}`, {
|
||||
credentials: "include",
|
||||
headers,
|
||||
...init
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const payload = await response.json().catch(() => ({ detail: "Request failed" }));
|
||||
throw new ApiError(response.status, payload.detail ?? "Request failed");
|
||||
}
|
||||
|
||||
return response.json() as Promise<T>;
|
||||
}
|
||||
|
||||
export const api = {
|
||||
csrf: () => request<{ detail: string }>("/auth/csrf/"),
|
||||
me: () => request<{ user: import("$lib/types/domain").AuthUser }>("/auth/me/"),
|
||||
login: (username: string, password: string) =>
|
||||
request<{ user: import("$lib/types/domain").AuthUser }>("/auth/login/", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ username, password })
|
||||
}),
|
||||
logout: () => request<{ detail: string }>("/auth/logout/", { method: "POST" }),
|
||||
businesses: () => request<{ results: import("$lib/types/domain").Business[] }>("/businesses/"),
|
||||
businessSummary: (businessId: number) =>
|
||||
request<import("$lib/types/domain").BusinessSummaryPayload>(`/businesses/${businessId}/summary/`),
|
||||
products: (q = "") =>
|
||||
request<{ results: import("$lib/types/domain").Product[] }>(`/products/${q ? `?q=${encodeURIComponent(q)}` : ""}`),
|
||||
categories: () => request<{ results: import("$lib/types/domain").Category[] }>("/categories/"),
|
||||
dashboardOverview: () => request<import("$lib/types/domain").DashboardOverview>("/dashboard/overview/"),
|
||||
dashboardBusinessSummary: () =>
|
||||
request<{ results: import("$lib/types/domain").DashboardBusinessSummary[] }>("/dashboard/business-summary/"),
|
||||
invoices: (params?: { q?: string; payment_status?: string; vendor_id?: number; business_id?: number }) => {
|
||||
const search = new URLSearchParams();
|
||||
if (params?.q) search.set("q", params.q);
|
||||
if (params?.payment_status) search.set("payment_status", params.payment_status);
|
||||
if (params?.vendor_id) search.set("vendor_id", String(params.vendor_id));
|
||||
if (params?.business_id) search.set("business_id", String(params.business_id));
|
||||
const suffix = search.toString() ? `?${search.toString()}` : "";
|
||||
return request<{ results: import("$lib/types/domain").Invoice[] }>(`/invoices/${suffix}`);
|
||||
},
|
||||
createInvoice: (payload: import("$lib/types/domain").InvoiceCreatePayload) =>
|
||||
request<import("$lib/types/domain").Invoice>("/invoices/", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload)
|
||||
}),
|
||||
invoice: (invoiceId: number) => request<import("$lib/types/domain").Invoice>(`/invoices/${invoiceId}/`),
|
||||
updateInvoice: (invoiceId: number, payload: import("$lib/types/domain").InvoiceCreatePayload) =>
|
||||
request<import("$lib/types/domain").Invoice>(`/invoices/${invoiceId}/`, {
|
||||
method: "PUT",
|
||||
body: JSON.stringify(payload)
|
||||
}),
|
||||
deleteInvoice: (invoiceId: number) =>
|
||||
request<{ detail: string }>(`/invoices/${invoiceId}/`, { method: "DELETE" }),
|
||||
vendors: (params?: { q?: string; business_id?: number; category_id?: number }) => {
|
||||
const search = new URLSearchParams();
|
||||
if (params?.q) search.set("q", params.q);
|
||||
if (params?.business_id) search.set("business_id", String(params.business_id));
|
||||
if (params?.category_id) search.set("category_id", String(params.category_id));
|
||||
const suffix = search.toString() ? `?${search.toString()}` : "";
|
||||
return request<{ results: import("$lib/types/domain").Vendor[] }>(`/vendors/${suffix}`);
|
||||
},
|
||||
createVendor: (payload: Partial<import("$lib/types/domain").Vendor> & { name: string }) =>
|
||||
request<import("$lib/types/domain").Vendor>("/vendors/", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload)
|
||||
}),
|
||||
updateVendor: (vendorId: number, payload: Partial<import("$lib/types/domain").Vendor>) =>
|
||||
request<import("$lib/types/domain").Vendor>(`/vendors/${vendorId}/`, {
|
||||
method: "PUT",
|
||||
body: JSON.stringify(payload)
|
||||
}),
|
||||
inventory: (params?: { q?: string; category_id?: number }) => {
|
||||
const search = new URLSearchParams();
|
||||
if (params?.q) search.set("q", params.q);
|
||||
if (params?.category_id) search.set("category_id", String(params.category_id));
|
||||
const suffix = search.toString() ? `?${search.toString()}` : "";
|
||||
return request<{ results: import("$lib/types/domain").InventoryItem[] }>(`/inventory/${suffix}`);
|
||||
},
|
||||
events: (params?: { business_id?: number; event_type?: string }) => {
|
||||
const search = new URLSearchParams();
|
||||
if (params?.business_id) search.set("business_id", String(params.business_id));
|
||||
if (params?.event_type) search.set("event_type", params.event_type);
|
||||
const suffix = search.toString() ? `?${search.toString()}` : "";
|
||||
return request<{ results: import("$lib/types/domain").EventItem[] }>(`/events/${suffix}`);
|
||||
},
|
||||
createEvent: (
|
||||
payload: Pick<
|
||||
import("$lib/types/domain").EventItem,
|
||||
"business_id" | "title" | "description" | "event_type" | "start_datetime" | "end_datetime" | "all_day" | "location" | "color" | "recurrence_type" | "recurrence_end_date"
|
||||
>
|
||||
) =>
|
||||
request<import("$lib/types/domain").EventItem>("/events/", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload)
|
||||
}),
|
||||
updateEvent: (eventId: number, payload: Partial<import("$lib/types/domain").EventItem>) =>
|
||||
request<import("$lib/types/domain").EventItem>(`/events/${eventId}/`, {
|
||||
method: "PUT",
|
||||
body: JSON.stringify(payload)
|
||||
}),
|
||||
deleteEvent: (eventId: number) =>
|
||||
request<{ detail: string }>(`/events/${eventId}/`, { method: "DELETE" }),
|
||||
scheduleOverview: (params?: { business_id?: number }) => {
|
||||
const search = new URLSearchParams();
|
||||
if (params?.business_id) search.set("business_id", String(params.business_id));
|
||||
const suffix = search.toString() ? `?${search.toString()}` : "";
|
||||
return request<import("$lib/types/domain").ScheduleOverview>(`/schedule/overview/${suffix}`);
|
||||
},
|
||||
settingsOverview: () => request<import("$lib/types/domain").SettingsOverview>("/settings/overview/"),
|
||||
createUser: (payload: {
|
||||
username: string;
|
||||
password: string;
|
||||
display_name?: string;
|
||||
email?: string;
|
||||
role_id?: number | null;
|
||||
is_active?: boolean;
|
||||
business_ids?: number[];
|
||||
}) =>
|
||||
request<import("$lib/types/domain").SettingsUser>("/settings/users/", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload)
|
||||
}),
|
||||
updateUser: (
|
||||
userId: number,
|
||||
payload: {
|
||||
username?: string;
|
||||
password?: string;
|
||||
display_name?: string;
|
||||
email?: string;
|
||||
role_id?: number | null;
|
||||
is_active?: boolean;
|
||||
is_superuser?: boolean;
|
||||
business_ids?: number[];
|
||||
}
|
||||
) =>
|
||||
request<import("$lib/types/domain").SettingsUser>(`/settings/users/${userId}/`, {
|
||||
method: "PUT",
|
||||
body: JSON.stringify(payload)
|
||||
}),
|
||||
deleteUser: (userId: number) =>
|
||||
request<{ detail: string }>(`/settings/users/${userId}/`, { method: "DELETE" }),
|
||||
devices: () => request<{ results: import("$lib/types/domain").DeviceItem[] }>("/devices/"),
|
||||
createDevice: (payload: {
|
||||
ip_address: string;
|
||||
label?: string;
|
||||
user_agent?: string;
|
||||
is_active?: boolean;
|
||||
ipv6_prefix?: string;
|
||||
known_ips?: string[];
|
||||
}) =>
|
||||
request<import("$lib/types/domain").DeviceItem>("/devices/", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload)
|
||||
}),
|
||||
deleteDevice: (deviceId: number) =>
|
||||
request<{ detail: string }>(`/devices/${deviceId}/`, { method: "DELETE" }),
|
||||
deviceTokens: () => request<{ results: import("$lib/types/domain").DeviceRegistrationTokenItem[] }>("/devices/tokens/"),
|
||||
createDeviceToken: (payload: { label?: string; expires_in_days?: number }) =>
|
||||
request<import("$lib/types/domain").DeviceRegistrationTokenItem>("/devices/tokens/", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload)
|
||||
}),
|
||||
deleteDeviceToken: (tokenId: number) =>
|
||||
request<{ detail: string }>(`/devices/tokens/${tokenId}/`, { method: "DELETE" }),
|
||||
notifications: () => request<{ results: unknown[] }>("/notifications/")
|
||||
};
|
||||
52
frontend/src/lib/components/app-shell/sidebar.svelte
Normal file
52
frontend/src/lib/components/app-shell/sidebar.svelte
Normal file
@@ -0,0 +1,52 @@
|
||||
<script lang="ts">
|
||||
import { goto } from "$app/navigation";
|
||||
import { api } from "$lib/api/client";
|
||||
import Button from "$lib/components/ui/button.svelte";
|
||||
import { authUser, clearAuth, hasPermission } from "$lib/stores/auth";
|
||||
|
||||
const items = [
|
||||
{ href: "/app/dashboard", label: "Dashboard", permission: "dashboard.view" },
|
||||
{ href: "/app/invoices", label: "Invoices", permission: "invoices.view" },
|
||||
{ href: "/app/vendors", label: "Vendors", permission: "vendors.view" },
|
||||
{ href: "/app/inventory", label: "Inventory", permission: "inventory.view" },
|
||||
{ href: "/app/events", label: "Events", permission: "events.view" },
|
||||
{ href: "/app/schedule", label: "Schedule", permission: "shifts.view" },
|
||||
{ href: "/app/settings", label: "Settings", permission: "users.manage" },
|
||||
{ href: "/app/devices", label: "Devices", permission: "users.manage" }
|
||||
];
|
||||
|
||||
async function signOut() {
|
||||
try {
|
||||
await api.logout();
|
||||
} catch {
|
||||
// Ignore logout transport failures and clear local session state anyway.
|
||||
}
|
||||
clearAuth();
|
||||
goto("/login");
|
||||
}
|
||||
|
||||
$: visibleItems = items.filter((item) => !$authUser || hasPermission(item.permission));
|
||||
</script>
|
||||
|
||||
<aside class="panel flex w-full max-w-64 flex-col gap-3 p-4">
|
||||
<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>
|
||||
</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}>
|
||||
{item.label}
|
||||
</a>
|
||||
{/each}
|
||||
</nav>
|
||||
<div class="mt-auto border-t border-[var(--border)] pt-4">
|
||||
<p class="text-sm font-medium">{$authUser?.display_name || $authUser?.username || "Unknown user"}</p>
|
||||
<p class="mt-1 text-xs uppercase tracking-[0.2em] text-[var(--muted-foreground)]">
|
||||
{$authUser?.role || "No role"}
|
||||
</p>
|
||||
<div class="mt-3">
|
||||
<Button type="button" variant="ghost" on:click={signOut}>Sign out</Button>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
42
frontend/src/lib/components/ui/button.svelte
Normal file
42
frontend/src/lib/components/ui/button.svelte
Normal file
@@ -0,0 +1,42 @@
|
||||
<script lang="ts">
|
||||
import { createEventDispatcher } from "svelte";
|
||||
|
||||
export let href: string | undefined = undefined;
|
||||
export let type: "button" | "submit" | "reset" = "button";
|
||||
export let variant: "primary" | "secondary" | "ghost" | "destructive" = "primary";
|
||||
export let disabled = false;
|
||||
|
||||
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"
|
||||
};
|
||||
|
||||
function handleClick(event: MouseEvent) {
|
||||
dispatch("click", event);
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if href}
|
||||
<a
|
||||
class={`inline-flex items-center justify-center rounded-xl px-4 py-2 text-sm font-medium transition ${variants[variant]}`}
|
||||
{href}
|
||||
{...$$restProps}
|
||||
on:click={handleClick}
|
||||
>
|
||||
<slot />
|
||||
</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]}`}
|
||||
{type}
|
||||
{disabled}
|
||||
{...$$restProps}
|
||||
on:click={handleClick}
|
||||
>
|
||||
<slot />
|
||||
</button>
|
||||
{/if}
|
||||
3
frontend/src/lib/components/ui/card.svelte
Normal file
3
frontend/src/lib/components/ui/card.svelte
Normal file
@@ -0,0 +1,3 @@
|
||||
<div class="panel p-5">
|
||||
<slot />
|
||||
</div>
|
||||
51
frontend/src/lib/stores/auth.ts
Normal file
51
frontend/src/lib/stores/auth.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import { get, writable } from "svelte/store";
|
||||
import type { AuthUser } from "$lib/types/domain";
|
||||
import { ApiError, api } from "$lib/api/client";
|
||||
|
||||
export const authUser = writable<AuthUser | null>(null);
|
||||
export const authReady = writable(false);
|
||||
|
||||
let bootstrapPromise: Promise<AuthUser | null> | null = null;
|
||||
|
||||
export function clearAuth() {
|
||||
authUser.set(null);
|
||||
authReady.set(true);
|
||||
}
|
||||
|
||||
export function hasPermission(permissionKey: string): boolean {
|
||||
const user = get(authUser);
|
||||
if (!user) return false;
|
||||
if (user.is_superuser) return true;
|
||||
return user.permission_keys.includes(permissionKey);
|
||||
}
|
||||
|
||||
export async function bootstrapAuth(force = false): Promise<AuthUser | null> {
|
||||
if (!force && get(authReady)) {
|
||||
return get(authUser);
|
||||
}
|
||||
if (!force && bootstrapPromise) {
|
||||
return bootstrapPromise;
|
||||
}
|
||||
|
||||
authReady.set(false);
|
||||
bootstrapPromise = (async () => {
|
||||
try {
|
||||
const data = await api.me();
|
||||
authUser.set(data.user);
|
||||
authReady.set(true);
|
||||
return data.user;
|
||||
} catch (error) {
|
||||
if (error instanceof ApiError && error.status === 401) {
|
||||
authUser.set(null);
|
||||
authReady.set(true);
|
||||
return null;
|
||||
}
|
||||
authReady.set(true);
|
||||
throw error;
|
||||
} finally {
|
||||
bootstrapPromise = null;
|
||||
}
|
||||
})();
|
||||
|
||||
return bootstrapPromise;
|
||||
}
|
||||
306
frontend/src/lib/types/domain.ts
Normal file
306
frontend/src/lib/types/domain.ts
Normal file
@@ -0,0 +1,306 @@
|
||||
export interface AuthUser {
|
||||
id: number;
|
||||
username: string;
|
||||
display_name: string;
|
||||
role: string | null;
|
||||
is_superuser: boolean;
|
||||
permission_keys: string[];
|
||||
allowed_business_ids: number[];
|
||||
}
|
||||
|
||||
export interface DashboardOverview {
|
||||
total_revenue: number;
|
||||
total_expenses: number;
|
||||
outstanding_invoices: number;
|
||||
business_count: number;
|
||||
vendor_count: number;
|
||||
invoice_count: number;
|
||||
total_vat: number;
|
||||
unread_notifications: number;
|
||||
}
|
||||
|
||||
export interface Business {
|
||||
id: number;
|
||||
legacy_id: number | null;
|
||||
name: string;
|
||||
short_code: string;
|
||||
currency: string;
|
||||
}
|
||||
|
||||
export interface Vendor {
|
||||
id: number;
|
||||
legacy_id: number | null;
|
||||
name: string;
|
||||
vat_number: string;
|
||||
registration_id: string;
|
||||
contact_email: string;
|
||||
contact_phone: string;
|
||||
address: string;
|
||||
notes: string;
|
||||
is_active: boolean;
|
||||
business_ids: number[];
|
||||
category_ids: number[];
|
||||
}
|
||||
|
||||
export interface Product {
|
||||
id: number;
|
||||
legacy_id: number | null;
|
||||
name: string;
|
||||
gtin: string;
|
||||
vat_rate: number;
|
||||
uom: string;
|
||||
currency_code: string;
|
||||
category_ids: number[];
|
||||
net_purchase_price: number;
|
||||
display_sales_price: number;
|
||||
}
|
||||
|
||||
export interface InventoryItem {
|
||||
product_id: number;
|
||||
legacy_product_id: number | null;
|
||||
product_name: string;
|
||||
gtin: string;
|
||||
quantity_on_hand: number;
|
||||
uom: string;
|
||||
vat_rate: number;
|
||||
net_purchase_price: number;
|
||||
display_sales_price: number;
|
||||
category_count: number;
|
||||
category_ids: number[];
|
||||
category_names: string[];
|
||||
}
|
||||
|
||||
export interface DashboardBusinessSummary {
|
||||
business_id: number;
|
||||
business_name: string;
|
||||
short_code: string;
|
||||
currency: string;
|
||||
total_revenue: number;
|
||||
total_expenses: number;
|
||||
outstanding_invoices: number;
|
||||
invoice_count: number;
|
||||
}
|
||||
|
||||
export interface BusinessSummaryPayload {
|
||||
business: Business;
|
||||
stats: {
|
||||
invoice_count: number;
|
||||
outstanding_invoices: number;
|
||||
total_expenses: number;
|
||||
total_revenue: number;
|
||||
total_vat: number;
|
||||
inventory_items: number;
|
||||
};
|
||||
recent_revenue: Array<{
|
||||
business_date: string;
|
||||
sales_revenue: number;
|
||||
food_revenue: number;
|
||||
alcohol_revenue: number;
|
||||
tips_payable: number;
|
||||
vat_total: number;
|
||||
}>;
|
||||
recent_invoices: Array<{
|
||||
id: number;
|
||||
vendor_name: string;
|
||||
invoice_number: string;
|
||||
gross_total: number;
|
||||
payment_status: string;
|
||||
due_date: string | null;
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface EventItem {
|
||||
id: number;
|
||||
legacy_id: number | null;
|
||||
business_id: number;
|
||||
business_name: string;
|
||||
title: string;
|
||||
description: string;
|
||||
event_type: string;
|
||||
start_datetime: string;
|
||||
end_datetime: string;
|
||||
all_day: boolean;
|
||||
location: string;
|
||||
color: string;
|
||||
recurrence_type: string;
|
||||
recurrence_end_date: string | null;
|
||||
created_by: string | null;
|
||||
is_active: boolean;
|
||||
}
|
||||
|
||||
export interface ScheduleRole {
|
||||
id: number;
|
||||
legacy_id: number | null;
|
||||
business_id: number;
|
||||
business_name: string;
|
||||
name: string;
|
||||
color: string;
|
||||
sort_order: number;
|
||||
}
|
||||
|
||||
export interface ScheduleTemplate {
|
||||
id: number;
|
||||
legacy_id: number | null;
|
||||
business_id: number;
|
||||
business_name: string;
|
||||
name: string;
|
||||
start_datetime: string;
|
||||
end_datetime: string;
|
||||
min_staff: number;
|
||||
max_staff: number;
|
||||
color: string;
|
||||
recurrence_type: string;
|
||||
recurrence_end_date: string | null;
|
||||
shift_role_name: string | null;
|
||||
assignment_count: number;
|
||||
}
|
||||
|
||||
export interface ScheduleAssignment {
|
||||
id: number;
|
||||
legacy_id: number | null;
|
||||
shift_template_id: number;
|
||||
shift_name: string;
|
||||
user_name: string;
|
||||
occurrence_date: string;
|
||||
status: string;
|
||||
start_override: string | null;
|
||||
end_override: string | null;
|
||||
notes: string;
|
||||
}
|
||||
|
||||
export interface ScheduleOverview {
|
||||
roles: ScheduleRole[];
|
||||
templates: ScheduleTemplate[];
|
||||
assignments: ScheduleAssignment[];
|
||||
}
|
||||
|
||||
export interface SettingsRole {
|
||||
id: number;
|
||||
name: string;
|
||||
description: string;
|
||||
is_system: boolean;
|
||||
permission_keys: string[];
|
||||
user_count: number;
|
||||
}
|
||||
|
||||
export interface SettingsPermission {
|
||||
id: number;
|
||||
key: string;
|
||||
label: string;
|
||||
group: string;
|
||||
}
|
||||
|
||||
export interface SettingsUser {
|
||||
id: number;
|
||||
username: string;
|
||||
display_name: string;
|
||||
email: string;
|
||||
is_active: boolean;
|
||||
is_superuser: boolean;
|
||||
role_id: number | null;
|
||||
role_name: string | null;
|
||||
last_login: string | null;
|
||||
last_login_ip: string | null;
|
||||
business_ids: number[];
|
||||
}
|
||||
|
||||
export interface SettingsOverview {
|
||||
roles: SettingsRole[];
|
||||
users: SettingsUser[];
|
||||
permissions: SettingsPermission[];
|
||||
}
|
||||
|
||||
export interface DeviceItem {
|
||||
id: number;
|
||||
ip_address: string;
|
||||
label: string;
|
||||
user_agent: string;
|
||||
registered_at: string | null;
|
||||
last_seen_at: string | null;
|
||||
is_active: boolean;
|
||||
ipv6_prefix: string;
|
||||
device_token: string;
|
||||
known_ips: string[];
|
||||
}
|
||||
|
||||
export interface DeviceRegistrationTokenItem {
|
||||
id: number;
|
||||
token: string;
|
||||
label: string;
|
||||
created_at: string;
|
||||
expires_at: string;
|
||||
used_at: string | null;
|
||||
used_by_ip: string | null;
|
||||
created_by: string | null;
|
||||
}
|
||||
|
||||
export interface Category {
|
||||
id: number;
|
||||
legacy_id: number | null;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface InvoiceLineItem {
|
||||
id?: number;
|
||||
product_id: number;
|
||||
product_name?: string;
|
||||
quantity: number;
|
||||
unit_price: number;
|
||||
total_price: number;
|
||||
vat_rate: number;
|
||||
vat_amount: number;
|
||||
line_order: number;
|
||||
}
|
||||
|
||||
export interface Invoice {
|
||||
id: number;
|
||||
legacy_id: number | null;
|
||||
business_id: number | null;
|
||||
business_name: string | null;
|
||||
vendor_id: number;
|
||||
vendor_name: string;
|
||||
invoice_number: string;
|
||||
invoice_date: string | null;
|
||||
order_date: string | null;
|
||||
payment_status: string;
|
||||
paid_date: string | null;
|
||||
due_date: string | null;
|
||||
subtotal: number;
|
||||
discount_pct: number;
|
||||
discount_amount: number;
|
||||
total_after_discount: number;
|
||||
vat_amount: number;
|
||||
gross_total: number;
|
||||
currency: string;
|
||||
goods_received_status: string;
|
||||
goods_date: string | null;
|
||||
notes: string;
|
||||
inventory_updated: boolean;
|
||||
vat_exempt: boolean;
|
||||
category_ids: number[];
|
||||
line_items: InvoiceLineItem[];
|
||||
}
|
||||
|
||||
export interface InvoiceCreatePayload {
|
||||
business_id: number | null;
|
||||
vendor_id: number;
|
||||
invoice_number: string;
|
||||
invoice_date?: string;
|
||||
order_date?: string;
|
||||
payment_status: "paid" | "unpaid";
|
||||
paid_date?: string;
|
||||
due_date?: string;
|
||||
discount_pct?: number;
|
||||
discount_amount?: number;
|
||||
goods_received_status: "received" | "not_received";
|
||||
goods_date?: string;
|
||||
currency?: string;
|
||||
vat_exempt?: boolean;
|
||||
notes?: string;
|
||||
category_ids: number[];
|
||||
line_items: Array<{
|
||||
product_id: number;
|
||||
quantity: number;
|
||||
unit_price: number;
|
||||
}>;
|
||||
}
|
||||
5
frontend/src/routes/+layout.svelte
Normal file
5
frontend/src/routes/+layout.svelte
Normal file
@@ -0,0 +1,5 @@
|
||||
<script lang="ts">
|
||||
import "../app.css";
|
||||
</script>
|
||||
|
||||
<slot />
|
||||
5
frontend/src/routes/+page.ts
Normal file
5
frontend/src/routes/+page.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import { redirect } from "@sveltejs/kit";
|
||||
|
||||
export const load = () => {
|
||||
throw redirect(302, "/app/dashboard");
|
||||
};
|
||||
45
frontend/src/routes/app/+layout.svelte
Normal file
45
frontend/src/routes/app/+layout.svelte
Normal file
@@ -0,0 +1,45 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from "svelte";
|
||||
import { goto } from "$app/navigation";
|
||||
import Sidebar from "$lib/components/app-shell/sidebar.svelte";
|
||||
import { authReady, bootstrapAuth } from "$lib/stores/auth";
|
||||
|
||||
let loading = true;
|
||||
let error = "";
|
||||
|
||||
onMount(async () => {
|
||||
try {
|
||||
const user = await bootstrapAuth();
|
||||
if (!user) {
|
||||
goto("/login");
|
||||
return;
|
||||
}
|
||||
} catch (err) {
|
||||
error = err instanceof Error ? err.message : "Failed to initialize session";
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
{#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">
|
||||
<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>
|
||||
</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>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="mx-auto flex min-h-screen max-w-7xl gap-6 px-4 py-4 lg:px-6">
|
||||
<Sidebar />
|
||||
<main class="min-w-0 flex-1">
|
||||
<slot />
|
||||
</main>
|
||||
</div>
|
||||
{/if}
|
||||
1
frontend/src/routes/app/+layout.ts
Normal file
1
frontend/src/routes/app/+layout.ts
Normal file
@@ -0,0 +1 @@
|
||||
export const ssr = false;
|
||||
84
frontend/src/routes/app/business/[id]/+page.svelte
Normal file
84
frontend/src/routes/app/business/[id]/+page.svelte
Normal file
@@ -0,0 +1,84 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from "svelte";
|
||||
import { page } from "$app/state";
|
||||
import Card from "$lib/components/ui/card.svelte";
|
||||
import { api } from "$lib/api/client";
|
||||
import type { BusinessSummaryPayload } from "$lib/types/domain";
|
||||
|
||||
let summary: BusinessSummaryPayload | null = null;
|
||||
let error = "";
|
||||
|
||||
onMount(async () => {
|
||||
try {
|
||||
summary = await api.businessSummary(Number(page.params.id));
|
||||
} catch (err) {
|
||||
error = err instanceof Error ? err.message : "Failed to load business summary";
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="space-y-6">
|
||||
{#if error}
|
||||
<Card><p class="text-sm text-red-700">{error}</p></Card>
|
||||
{:else if summary}
|
||||
<div>
|
||||
<p class="text-sm uppercase tracking-[0.3em] text-[var(--muted-foreground)]">{summary.business.short_code}</p>
|
||||
<h2 class="mt-2 text-3xl font-semibold">{summary.business.name}</h2>
|
||||
</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>
|
||||
</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">
|
||||
{#each summary.recent_revenue as row}
|
||||
<div class="flex items-center justify-between border-b border-[var(--border)] py-2 last:border-0">
|
||||
<div>
|
||||
<p class="font-medium">{row.business_date}</p>
|
||||
<p class="text-sm text-[var(--muted-foreground)]">Food {row.food_revenue.toFixed(2)} • Alcohol {row.alcohol_revenue.toFixed(2)}</p>
|
||||
</div>
|
||||
<div class="text-right">
|
||||
<p class="font-semibold">{row.sales_revenue.toFixed(2)}</p>
|
||||
<p class="text-sm text-[var(--muted-foreground)]">VAT {row.vat_total.toFixed(2)}</p>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</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">
|
||||
{#each summary.recent_invoices as invoice}
|
||||
<div class="flex items-center justify-between border-b border-[var(--border)] py-2 last:border-0">
|
||||
<div>
|
||||
<p class="font-medium">{invoice.vendor_name}</p>
|
||||
<p class="text-sm text-[var(--muted-foreground)]">{invoice.invoice_number || "Draft / unnumbered"}</p>
|
||||
</div>
|
||||
<div class="text-right">
|
||||
<p class="font-semibold">{invoice.gross_total.toFixed(2)}</p>
|
||||
<p class="text-sm text-[var(--muted-foreground)]">{invoice.payment_status}</p>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
{:else}
|
||||
<Card>Loading business summary…</Card>
|
||||
{/if}
|
||||
</div>
|
||||
74
frontend/src/routes/app/dashboard/+page.svelte
Normal file
74
frontend/src/routes/app/dashboard/+page.svelte
Normal file
@@ -0,0 +1,74 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from "svelte";
|
||||
import Button from "$lib/components/ui/button.svelte";
|
||||
import Card from "$lib/components/ui/card.svelte";
|
||||
import { api } from "$lib/api/client";
|
||||
import type { DashboardBusinessSummary, DashboardOverview } from "$lib/types/domain";
|
||||
|
||||
let overview: DashboardOverview | null = null;
|
||||
let businesses: DashboardBusinessSummary[] = [];
|
||||
let error = "";
|
||||
|
||||
onMount(async () => {
|
||||
try {
|
||||
const [overviewData, businessData] = await Promise.all([
|
||||
api.dashboardOverview(),
|
||||
api.dashboardBusinessSummary()
|
||||
]);
|
||||
overview = overviewData;
|
||||
businesses = businessData.results;
|
||||
} catch (err) {
|
||||
error = err instanceof Error ? err.message : "Failed to load dashboard";
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="space-y-6">
|
||||
<div>
|
||||
<p class="text-sm uppercase tracking-[0.3em] text-[var(--muted-foreground)]">Overview</p>
|
||||
<h2 class="mt-2 text-3xl font-semibold">Consolidated dashboard</h2>
|
||||
</div>
|
||||
|
||||
{#if error}
|
||||
<Card><p class="text-sm text-red-700">{error}</p></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>
|
||||
</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">
|
||||
{#each businesses as business}
|
||||
<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">{business.business_name}</p>
|
||||
<p class="text-xs uppercase tracking-[0.2em] text-[var(--muted-foreground)]">{business.short_code}</p>
|
||||
</div>
|
||||
<Button href={`/app/business/${business.business_id}`} variant="secondary">Open</Button>
|
||||
</div>
|
||||
<div class="mt-4 grid gap-3 md:grid-cols-4">
|
||||
<div><p class="text-xs uppercase tracking-[0.2em] text-[var(--muted-foreground)]">Revenue</p><p class="mt-1 font-semibold">{business.total_revenue.toFixed(2)} {business.currency}</p></div>
|
||||
<div><p class="text-xs uppercase tracking-[0.2em] text-[var(--muted-foreground)]">Expenses</p><p class="mt-1 font-semibold">{business.total_expenses.toFixed(2)} {business.currency}</p></div>
|
||||
<div><p class="text-xs uppercase tracking-[0.2em] text-[var(--muted-foreground)]">Outstanding</p><p class="mt-1 font-semibold">{business.outstanding_invoices}</p></div>
|
||||
<div><p class="text-xs uppercase tracking-[0.2em] text-[var(--muted-foreground)]">Invoices</p><p class="mt-1 font-semibold">{business.invoice_count}</p></div>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</Card>
|
||||
{:else}
|
||||
<Card>Loading dashboard…</Card>
|
||||
{/if}
|
||||
</div>
|
||||
209
frontend/src/routes/app/devices/+page.svelte
Normal file
209
frontend/src/routes/app/devices/+page.svelte
Normal file
@@ -0,0 +1,209 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from "svelte";
|
||||
import Button from "$lib/components/ui/button.svelte";
|
||||
import Card from "$lib/components/ui/card.svelte";
|
||||
import { api } from "$lib/api/client";
|
||||
import type { DeviceItem, DeviceRegistrationTokenItem } from "$lib/types/domain";
|
||||
|
||||
let devices: DeviceItem[] = [];
|
||||
let tokens: DeviceRegistrationTokenItem[] = [];
|
||||
let loading = true;
|
||||
let savingDevice = false;
|
||||
let savingToken = false;
|
||||
let error = "";
|
||||
|
||||
let deviceForm = {
|
||||
ip_address: "",
|
||||
label: "",
|
||||
user_agent: "",
|
||||
ipv6_prefix: "",
|
||||
known_ips: "",
|
||||
is_active: true
|
||||
};
|
||||
|
||||
let tokenForm = {
|
||||
label: "",
|
||||
expires_in_days: 7
|
||||
};
|
||||
|
||||
async function loadData() {
|
||||
loading = true;
|
||||
error = "";
|
||||
try {
|
||||
const [deviceData, tokenData] = await Promise.all([api.devices(), api.deviceTokens()]);
|
||||
devices = deviceData.results;
|
||||
tokens = tokenData.results;
|
||||
} catch (err) {
|
||||
error = err instanceof Error ? err.message : "Failed to load devices";
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMount(loadData);
|
||||
|
||||
async function saveDevice() {
|
||||
savingDevice = true;
|
||||
error = "";
|
||||
try {
|
||||
await api.createDevice({
|
||||
ip_address: deviceForm.ip_address,
|
||||
label: deviceForm.label,
|
||||
user_agent: deviceForm.user_agent,
|
||||
ipv6_prefix: deviceForm.ipv6_prefix,
|
||||
known_ips: deviceForm.known_ips.split(",").map((value) => value.trim()).filter(Boolean),
|
||||
is_active: deviceForm.is_active
|
||||
});
|
||||
deviceForm = {
|
||||
ip_address: "",
|
||||
label: "",
|
||||
user_agent: "",
|
||||
ipv6_prefix: "",
|
||||
known_ips: "",
|
||||
is_active: true
|
||||
};
|
||||
await loadData();
|
||||
} catch (err) {
|
||||
error = err instanceof Error ? err.message : "Failed to create device";
|
||||
} finally {
|
||||
savingDevice = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function saveToken() {
|
||||
savingToken = true;
|
||||
error = "";
|
||||
try {
|
||||
await api.createDeviceToken(tokenForm);
|
||||
tokenForm = { label: "", expires_in_days: 7 };
|
||||
await loadData();
|
||||
} catch (err) {
|
||||
error = err instanceof Error ? err.message : "Failed to create token";
|
||||
} finally {
|
||||
savingToken = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function removeDevice(deviceId: number) {
|
||||
error = "";
|
||||
try {
|
||||
await api.deleteDevice(deviceId);
|
||||
await loadData();
|
||||
} catch (err) {
|
||||
error = err instanceof Error ? err.message : "Failed to delete device";
|
||||
}
|
||||
}
|
||||
|
||||
async function removeToken(tokenId: number) {
|
||||
error = "";
|
||||
try {
|
||||
await api.deleteDeviceToken(tokenId);
|
||||
await loadData();
|
||||
} catch (err) {
|
||||
error = err instanceof Error ? err.message : "Failed to delete token";
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="space-y-6">
|
||||
<div>
|
||||
<p class="text-sm uppercase tracking-[0.3em] text-[var(--muted-foreground)]">Administration</p>
|
||||
<h2 class="mt-2 text-3xl font-semibold">Device access</h2>
|
||||
</div>
|
||||
|
||||
{#if error}
|
||||
<Card><p class="text-sm text-red-700">{error}</p></Card>
|
||||
{/if}
|
||||
|
||||
{#if loading}
|
||||
<Card>Loading devices…</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>
|
||||
</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">
|
||||
{#each devices as device}
|
||||
<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">{device.label || device.ip_address}</p>
|
||||
<p class="text-sm text-[var(--muted-foreground)]">{device.ip_address} {device.ipv6_prefix ? `• ${device.ipv6_prefix}` : ""}</p>
|
||||
<p class="mt-1 text-xs uppercase tracking-[0.2em] text-[var(--muted-foreground)]">
|
||||
{device.known_ips.length} known IPs • {device.last_seen_at ? "seen" : "never seen"}
|
||||
</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>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</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">
|
||||
{#each tokens as token}
|
||||
<div class="rounded-2xl border border-[var(--border)] bg-white/60 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>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</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>
|
||||
<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" />
|
||||
<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>
|
||||
</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>
|
||||
<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} />
|
||||
<Button type="submit" disabled={savingToken}>{savingToken ? "Saving…" : "Create token"}</Button>
|
||||
</form>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
209
frontend/src/routes/app/events/+page.svelte
Normal file
209
frontend/src/routes/app/events/+page.svelte
Normal file
@@ -0,0 +1,209 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from "svelte";
|
||||
import Button from "$lib/components/ui/button.svelte";
|
||||
import Card from "$lib/components/ui/card.svelte";
|
||||
import { api } from "$lib/api/client";
|
||||
import type { Business, EventItem } from "$lib/types/domain";
|
||||
|
||||
let events: EventItem[] = [];
|
||||
let businesses: Business[] = [];
|
||||
let loading = true;
|
||||
let saving = false;
|
||||
let error = "";
|
||||
let selectedBusiness = "";
|
||||
let editingEventId: number | null = null;
|
||||
|
||||
let form = {
|
||||
business_id: 0,
|
||||
title: "",
|
||||
description: "",
|
||||
event_type: "other",
|
||||
start_datetime: "",
|
||||
end_datetime: "",
|
||||
all_day: false,
|
||||
location: "",
|
||||
color: "#ba6c46",
|
||||
recurrence_type: "none",
|
||||
recurrence_end_date: ""
|
||||
};
|
||||
|
||||
async function loadData() {
|
||||
loading = true;
|
||||
error = "";
|
||||
try {
|
||||
const [eventData, businessData] = await Promise.all([
|
||||
api.events({ business_id: selectedBusiness ? Number(selectedBusiness) : undefined }),
|
||||
api.businesses()
|
||||
]);
|
||||
events = eventData.results;
|
||||
businesses = businessData.results;
|
||||
if (!form.business_id && businesses[0]) form.business_id = businesses[0].id;
|
||||
} catch (err) {
|
||||
error = err instanceof Error ? err.message : "Failed to load events";
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMount(loadData);
|
||||
|
||||
async function saveEvent() {
|
||||
saving = true;
|
||||
error = "";
|
||||
try {
|
||||
const payload = {
|
||||
...form,
|
||||
business_id: Number(form.business_id),
|
||||
recurrence_end_date: form.recurrence_end_date || null
|
||||
};
|
||||
if (editingEventId) {
|
||||
await api.updateEvent(editingEventId, payload);
|
||||
} else {
|
||||
await api.createEvent(payload);
|
||||
}
|
||||
form = {
|
||||
business_id: form.business_id,
|
||||
title: "",
|
||||
description: "",
|
||||
event_type: "other",
|
||||
start_datetime: "",
|
||||
end_datetime: "",
|
||||
all_day: false,
|
||||
location: "",
|
||||
color: "#ba6c46",
|
||||
recurrence_type: "none",
|
||||
recurrence_end_date: ""
|
||||
};
|
||||
editingEventId = null;
|
||||
await loadData();
|
||||
} catch (err) {
|
||||
error = err instanceof Error ? err.message : "Failed to save event";
|
||||
} finally {
|
||||
saving = false;
|
||||
}
|
||||
}
|
||||
|
||||
function editEvent(event: EventItem) {
|
||||
editingEventId = event.id;
|
||||
form = {
|
||||
business_id: event.business_id,
|
||||
title: event.title,
|
||||
description: event.description,
|
||||
event_type: event.event_type,
|
||||
start_datetime: event.start_datetime.slice(0, 16),
|
||||
end_datetime: event.end_datetime.slice(0, 16),
|
||||
all_day: event.all_day,
|
||||
location: event.location,
|
||||
color: event.color || "#ba6c46",
|
||||
recurrence_type: event.recurrence_type,
|
||||
recurrence_end_date: event.recurrence_end_date || ""
|
||||
};
|
||||
}
|
||||
|
||||
async function removeEvent(eventId: number) {
|
||||
error = "";
|
||||
try {
|
||||
await api.deleteEvent(eventId);
|
||||
if (editingEventId === eventId) editingEventId = null;
|
||||
await loadData();
|
||||
} catch (err) {
|
||||
error = err instanceof Error ? err.message : "Failed to delete event";
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="space-y-6">
|
||||
<div class="flex items-end justify-between gap-4">
|
||||
<div>
|
||||
<p class="text-sm uppercase tracking-[0.3em] text-[var(--muted-foreground)]">Operations</p>
|
||||
<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}>
|
||||
<option value="">All businesses</option>
|
||||
{#each businesses as business}
|
||||
<option value={business.id}>{business.name}</option>
|
||||
{/each}
|
||||
</select>
|
||||
<Button type="button" on:click={loadData}>Filter</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if error}
|
||||
<Card><p class="text-sm text-red-700">{error}</p></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>
|
||||
{#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="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<p class="font-medium">{event.title}</p>
|
||||
<p class="text-sm text-[var(--muted-foreground)]">{event.business_name} • {event.event_type}</p>
|
||||
<p class="mt-1 text-sm">{new Date(event.start_datetime).toLocaleString()} to {new Date(event.end_datetime).toLocaleString()}</p>
|
||||
<p class="mt-1 text-sm text-[var(--muted-foreground)]">{event.location || "No location"} {event.recurrence_type !== "none" ? `• ${event.recurrence_type}` : ""}</p>
|
||||
</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>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</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>
|
||||
<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}>
|
||||
{#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>
|
||||
<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}>
|
||||
<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}>
|
||||
<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} />
|
||||
</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} />
|
||||
<label class="flex items-center gap-2 text-sm">
|
||||
<input type="checkbox" bind:checked={form.all_day} />
|
||||
All day
|
||||
</label>
|
||||
</div>
|
||||
<Button type="submit" disabled={saving}>{saving ? "Saving…" : editingEventId ? "Update event" : "Create event"}</Button>
|
||||
</form>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
93
frontend/src/routes/app/inventory/+page.svelte
Normal file
93
frontend/src/routes/app/inventory/+page.svelte
Normal file
@@ -0,0 +1,93 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from "svelte";
|
||||
import Button from "$lib/components/ui/button.svelte";
|
||||
import Card from "$lib/components/ui/card.svelte";
|
||||
import { api } from "$lib/api/client";
|
||||
import type { Category, InventoryItem } from "$lib/types/domain";
|
||||
|
||||
let rows: InventoryItem[] = [];
|
||||
let categories: Category[] = [];
|
||||
let loading = true;
|
||||
let error = "";
|
||||
let query = "";
|
||||
let selectedCategory = "";
|
||||
|
||||
async function loadData() {
|
||||
loading = true;
|
||||
error = "";
|
||||
try {
|
||||
const [inventoryData, categoryData] = await Promise.all([
|
||||
api.inventory({
|
||||
q: query || undefined,
|
||||
category_id: selectedCategory ? Number(selectedCategory) : undefined
|
||||
}),
|
||||
api.categories()
|
||||
]);
|
||||
rows = inventoryData.results;
|
||||
categories = categoryData.results;
|
||||
} catch (err) {
|
||||
error = err instanceof Error ? err.message : "Failed to load inventory";
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMount(loadData);
|
||||
</script>
|
||||
|
||||
<div class="space-y-6">
|
||||
<div class="flex items-end justify-between gap-4">
|
||||
<div>
|
||||
<p class="text-sm uppercase tracking-[0.3em] text-[var(--muted-foreground)]">Operations</p>
|
||||
<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}>
|
||||
<option value="">All categories</option>
|
||||
{#each categories as category}
|
||||
<option value={category.id}>{category.name}</option>
|
||||
{/each}
|
||||
</select>
|
||||
<Button type="button" on:click={loadData}>Filter</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if error}
|
||||
<Card><p class="text-sm text-red-700">{error}</p></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>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
{#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>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</Card>
|
||||
</div>
|
||||
382
frontend/src/routes/app/invoices/+page.svelte
Normal file
382
frontend/src/routes/app/invoices/+page.svelte
Normal file
@@ -0,0 +1,382 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from "svelte";
|
||||
import Card from "$lib/components/ui/card.svelte";
|
||||
import Button from "$lib/components/ui/button.svelte";
|
||||
import { api } from "$lib/api/client";
|
||||
import type { Business, Category, Invoice, Product, Vendor } from "$lib/types/domain";
|
||||
|
||||
let invoices: Invoice[] = [];
|
||||
let vendors: Vendor[] = [];
|
||||
let products: Product[] = [];
|
||||
let businesses: Business[] = [];
|
||||
let categories: Category[] = [];
|
||||
let loading = true;
|
||||
let error = "";
|
||||
let saving = false;
|
||||
let query = "";
|
||||
let selectedInvoice: Invoice | null = null;
|
||||
let editingInvoiceId: number | null = null;
|
||||
|
||||
let form = {
|
||||
business_id: null as number | null,
|
||||
vendor_id: 0,
|
||||
invoice_number: "",
|
||||
invoice_date: "",
|
||||
due_date: "",
|
||||
payment_status: "unpaid" as "paid" | "unpaid",
|
||||
goods_received_status: "not_received" as "received" | "not_received",
|
||||
discount_pct: 0,
|
||||
currency: "CZK",
|
||||
notes: "",
|
||||
category_ids: [] as number[],
|
||||
line_items: [{ product_id: 0, quantity: 1, unit_price: 0 }]
|
||||
};
|
||||
|
||||
async function loadData(search = "") {
|
||||
loading = true;
|
||||
error = "";
|
||||
try {
|
||||
const [invoiceData, vendorData, productData, businessData, categoryData] = await Promise.all([
|
||||
api.invoices(search ? { q: search } : undefined),
|
||||
api.vendors(),
|
||||
api.products(),
|
||||
api.businesses(),
|
||||
api.categories()
|
||||
]);
|
||||
invoices = invoiceData.results;
|
||||
vendors = vendorData.results;
|
||||
products = productData.results;
|
||||
businesses = businessData.results;
|
||||
categories = categoryData.results;
|
||||
selectedInvoice = invoices[0] ?? null;
|
||||
} catch (err) {
|
||||
error = err instanceof Error ? err.message : "Failed to load invoices";
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMount(async () => {
|
||||
await loadData();
|
||||
});
|
||||
|
||||
function addLine() {
|
||||
form.line_items = [...form.line_items, { product_id: 0, quantity: 1, unit_price: 0 }];
|
||||
}
|
||||
|
||||
function startInvoiceDraft(invoice?: Invoice | null) {
|
||||
selectedInvoice = invoice ?? null;
|
||||
editingInvoiceId = invoice?.id ?? null;
|
||||
if (!invoice) {
|
||||
form = {
|
||||
business_id: null,
|
||||
vendor_id: 0,
|
||||
invoice_number: "",
|
||||
invoice_date: "",
|
||||
due_date: "",
|
||||
payment_status: "unpaid",
|
||||
goods_received_status: "not_received",
|
||||
discount_pct: 0,
|
||||
currency: "CZK",
|
||||
notes: "",
|
||||
category_ids: [],
|
||||
line_items: [{ product_id: 0, quantity: 1, unit_price: 0 }]
|
||||
};
|
||||
return;
|
||||
}
|
||||
form = {
|
||||
business_id: invoice.business_id,
|
||||
vendor_id: invoice.vendor_id,
|
||||
invoice_number: invoice.invoice_number,
|
||||
invoice_date: invoice.invoice_date ?? "",
|
||||
due_date: invoice.due_date ?? "",
|
||||
payment_status: invoice.payment_status as "paid" | "unpaid",
|
||||
goods_received_status: invoice.goods_received_status as "received" | "not_received",
|
||||
discount_pct: invoice.discount_pct,
|
||||
currency: invoice.currency,
|
||||
notes: invoice.notes,
|
||||
category_ids: [...invoice.category_ids],
|
||||
line_items:
|
||||
invoice.line_items.map((line) => ({
|
||||
product_id: line.product_id,
|
||||
quantity: line.quantity,
|
||||
unit_price: line.unit_price
|
||||
})) || [{ product_id: 0, quantity: 1, unit_price: 0 }]
|
||||
};
|
||||
}
|
||||
|
||||
function toggleCategory(categoryId: number) {
|
||||
form.category_ids = form.category_ids.includes(categoryId)
|
||||
? form.category_ids.filter((id) => id !== categoryId)
|
||||
: [...form.category_ids, categoryId];
|
||||
}
|
||||
|
||||
async function saveInvoice() {
|
||||
saving = true;
|
||||
error = "";
|
||||
try {
|
||||
const normalizedLineItems = form.line_items
|
||||
.filter((line) => line.product_id && line.quantity && line.unit_price)
|
||||
.map((line) => ({
|
||||
product_id: Number(line.product_id),
|
||||
quantity: Number(line.quantity),
|
||||
unit_price: Number(line.unit_price)
|
||||
}));
|
||||
if (!Number(form.vendor_id)) {
|
||||
throw new Error("Vendor is required");
|
||||
}
|
||||
if (!normalizedLineItems.length) {
|
||||
throw new Error("Add at least one complete line item");
|
||||
}
|
||||
const payload = {
|
||||
...form,
|
||||
business_id: form.business_id ? Number(form.business_id) : null,
|
||||
vendor_id: Number(form.vendor_id),
|
||||
discount_pct: Number(form.discount_pct || 0),
|
||||
category_ids: form.category_ids.map((id) => Number(id)),
|
||||
line_items: normalizedLineItems
|
||||
};
|
||||
const saved = editingInvoiceId
|
||||
? await api.updateInvoice(editingInvoiceId, payload)
|
||||
: await api.createInvoice(payload);
|
||||
await loadData(query);
|
||||
startInvoiceDraft(invoices.find((invoice) => invoice.id === saved.id) ?? saved);
|
||||
} catch (err) {
|
||||
error = err instanceof Error ? err.message : "Failed to save invoice";
|
||||
} finally {
|
||||
saving = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteInvoice() {
|
||||
if (!editingInvoiceId) return;
|
||||
saving = true;
|
||||
error = "";
|
||||
try {
|
||||
await api.deleteInvoice(editingInvoiceId);
|
||||
await loadData(query);
|
||||
startInvoiceDraft(invoices[0] ?? null);
|
||||
} catch (err) {
|
||||
error = err instanceof Error ? err.message : "Failed to delete invoice";
|
||||
} finally {
|
||||
saving = false;
|
||||
}
|
||||
}
|
||||
|
||||
$: subtotal = form.line_items.reduce((sum, line) => sum + Number(line.quantity || 0) * Number(line.unit_price || 0), 0);
|
||||
</script>
|
||||
|
||||
<div class="space-y-6">
|
||||
<div class="flex items-end justify-between gap-4">
|
||||
<div>
|
||||
<p class="text-sm uppercase tracking-[0.3em] text-[var(--muted-foreground)]">Operations</p>
|
||||
<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"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if error}
|
||||
<Card><p class="text-sm text-red-700">{error}</p></Card>
|
||||
{/if}
|
||||
|
||||
<div class="grid gap-6 xl:grid-cols-[1.1fr_0.9fr]">
|
||||
<div class="space-y-6">
|
||||
<Card>
|
||||
<div class="mb-4 flex items-center justify-between">
|
||||
<div>
|
||||
<h3 class="text-xl font-semibold">Recent invoices</h3>
|
||||
<p class="text-sm text-[var(--muted-foreground)]">Consolidated imported data plus new Django-backed invoices.</p>
|
||||
</div>
|
||||
<div class="text-sm text-[var(--muted-foreground)]">{invoices.length} loaded</div>
|
||||
</div>
|
||||
|
||||
{#if loading}
|
||||
<p>Loading invoices…</p>
|
||||
{:else}
|
||||
<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"
|
||||
on:click={() => startInvoiceDraft(invoice)}
|
||||
>
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<p class="font-medium">{invoice.vendor_name}</p>
|
||||
<p class="text-sm text-[var(--muted-foreground)]">{invoice.invoice_number || "Draft / unnumbered"}</p>
|
||||
<p class="mt-1 text-xs uppercase tracking-[0.2em] text-[var(--muted-foreground)]">
|
||||
{invoice.payment_status} {invoice.business_name ? `• ${invoice.business_name}` : ""}
|
||||
</p>
|
||||
</div>
|
||||
<div class="text-right">
|
||||
<p class="font-semibold">{invoice.gross_total.toFixed(2)} {invoice.currency}</p>
|
||||
<p class="text-sm text-[var(--muted-foreground)]">{invoice.due_date ?? "No due date"}</p>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</Card>
|
||||
|
||||
{#if selectedInvoice}
|
||||
<Card>
|
||||
<div class="mb-4 flex items-center justify-between">
|
||||
<div>
|
||||
<h3 class="text-xl font-semibold">{selectedInvoice.vendor_name}</h3>
|
||||
<p class="text-sm text-[var(--muted-foreground)]">{selectedInvoice.invoice_number || "Draft / unnumbered"}</p>
|
||||
</div>
|
||||
<div class="text-right">
|
||||
<p class="text-lg font-semibold">{selectedInvoice.gross_total.toFixed(2)} {selectedInvoice.currency}</p>
|
||||
<p class="text-sm text-[var(--muted-foreground)]">VAT {selectedInvoice.vat_amount.toFixed(2)}</p>
|
||||
</div>
|
||||
</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>
|
||||
|
||||
<div class="mt-4 space-y-2">
|
||||
{#each selectedInvoice.line_items as line}
|
||||
<div class="flex items-center justify-between border-b border-[var(--border)] py-2 last:border-0">
|
||||
<div>
|
||||
<p class="font-medium">{line.product_name}</p>
|
||||
<p class="text-sm text-[var(--muted-foreground)]">{line.quantity} × {line.unit_price.toFixed(2)}</p>
|
||||
</div>
|
||||
<p>{line.total_price.toFixed(2)}</p>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</Card>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<div class="mb-4 flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<h3 class="text-xl font-semibold">{editingInvoiceId ? "Edit invoice" : "New invoice"}</h3>
|
||||
<p class="text-sm text-[var(--muted-foreground)]">This writes to the new Django model layer, not the old FastAPI route logic.</p>
|
||||
</div>
|
||||
{#if editingInvoiceId}
|
||||
<div class="flex gap-2">
|
||||
<Button type="button" variant="ghost" on:click={() => startInvoiceDraft(null)}>Reset</Button>
|
||||
<Button type="button" variant="destructive" on:click={deleteInvoice} disabled={saving}>Delete</Button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<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}>
|
||||
<option value="">Unassigned</option>
|
||||
{#each businesses as business}
|
||||
<option value={business.id}>{business.name}</option>
|
||||
{/each}
|
||||
</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>
|
||||
<option value={0}>Select vendor</option>
|
||||
{#each vendors as vendor}
|
||||
<option value={vendor.id}>{vendor.name}</option>
|
||||
{/each}
|
||||
</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} />
|
||||
</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} />
|
||||
</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} />
|
||||
</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} />
|
||||
</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}>
|
||||
<option value="unpaid">Unpaid</option>
|
||||
<option value="paid">Paid</option>
|
||||
</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}>
|
||||
<option value="not_received">Not received</option>
|
||||
<option value="received">Received</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="mb-2 block text-sm font-medium">Categories</label>
|
||||
<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"}`}
|
||||
type="button"
|
||||
on:click={() => toggleCategory(category.id)}
|
||||
>
|
||||
{category.name}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-3">
|
||||
<div class="flex items-center justify-between">
|
||||
<label class="block text-sm font-medium">Line items</label>
|
||||
<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}>
|
||||
<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} />
|
||||
<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>
|
||||
</div>
|
||||
{/each}
|
||||
</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>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between rounded-2xl bg-black/5 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>
|
||||
</div>
|
||||
<Button type="submit" disabled={saving}>
|
||||
{saving ? "Saving…" : editingInvoiceId ? "Update invoice" : "Create invoice"}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
131
frontend/src/routes/app/schedule/+page.svelte
Normal file
131
frontend/src/routes/app/schedule/+page.svelte
Normal file
@@ -0,0 +1,131 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from "svelte";
|
||||
import Button from "$lib/components/ui/button.svelte";
|
||||
import Card from "$lib/components/ui/card.svelte";
|
||||
import { api } from "$lib/api/client";
|
||||
import type { Business, ScheduleOverview } from "$lib/types/domain";
|
||||
|
||||
let overview: ScheduleOverview | null = null;
|
||||
let businesses: Business[] = [];
|
||||
let selectedBusiness = "";
|
||||
let loading = true;
|
||||
let error = "";
|
||||
|
||||
async function loadData() {
|
||||
loading = true;
|
||||
error = "";
|
||||
try {
|
||||
const [scheduleData, businessData] = await Promise.all([
|
||||
api.scheduleOverview({ business_id: selectedBusiness ? Number(selectedBusiness) : undefined }),
|
||||
api.businesses()
|
||||
]);
|
||||
overview = scheduleData;
|
||||
businesses = businessData.results;
|
||||
} catch (err) {
|
||||
error = err instanceof Error ? err.message : "Failed to load schedule";
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMount(loadData);
|
||||
</script>
|
||||
|
||||
<div class="space-y-6">
|
||||
<div class="flex items-end justify-between gap-4">
|
||||
<div>
|
||||
<p class="text-sm uppercase tracking-[0.3em] text-[var(--muted-foreground)]">Operations</p>
|
||||
<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}>
|
||||
<option value="">All businesses</option>
|
||||
{#each businesses as business}
|
||||
<option value={business.id}>{business.name}</option>
|
||||
{/each}
|
||||
</select>
|
||||
<Button type="button" on:click={loadData}>Filter</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if error}
|
||||
<Card><p class="text-sm text-red-700">{error}</p></Card>
|
||||
{:else if loading || !overview}
|
||||
<Card>Loading schedule…</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>
|
||||
</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">
|
||||
{#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>
|
||||
{/each}
|
||||
</div>
|
||||
</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">
|
||||
{#each overview.templates as template}
|
||||
<div class="rounded-2xl border border-[var(--border)] bg-white/60 p-4">
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<p class="font-medium">{template.name}</p>
|
||||
<p class="text-sm text-[var(--muted-foreground)]">{template.business_name} {template.shift_role_name ? `• ${template.shift_role_name}` : ""}</p>
|
||||
<p class="mt-1 text-sm">{new Date(template.start_datetime).toLocaleString()} to {new Date(template.end_datetime).toLocaleString()}</p>
|
||||
<p class="mt-1 text-xs uppercase tracking-[0.2em] text-[var(--muted-foreground)]">
|
||||
Staff {template.min_staff} to {template.max_staff} • {template.recurrence_type}
|
||||
</p>
|
||||
</div>
|
||||
<div class="text-right">
|
||||
<p class="font-semibold">{template.assignment_count}</p>
|
||||
<p class="text-sm text-[var(--muted-foreground)]">assignments</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</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">
|
||||
{#each overview.assignments as assignment}
|
||||
<div class="flex items-start justify-between border-b border-[var(--border)] py-2 last:border-0">
|
||||
<div>
|
||||
<p class="font-medium">{assignment.user_name}</p>
|
||||
<p class="text-sm text-[var(--muted-foreground)]">{assignment.shift_name}</p>
|
||||
<p class="mt-1 text-sm">{assignment.occurrence_date}</p>
|
||||
</div>
|
||||
<div class="text-right">
|
||||
<p class="font-semibold">{assignment.status}</p>
|
||||
<p class="text-sm text-[var(--muted-foreground)]">{assignment.notes || "No note"}</p>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
253
frontend/src/routes/app/settings/+page.svelte
Normal file
253
frontend/src/routes/app/settings/+page.svelte
Normal file
@@ -0,0 +1,253 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from "svelte";
|
||||
import Button from "$lib/components/ui/button.svelte";
|
||||
import Card from "$lib/components/ui/card.svelte";
|
||||
import { api } from "$lib/api/client";
|
||||
import type { Business, SettingsOverview, SettingsUser } from "$lib/types/domain";
|
||||
|
||||
let overview: SettingsOverview | null = null;
|
||||
let businesses: Business[] = [];
|
||||
let loading = true;
|
||||
let saving = false;
|
||||
let error = "";
|
||||
let editingUserId: number | null = null;
|
||||
|
||||
let form = {
|
||||
username: "",
|
||||
password: "",
|
||||
display_name: "",
|
||||
email: "",
|
||||
role_id: "" as string | number,
|
||||
business_ids: [] as number[],
|
||||
is_active: true,
|
||||
is_superuser: false
|
||||
};
|
||||
|
||||
async function loadData() {
|
||||
loading = true;
|
||||
error = "";
|
||||
try {
|
||||
const [settingsData, businessData] = await Promise.all([api.settingsOverview(), api.businesses()]);
|
||||
overview = settingsData;
|
||||
businesses = businessData.results;
|
||||
} catch (err) {
|
||||
error = err instanceof Error ? err.message : "Failed to load settings";
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMount(loadData);
|
||||
|
||||
function toggleBusiness(id: number) {
|
||||
form.business_ids = form.business_ids.includes(id)
|
||||
? form.business_ids.filter((value) => value !== id)
|
||||
: [...form.business_ids, id];
|
||||
}
|
||||
|
||||
function startEditUser(user: SettingsUser) {
|
||||
editingUserId = user.id;
|
||||
form = {
|
||||
username: user.username,
|
||||
password: "",
|
||||
display_name: user.display_name,
|
||||
email: user.email,
|
||||
role_id: user.role_id ?? "",
|
||||
business_ids: [...user.business_ids],
|
||||
is_active: user.is_active,
|
||||
is_superuser: user.is_superuser
|
||||
};
|
||||
}
|
||||
|
||||
function resetForm() {
|
||||
editingUserId = null;
|
||||
form = {
|
||||
username: "",
|
||||
password: "",
|
||||
display_name: "",
|
||||
email: "",
|
||||
role_id: "",
|
||||
business_ids: [],
|
||||
is_active: true,
|
||||
is_superuser: false
|
||||
};
|
||||
}
|
||||
|
||||
async function saveUser() {
|
||||
saving = true;
|
||||
error = "";
|
||||
try {
|
||||
const basePayload = {
|
||||
username: form.username,
|
||||
display_name: form.display_name,
|
||||
email: form.email,
|
||||
role_id: form.role_id ? Number(form.role_id) : null,
|
||||
business_ids: form.business_ids,
|
||||
is_active: form.is_active,
|
||||
is_superuser: form.is_superuser
|
||||
};
|
||||
if (editingUserId) {
|
||||
await api.updateUser(editingUserId, {
|
||||
...basePayload,
|
||||
password: form.password || undefined
|
||||
});
|
||||
} else {
|
||||
if (!form.password) {
|
||||
throw new Error("Password is required");
|
||||
}
|
||||
await api.createUser({
|
||||
...basePayload,
|
||||
password: form.password
|
||||
});
|
||||
}
|
||||
resetForm();
|
||||
await loadData();
|
||||
} catch (err) {
|
||||
error = err instanceof Error ? err.message : "Failed to save user";
|
||||
} finally {
|
||||
saving = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteUser() {
|
||||
if (!editingUserId) return;
|
||||
saving = true;
|
||||
error = "";
|
||||
try {
|
||||
await api.deleteUser(editingUserId);
|
||||
resetForm();
|
||||
await loadData();
|
||||
} catch (err) {
|
||||
error = err instanceof Error ? err.message : "Failed to delete user";
|
||||
} finally {
|
||||
saving = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="space-y-6">
|
||||
<div>
|
||||
<p class="text-sm uppercase tracking-[0.3em] text-[var(--muted-foreground)]">Administration</p>
|
||||
<h2 class="mt-2 text-3xl font-semibold">Settings and access</h2>
|
||||
</div>
|
||||
|
||||
{#if error}
|
||||
<Card><p class="text-sm text-red-700">{error}</p></Card>
|
||||
{/if}
|
||||
|
||||
{#if loading || !overview}
|
||||
<Card>Loading settings…</Card>
|
||||
{:else}
|
||||
<div class="grid gap-4 md:grid-cols-3">
|
||||
<Card><p class="text-sm text-[var(--muted-foreground)]">Users</p><p class="mt-2 text-3xl font-semibold">{overview.users.length}</p></Card>
|
||||
<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)]">Permissions</p><p class="mt-2 text-3xl font-semibold">{overview.permissions.length}</p></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">Users</h3>
|
||||
<p class="text-sm text-[var(--muted-foreground)]">Current account state imported into Django auth.</p>
|
||||
</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)}>
|
||||
<div class="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<p class="font-medium">{user.display_name || user.username}</p>
|
||||
<p class="text-sm text-[var(--muted-foreground)]">{user.username} {user.email ? `• ${user.email}` : ""}</p>
|
||||
<p class="mt-1 text-xs uppercase tracking-[0.2em] text-[var(--muted-foreground)]">
|
||||
{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>
|
||||
</div>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<div class="mb-4">
|
||||
<h3 class="text-xl font-semibold">Roles</h3>
|
||||
<p class="text-sm text-[var(--muted-foreground)]">Domain roles and assigned permission keys.</p>
|
||||
</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="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<p class="font-medium">{role.name}</p>
|
||||
<p class="text-sm text-[var(--muted-foreground)]">{role.description || "No description"}</p>
|
||||
<p class="mt-1 text-xs uppercase tracking-[0.2em] text-[var(--muted-foreground)]">
|
||||
{role.user_count} users • {role.permission_keys.length} permissions
|
||||
</p>
|
||||
</div>
|
||||
<p class="text-sm text-[var(--muted-foreground)]">{role.is_system ? "System" : "Custom"}</p>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<div class="mb-4 flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<h3 class="text-xl font-semibold">{editingUserId ? "Edit user" : "Create user"}</h3>
|
||||
<p class="text-sm text-[var(--muted-foreground)]">Minimal replacement for the old settings page bulk CRUD.</p>
|
||||
</div>
|
||||
{#if editingUserId}
|
||||
<div class="flex gap-2">
|
||||
<Button type="button" variant="ghost" on:click={resetForm}>Reset</Button>
|
||||
<Button type="button" variant="destructive" on:click={deleteUser} disabled={saving}>Delete</Button>
|
||||
</div>
|
||||
{/if}
|
||||
</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" />
|
||||
</div>
|
||||
<select class="w-full rounded-xl border border-[var(--input)] bg-white/70 px-3 py-2" bind:value={form.role_id}>
|
||||
<option value="">No role</option>
|
||||
{#each overview.roles as role}
|
||||
<option value={role.id}>{role.name}</option>
|
||||
{/each}
|
||||
</select>
|
||||
|
||||
<div>
|
||||
<p class="mb-2 text-sm font-medium">Business access</p>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
{#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"}`}
|
||||
on:click={() => toggleBusiness(business.id)}
|
||||
>
|
||||
{business.name}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<label class="flex items-center gap-2 text-sm">
|
||||
<input type="checkbox" bind:checked={form.is_active} />
|
||||
Active user
|
||||
</label>
|
||||
|
||||
<label class="flex items-center gap-2 text-sm">
|
||||
<input type="checkbox" bind:checked={form.is_superuser} />
|
||||
Superuser
|
||||
</label>
|
||||
|
||||
<Button type="submit" disabled={saving}>{saving ? "Saving…" : editingUserId ? "Update user" : "Create user"}</Button>
|
||||
</form>
|
||||
</Card>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
230
frontend/src/routes/app/vendors/+page.svelte
vendored
Normal file
230
frontend/src/routes/app/vendors/+page.svelte
vendored
Normal file
@@ -0,0 +1,230 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from "svelte";
|
||||
import Button from "$lib/components/ui/button.svelte";
|
||||
import Card from "$lib/components/ui/card.svelte";
|
||||
import { api } from "$lib/api/client";
|
||||
import type { Business, Category, Vendor } from "$lib/types/domain";
|
||||
|
||||
let vendors: Vendor[] = [];
|
||||
let businesses: Business[] = [];
|
||||
let categories: Category[] = [];
|
||||
let loading = true;
|
||||
let saving = false;
|
||||
let error = "";
|
||||
let query = "";
|
||||
let selectedBusiness = "";
|
||||
let selectedCategory = "";
|
||||
let editingVendorId: number | null = null;
|
||||
|
||||
let form = {
|
||||
name: "",
|
||||
vat_number: "",
|
||||
registration_id: "",
|
||||
contact_email: "",
|
||||
contact_phone: "",
|
||||
address: "",
|
||||
notes: "",
|
||||
is_active: true,
|
||||
business_ids: [] as number[],
|
||||
category_ids: [] as number[]
|
||||
};
|
||||
|
||||
async function loadData() {
|
||||
loading = true;
|
||||
error = "";
|
||||
try {
|
||||
const [vendorData, businessData, categoryData] = await Promise.all([
|
||||
api.vendors({
|
||||
q: query || undefined,
|
||||
business_id: selectedBusiness ? Number(selectedBusiness) : undefined,
|
||||
category_id: selectedCategory ? Number(selectedCategory) : undefined
|
||||
}),
|
||||
api.businesses(),
|
||||
api.categories()
|
||||
]);
|
||||
vendors = vendorData.results;
|
||||
businesses = businessData.results;
|
||||
categories = categoryData.results;
|
||||
} catch (err) {
|
||||
error = err instanceof Error ? err.message : "Failed to load vendors";
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMount(loadData);
|
||||
|
||||
function toggleId(list: number[], id: number) {
|
||||
return list.includes(id) ? list.filter((item) => item !== id) : [...list, id];
|
||||
}
|
||||
|
||||
function startEdit(vendor: Vendor) {
|
||||
editingVendorId = vendor.id;
|
||||
form = {
|
||||
name: vendor.name,
|
||||
vat_number: vendor.vat_number,
|
||||
registration_id: vendor.registration_id,
|
||||
contact_email: vendor.contact_email,
|
||||
contact_phone: vendor.contact_phone,
|
||||
address: vendor.address,
|
||||
notes: vendor.notes,
|
||||
is_active: vendor.is_active,
|
||||
business_ids: [...vendor.business_ids],
|
||||
category_ids: [...vendor.category_ids]
|
||||
};
|
||||
}
|
||||
|
||||
function resetForm() {
|
||||
editingVendorId = null;
|
||||
form = {
|
||||
name: "",
|
||||
vat_number: "",
|
||||
registration_id: "",
|
||||
contact_email: "",
|
||||
contact_phone: "",
|
||||
address: "",
|
||||
notes: "",
|
||||
is_active: true,
|
||||
business_ids: [],
|
||||
category_ids: []
|
||||
};
|
||||
}
|
||||
|
||||
async function saveVendor() {
|
||||
saving = true;
|
||||
error = "";
|
||||
try {
|
||||
if (editingVendorId) {
|
||||
await api.updateVendor(editingVendorId, form);
|
||||
} else {
|
||||
await api.createVendor(form);
|
||||
}
|
||||
resetForm();
|
||||
await loadData();
|
||||
} catch (err) {
|
||||
error = err instanceof Error ? err.message : "Failed to save vendor";
|
||||
} finally {
|
||||
saving = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="space-y-6">
|
||||
<div class="flex items-end justify-between gap-4">
|
||||
<div>
|
||||
<p class="text-sm uppercase tracking-[0.3em] text-[var(--muted-foreground)]">Operations</p>
|
||||
<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" />
|
||||
<Button type="button" on:click={loadData}>Filter</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if error}
|
||||
<Card><p class="text-sm text-red-700">{error}</p></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}>
|
||||
<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}>
|
||||
<option value="">All categories</option>
|
||||
{#each categories as category}
|
||||
<option value={category.id}>{category.name}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{#if loading}
|
||||
<p>Loading vendors…</p>
|
||||
{: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)}>
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<p class="font-medium">{vendor.name}</p>
|
||||
<p class="text-sm text-[var(--muted-foreground)]">{vendor.contact_email || vendor.contact_phone || "No primary contact"}</p>
|
||||
<p class="mt-1 text-xs uppercase tracking-[0.2em] text-[var(--muted-foreground)]">
|
||||
{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>
|
||||
</div>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<div class="mb-4 flex items-center justify-between">
|
||||
<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>
|
||||
</div>
|
||||
{#if editingVendorId}
|
||||
<Button type="button" variant="ghost" on:click={resetForm}>Reset</Button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<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" />
|
||||
</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>
|
||||
|
||||
<div>
|
||||
<p class="mb-2 text-sm font-medium">Businesses</p>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
{#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"}`}
|
||||
on:click={() => (form.business_ids = toggleId(form.business_ids, business.id))}
|
||||
>
|
||||
{business.name}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p class="mb-2 text-sm font-medium">Categories</p>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
{#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"}`}
|
||||
on:click={() => (form.category_ids = toggleId(form.category_ids, category.id))}
|
||||
>
|
||||
{category.name}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<label class="flex items-center gap-2 text-sm">
|
||||
<input type="checkbox" bind:checked={form.is_active} />
|
||||
Active vendor
|
||||
</label>
|
||||
|
||||
<Button type="submit" disabled={saving}>
|
||||
{saving ? "Saving…" : editingVendorId ? "Update vendor" : "Create vendor"}
|
||||
</Button>
|
||||
</form>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
84
frontend/src/routes/login/+page.svelte
Normal file
84
frontend/src/routes/login/+page.svelte
Normal file
@@ -0,0 +1,84 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from "svelte";
|
||||
import { goto } from "$app/navigation";
|
||||
import Button from "$lib/components/ui/button.svelte";
|
||||
import Card from "$lib/components/ui/card.svelte";
|
||||
import { api } from "$lib/api/client";
|
||||
import { authUser, authReady, bootstrapAuth } from "$lib/stores/auth";
|
||||
|
||||
let username = "";
|
||||
let password = "";
|
||||
let error = "";
|
||||
let submitting = false;
|
||||
let checkingSession = true;
|
||||
|
||||
onMount(async () => {
|
||||
try {
|
||||
const user = await bootstrapAuth();
|
||||
if (user) {
|
||||
goto("/app/dashboard");
|
||||
return;
|
||||
}
|
||||
} finally {
|
||||
checkingSession = false;
|
||||
}
|
||||
});
|
||||
|
||||
async function submit() {
|
||||
submitting = true;
|
||||
error = "";
|
||||
try {
|
||||
await api.csrf();
|
||||
const data = await api.login(username, password);
|
||||
authUser.set(data.user);
|
||||
authReady.set(true);
|
||||
goto("/app/dashboard");
|
||||
} catch (err) {
|
||||
error = err instanceof Error ? err.message : "Login failed";
|
||||
} finally {
|
||||
submitting = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
{#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">
|
||||
<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>
|
||||
</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>
|
||||
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
10
frontend/svelte.config.js
Normal file
10
frontend/svelte.config.js
Normal file
@@ -0,0 +1,10 @@
|
||||
import adapter from '@sveltejs/adapter-auto';
|
||||
|
||||
/** @type {import('@sveltejs/kit').Config} */
|
||||
const config = {
|
||||
kit: {
|
||||
adapter: adapter()
|
||||
}
|
||||
};
|
||||
|
||||
export default config;
|
||||
14
frontend/tsconfig.json
Normal file
14
frontend/tsconfig.json
Normal file
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"extends": "./.svelte-kit/tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"allowJs": true,
|
||||
"checkJs": false,
|
||||
"esModuleInterop": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"moduleResolution": "bundler",
|
||||
"resolveJsonModule": true,
|
||||
"skipLibCheck": true,
|
||||
"sourceMap": true,
|
||||
"strict": true
|
||||
}
|
||||
}
|
||||
7
frontend/vite.config.ts
Normal file
7
frontend/vite.config.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { sveltekit } from '@sveltejs/kit/vite';
|
||||
import tailwindcss from '@tailwindcss/vite';
|
||||
import { defineConfig } from 'vite';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [tailwindcss(), sveltekit()]
|
||||
});
|
||||
Reference in New Issue
Block a user