diff --git a/api/App/Endpoints/LokEndpoints.cs b/api/App/Endpoints/LokEndpoints.cs index c2827f4..f7540f7 100644 --- a/api/App/Endpoints/LokEndpoints.cs +++ b/api/App/Endpoints/LokEndpoints.cs @@ -2,27 +2,74 @@ public static class LokEndpoints { public static void MapLokEndpoints(WebApplication app) { - app.MapPost("/lok/open-hours", async (LokOpenHours openHours, LokService lokService) => + app.MapPost("/lok/open-hours", async (HttpContext httpContext) => { + var lokService = httpContext.RequestServices.GetRequiredService(); + var openHours = await httpContext.Request.ReadFromJsonAsync(); + + if (openHours is null) + { + httpContext.Response.StatusCode = StatusCodes.Status400BadRequest; + await httpContext.Response.WriteAsJsonAsync(new + { + Message = "Request body is required." + }); + return; + } + + if (string.IsNullOrWhiteSpace(openHours.Name)) + { + httpContext.Response.StatusCode = StatusCodes.Status400BadRequest; + await httpContext.Response.WriteAsJsonAsync(new + { + Message = "Open hours version name is required." + }); + return; + } + var createdOpenHours = await lokService.InsertOpenHours(openHours); - return Results.Created("/lok/open-hours", createdOpenHours); + httpContext.Response.StatusCode = StatusCodes.Status201Created; + httpContext.Response.Headers.Location = "/lok/open-hours"; + await httpContext.Response.WriteAsJsonAsync(createdOpenHours); }) .WithName("CreateLokOpenHours"); - app.MapGet("/lok/open-hours", async (LokService lokService) => + app.MapGet("/lok/open-hours", async (HttpContext httpContext) => { + var lokService = httpContext.RequestServices.GetRequiredService(); var openHours = await lokService.GetOpenHours(); if (openHours.Count == 0) { - return Results.NotFound(new + httpContext.Response.StatusCode = StatusCodes.Status404NotFound; + await httpContext.Response.WriteAsJsonAsync(new { Message = "Open hours not found." }); + return; } - return Results.Ok(openHours); + await httpContext.Response.WriteAsJsonAsync(openHours); }) .WithName("GetLokOpenHours"); + + app.MapDelete("/lok/open-hours/{id:long}", async (HttpContext httpContext, long id) => + { + var lokService = httpContext.RequestServices.GetRequiredService(); + var deleted = await lokService.DeleteOpenHours(id); + + if (!deleted) + { + httpContext.Response.StatusCode = StatusCodes.Status404NotFound; + await httpContext.Response.WriteAsJsonAsync(new + { + Message = "Open hours version not found." + }); + return; + } + + httpContext.Response.StatusCode = StatusCodes.Status204NoContent; + }) + .WithName("DeleteLokOpenHours"); } } \ No newline at end of file diff --git a/api/App/Endpoints/SystemEndpoints.cs b/api/App/Endpoints/SystemEndpoints.cs index d0cf668..64b9042 100644 --- a/api/App/Endpoints/SystemEndpoints.cs +++ b/api/App/Endpoints/SystemEndpoints.cs @@ -11,18 +11,18 @@ public static class SystemEndpoints }) .WithName("GetVersion"); - app.MapGet("/health/db", async (SqliteConnection connection) => + app.MapGet("/health/db", async (Microsoft.Data.Sqlite.SqliteConnection connection) => { await connection.OpenAsync(); await using var command = connection.CreateCommand(); command.CommandText = "SELECT 1"; var result = await command.ExecuteScalarAsync(); - return Results.Ok(new + return new { Database = "ok", Result = result - }); + }; }) .WithName("GetDatabaseHealth"); } diff --git a/api/App/Models/LokOpenHours.cs b/api/App/Models/LokOpenHours.cs index 86d452c..6500981 100644 --- a/api/App/Models/LokOpenHours.cs +++ b/api/App/Models/LokOpenHours.cs @@ -1,5 +1,9 @@ public class LokOpenHours { + public long Id { get; set; } + + public string Name { get; set; } = string.Empty; + public DateTime Version { get; set; } public string Paragraph1 { get; set; } = string.Empty; diff --git a/api/App/Program.cs b/api/App/Program.cs index 041b8c6..7acd47b 100644 --- a/api/App/Program.cs +++ b/api/App/Program.cs @@ -55,6 +55,15 @@ public class Program command.CommandText = "ALTER TABLE LokOpenHours ADD COLUMN version TEXT NOT NULL DEFAULT '';"; command.ExecuteNonQuery(); } + + command.CommandText = "SELECT COUNT(*) FROM pragma_table_info('LokOpenHours') WHERE name = 'name';"; + var hasNameColumn = Convert.ToInt32(command.ExecuteScalar()) > 0; + + if (!hasNameColumn) + { + command.CommandText = "ALTER TABLE LokOpenHours ADD COLUMN name TEXT NOT NULL DEFAULT '';"; + command.ExecuteNonQuery(); + } } } diff --git a/api/App/Services/LokService.cs b/api/App/Services/LokService.cs index 479e6be..de5fb7a 100644 --- a/api/App/Services/LokService.cs +++ b/api/App/Services/LokService.cs @@ -19,7 +19,7 @@ public class LokService await using var command = _connection.CreateCommand(); command.CommandText = @" - SELECT version, paragraph1, paragraph2, paragraph3, paragraph4, kitchenNotice + SELECT id, name, version, paragraph1, paragraph2, paragraph3, paragraph4, kitchenNotice FROM LokOpenHours ORDER BY datetime(version) DESC, id DESC LIMIT 5"; @@ -32,6 +32,8 @@ public class LokService { openHoursList.Add(new LokOpenHours { + Id = reader["id"] is long id ? id : Convert.ToInt64(reader["id"]), + Name = reader["name"]?.ToString() ?? string.Empty, Version = ParseVersion(reader["version"]?.ToString()), Paragraph1 = reader["paragraph1"]?.ToString() ?? string.Empty, Paragraph2 = reader["paragraph2"]?.ToString() ?? string.Empty, @@ -55,9 +57,11 @@ public class LokService await using var command = _connection.CreateCommand(); command.CommandText = @" - INSERT INTO LokOpenHours (version, paragraph1, paragraph2, paragraph3, paragraph4, kitchenNotice) - VALUES (@version, @paragraph1, @paragraph2, @paragraph3, @paragraph4, @kitchenNotice);"; + INSERT INTO LokOpenHours (name, version, paragraph1, paragraph2, paragraph3, paragraph4, kitchenNotice) + VALUES (@name, @version, @paragraph1, @paragraph2, @paragraph3, @paragraph4, @kitchenNotice); + SELECT last_insert_rowid();"; + command.Parameters.AddWithValue("@name", openHours.Name ?? string.Empty); command.Parameters.AddWithValue("@version", version.ToString("O")); command.Parameters.AddWithValue("@paragraph1", openHours.Paragraph1 ?? string.Empty); command.Parameters.AddWithValue("@paragraph2", openHours.Paragraph2 ?? string.Empty); @@ -65,10 +69,12 @@ public class LokService command.Parameters.AddWithValue("@paragraph4", openHours.Paragraph4 ?? string.Empty); command.Parameters.AddWithValue("@kitchenNotice", openHours.KitchenNotice ?? string.Empty); - await command.ExecuteNonQueryAsync(); + var insertedId = await command.ExecuteScalarAsync(); return new LokOpenHours { + Id = Convert.ToInt64(insertedId), + Name = openHours.Name ?? string.Empty, Version = version, Paragraph1 = openHours.Paragraph1 ?? string.Empty, Paragraph2 = openHours.Paragraph2 ?? string.Empty, @@ -78,6 +84,24 @@ public class LokService }; } + public async Task DeleteOpenHours(long id) + { + if (_connection.State != ConnectionState.Open) + { + await _connection.OpenAsync(); + } + + await using var command = _connection.CreateCommand(); + command.CommandText = @" + DELETE FROM LokOpenHours + WHERE id = @id;"; + + command.Parameters.AddWithValue("@id", id); + + var affectedRows = await command.ExecuteNonQueryAsync(); + return affectedRows > 0; + } + private static DateTime ParseVersion(string? value) { if (!string.IsNullOrWhiteSpace(value) && DateTime.TryParse(value, out var parsed)) diff --git a/api/Database/init.sql b/api/Database/init.sql index cc65312..2262586 100644 --- a/api/Database/init.sql +++ b/api/Database/init.sql @@ -1,5 +1,6 @@ CREATE TABLE IF NOT EXISTS LokOpenHours ( id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL DEFAULT '', version TEXT NOT NULL, paragraph1 TEXT NOT NULL DEFAULT '', paragraph2 TEXT NOT NULL DEFAULT '', diff --git a/api/Database/klapi.db b/api/Database/klapi.db index 2a6faa7..6f65c6e 100644 Binary files a/api/Database/klapi.db and b/api/Database/klapi.db differ diff --git a/ui/src/api/index.ts b/ui/src/api/index.ts index 0a2a3a8..6094e51 100644 --- a/ui/src/api/index.ts +++ b/ui/src/api/index.ts @@ -1,4 +1,4 @@ -import { query } from "@solidjs/router"; +import { action, query } from "@solidjs/router"; const API_BASE_URL = process.env.API_BASE_URL ?? "http://localhost:5013"; @@ -19,6 +19,10 @@ async function fetchApi(path: string, init?: RequestInit): Promise { throw new Error(`API ${response.status}: ${text || response.statusText}`); } + if (response.status === 204) { + return undefined as T; + } + return (await response.json()) as T; } @@ -27,3 +31,60 @@ export const queryApiVersion = query(async () => { const data = await fetchApi<{ version: string }>("/"); return data.version; }, "api-version"); + +export type LokOpenHours = { + id: number; + name: string; + version: string; + paragraph1: string; + paragraph2: string; + paragraph3: string; + paragraph4: string; + kitchenNotice: string; +}; + +export const queryLokOpenHours = query(async (_refreshKey = 0) => { + "use server"; + return await fetchApi("/lok/open-hours"); +}, "lok-open-hours"); + +export const createLokOpenHours = action(async (formData: FormData) => { + "use server"; + const name = String(formData.get("name") ?? "").trim(); + + if (!name) { + throw new Error("Open hours version name is required."); + } + + const payload = { + id: 0, + name, + version: new Date().toISOString(), + paragraph1: String(formData.get("paragraph1") ?? ""), + paragraph2: String(formData.get("paragraph2") ?? ""), + paragraph3: String(formData.get("paragraph3") ?? ""), + paragraph4: String(formData.get("paragraph4") ?? ""), + kitchenNotice: String(formData.get("kitchenNotice") ?? ""), + } satisfies LokOpenHours; + + return await fetchApi("/lok/open-hours", { + method: "POST", + body: JSON.stringify(payload), + }); +}); + +export const deleteLokOpenHours = action(async (formData: FormData) => { + "use server"; + const idValue = String(formData.get("id") ?? "").trim(); + const id = Number(idValue); + + if (!Number.isFinite(id) || id <= 0) { + throw new Error("Open hours id is required for delete."); + } + + await fetchApi(`/lok/open-hours/${id}`, { + method: "DELETE", + }); + + return { deleted: true }; +}); diff --git a/ui/src/components/OpenHoursForm.tsx b/ui/src/components/OpenHoursForm.tsx new file mode 100644 index 0000000..c0ce1c3 --- /dev/null +++ b/ui/src/components/OpenHoursForm.tsx @@ -0,0 +1,228 @@ +import { createAsync, useSubmission } from "@solidjs/router"; +import { For, Show, createEffect, createMemo, createSignal } from "solid-js"; +import { createLokOpenHours, deleteLokOpenHours, queryLokOpenHours } from "~/api"; +import { t } from "~/i18n"; + +const NEW_VERSION_OPTION = "__new__"; + +export default function OpenHoursForm() { + const [refreshKey, setRefreshKey] = createSignal(0); + const openHours = createAsync(() => queryLokOpenHours(refreshKey()).catch(() => [])); + const createSubmission = useSubmission(createLokOpenHours); + const deleteSubmission = useSubmission(deleteLokOpenHours); + const [selectedVersion, setSelectedVersion] = createSignal(""); + const [name, setName] = createSignal(""); + const [paragraph1, setParagraph1] = createSignal(""); + const [paragraph2, setParagraph2] = createSignal(""); + const [paragraph3, setParagraph3] = createSignal(""); + const [paragraph4, setParagraph4] = createSignal(""); + const [kitchenNotice, setKitchenNotice] = createSignal(""); + + const latestFive = createMemo(() => openHours() ?? []); + + const selectedOpenHours = createMemo(() => + latestFive().find(version => String(version.id) === selectedVersion()) + ); + + createEffect(() => { + if (!createSubmission.result) return; + setRefreshKey(previous => previous + 1); + setSelectedVersion(""); + }); + + createEffect(() => { + if (!deleteSubmission.result) return; + setRefreshKey(previous => previous + 1); + setSelectedVersion(""); + }); + + createEffect(() => { + const versions = latestFive(); + const current = selectedVersion(); + + if (versions.length === 0) { + if (current !== NEW_VERSION_OPTION) { + setSelectedVersion(NEW_VERSION_OPTION); + } + return; + } + + if (current === NEW_VERSION_OPTION) { + return; + } + + const hasCurrent = versions.some(version => String(version.id) === current); + if (!current || !hasCurrent) { + setSelectedVersion(String(versions[0].id)); + } + }); + + const reuseSelected = () => { + const selected = selectedOpenHours(); + if (!selected) { + setName(""); + setParagraph1(""); + setParagraph2(""); + setParagraph3(""); + setParagraph4(""); + setKitchenNotice(""); + return; + } + + setName(selected.name); + setParagraph1(selected.paragraph1); + setParagraph2(selected.paragraph2); + setParagraph3(selected.paragraph3); + setParagraph4(selected.paragraph4); + setKitchenNotice(selected.kitchenNotice); + }; + + createEffect(() => { + reuseSelected(); + }); + + return ( +
+

{t("home.openHours.heading")}

+ + 0} fallback={

{t("home.openHours.empty")}

}> +
+ +
+ +
+ + +
+
+ +
+
+ + setName(event.currentTarget.value)} + class="mt-1 w-full rounded-md border border-[#C99763] bg-[#FFF7EE] px-3 py-2 text-[#4C250E] focus:outline-none focus:ring-2 focus:ring-[#A56C38]" + /> + +

{t("home.openHours.nameRequired")}

+
+
+ +
+ +