From 9d6473d4bf10da711c973dc2ee4aa010929e2387 Mon Sep 17 00:00:00 2001 From: Dilan Gilluly Date: Thu, 25 Jun 2026 17:31:56 -0400 Subject: [PATCH] (add): Baskets function --- .../data/__pycache__/baskets.cpython-314.pyc | Bin 0 -> 7417 bytes api/app/data/baskets.py | 65 ++++++ api/app/db.py | 2 + api/app/routers.py | 2 + client/drizzle.config.js | 2 +- client/src/app.html | 2 +- .../lib/client/components/CommandBar.svelte | 135 ++++++----- .../lib/client/components/HeaderBar.svelte | 16 +- client/src/lib/client/styles.js | 49 ++-- client/src/lib/server/db/index.js | 2 +- client/src/lib/server/db/schema.js | 38 ++- client/src/lib/server/settings/index.js | 6 +- client/src/routes/+layout.svelte | 4 +- client/src/routes/+page.server.js | 10 +- client/src/routes/+page.svelte | 174 +++++++------- client/src/routes/api/+server.js | 20 +- client/src/routes/api/auth/+server.js | 122 +++++----- client/src/routes/api/baskets/+server.js | 45 ++++ .../routes/api/baskets/[prefix]/+server.js | 30 +++ .../api/baskets/[prefix]/[b_id]/+server.js | 38 +++ .../[prefix]/[id_from]/[id_to]/+server.js | 39 ++++ client/src/routes/api/prefixes/+server.js | 106 +++++---- client/src/routes/api/settings/+server.js | 12 +- .../[prefix]/[id_from]/[id_to]/+server.js | 8 +- .../routes/baskets/[prefix]/+page.server.js | 7 + .../src/routes/baskets/[prefix]/+page.svelte | 220 ++++++++++++++++++ client/src/routes/settings/+page.server.js | 8 +- client/src/routes/settings/+page.svelte | 153 ++++++------ .../routes/settings/auth-keys/+page.server.js | 8 +- .../routes/settings/auth-keys/+page.svelte | 30 +-- .../routes/settings/prefixes/+page.server.js | 8 +- .../routes/tickets/[prefix]/+page.server.js | 12 +- .../src/routes/tickets/[prefix]/+page.svelte | 77 +++--- 33 files changed, 987 insertions(+), 463 deletions(-) create mode 100644 api/app/data/__pycache__/baskets.cpython-314.pyc create mode 100644 api/app/data/baskets.py create mode 100644 client/src/routes/api/baskets/+server.js create mode 100644 client/src/routes/api/baskets/[prefix]/+server.js create mode 100644 client/src/routes/api/baskets/[prefix]/[b_id]/+server.js create mode 100644 client/src/routes/api/baskets/[prefix]/[id_from]/[id_to]/+server.js create mode 100644 client/src/routes/baskets/[prefix]/+page.server.js create mode 100644 client/src/routes/baskets/[prefix]/+page.svelte diff --git a/api/app/data/__pycache__/baskets.cpython-314.pyc b/api/app/data/__pycache__/baskets.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..1568686f448fc3c2c9704ad4261c36b0e5bf66ad GIT binary patch literal 7417 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 RXPwy3kKI}xKsTPi{1@`k8nXZZ literal 0 HcmV?d00001 diff --git a/api/app/data/baskets.py b/api/app/data/baskets.py new file mode 100644 index 0000000..6b82614 --- /dev/null +++ b/api/app/data/baskets.py @@ -0,0 +1,65 @@ +from dataclasses import dataclass +from db import RepoTemplate +from system.auth import AuthRepo +from fastapi import APIRouter, Header + +@dataclass +class Basket: + prefix: str + b_id: int + description: str = "" + donors: str = "" + winning_ticket: int = 0 + +class BasketRepo(RepoTemplate): + def get_all_baskets(self): + self.cur.execute("SELECT * FROM baskets ORDER BY prefix, b_id") + results = self.cur.fetchall() + return [Basket(*r) for r in results] + def get_prefix_baskets(self, prefix: str): + self.cur.execute("SELECT * FROM baskets WHERE prefix = ? ORDER BY prefix, b_id", (prefix,)) + results = self.cur.fetchall() + return [Basket(*r) for r in results] + def get_single_basket(self, prefix: str, b_id: int): + self.cur.execute("SELECT * FROM baskets WHERE prefix = ? AND b_id = ? ORDER BY prefix, b_id", (prefix, b_id)) + results = self.cur.fetchall() + return [Basket(*r) for r in results] + def get_range_baskets(self, prefix: str, id_from: int, id_to: int): + self.cur.execute("""SELECT * FROM baskets WHERE prefix = ? AND b_id BETWEEN ? AND ? + ORDER BY prefix, b_id""", (prefix, id_from, id_to)) + results = self.cur.fetchall() + return [Basket(*r) for r in results] + def post_baskets(self, bs: list[Basket]): + for b in bs: + self.cur.execute("""INSERT INTO baskets VALUES (?, ?, ?, ?, ?) ON CONFLICT (prefix, b_id) DO UPDATE SET + description = EXCLUDED.description, donors = EXCLUDED.donors""", + (b.prefix, b.b_id, b.description, b.donors, b.winning_ticket)) + self.conn.commit() + return bs + +basket_router = APIRouter(prefix="/api/baskets") + +@basket_router.get("") +def get_all_baskets(tam_key: str = Header("")): + AuthRepo().verify_key(tam_key) + return BasketRepo().get_all_baskets() + +@basket_router.get("/{prefix}") +def get_prefix_baskets(prefix: str, tam_key: str = Header("")): + AuthRepo().verify_key(tam_key) + return BasketRepo().get_prefix_baskets(prefix) + +@basket_router.get("/{prefix}/{b_id}") +def get_single_basket(prefix: str, b_id: int, tam_key: str = Header("")): + AuthRepo().verify_key(tam_key) + return BasketRepo().get_single_basket(prefix, b_id) + +@basket_router.get("/{prefix}/{id_from}/{id_to}") +def get_range_baskets(prefix: str, id_from: int, id_to: int, tam_key: str = Header("")): + AuthRepo().verify_key(tam_key) + return BasketRepo().get_range_baskets(prefix, id_from, id_to) + +@basket_router.post("") +def post_baskets(bs: list[Basket], tam_key: str = Header("")): + AuthRepo().verify_key(tam_key) + return BasketRepo().post_baskets(bs) diff --git a/api/app/db.py b/api/app/db.py index 3e1c02b..b1fed1c 100644 --- a/api/app/db.py +++ b/api/app/db.py @@ -19,6 +19,8 @@ def init_db(): cur.execute("CREATE TABLE IF NOT EXISTS prefixes (prefix TEXT PRIMARY KEY, color TEXT, weight INTEGER)") cur.execute("""CREATE TABLE IF NOT EXISTS tickets (prefix TEXT, t_id INTEGER, first_name TEXT, last_name TEXT, phone_number TEXT, pref TEXT, PRIMARY KEY (prefix, t_id))""") + cur.execute("""CREATE TABLE IF NOT EXISTS baskets (prefix TEXT, b_id INTEGER, description TEXT, donors TEXT, + winning_ticket INTEGER, PRIMARY KEY (prefix, b_id))""") conn.commit() conn.close() diff --git a/api/app/routers.py b/api/app/routers.py index e008d6a..e7a2ba5 100644 --- a/api/app/routers.py +++ b/api/app/routers.py @@ -2,8 +2,10 @@ from fastapi import FastAPI from system.auth import auth_router from system.prefixes import prefix_router from data.tickets import tickets_router +from data.baskets import basket_router def imp_routers(app: FastAPI): app.include_router(auth_router) app.include_router(prefix_router) app.include_router(tickets_router) + app.include_router(basket_router) diff --git a/client/drizzle.config.js b/client/drizzle.config.js index a817cd2..11902d4 100644 --- a/client/drizzle.config.js +++ b/client/drizzle.config.js @@ -1,6 +1,6 @@ import { defineConfig } from 'drizzle-kit'; -const dbURL = (process.env.TAM_DATA_DIR || "./data") + "/tam-local.db" +const dbURL = (process.env.TAM_DATA_DIR || './data') + '/tam-local.db'; export default defineConfig({ schema: './src/lib/server/db/schema.js', diff --git a/client/src/app.html b/client/src/app.html index 6a2bb58..a3adaa3 100644 --- a/client/src/app.html +++ b/client/src/app.html @@ -6,7 +6,7 @@ %sveltekit.head% - +
%sveltekit.body%
diff --git a/client/src/lib/client/components/CommandBar.svelte b/client/src/lib/client/components/CommandBar.svelte index c94cae9..1acc25f 100644 --- a/client/src/lib/client/components/CommandBar.svelte +++ b/client/src/lib/client/components/CommandBar.svelte @@ -1,67 +1,82 @@
-
- {#if functions.dupDown} - - {/if} - {#if functions.dupUp} - - {/if} - {#if functions.nextLine} - - {/if} - {#if functions.prevLine} - - {/if} - {#if functions.copy} - - {/if} - {#if functions.paste} - - {/if} -
-
- {#if functions.save} - - {/if} -
+
+ {#if functions.dupDown} + + {/if} + {#if functions.dupUp} + + {/if} + {#if functions.nextLine} + + {/if} + {#if functions.prevLine} + + {/if} + {#if functions.copy} + + {/if} + {#if functions.paste} + + {/if} +
+
+ {#if functions.save} + + {/if} +
diff --git a/client/src/lib/client/components/HeaderBar.svelte b/client/src/lib/client/components/HeaderBar.svelte index 86806d8..82e0d44 100644 --- a/client/src/lib/client/components/HeaderBar.svelte +++ b/client/src/lib/client/components/HeaderBar.svelte @@ -1,13 +1,13 @@ -
- Main Menu - {#if children} - {@render children()} - {/if} +
+ Main Menu + {#if children} + {@render children()} + {/if}
diff --git a/client/src/lib/client/styles.js b/client/src/lib/client/styles.js index 980f9e3..e68e77d 100644 --- a/client/src/lib/client/styles.js +++ b/client/src/lib/client/styles.js @@ -1,29 +1,34 @@ export const tS = { - green: 'text-green-800 font-bold', - yellow: 'text-yellow-800 font-bold', - red: 'text-red-800 font-bold' -} + green: 'text-green-800 font-bold', + yellow: 'text-yellow-800 font-bold', + red: 'text-red-800 font-bold' +}; export const bS = { - gray: 'px-2 py-1 bg-gray-300 border border-gray-800 rounded hover:bg-gray-400 cursor-pointer', - white: 'px-2 py-1 bg-white border border-gray-700 rounded hover:bg-gray-200 cursor-pointer', - blue: 'px-2 py-1 bg-blue-300 border border-gray-700 rounded hover:bg-blue-400 cursor-pointer', - yellow: 'px-2 py-1 bg-yellow-300 border border-gray-700 rounded hover:bg-yellow-400 cursor-pointer', - green: 'px-2 py-1 bg-green-300 border border-gray-700 rounded hover:bg-green-400 cursor-pointer', - orange: 'px-2 py-1 bg-orange-300 border border-gray-700 rounded hover:bg-orange-400 cursor-pointer', - red: 'px-2 py-1 bg-red-300 border border-gray-700 rounded hover:bg-red-400 cursor-pointer' -} + gray: 'px-2 py-1 bg-gray-300 border border-gray-800 rounded hover:bg-gray-400 cursor-pointer', + white: 'px-2 py-1 bg-white border border-gray-700 rounded hover:bg-gray-200 cursor-pointer', + blue: 'px-2 py-1 bg-blue-300 border border-gray-700 rounded hover:bg-blue-400 cursor-pointer', + yellow: + 'px-2 py-1 bg-yellow-300 border border-gray-700 rounded hover:bg-yellow-400 cursor-pointer', + green: 'px-2 py-1 bg-green-300 border border-gray-700 rounded hover:bg-green-400 cursor-pointer', + orange: + 'px-2 py-1 bg-orange-300 border border-gray-700 rounded hover:bg-orange-400 cursor-pointer', + red: 'px-2 py-1 bg-red-300 border border-gray-700 rounded hover:bg-red-400 cursor-pointer' +}; export const bAS = { - gray: 'px-2 py-1 bg-gray-300 border-4 border-gray-800 rounded hover:bg-gray-400 cursor-pointer', - white: 'px-2 py-1 bg-white border-4 border-gray-700 rounded hover:bg-gray-200 cursor-pointer', - blue: 'px-2 py-1 bg-blue-300 border-4 border-gray-700 rounded hover:bg-blue-400 cursor-pointer', - yellow: 'px-2 py-1 bg-yellow-300 border-4 border-gray-700 rounded hover:bg-yellow-400 cursor-pointer', - green: 'px-2 py-1 bg-green-300 border-4 border-gray-700 rounded hover:bg-green-400 cursor-pointer', - orange: 'px-2 py-1 bg-orange-300 border-4 border-gray-700 rounded hover:bg-orange-400 cursor-pointer', - red: 'px-2 py-1 bg-red-300 border-4 border-gray-700 rounded hover:bg-red-400 cursor-pointer' -} + gray: 'px-2 py-1 bg-gray-300 border-4 border-gray-800 rounded hover:bg-gray-400 cursor-pointer', + white: 'px-2 py-1 bg-white border-4 border-gray-700 rounded hover:bg-gray-200 cursor-pointer', + blue: 'px-2 py-1 bg-blue-300 border-4 border-gray-700 rounded hover:bg-blue-400 cursor-pointer', + yellow: + 'px-2 py-1 bg-yellow-300 border-4 border-gray-700 rounded hover:bg-yellow-400 cursor-pointer', + green: + 'px-2 py-1 bg-green-300 border-4 border-gray-700 rounded hover:bg-green-400 cursor-pointer', + orange: + 'px-2 py-1 bg-orange-300 border-4 border-gray-700 rounded hover:bg-orange-400 cursor-pointer', + red: 'px-2 py-1 bg-red-300 border-4 border-gray-700 rounded hover:bg-red-400 cursor-pointer' +}; export const iS = { - normal: 'p-1 border border-black' -} + normal: 'p-1 border border-black' +}; diff --git a/client/src/lib/server/db/index.js b/client/src/lib/server/db/index.js index b4ddbbb..0c2362d 100644 --- a/client/src/lib/server/db/index.js +++ b/client/src/lib/server/db/index.js @@ -3,7 +3,7 @@ import Database from 'better-sqlite3'; import * as schema from './schema'; import { env } from '$env/dynamic/private'; -const dbURL = (env.TAM_DATA_DIR || "./data") + "/tam-local.db"; +const dbURL = (env.TAM_DATA_DIR || './data') + '/tam-local.db'; const client = new Database(dbURL); diff --git a/client/src/lib/server/db/schema.js b/client/src/lib/server/db/schema.js index 7799e71..33185a7 100644 --- a/client/src/lib/server/db/schema.js +++ b/client/src/lib/server/db/schema.js @@ -1,16 +1,32 @@ import { integer, sqliteTable, text, primaryKey } from 'drizzle-orm/sqlite-core'; export const prefixes = sqliteTable('prefixes', { - prefix: text('prefix').primaryKey(), - color: text('color'), - weight: integer('weight') + prefix: text('prefix').primaryKey(), + color: text('color'), + weight: integer('weight') }); -export const tickets = sqliteTable('tickets', { - prefix: text('prefix'), - t_id: integer('t_id'), - first_name: text('first_name'), - last_name: text('last_name'), - phone_number: text('phone_number'), - pref: text('pref') -}, (t) => [primaryKey({columns: [t.prefix, t.t_id]})]) +export const tickets = sqliteTable( + 'tickets', + { + prefix: text('prefix'), + t_id: integer('t_id'), + first_name: text('first_name'), + last_name: text('last_name'), + phone_number: text('phone_number'), + pref: text('pref') + }, + (t) => [primaryKey({ columns: [t.prefix, t.t_id] })] +); + +export const baskets = sqliteTable( + 'baskets', + { + prefix: text('prefix'), + b_id: integer('b_id'), + description: text('description'), + donors: text('donors'), + winning_ticket: integer('winning_ticket') + }, + (b) => [primaryKey({ columns: [b.prefix, b.b_id] })] +); diff --git a/client/src/lib/server/settings/index.js b/client/src/lib/server/settings/index.js index 9874e16..3f8e4fa 100644 --- a/client/src/lib/server/settings/index.js +++ b/client/src/lib/server/settings/index.js @@ -5,7 +5,7 @@ const defaultSettings = { remote_server: '', remote_key: '', remote_port: '8000', - remote_tls: false, + remote_tls: false, default_pref: 'CALL', venue_name: 'TAM' }; @@ -15,8 +15,8 @@ export const getSettings = () => { try { const settings = JSON.parse(readFileSync(settingsPath, 'utf-8')); return settings; - } catch { - mkdirSync(env.TAM_DATA_DIR || './data', {recursive: true}) + } catch { + mkdirSync(env.TAM_DATA_DIR || './data', { recursive: true }); writeFileSync(settingsPath, JSON.stringify(defaultSettings, null, 2), 'utf-8'); return defaultSettings; } diff --git a/client/src/routes/+layout.svelte b/client/src/routes/+layout.svelte index 5fa298c..f3c9a2d 100644 --- a/client/src/routes/+layout.svelte +++ b/client/src/routes/+layout.svelte @@ -6,7 +6,7 @@ - - + + {@render children()} diff --git a/client/src/routes/+page.server.js b/client/src/routes/+page.server.js index 26ea848..8985b99 100644 --- a/client/src/routes/+page.server.js +++ b/client/src/routes/+page.server.js @@ -1,10 +1,10 @@ import { getSettings } from '$lib/server/settings'; export const load = async ({ fetch }) => { - const settings = getSettings(); - const res = await fetch('/api'); - const data = await res.json(); - const resPrefixes = await fetch('/api/prefixes'); - const dataPrefixes = await resPrefixes.json(); + const settings = getSettings(); + const res = await fetch('/api'); + const data = await res.json(); + const resPrefixes = await fetch('/api/prefixes'); + const dataPrefixes = await resPrefixes.json(); return { ...data, venueName: settings.venue_name, prefixes: dataPrefixes }; }; diff --git a/client/src/routes/+page.svelte b/client/src/routes/+page.svelte index f0c53d2..fee5410 100644 --- a/client/src/routes/+page.svelte +++ b/client/src/routes/+page.svelte @@ -1,94 +1,108 @@ - {pageTitle} + {pageTitle}
-

{pageTitle}

-

{data.venueName}

+

{pageTitle}

+

{data.venueName}

-
-
-

Prefix Selection:

- {#each prefixes as prefix (prefix.prefix)} - - {/each} -
- {#if curPrefix} -
-

Forms:

-
- Tickets -
-
- {:else} -
-

Please select a prefix to continue.

-
- {/if} -
+
+
+

Prefix Selection:

+ {#each prefixes as prefix (prefix.prefix)} + + {/each} +
+ {#if curPrefix} +
+

Forms:

+ +
+ {:else} +
+

Please select a prefix to continue.

+
+ {/if} +
-{#if adminMode} -
-

Admin Mode:

-
- Settings -
-
-{/if} + {#if adminMode} +
+

Admin Mode:

+
+ Settings +
+
+ {/if} - +
diff --git a/client/src/routes/api/+server.js b/client/src/routes/api/+server.js index 3ccd716..faa9241 100644 --- a/client/src/routes/api/+server.js +++ b/client/src/routes/api/+server.js @@ -5,16 +5,16 @@ export const GET = async () => { const s = getSettings(); if (s.remote_server) { const connStr = getPath(s); - try { - const res = await fetch(`${connStr}/api`, { - headers: { 'TAM-KEY': s.remote_key } - }); - if (!res.ok) throw error(res.status); - const data = await res.json() - return json(data); - } catch { - const data = { whoami: 'TAM Server', authenticated: false, healthy: false }; - return json(data); + try { + const res = await fetch(`${connStr}/api`, { + headers: { 'TAM-KEY': s.remote_key } + }); + if (!res.ok) throw error(res.status); + const data = await res.json(); + return json(data); + } catch { + const data = { whoami: 'TAM Server', authenticated: false, healthy: false }; + return json(data); } } else { const data = { whoami: 'TAM Client' }; diff --git a/client/src/routes/api/auth/+server.js b/client/src/routes/api/auth/+server.js index 0341644..efaa0a2 100644 --- a/client/src/routes/api/auth/+server.js +++ b/client/src/routes/api/auth/+server.js @@ -1,67 +1,67 @@ -import { getSettings, getPath } from "$lib/server/settings"; -import { json, error } from "@sveltejs/kit"; +import { getSettings, getPath } from '$lib/server/settings'; +import { json, error } from '@sveltejs/kit'; export const GET = async ({ request }) => { - const tamPwd = request.headers.get('TAM-PWD'); - const s = getSettings(); - if (s.remote_server) { - const connStr = getPath(s); - try { - const res = await fetch(`${connStr}/api/auth`, { - headers: { 'TAM-PW': tamPwd } - }); - if (!res.ok) throw error(res.status); - const data = await res.json(); - return json(data); - } catch { - throw error(502) - } - } else { - throw error(500, 'Not configured.') - } -} + const tamPwd = request.headers.get('TAM-PWD'); + const s = getSettings(); + if (s.remote_server) { + const connStr = getPath(s); + try { + const res = await fetch(`${connStr}/api/auth`, { + headers: { 'TAM-PW': tamPwd } + }); + if (!res.ok) throw error(res.status); + const data = await res.json(); + return json(data); + } catch { + throw error(502); + } + } else { + throw error(500, 'Not configured.'); + } +}; export const POST = async ({ request }) => { - const tamPwd = request.headers.get('TAM-PWD'); - const reqBody = await request.json(); - const s = getSettings(); - if (s.remote_server) { - const connStr = getPath(s); - try { - const res = await fetch(`${connStr}/api/auth`, { - headers: { 'TAM-PW': tamPwd, 'Content-Type': 'application/json' }, - method: 'POST', - body: JSON.stringify(reqBody) - }); - if (!res.ok) throw error(res.status); - const data = await res.json(); - return json(data); - } catch { - throw error(502); - } - } else { - throw error(500, 'Not configured.') - } -} + const tamPwd = request.headers.get('TAM-PWD'); + const reqBody = await request.json(); + const s = getSettings(); + if (s.remote_server) { + const connStr = getPath(s); + try { + const res = await fetch(`${connStr}/api/auth`, { + headers: { 'TAM-PW': tamPwd, 'Content-Type': 'application/json' }, + method: 'POST', + body: JSON.stringify(reqBody) + }); + if (!res.ok) throw error(res.status); + const data = await res.json(); + return json(data); + } catch { + throw error(502); + } + } else { + throw error(500, 'Not configured.'); + } +}; export const DELETE = async ({ request, url }) => { - const tamPwd = request.headers.get('TAM-PWD'); - const keyToDel = url.searchParams.get('key_to_del'); - const s = getSettings(); - if (s.remote_server) { - const connStr = getPath(s); - try { - const res = await fetch(`${connStr}/api/auth?key_to_del=${keyToDel}`, { - headers: { 'TAM-PW': tamPwd }, - method: 'DELETE' - }) - if (!res.ok) throw error(res.status) - const data = await res.json(); - return json(data); - } catch { - throw error(502); - } - } else { - throw error(500, 'Not configured.') - } -} + const tamPwd = request.headers.get('TAM-PWD'); + const keyToDel = url.searchParams.get('key_to_del'); + const s = getSettings(); + if (s.remote_server) { + const connStr = getPath(s); + try { + const res = await fetch(`${connStr}/api/auth?key_to_del=${keyToDel}`, { + headers: { 'TAM-PW': tamPwd }, + method: 'DELETE' + }); + if (!res.ok) throw error(res.status); + const data = await res.json(); + return json(data); + } catch { + throw error(502); + } + } else { + throw error(500, 'Not configured.'); + } +}; diff --git a/client/src/routes/api/baskets/+server.js b/client/src/routes/api/baskets/+server.js new file mode 100644 index 0000000..5e51769 --- /dev/null +++ b/client/src/routes/api/baskets/+server.js @@ -0,0 +1,45 @@ +import { db } from '$lib/server/db'; +import { baskets } from '$lib/server/db/schema'; +import { getSettings, getPath } from '$lib/server/settings'; +import { error, json } from '@sveltejs/kit'; +import { sql } from 'drizzle-orm'; + +export const GET = async () => { + const s = getSettings(); + if (s.remote_server) { + const connStr = getPath(s); + try { + const res = await fetch(`${connStr}/api/baskets`, { headers: { 'TAM-KEY': s.remote_key } }); + if (!res.ok) throw error(res.status); + const data = await res.json(); + return json(data); + } catch { + return json([]); + } + } else { + const data = await db.select().from(baskets).orderBy(baskets.prefix, baskets.b_id); + return json(data); + } +}; + +export const POST = async ({ request }) => { + const reqData = await request.json(); + const s = getSettings(); + if (s.remote_server) { + const connStr = getPath(s); + const res = await fetch(`${connStr}/api/baskets`, { + method: 'POST', + headers: { 'TAM-KEY': s.remote_key, 'Content-Type': 'application/json' }, + body: JSON.stringify(reqData) + }); + if (!res.ok) throw error(res.status); + } + await db + .insert(baskets) + .values(reqData) + .onConflictDoUpdate({ + target: [baskets.prefix, baskets.b_id], + set: { description: sql`EXCLUDED.description`, donors: sql`EXCLUDED.donors` } + }); + return json(reqData); +}; diff --git a/client/src/routes/api/baskets/[prefix]/+server.js b/client/src/routes/api/baskets/[prefix]/+server.js new file mode 100644 index 0000000..d4b50a9 --- /dev/null +++ b/client/src/routes/api/baskets/[prefix]/+server.js @@ -0,0 +1,30 @@ +import { db } from '$lib/server/db/index.js'; +import { baskets } from '$lib/server/db/schema.js'; +import { getPath, getSettings } from '$lib/server/settings'; +import { error, json } from '@sveltejs/kit'; +import { eq } from 'drizzle-orm'; + +export const GET = async ({ params }) => { + const { prefix } = params; + const s = getSettings(); + if (s.remote_server) { + const connStr = getPath(s); + try { + const req = await fetch(`${connStr}/api/baskets/${prefix}`, { + headers: { 'TAM-KEY': s.remote_key } + }); + if (!req.ok) throw error(req.status); + const data = await req.json(); + return json(data); + } catch { + return json([]); + } + } else { + const data = await db + .select() + .from(baskets) + .where(eq(baskets.prefix, prefix)) + .orderBy(baskets.prefix, baskets.b_id); + return json(data); + } +}; diff --git a/client/src/routes/api/baskets/[prefix]/[b_id]/+server.js b/client/src/routes/api/baskets/[prefix]/[b_id]/+server.js new file mode 100644 index 0000000..1bc1a62 --- /dev/null +++ b/client/src/routes/api/baskets/[prefix]/[b_id]/+server.js @@ -0,0 +1,38 @@ +import { db } from '$lib/server/db/index.js'; +import { baskets } from '$lib/server/db/schema.js'; +import { getPath, getSettings } from '$lib/server/settings'; +import { error, json } from '@sveltejs/kit'; +import { and, eq } from 'drizzle-orm'; + +export const GET = async ({ params }) => { + const [prefix, b_id] = [params.prefix, parseInt(params.b_id)]; + const s = getSettings(); + if (s.remote_server) { + const connStr = getPath(s); + try { + const res = await fetch(`${connStr}/api/tickets/${prefix}/${b_id}`, { + headers: { 'TAM-KEY': s.remote_key } + }); + if (!res.ok) throw error(res.status); + const [data] = await res.json(); + if (data) { + return json(data); + } else { + return json({ prefix, b_id, description: '', donors: '', winning_ticket: 0 }); + } + } catch { + return json({ prefix, b_id, description: '', donors: '', winning_ticket: 0 }); + } + } else { + const [data] = await db + .select() + .from(baskets) + .where(and(eq(baskets.prefix, prefix), eq(baskets.b_id, b_id))) + .orderBy(baskets.prefix, baskets.b_id); + if (data) { + return json(data); + } else { + return json({ prefix, b_id, descrption: '', donors: '', winning_ticket: 0 }); + } + } +}; diff --git a/client/src/routes/api/baskets/[prefix]/[id_from]/[id_to]/+server.js b/client/src/routes/api/baskets/[prefix]/[id_from]/[id_to]/+server.js new file mode 100644 index 0000000..2de665f --- /dev/null +++ b/client/src/routes/api/baskets/[prefix]/[id_from]/[id_to]/+server.js @@ -0,0 +1,39 @@ +import { db } from '$lib/server/db/index.js'; +import { baskets } from '$lib/server/db/schema.js'; +import { getPath, getSettings } from '$lib/server/settings'; +import { error, json } from '@sveltejs/kit'; +import { and, between, eq } from 'drizzle-orm'; + +export const GET = async ({ params }) => { + const s = getSettings(); + const [prefix, id_from, id_to] = [ + params.prefix, + parseInt(params.id_from), + parseInt(params.id_to) + ]; + const rtnData = {}; + for (let i = id_from; i <= id_to; i++) { + rtnData[i] = { prefix, b_id: i, description: '', donors: '', winning_ticket: 0 }; + } + if (s.remote_server) { + const connStr = getPath(s); + try { + const res = await fetch(`${connStr}/api/baskets/${prefix}/${id_from}/${id_to}`, { + headers: { 'TAM-KEY': s.remote_key } + }); + if (!res.ok) throw error(res.status); + const data = Array.from(await res.json()); + data.forEach((b) => (rtnData[b.b_id] = b)); + } catch { + return json([]); + } + } else { + const data = await db + .select() + .from(baskets) + .where(and(eq(baskets.prefix, prefix), between(baskets.b_id, id_from, id_to))) + .orderBy(baskets.prefix, baskets.b_id); + data.forEach((b) => (rtnData[b.b_id] = b)); + } + return json(Object.values(rtnData)); +}; diff --git a/client/src/routes/api/prefixes/+server.js b/client/src/routes/api/prefixes/+server.js index 36d616b..41a4345 100644 --- a/client/src/routes/api/prefixes/+server.js +++ b/client/src/routes/api/prefixes/+server.js @@ -1,56 +1,62 @@ -import { getSettings, getPath } from "$lib/server/settings" -import { json, error } from "@sveltejs/kit"; -import { db } from "$lib/server/db"; -import { prefixes } from "$lib/server/db/schema"; -import { sql, eq } from "drizzle-orm"; +import { getSettings, getPath } from '$lib/server/settings'; +import { json, error } from '@sveltejs/kit'; +import { db } from '$lib/server/db'; +import { prefixes } from '$lib/server/db/schema'; +import { sql, eq } from 'drizzle-orm'; export const GET = async () => { - const s = getSettings(); - if (s.remote_server) { - const connStr = getPath(s); - try { - const res = await fetch(`${connStr}/api/prefixes`, { - headers: { 'TAM-KEY': s.remote_key } - }); - if (!res.ok) throw error(res.status); - const data = await res.json(); - return json(data); - } catch { - return json([]); - } - } else { - const data = await db.select().from(prefixes).orderBy(prefixes.weight, prefixes.prefix); - return json(data); - } -} + const s = getSettings(); + if (s.remote_server) { + const connStr = getPath(s); + try { + const res = await fetch(`${connStr}/api/prefixes`, { + headers: { 'TAM-KEY': s.remote_key } + }); + if (!res.ok) throw error(res.status); + const data = await res.json(); + return json(data); + } catch { + return json([]); + } + } else { + const data = await db.select().from(prefixes).orderBy(prefixes.weight, prefixes.prefix); + return json(data); + } +}; export const POST = async ({ request }) => { - const reqData = await request.json(); - const s = getSettings(); - if (s.remote_server) { - const connStr = getPath(s); - const res = await fetch(`${connStr}/api/prefixes`, { - method: 'POST', - headers: { 'TAM-KEY': s.remote_key, 'Content-Type': 'application/json' }, - body: JSON.stringify(reqData) - }) - if (!res.ok) throw error(res.status); - } - await db.insert(prefixes).values(reqData).onConflictDoUpdate({ target: prefixes.prefix, set: { color: sql`EXCLUDED.color`, weight: sql`EXCLUDED.weight` } }); - return json(reqData); -} + const reqData = await request.json(); + const s = getSettings(); + if (s.remote_server) { + const connStr = getPath(s); + const res = await fetch(`${connStr}/api/prefixes`, { + method: 'POST', + headers: { 'TAM-KEY': s.remote_key, 'Content-Type': 'application/json' }, + body: JSON.stringify(reqData) + }); + if (!res.ok) throw error(res.status); + } + await db + .insert(prefixes) + .values(reqData) + .onConflictDoUpdate({ + target: prefixes.prefix, + set: { color: sql`EXCLUDED.color`, weight: sql`EXCLUDED.weight` } + }); + return json(reqData); +}; export const DELETE = async ({ url }) => { - const p = url.searchParams.get('p'); - const s = getSettings(); - if (s.remote_server) { - const connStr = getPath(s); - const res = await fetch(`${connStr}/api/prefixes?p=${p}`, { - method: 'DELETE', - headers: { 'TAM-KEY': s.remote_key } - }); - if (!res.ok) throw error(res.status); - } - await db.delete(prefixes).where(eq(prefixes.prefix, p)); - return json({ message: "Deleted successfully."}); -} + const p = url.searchParams.get('p'); + const s = getSettings(); + if (s.remote_server) { + const connStr = getPath(s); + const res = await fetch(`${connStr}/api/prefixes?p=${p}`, { + method: 'DELETE', + headers: { 'TAM-KEY': s.remote_key } + }); + if (!res.ok) throw error(res.status); + } + await db.delete(prefixes).where(eq(prefixes.prefix, p)); + return json({ message: 'Deleted successfully.' }); +}; diff --git a/client/src/routes/api/settings/+server.js b/client/src/routes/api/settings/+server.js index b03f59a..f4bb5d1 100644 --- a/client/src/routes/api/settings/+server.js +++ b/client/src/routes/api/settings/+server.js @@ -1,8 +1,8 @@ -import { setSettings } from "$lib/server/settings"; -import { json } from "@sveltejs/kit"; +import { setSettings } from '$lib/server/settings'; +import { json } from '@sveltejs/kit'; export const POST = async ({ request }) => { - const newSettings = await request.json(); - setSettings(newSettings); - return json(newSettings); -} + const newSettings = await request.json(); + setSettings(newSettings); + return json(newSettings); +}; diff --git a/client/src/routes/api/tickets/[prefix]/[id_from]/[id_to]/+server.js b/client/src/routes/api/tickets/[prefix]/[id_from]/[id_to]/+server.js index 50844cb..8ce965a 100644 --- a/client/src/routes/api/tickets/[prefix]/[id_from]/[id_to]/+server.js +++ b/client/src/routes/api/tickets/[prefix]/[id_from]/[id_to]/+server.js @@ -31,8 +31,12 @@ export const GET = async ({ params }) => { } catch { return json([]); } - } else { - const data = await db.select().from(tickets).where(and(eq(tickets.prefix, prefix), between(tickets.t_id, id_from, id_to))).orderBy(tickets.prefix, tickets.t_id); + } else { + const data = await db + .select() + .from(tickets) + .where(and(eq(tickets.prefix, prefix), between(tickets.t_id, id_from, id_to))) + .orderBy(tickets.prefix, tickets.t_id); data.forEach((t) => (rtnData[t.t_id] = t)); } return json(Object.values(rtnData)); diff --git a/client/src/routes/baskets/[prefix]/+page.server.js b/client/src/routes/baskets/[prefix]/+page.server.js new file mode 100644 index 0000000..93d9016 --- /dev/null +++ b/client/src/routes/baskets/[prefix]/+page.server.js @@ -0,0 +1,7 @@ +export const load = async ({ params, fetch }) => { + const { prefix } = params; + const res = await fetch('/api/prefixes'); + const prefixes = await res.json(); + const prefixObj = Array.from(prefixes).find((p) => p.prefix == prefix); + return { prefix: prefixObj, prefixes }; +}; diff --git a/client/src/routes/baskets/[prefix]/+page.svelte b/client/src/routes/baskets/[prefix]/+page.svelte new file mode 100644 index 0000000..37c947e --- /dev/null +++ b/client/src/routes/baskets/[prefix]/+page.svelte @@ -0,0 +1,220 @@ + + + + {pageTitle} + + + + + + + + + {#each headers as header (header)} + + {/each} + + + + {#each items as item, idx (item.b_id)} + { + changeIdx(idx); + e.target.scrollIntoView({ block: 'center' }); + }} + > + + + + + + {/each} + +
+ +
Baskets:
+ {#each prefixes as p (p.prefix)} + {p.prefix} + {/each} +
+

{pageTitle}

+ +
{header}
{item.b_id} (item.changed = true)} + bind:value={item.description} + /> (item.changed = true)} + bind:value={item.donors} + />
diff --git a/client/src/routes/settings/+page.server.js b/client/src/routes/settings/+page.server.js index adc5e8e..6895322 100644 --- a/client/src/routes/settings/+page.server.js +++ b/client/src/routes/settings/+page.server.js @@ -1,6 +1,6 @@ -import { getSettings } from "$lib/server/settings"; +import { getSettings } from '$lib/server/settings'; export const load = () => { - const settings = getSettings(); - return { settings }; -} + const settings = getSettings(); + return { settings }; +}; diff --git a/client/src/routes/settings/+page.svelte b/client/src/routes/settings/+page.svelte index 14bd844..aa67826 100644 --- a/client/src/routes/settings/+page.svelte +++ b/client/src/routes/settings/+page.svelte @@ -1,85 +1,96 @@ - {pageTitle} + {pageTitle} -
Settings Sections:
- {#if data.settings.remote_server} - Auth Keys - {/if} - Prefixes +
Settings Sections:
+ {#if data.settings.remote_server} + Auth Keys + {/if} + Prefixes
-

{pageTitle}

-
-

Remote Mode:

-
-
Remote Server:
- -
-
-
Remote Port:
- -
-
-
Remote TLS:
- -
-

Default Preferences:

-
-
Contact Preference:
- -
-
-
Venue Name:
- -
-
- - -
-
-

{status.message}

-
-
+

{pageTitle}

+
+

Remote Mode:

+
+
Remote Server:
+ +
+
+
Remote Port:
+ +
+
+
Remote TLS:
+ +
+

Default Preferences:

+
+
Contact Preference:
+ +
+
+
Venue Name:
+ +
+
+ + +
+
+

{status.message}

+
+
diff --git a/client/src/routes/settings/auth-keys/+page.server.js b/client/src/routes/settings/auth-keys/+page.server.js index 53c3df8..83b78db 100644 --- a/client/src/routes/settings/auth-keys/+page.server.js +++ b/client/src/routes/settings/auth-keys/+page.server.js @@ -1,6 +1,6 @@ -import { getSettings } from "$lib/server/settings"; +import { getSettings } from '$lib/server/settings'; export const load = () => { - const s = getSettings(); - return { authKey: s.remote_key }; -} + const s = getSettings(); + return { authKey: s.remote_key }; +}; diff --git a/client/src/routes/settings/auth-keys/+page.svelte b/client/src/routes/settings/auth-keys/+page.svelte index af8619b..6936e1d 100644 --- a/client/src/routes/settings/auth-keys/+page.svelte +++ b/client/src/routes/settings/auth-keys/+page.svelte @@ -91,10 +91,10 @@ headers: { 'TAM-PWD': auth.pwd } }); if (res.ok) { - setTimeout(() => { - const newKeys = authKeys.filter(i => i.auth_key !== key.auth_key); - authKeys = [...newKeys]; - }, 1) + setTimeout(() => { + const newKeys = authKeys.filter((i) => i.auth_key !== key.auth_key); + authKeys = [...newKeys]; + }, 1); } }}>Delete @@ -109,17 +109,17 @@ diff --git a/client/src/routes/settings/prefixes/+page.server.js b/client/src/routes/settings/prefixes/+page.server.js index 3b544e2..b8e39f9 100644 --- a/client/src/routes/settings/prefixes/+page.server.js +++ b/client/src/routes/settings/prefixes/+page.server.js @@ -1,5 +1,5 @@ export const load = async ({ fetch }) => { - const res = await fetch('/api/prefixes'); - const prefixes = await res.json(); - return { prefixes }; -} + const res = await fetch('/api/prefixes'); + const prefixes = await res.json(); + return { prefixes }; +}; diff --git a/client/src/routes/tickets/[prefix]/+page.server.js b/client/src/routes/tickets/[prefix]/+page.server.js index 77039e4..93d9016 100644 --- a/client/src/routes/tickets/[prefix]/+page.server.js +++ b/client/src/routes/tickets/[prefix]/+page.server.js @@ -1,7 +1,7 @@ export const load = async ({ params, fetch }) => { - const { prefix } = params; - const res = await fetch('/api/prefixes'); - const prefixes = await res.json(); - const prefixObj = Array.from(prefixes).find(p => p.prefix == prefix); - return { prefix: prefixObj, prefixes}; -} + const { prefix } = params; + const res = await fetch('/api/prefixes'); + const prefixes = await res.json(); + const prefixObj = Array.from(prefixes).find((p) => p.prefix == prefix); + return { prefix: prefixObj, prefixes }; +}; diff --git a/client/src/routes/tickets/[prefix]/+page.svelte b/client/src/routes/tickets/[prefix]/+page.svelte index e407636..f2ef543 100644 --- a/client/src/routes/tickets/[prefix]/+page.svelte +++ b/client/src/routes/tickets/[prefix]/+page.svelte @@ -76,55 +76,55 @@ focusIdx(nextIdx); }, 1); } else { - setTimeout(() => { + setTimeout(() => { focusIdx(curIdx); - }, 1) + }, 1); } }, prevLine: () => { - if (items[prevIdx]) { + if (items[prevIdx]) { setTimeout(() => { - focusIdx(prevIdx); + focusIdx(prevIdx); }, 1); } else { - setTimeout(() => { - focusIdx(curIdx) + setTimeout(() => { + focusIdx(curIdx); }, 1); - }; + } }, dupDown: () => { - if (items[nextIdx]) { - const buffer = {...items[curIdx]}; - ["prefix", "t_id"].forEach(key => delete buffer[key]); - items[nextIdx] = {...items[nextIdx], ...buffer, changed: true}; + if (items[nextIdx]) { + const buffer = { ...items[curIdx] }; + ['prefix', 't_id'].forEach((key) => delete buffer[key]); + items[nextIdx] = { ...items[nextIdx], ...buffer, changed: true }; functions.nextLine(); } else { - focusIdx(curIdx); + focusIdx(curIdx); } }, dupUp: () => { - if (curIdx > 0) { - const buffer = {...items[curIdx]}; - ["prefix", "t_id"].forEach(key => delete buffer[key]); - items[prevIdx] = {...items[prevIdx], ...buffer, changed: true}; + if (curIdx > 0) { + const buffer = { ...items[curIdx] }; + ['prefix', 't_id'].forEach((key) => delete buffer[key]); + items[prevIdx] = { ...items[prevIdx], ...buffer, changed: true }; functions.prevLine(); } else { - focusIdx(curIdx); + focusIdx(curIdx); } }, copy: () => { - if (items[curIdx]) { - const buffer = {...items[curIdx]}; - ["prefix", "t_id"].forEach(key => delete buffer[key]); + if (items[curIdx]) { + const buffer = { ...items[curIdx] }; + ['prefix', 't_id'].forEach((key) => delete buffer[key]); window.localStorage.setItem('tam-ticket', JSON.stringify(buffer)); - }; + } focusIdx(curIdx); }, paste: () => { - if (items[curIdx]) { + if (items[curIdx]) { const buffer = JSON.parse(window.localStorage.getItem('tam-ticket')); - items[curIdx] = {...items[curIdx], ...buffer, changed: true}; - }; + items[curIdx] = { ...items[curIdx], ...buffer, changed: true }; + } focusIdx(curIdx); } }; @@ -154,8 +154,8 @@ {pageTitle} - - +
+ - - - - - - - - - +

{pageTitle}

+ + {#each headers as header (header)} @@ -207,6 +201,7 @@ > (item.changed = true)} bind:value={item.last_name} /> (item.changed = true)} bind:value={item.phone_number} /> { item.pref == 'CALL' ? (item.pref = 'TEXT') : (item.pref = 'CALL'); item.changed = true; + }} + onkeydown={(e) => { + if (e.key == 't') { + if (item.pref != 'TEXT') item.changed = true; + item.pref = 'TEXT'; + } else if (e.key == 'c') { + if (item.pref != 'CALL') item.changed = true; + item.pref = 'CALL'; + } }}>{item.pref}
@@ -167,16 +167,10 @@ > {/each} -

{pageTitle}