(add): Sheets, Ticket Search

This commit is contained in:
2026-07-08 20:22:59 -04:00
parent cdbac0e69a
commit 76040c1f91
23 changed files with 735 additions and 179 deletions
+37
View File
@@ -0,0 +1,37 @@
from fastapi import APIRouter, Header
from .tickets import Ticket
from db import RepoTemplate
from system.auth import AuthRepo
chunk_size = 300
def wild_enc(str_in: str):
return f"%{str_in}%"
class SearchRepo(RepoTemplate):
def ticket_search(self, first_name: str, last_name: str, phone_number: str):
self.cur.execute("SELECT * FROM tickets WHERE first_name LIKE ? AND last_name LIKE ? AND phone_number LIKE ? ORDER BY prefix, t_id", (wild_enc(first_name), wild_enc(last_name), wild_enc(phone_number)))
results = self.cur.fetchall()
return [Ticket(*r) for r in results]
def post_tickets(self, ts: list[Ticket]):
for i in range(0, len(ts), chunk_size):
chunk = ts[i:i+chunk_size]
for t in chunk:
self.cur.execute("""INSERT INTO tickets VALUES (?, ?, ?, ?, ?, ?) ON CONFLICT (prefix, t_id)
DO UPDATE SET first_name = EXCLUDED.first_name, last_name = EXCLUDED.last_name,
phone_number = EXCLUDED.phone_number, pref = EXCLUDED.pref""",
(t.prefix, t.t_id, t.first_name, t.last_name, t.phone_number, t.pref))
self.conn.commit()
return ts
search_router = APIRouter(prefix="/api/search")
@search_router.get("/tickets")
def search_tickets(first_name: str = "", last_name: str = "", phone_number: str = "", tam_key: str = Header("")):
AuthRepo().verify_key(tam_key)
return SearchRepo().ticket_search(first_name, last_name, phone_number)
@search_router.post("/tickets")
def post_tickets(ts: list[Ticket], tam_key: str = Header("")):
AuthRepo().verify_key(tam_key)
return SearchRepo().post_tickets(ts)
+2
View File
@@ -5,6 +5,7 @@ from data.tickets import tickets_router
from data.baskets import basket_router from data.baskets import basket_router
from data.drawing import drawing_router from data.drawing import drawing_router
from data.reports import reports_router from data.reports import reports_router
from data.search import search_router
def imp_routers(app: FastAPI): def imp_routers(app: FastAPI):
app.include_router(auth_router) app.include_router(auth_router)
@@ -13,3 +14,4 @@ def imp_routers(app: FastAPI):
app.include_router(basket_router) app.include_router(basket_router)
app.include_router(drawing_router) app.include_router(drawing_router)
app.include_router(reports_router) app.include_router(reports_router)
app.include_router(search_router)
+1 -2
View File
@@ -1,2 +1 @@
# Drizzle TAM_DATA_DIR=./data
DATABASE_URL=local.db
+7 -7
View File
@@ -1,4 +1,4 @@
CREATE TABLE `baskets` ( CREATE TABLE IF NOT EXISTS `baskets` (
`prefix` text, `prefix` text,
`b_id` integer, `b_id` integer,
`description` text, `description` text,
@@ -7,13 +7,13 @@ CREATE TABLE `baskets` (
PRIMARY KEY(`prefix`, `b_id`) PRIMARY KEY(`prefix`, `b_id`)
); );
--> statement-breakpoint --> statement-breakpoint
CREATE TABLE `prefixes` ( CREATE TABLE IF NOT EXISTS `prefixes` (
`prefix` text PRIMARY KEY NOT NULL, `prefix` text PRIMARY KEY NOT NULL,
`color` text, `color` text,
`weight` integer `weight` integer
); );
--> statement-breakpoint --> statement-breakpoint
CREATE TABLE `tickets` ( CREATE TABLE IF NOT EXISTS `tickets` (
`prefix` text, `prefix` text,
`t_id` integer, `t_id` integer,
`first_name` text, `first_name` text,
@@ -23,16 +23,16 @@ CREATE TABLE `tickets` (
PRIMARY KEY(`prefix`, `t_id`) PRIMARY KEY(`prefix`, `t_id`)
); );
--> statement-breakpoint --> statement-breakpoint
CREATE VIEW `drawing` AS SELECT b.prefix, b.b_id, b.description, b.winning_ticket, t.last_name, t.first_name, t.phone_number CREATE VIEW IF NOT EXISTS `drawing` AS SELECT b.prefix, b.b_id, b.description, b.winning_ticket, t.last_name, t.first_name, t.phone_number
FROM baskets b LEFT JOIN tickets t ON b.prefix = t.prefix AND b.winning_ticket = t.t_id FROM baskets b LEFT JOIN tickets t ON b.prefix = t.prefix AND b.winning_ticket = t.t_id
ORDER BY b.prefix, b.b_id;--> statement-breakpoint ORDER BY b.prefix, b.b_id;--> statement-breakpoint
CREATE VIEW `report_by_basket` AS SELECT b.prefix, b.b_id, b.description, b.donors, b.winning_ticket, t.last_name, t.first_name, t.phone_number, t.pref CREATE VIEW IF NOT EXISTS `report_by_basket` AS SELECT b.prefix, b.b_id, b.description, b.donors, b.winning_ticket, t.last_name, t.first_name, t.phone_number, t.pref
FROM baskets b LEFT JOIN tickets t on b.prefix = t.prefix AND b.winning_ticket = t.t_id FROM baskets b LEFT JOIN tickets t on b.prefix = t.prefix AND b.winning_ticket = t.t_id
ORDER BY b.prefix, b.b_id;--> statement-breakpoint ORDER BY b.prefix, b.b_id;--> statement-breakpoint
CREATE VIEW `report_by_name` AS SELECT t.last_name, t.first_name, t.phone_number, t.pref, b.prefix, b.b_id, b.description, b.donors, b.winning_ticket CREATE VIEW IF NOT EXISTS `report_by_name` AS SELECT t.last_name, t.first_name, t.phone_number, t.pref, b.prefix, b.b_id, b.description, b.donors, b.winning_ticket
FROM baskets b LEFT JOIN tickets t ON b.prefix = t.prefix AND b.winning_ticket = t.t_id FROM baskets b LEFT JOIN tickets t ON b.prefix = t.prefix AND b.winning_ticket = t.t_id
ORDER BY t.last_name, t.first_name, t.phone_number, b.prefix, b.b_id;--> statement-breakpoint ORDER BY t.last_name, t.first_name, t.phone_number, b.prefix, b.b_id;--> statement-breakpoint
CREATE VIEW `report_counts` AS SELECT prefix, COUNT(DISTINCT(CONCAT(first_name, last_name, phone_number))) AS unique_buyers, COUNT(*) AS total_buys CREATE VIEW IF NOT EXISTS `report_counts` AS SELECT prefix, COUNT(DISTINCT(CONCAT(first_name, last_name, phone_number))) AS unique_buyers, COUNT(*) AS total_buys
FROM tickets FROM tickets
GROUP BY prefix GROUP BY prefix
UNION ALL UNION ALL
+41
View File
@@ -0,0 +1,41 @@
import { db } from "$lib/server/db";
export const init = async () => {
await db.run(`CREATE TABLE IF NOT EXISTS baskets (
prefix text,
b_id integer,
description text,
donors text,
winning_ticket integer,
PRIMARY KEY(prefix, b_id)
)`);
await db.run(`CREATE TABLE IF NOT EXISTS prefixes (
prefix text PRIMARY KEY NOT NULL,
color text,
weight integer
)`);
await db.run(`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)
)`);
await db.run(`CREATE VIEW IF NOT EXISTS drawing AS SELECT b.prefix, b.b_id, b.description, b.winning_ticket, t.last_name, t.first_name, t.phone_number
FROM baskets b LEFT JOIN tickets t ON b.prefix = t.prefix AND b.winning_ticket = t.t_id
ORDER BY b.prefix, b.b_id`);
await db.run(`CREATE VIEW IF NOT EXISTS report_by_basket AS SELECT b.prefix, b.b_id, b.description, b.donors, b.winning_ticket, t.last_name, t.first_name, t.phone_number, t.pref
FROM baskets b LEFT JOIN tickets t on b.prefix = t.prefix AND b.winning_ticket = t.t_id
ORDER BY b.prefix, b.b_id`);
await db.run(`CREATE VIEW IF NOT EXISTS report_by_name AS SELECT t.last_name, t.first_name, t.phone_number, t.pref, b.prefix, b.b_id, b.description, b.donors, b.winning_ticket
FROM baskets b LEFT JOIN tickets t ON b.prefix = t.prefix AND b.winning_ticket = t.t_id
ORDER BY t.last_name, t.first_name, t.phone_number, b.prefix, b.b_id`)
await db.run(`CREATE VIEW IF NOT EXISTS report_counts AS SELECT prefix, COUNT(DISTINCT(CONCAT(first_name, last_name, phone_number))) AS unique_buyers, COUNT(*) AS total_buys
FROM tickets
GROUP BY prefix
UNION ALL
SELECT 'Total', COUNT(DISTINCT(CONCAT(first_name, last_name, phone_number))), COUNT(*)
FROM tickets`);
}
@@ -0,0 +1,60 @@
<script>
import { browser } from '$app/environment';
import hotkeys from 'hotkeys-js';
import { bS, iS } from '../styles';
let { prefix, functions, searchForm = $bindable() } = $props();
if (browser) {
hotkeys.filter = () => {
return true;
};
hotkeys('alt+q', (e) => {
e.preventDefault();
const first_name = document.getElementById('search_first_name');
if (first_name) first_name.select();
});
hotkeys('alt+w', (e) => {
e.preventDefault();
const last_name = document.getElementById('search_last_name');
if (last_name) last_name.select();
});
hotkeys('alt+e', (e) => {
e.preventDefault();
const phone_number = document.getElementById('search_phone_number');
if (phone_number) phone_number.select();
});
}
</script>
<div class="flex flex-row justify-between gap-1 p-1">
<div class="flex flex-row gap-1 items-center">
<div>First Name:</div>
<input
type="text"
id="search_first_name"
class={iS.normal}
bind:value={searchForm.first_name}
onclick={(e) => e.target.select()}
/>
<div>Last Name:</div>
<input
type="text"
id="search_last_name"
class={iS.normal}
bind:value={searchForm.last_name}
onclick={(e) => e.target.select()}
/>
<div>Phone Number:</div>
<input
type="text"
id="search_phone_number"
class={iS.normal}
bind:value={searchForm.phone_number}
onclick={(e) => e.target.select()}
/>
{#if functions.search}
<button class={bS[prefix.color]} onclick={() => functions.search()}>Search</button>
{/if}
</div>
</div>
+8 -8
View File
@@ -1,12 +1,12 @@
export const tS = { export const tS = {
gray: 'text-gray-700 font-bold', gray: 'text-gray-700',
white: 'font-bold', white: 'text-black',
blue: 'text-blue-700 font-bold', blue: 'text-blue-700',
yellow: 'text-yellow-600 font-bold', yellow: 'text-yellow-600',
green: 'text-green-700 font-bold', green: 'text-green-700',
orange: 'text-orange-700 font-bold', orange: 'text-orange-700',
purple: 'text-purple-700 font-bold', purple: 'text-purple-700',
red: 'text-red-700 font-bold' red: 'text-red-700'
}; };
export const bS = { export const bS = {
+3 -2
View File
@@ -79,9 +79,10 @@ export const reportCounts = sqliteView('report_counts', {
prefix: text('prefix'), prefix: text('prefix'),
unique_buyers: integer('unique_buyers'), unique_buyers: integer('unique_buyers'),
total_buys: integer('total_buys') total_buys: integer('total_buys')
}).as(sql`SELECT prefix, COUNT(DISTINCT(CONCAT(first_name, last_name, phone_number))) AS unique_buyers, COUNT(*) AS total_buys })
.as(sql`SELECT prefix, COUNT(DISTINCT(CONCAT(first_name, last_name, phone_number))) AS unique_buyers, COUNT(*) AS total_buys
FROM tickets FROM tickets
GROUP BY prefix GROUP BY prefix
UNION ALL UNION ALL
SELECT 'Total', COUNT(DISTINCT(CONCAT(first_name, last_name, phone_number))), COUNT(*) SELECT 'Total', COUNT(DISTINCT(CONCAT(first_name, last_name, phone_number))), COUNT(*)
FROM tickets`) FROM tickets`);
+10 -3
View File
@@ -1,5 +1,5 @@
<script> <script>
import favicon from '$lib/assets/favicon.svg' import favicon from '$lib/assets/favicon.svg';
import { tS, bS, bAS } from '$lib/client/styles.js'; import { tS, bS, bAS } from '$lib/client/styles.js';
import { browser } from '$app/environment'; import { browser } from '$app/environment';
import { resolve } from '$app/paths'; import { resolve } from '$app/paths';
@@ -51,7 +51,7 @@
<div class="p-1" id="app_container"> <div class="p-1" id="app_container">
<div class="flex flex-row gap-1 items-center"> <div class="flex flex-row gap-1 items-center">
<div> <div>
<img src={favicon} alt="TAM Logo" style="height: 4rem"> <img src={favicon} alt="TAM Logo" style="height: 4rem" />
</div> </div>
<div> <div>
<h1 class="text-xl font-bold">{pageTitle}</h1> <h1 class="text-xl font-bold">{pageTitle}</h1>
@@ -106,6 +106,7 @@
<div class="flex flex-col gap-1 items-center text-center p-1 border border-black rounded"> <div class="flex flex-col gap-1 items-center text-center p-1 border border-black rounded">
<h2 class="text-lg font-bold">Prefix Independent:</h2> <h2 class="text-lg font-bold">Prefix Independent:</h2>
<a href={resolve('/reports/counts')} class="{bS.gray} w-full">Ticket Counts</a> <a href={resolve('/reports/counts')} class="{bS.gray} w-full">Ticket Counts</a>
<a href={resolve('/sheets')} class="{bS.gray} w-full">Print Sheets</a>
</div> </div>
</div> </div>
@@ -114,6 +115,7 @@
<h2 class="text-lg font-bold">Admin Mode:</h2> <h2 class="text-lg font-bold">Admin Mode:</h2>
<div class="flex flex-row gap-1"> <div class="flex flex-row gap-1">
<a href={resolve('/settings')} class={bS.gray}>Settings</a> <a href={resolve('/settings')} class={bS.gray}>Settings</a>
<a href={resolve('/search/tickets')} class={bS.gray}>Search Tickets</a>
</div> </div>
</div> </div>
{/if} {/if}
@@ -132,7 +134,12 @@
{/if} {/if}
<div class="text-center text-xs"> <div class="text-center text-xs">
<p>&copy; 2026 Ticket Auction Manager</p> <p>&copy; 2026 Ticket Auction Manager</p>
<p>Created by Dilan Gilluly. Support me on <a href="https://ko-fi.com/techguydilan" class="text-blue-500">Ko-Fi</a>.</p> <p>
Created by Dilan Gilluly. Support me on <a
href="https://ko-fi.com/techguydilan"
class="text-blue-500">Ko-Fi</a
>.
</p>
</div> </div>
</div> </div>
</div> </div>
@@ -1,22 +1,24 @@
import { db } from "$lib/server/db"; import { db } from '$lib/server/db';
import { reportCounts } from "$lib/server/db/schema"; import { reportCounts } from '$lib/server/db/schema';
import { getPath, getSettings } from "$lib/server/settings" import { getPath, getSettings } from '$lib/server/settings';
import { error, json } from "@sveltejs/kit"; import { error, json } from '@sveltejs/kit';
export const GET = async () => { export const GET = async () => {
const s = getSettings(); const s = getSettings();
if (s.remote_server) { if (s.remote_server) {
const connStr = getPath(s); const connStr = getPath(s);
try { try {
const res = await fetch(`${connStr}/api/reports/counts`, {headers: {'TAM-KEY': s.remote_key}}); const res = await fetch(`${connStr}/api/reports/counts`, {
headers: { 'TAM-KEY': s.remote_key }
});
if (!res.ok) throw error(res.status); if (!res.ok) throw error(res.status);
const data = await res.json(); const data = await res.json();
return json(data); return json(data);
} catch { } catch {
return json([]) return json([]);
} }
} else { } else {
const data = await db.select().from(reportCounts); const data = await db.select().from(reportCounts);
return json(data); return json(data);
} }
} };
@@ -0,0 +1,72 @@
import { db } from '$lib/server/db/index.js';
import { tickets } from '$lib/server/db/schema.js';
import { getPath, getSettings } from '$lib/server/settings/index.js';
import { error, json } from '@sveltejs/kit';
import { and, like, sql } from 'drizzle-orm';
const chunk_size = 300;
export const GET = async ({ url }) => {
const sParams = {
first_name: url.searchParams.get('first_name') || '',
last_name: url.searchParams.get('last_name') || '',
phone_number: url.searchParams.get('phone_number') || ''
};
const s = getSettings();
if (s.remote_server) {
const connStr = getPath(s);
const strParams = new URLSearchParams(sParams).toString();
try {
const res = await fetch(`${connStr}/api/search/tickets?${strParams}`, {
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(tickets)
.where(
and(
like(tickets.first_name, `%${sParams.first_name}%`),
like(tickets.last_name, `%${sParams.last_name}%`),
like(tickets.phone_number, `%${sParams.phone_number}%`)
)
)
.orderBy(tickets.prefix, tickets.t_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/search/tickets`, {
method: 'POST',
headers: { 'TAM-KEY': s.remote_key, 'Content-Type': 'application/json' },
body: JSON.stringify(reqData)
});
if (!res.ok) throw error(res.status);
}
for (let i = 0; i < reqData.length; i += chunk_size) {
await db
.insert(tickets)
.values(reqData.slice(i, i + chunk_size))
.onConflictDoUpdate({
target: [tickets.prefix, tickets.t_id],
set: {
first_name: sql`EXCLUDED.first_name`,
last_name: sql`EXCLUDED.last_name`,
phone_number: sql`EXCLUDED.phone_number`,
pref: sql`EXCLUDED.pref`
}
});
}
return json(reqData);
};
@@ -204,7 +204,8 @@
{:else} {:else}
<tr> <tr>
<td class="p-0.5 border text-center" colspan="50"> <td class="p-0.5 border text-center" colspan="50">
No rows loaded. Please use the pager at the top to put in the first, then last number on the sheet, click Go, and that should load in the sheet. No rows loaded. Please use the pager at the top to put in the first, then last number on
the sheet, click Go, and that should load in the sheet.
</td> </td>
</tr> </tr>
{/each} {/each}
@@ -211,7 +211,8 @@
{:else} {:else}
<tr> <tr>
<td class="p-0.5 border text-center" colspan="50"> <td class="p-0.5 border text-center" colspan="50">
No rows loaded. Please use the pager at the top to put in the first, then last number on the sheet, click Go, and that should load in the sheet. No rows loaded. Please use the pager at the top to put in the first, then last number on
the sheet, click Go, and that should load in the sheet.
</td> </td>
</tr> </tr>
{/each} {/each}
@@ -36,8 +36,7 @@
{#each data.prefixes as p (p.prefix)} {#each data.prefixes as p (p.prefix)}
<a <a
href={resolve('/reports/byname/[prefix]', { prefix: p.prefix })} href={resolve('/reports/byname/[prefix]', { prefix: p.prefix })}
class={p.prefix == prefix.prefix ? bAS[p.color] : bS[p.color]} class={p.prefix == prefix.prefix ? bAS[p.color] : bS[p.color]}>{p.prefix}</a
>{p.prefix}</a
> >
{/each} {/each}
</HeaderBar> </HeaderBar>
@@ -85,7 +84,7 @@
</thead> </thead>
<tbody class="text-sm"> <tbody class="text-sm">
{#each reportLines as line, idx (idx)} {#each reportLines as line, idx (idx)}
<tr> <tr class="break-inside-avoid">
<td class="p-0.5 border">{line.b_id}</td> <td class="p-0.5 border">{line.b_id}</td>
<td class="p-0.5 border">{line.description || ''}</td> <td class="p-0.5 border">{line.description || ''}</td>
<td class="p-0.5 border">{line.winning_ticket}</td> <td class="p-0.5 border">{line.winning_ticket}</td>
@@ -36,8 +36,7 @@
{#each data.prefixes as p (p.prefix)} {#each data.prefixes as p (p.prefix)}
<a <a
href={resolve('/reports/byname/[prefix]', { prefix: p.prefix })} href={resolve('/reports/byname/[prefix]', { prefix: p.prefix })}
class={p.prefix == prefix.prefix ? bAS[p.color] : bS[p.color]} class={p.prefix == prefix.prefix ? bAS[p.color] : bS[p.color]}>{p.prefix}</a
>{p.prefix}</a
> >
{/each} {/each}
</HeaderBar> </HeaderBar>
@@ -85,7 +84,7 @@
</thead> </thead>
<tbody class="text-sm"> <tbody class="text-sm">
{#each reportLines as line, idx (idx)} {#each reportLines as line, idx (idx)}
<tr> <tr class="break-inside-avoid">
<td class="p-0.5 border">{line.last_name || ''}, {line.first_name || ''}</td> <td class="p-0.5 border">{line.last_name || ''}, {line.first_name || ''}</td>
<td class="p-0.5 border">{line.phone_number || ''}</td> <td class="p-0.5 border">{line.phone_number || ''}</td>
<td class="p-0.5 border">{line.b_id}</td> <td class="p-0.5 border">{line.b_id}</td>
@@ -2,4 +2,4 @@ export const load = async ({ fetch }) => {
const res = await fetch('/api/prefixes'); const res = await fetch('/api/prefixes');
const prefixes = await res.json(); const prefixes = await res.json();
return { prefixes }; return { prefixes };
} };
+12 -11
View File
@@ -7,24 +7,24 @@
let prefixes = $derived(data.prefixes); let prefixes = $derived(data.prefixes);
let tableData = $state([]); let tableData = $state([]);
let currentTimeout = $state(); let currentTimeout = $state();
let lastRefreshed = $state(""); let lastRefreshed = $state('');
let interval = $state("0"); let interval = $state('0');
const loadCounts = async () => { const loadCounts = async () => {
const rtnData = {}; const rtnData = {};
const res = await fetch('/api/reports/counts'); const res = await fetch('/api/reports/counts');
if (res.ok) { if (res.ok) {
prefixes.forEach(p => rtnData[p.prefix] = {...p}); prefixes.forEach((p) => (rtnData[p.prefix] = { ...p }));
const resData = await res.json(); const resData = await res.json();
resData.forEach(c => rtnData[c.prefix] = {...rtnData[c.prefix], ...c}); resData.forEach((c) => (rtnData[c.prefix] = { ...rtnData[c.prefix], ...c }));
tableData = [...Object.values(rtnData)]; tableData = [...Object.values(rtnData)];
const now = new Date(); const now = new Date();
lastRefreshed = now.toLocaleString(); lastRefreshed = now.toLocaleString();
clearTimeout(currentTimeout) clearTimeout(currentTimeout);
if (interval > 0) { if (interval > 0) {
currentTimeout = setTimeout(loadCounts, interval); currentTimeout = setTimeout(loadCounts, interval);
}; }
}; }
}; };
const pageTitle = 'Ticket Counts | TAM'; const pageTitle = 'Ticket Counts | TAM';
@@ -39,8 +39,7 @@
</svelte:head> </svelte:head>
<div id="app-container" class="p-1"> <div id="app-container" class="p-1">
<HeaderBar> <HeaderBar></HeaderBar>
</HeaderBar>
<h1 class="text-xl font-bold">{pageTitle}</h1> <h1 class="text-xl font-bold">{pageTitle}</h1>
<table class="border-separate box-border w-full"> <table class="border-separate box-border w-full">
<thead> <thead>
@@ -52,7 +51,7 @@
</thead> </thead>
<tbody> <tbody>
{#each tableData as line (line.prefix)} {#each tableData as line (line.prefix)}
<tr class={tS[line.color] || ""}> <tr class={tS[line.color] || ''}>
<td class="border p-0.5">{line.prefix}</td> <td class="border p-0.5">{line.prefix}</td>
<td class="border p-0.5">{line.unique_buyers || 0}</td> <td class="border p-0.5">{line.unique_buyers || 0}</td>
<td class="border p-0.5">{line.total_buys || 0}</td> <td class="border p-0.5">{line.total_buys || 0}</td>
@@ -67,7 +66,9 @@
<option value="60000">1 Min</option> <option value="60000">1 Min</option>
<option value="120000">2 Min</option> <option value="120000">2 Min</option>
</select> </select>
<button class={bS.gray} onclick={() => loadCounts()}>Refresh{interval > 0 ? ` Every ${interval / 60000} Min` : ''}</button> <button class={bS.gray} onclick={() => loadCounts()}
>Refresh{interval > 0 ? ` Every ${interval / 60000} Min` : ''}</button
>
<div>Last refreshed: {lastRefreshed}</div> <div>Last refreshed: {lastRefreshed}</div>
</div> </div>
</div> </div>
@@ -0,0 +1,10 @@
export const load = async ({ fetch }) => {
const prefix = {
prefix: '',
color: 'gray',
weight: 0
};
const res = await fetch('/api/prefixes');
const prefixes = await res.json();
return { prefix, prefixes };
};
@@ -0,0 +1,233 @@
<script>
import { browser } from '$app/environment';
import { bS, iS, rBS, tS } from '$lib/client/styles';
import HeaderBar from '$lib/client/components/HeaderBar.svelte';
import CommandBar from '$lib/client/components/CommandBar.svelte';
import TicketSearchBar from '$lib/client/components/TicketSearchBar.svelte';
let { data } = $props();
let { prefix, prefixes } = $derived(data);
let pageTitle = 'Ticket Search | TAM';
let curIdx = $state(0),
nextIdx = $derived(curIdx + 1),
prevIdx = $derived(curIdx - 1);
const changeIdx = (idx) => {
curIdx = idx;
};
const focusIdx = (idx) => {
curIdx = idx;
const elemIdx = document.getElementById(`${idx}_first`);
if (elemIdx) {
elemIdx.select();
}
};
let colorMap = $derived.by(() => {
const mapData = {};
[...prefixes].forEach((p) => (mapData[p.prefix] = p.color));
return mapData;
});
let searchForm = $state({ first_name: '', last_name: '', phone_number: '' });
let items = $state([]);
let itemsBuffer = $derived(items.filter((i) => i.changed));
const functions = {
search: async () => {
const searchParams = new URLSearchParams({ ...searchForm });
const res = await fetch(`/api/search/tickets?${searchParams.toString()}`);
if (res.ok) {
const resData = await res.json();
items = [...resData];
setTimeout(() => focusIdx(0), 1);
}
},
save: async () => {
if (itemsBuffer.length > 0) {
const res = await fetch('/api/search/tickets', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(itemsBuffer)
});
if (res.ok) {
itemsBuffer.forEach((i) => (i.changed = false));
} else {
alert('Error saving items.');
}
}
setTimeout(() => {
focusIdx(0);
}, 1);
},
nextLine: () => {
if (items[nextIdx]) {
setTimeout(() => {
focusIdx(nextIdx);
}, 1);
} else {
setTimeout(() => {
focusIdx(curIdx);
}, 1);
}
},
prevLine: () => {
if (curIdx > 0) {
setTimeout(() => {
focusIdx(prevIdx);
}, 1);
} else {
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 };
functions.nextLine();
} else {
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 };
functions.prevLine();
} else {
focusIdx(curIdx);
}
},
copy: () => {
if (items[curIdx]) {
const buffer = { ...items[curIdx] };
['prefix', 't_id'].forEach((key) => delete buffer[key]);
window.localStorage.setItem('tam-ticket', JSON.stringify(buffer));
}
setTimeout(() => focusIdx(curIdx), 1);
},
paste: () => {
if (items[curIdx]) {
const buffer = JSON.parse(window.localStorage.getItem('tam-ticket'));
items[curIdx] = { ...items[curIdx], ...buffer, changed: true };
}
setTimeout(() => focusIdx(curIdx), 1);
}
};
const headers = [
'Prefix',
'Ticket ID',
'First Name',
'Last Name',
'Phone Number',
'Pref',
'Save?'
];
if (browser) {
window.addEventListener('beforeunload', (e) => {
if (itemsBuffer.length > 0) e.preventDefault();
});
}
</script>
<svelte:head>
<title>{pageTitle}</title>
</svelte:head>
<table class="w-full box-border border-separate p-1">
<thead class="sticky top-1 bg-white">
<tr>
<td colspan="50">
<HeaderBar></HeaderBar>
<h1 class="text-xl font-bold p-1">{pageTitle}</h1>
<TicketSearchBar {prefix} {functions} bind:searchForm />
<CommandBar {prefix} {functions} /></td
>
</tr>
<tr>
{#each headers as header (header)}
<th class="border text-left p-0.5">{header}</th>
{/each}
</tr>
</thead>
<tbody>
{#each items as item, idx (item.t_id)}
<tr
class="{tS[colorMap[item.prefix]]} focus-within:font-bold {rBS[prefix.color]}"
onfocusin={(e) => {
changeIdx(idx);
e.target.scrollIntoView({ block: 'center' });
}}
>
<td class="p-0.5 border">{item.prefix}</td>
<td class="p-0.5 border">{item.t_id}</td>
<td class="p-0.5 border"
><input
type="text"
class="{iS.normal} w-full"
id="{idx}_first"
onchangecapture={() => (item.changed = true)}
bind:value={item.first_name}
/></td
>
<td class="p-0.5 border"
><input
type="text"
class="{iS.normal} w-full"
id="{idx}_second"
onchangecapture={() => (item.changed = true)}
bind:value={item.last_name}
/></td
>
<td class="p-0.5 border"
><input
type="text"
class="{iS.normal} w-full"
id="{idx}_third"
onchangecapture={() => (item.changed = true)}
bind:value={item.phone_number}
/></td
>
<td class="p-0.5 border"
><button
class={bS[prefix.color]}
onclick={() => {
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}</button
></td
>
<td class="p-0.5 border"
><button
class={bS[prefix.color]}
tabindex="-1"
onclick={() => {
item.changed ? (item.changed = false) : (item.changed = true);
}}>{item.changed ? 'Yes' : 'No'}</button
></td
>
</tr>
{:else}
<tr>
<td class="p-0.5 border text-center" colspan="50">
No rows loaded. Please use the pager at the top to put in the first, then last number on
the sheet, click Go, and that should load in the sheet.
</td>
</tr>
{/each}
</tbody>
</table>
+16 -5
View File
@@ -35,18 +35,24 @@
<h2 class="text-lg font-bold">Remote Mode:</h2> <h2 class="text-lg font-bold">Remote Mode:</h2>
<div class="flex flex-row gap-1 items-center"> <div class="flex flex-row gap-1 items-center">
<div>Remote Server:</div> <div>Remote Server:</div>
<input type="text" class={iS.normal} bind:value={settings.remote_server} /> <input type="text" id="remote_server" class={iS.normal} bind:value={settings.remote_server} />
</div> </div>
<div class="flex flex-row gap-1 items-center"> <div class="flex flex-row gap-1 items-center">
<div>Remote Port:</div> <div>Remote Port:</div>
<input type="text" class={iS.normal} bind:value={settings.remote_port} /> <input type="text" id="remote_port" class={iS.normal} bind:value={settings.remote_port} />
</div> </div>
<div class="flex flex-row gap-1 items-center"> <div class="flex flex-row gap-1 items-center">
<div>Remote TLS:</div> <div>Remote TLS:</div>
<button <button
class={bS.gray} class={bS.gray}
onclick={() => { onclick={() => {
settings.remote_tls = !settings.remote_tls; if (settings.remote_tls) {
settings.remote_tls = false;
settings.remote_port = '8000';
} else if (!settings.remote_tls) {
settings.remote_tls = true;
settings.remote_port = '8443';
}
}}>{settings.remote_tls ? 'Yes' : 'No'}</button }}>{settings.remote_tls ? 'Yes' : 'No'}</button
> >
</div> </div>
@@ -64,7 +70,7 @@
</div> </div>
<div class="flex flex-row gap-1 items-center"> <div class="flex flex-row gap-1 items-center">
<div>Venue Name:</div> <div>Venue Name:</div>
<input type="text" class={iS.normal} bind:value={settings.venue_name} /> <input type="text" id="venue_name" class={iS.normal} bind:value={settings.venue_name} />
</div> </div>
<div class="flex flex-row gap-1 items-center"> <div class="flex flex-row gap-1 items-center">
<button <button
@@ -87,7 +93,12 @@
} }
}}>Save</button }}>Save</button
> >
<button class={bS.gray}>Cancel</button> <button
class={bS.gray}
onclick={() => {
settings = { ...data.settings };
}}>Cancel</button
>
</div> </div>
<div> <div>
<p class={tS[status.color]}>{status.message}</p> <p class={tS[status.color]}>{status.message}</p>
@@ -14,6 +14,21 @@
let authKeys = $state([]); let authKeys = $state([]);
let newDesc = $state(''); let newDesc = $state('');
let curKey = $state(''); let curKey = $state('');
async function getKeys() {
const res = await fetch('/api/auth', {
headers: { 'TAM-PWD': auth.pwd }
});
if (res.ok) {
auth.toggle = true;
const resData = await res.json();
authKeys = [...resData];
curKey = data.authKey;
} else {
auth.pwd = 'Invalid Password!';
setTimeout(() => (auth.pwd = ''), 3000);
}
}
</script> </script>
<svelte:head> <svelte:head>
@@ -30,24 +45,8 @@
{#if !auth.toggle} {#if !auth.toggle}
<div class="flex flex-row gap-1 items-center py-1"> <div class="flex flex-row gap-1 items-center py-1">
<div>Password:</div> <div>Password:</div>
<input type="password" class={iS.normal} bind:value={auth.pwd} /> <input type="password" id="pwd_input" class={iS.normal} bind:value={auth.pwd} />
<button <button class={bS.gray} onclick={getKeys}>Login</button>
class={bS.gray}
onclick={async () => {
const res = await fetch('/api/auth', {
headers: { 'TAM-PWD': auth.pwd }
});
if (res.ok) {
auth.toggle = true;
const resData = await res.json();
authKeys = [...resData];
curKey = data.authKey;
} else {
auth.pwd = 'Invalid Password!';
setTimeout(() => (auth.pwd = ''), 3000);
}
}}>Login</button
>
</div> </div>
{:else} {:else}
<table class="w-full my-1"> <table class="w-full my-1">
@@ -103,7 +102,9 @@
{/each} {/each}
<tr> <tr>
<td>New</td> <td>New</td>
<td><input type="text" class="{iS.normal} w-full" bind:value={newDesc} /></td> <td
><input type="text" id="new_desc" class="{iS.normal} w-full" bind:value={newDesc} /></td
>
<td> <td>
<button <button
class={bS.gray} class={bS.gray}
@@ -116,8 +117,7 @@
}); });
if (res.ok) { if (res.ok) {
newDesc = ''; newDesc = '';
const data = await res.json(); getKeys();
authKeys = [...authKeys, data];
} }
} }
}}>New Key</button }}>New Key</button
+80
View File
@@ -0,0 +1,80 @@
<script>
import { bS, iS } from '$lib/client/styles';
import HeaderBar from '$lib/client/components/HeaderBar.svelte';
const pageTitle = 'Print Ticket Sheets | TAM';
let inputs = $state({ start: 0, end: 0, perPage: 20, ticketType: '' });
let items = $state([]);
function runSheet() {
items = [];
for (let i = inputs.start; i <= inputs.end; i++) items = [...items, i];
}
function resetSheet() {
items = [];
}
</script>
<svelte:head>
<title>{pageTitle}</title>
</svelte:head>
<table class="p-1 border-separate box-border w-full text-left">
<thead class="sticky top-0">
<tr class="print:hidden">
<td colspan="50">
<HeaderBar>
<div>{pageTitle}</div>
</HeaderBar>
</td>
</tr>
<tr class="print:hidden">
<td colspan="50">
<div class="flex flex-row gap-1 py-1 items-center">
<div>Range:</div>
<input type="number" id="range_start" bind:value={inputs.start} class={iS.normal} />
<div class="px-2">-</div>
<input type="number" id="range_end" bind:value={inputs.end} class={iS.normal} />
<div>Per Page:</div>
<input type="number" id="per_page" bind:value={inputs.perPage} class={iS.normal} />
<div>Ticket Type:</div>
<input type="text" id="ticket_type" bind:value={inputs.ticketType} class={iS.normal} />
<button class={bS.gray} onclick={runSheet}>Create Sheets</button>
<button class={bS.gray} onclick={resetSheet}>Reset Sheet</button>
</div>
</td>
</tr>
{#if inputs.ticketType}
<tr>
<td colspan="50"><h2 class="font-bold">{inputs.ticketType} Ticket Sales</h2></td>
</tr>
{/if}
<tr>
<th class="p-0.5 border" style="width: 15%">Ticket #</th>
<th class="p-0.5 border" style="width: 40%">Name</th>
<th class="p-0.5 border" style="width: 35%">Phone Number</th>
<th class="p-0.5 border" style="width: 10%">Text?</th>
</tr>
</thead>
<tbody>
{#each items as i, idx (i)}
<tr
class="h-10 break-inside-avoid{(idx + 1) % inputs.perPage === 0 ? ' break-after-page' : ''}"
>
<td class="text-lg font-bold p-0.5 border">{i}</td>
<td class="border"></td>
<td class="border"></td>
<td class="border"></td>
</tr>
{:else}
<tr>
<td colspan="50" class="p-0.5 border"
>No rows created. Please use the form above to create rows.</td
>
</tr>
{/each}
</tbody>
</table>
@@ -149,8 +149,7 @@
{#each prefixes as p (p.prefix)} {#each prefixes as p (p.prefix)}
<a <a
href={resolve('/tickets/[prefix]', { prefix: p.prefix })} href={resolve('/tickets/[prefix]', { prefix: p.prefix })}
class={prefix.prefix == p.prefix ? bAS[p.color] : bS[p.color]} class={prefix.prefix == p.prefix ? bAS[p.color] : bS[p.color]}>{p.prefix}</a
>{p.prefix}</a
> >
{/each} {/each}
</HeaderBar> </HeaderBar>
@@ -233,7 +232,8 @@
{:else} {:else}
<tr> <tr>
<td class="p-0.5 border text-center" colspan="50"> <td class="p-0.5 border text-center" colspan="50">
No rows loaded. Please use the pager at the top to put in the first, then last number on the sheet, click Go, and that should load in the sheet. No rows loaded. Please use the pager at the top to put in the first, then last number on
the sheet, click Go, and that should load in the sheet.
</td> </td>
</tr> </tr>
{/each} {/each}