mirror of
https://github.com/Djeeberjr/fw-anwesenheit.git
synced 2026-04-30 18:49:09 +00:00
Compare commits
3 Commits
bd3f6731fd
...
7346b47816
| Author | SHA1 | Date | |
|---|---|---|---|
| 7346b47816 | |||
| cd63dd3ee4 | |||
| f5d4ae1e05 |
99
web/mock/data.json
Normal file
99
web/mock/data.json
Normal file
@@ -0,0 +1,99 @@
|
||||
{
|
||||
"mapping": [
|
||||
[
|
||||
"123456789ABC",
|
||||
{
|
||||
"first": "Feuerwehrman",
|
||||
"last": "Sam"
|
||||
}
|
||||
],
|
||||
[
|
||||
"A1B2C3D4E5F6",
|
||||
{
|
||||
"first": "Luna",
|
||||
"last": "Starforge"
|
||||
}
|
||||
],
|
||||
[
|
||||
"0F1E2D3C4B5A",
|
||||
{
|
||||
"first": "Gareth",
|
||||
"last": "Ironwill"
|
||||
}
|
||||
],
|
||||
[
|
||||
"ABCDEF123456",
|
||||
{
|
||||
"first": "Nina",
|
||||
"last": "Skylark"
|
||||
}
|
||||
],
|
||||
[
|
||||
"654321FEDCBA",
|
||||
{
|
||||
"first": "Tobias",
|
||||
"last": "Marrow"
|
||||
}
|
||||
],
|
||||
[
|
||||
"DEADBEEFCAFE",
|
||||
{
|
||||
"first": "Astra",
|
||||
"last": "Vale"
|
||||
}
|
||||
],
|
||||
[
|
||||
"BADA55C0FFEE",
|
||||
{
|
||||
"first": "Rowan",
|
||||
"last": "Tempest"
|
||||
}
|
||||
],
|
||||
[
|
||||
"C001D00D1337",
|
||||
{
|
||||
"first": "Juniper",
|
||||
"last": "Voss"
|
||||
}
|
||||
]
|
||||
],
|
||||
"days": [
|
||||
{
|
||||
"date": 20372,
|
||||
"ids": [
|
||||
"123456789ABC",
|
||||
"A1B2C3D4E5F6"
|
||||
]
|
||||
},
|
||||
{
|
||||
"date": 20373,
|
||||
"ids": [
|
||||
"0F1E2D3C4B5A",
|
||||
"ABCDEF123456"
|
||||
]
|
||||
},
|
||||
{
|
||||
"date": 20374,
|
||||
"ids": [
|
||||
"654321FEDCBA",
|
||||
"DEADBEEFCAFE",
|
||||
"BADA55C0FFEE"
|
||||
]
|
||||
},
|
||||
{
|
||||
"date": 20375,
|
||||
"ids": [
|
||||
"C001D00D1337",
|
||||
"A1B2C3D4E5F6",
|
||||
"123456789ABC"
|
||||
]
|
||||
},
|
||||
{
|
||||
"date": 20376,
|
||||
"ids": [
|
||||
"N0T3X1ST1D0",
|
||||
"654321FEDCBA"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,20 +1,14 @@
|
||||
import express from "express";
|
||||
import bodyParser from "body-parser";
|
||||
|
||||
import mockData from "./data.json" with {type: "json"};
|
||||
|
||||
const app = express();
|
||||
const port = 3000;
|
||||
|
||||
app.use(bodyParser.json());
|
||||
const SECS_IN_DAY = 86_400;
|
||||
|
||||
let mappings = [
|
||||
[
|
||||
"123456789ABC",
|
||||
{
|
||||
first: "Feuerwehrman",
|
||||
last: "Sam",
|
||||
},
|
||||
],
|
||||
];
|
||||
app.use(bodyParser.json());
|
||||
|
||||
function generateRandomId() {
|
||||
const chars = "ABCDEF0123456789";
|
||||
@@ -27,7 +21,7 @@ function generateRandomId() {
|
||||
|
||||
// GET /api/mapping
|
||||
app.get("/api/mapping", (req, res) => {
|
||||
res.json(mappings);
|
||||
res.json(mockData.mapping);
|
||||
});
|
||||
|
||||
// POST /api/mapping
|
||||
@@ -45,11 +39,45 @@ app.post("/api/mapping", (req, res) => {
|
||||
}
|
||||
|
||||
// Add new mapping
|
||||
mappings.push([id, name]);
|
||||
mockData.mappings.push([id, name]);
|
||||
|
||||
res.status(201).send("");
|
||||
});
|
||||
|
||||
app.get("/api/day", (req, res) => {
|
||||
let day;
|
||||
|
||||
if (req.query.day) {
|
||||
day = parseInt(req.query.day, 10);
|
||||
}else if (req.query.timestamp) {
|
||||
let ts = parseInt(req.query.timestamp, 10);
|
||||
day = ts / SECS_IN_DAY;
|
||||
}else {
|
||||
return res.status(400).json({ error: "Missing or invalid 'day' parameter" });
|
||||
}
|
||||
|
||||
if (isNaN(day)) {
|
||||
return res.status(400).json({ error: "Missing or invalid 'day' parameter" });
|
||||
}
|
||||
|
||||
let foundDay = mockData.days.find(e => e.date == day);
|
||||
|
||||
if (!foundDay) {
|
||||
return res.status(404).send("Not found");
|
||||
}
|
||||
|
||||
res.status(200).json(foundDay);
|
||||
});
|
||||
|
||||
app.get("/api/days", (req,res) => {
|
||||
|
||||
let qFrom = parseInt(req.query.from) / SECS_IN_DAY;
|
||||
let qTo = parseInt(req.query.to) / SECS_IN_DAY;
|
||||
|
||||
let days = mockData.days.filter(e => e.date >= qFrom && e.date <= qTo).map(e => e.date);
|
||||
|
||||
res.status(200).json(days);
|
||||
});
|
||||
|
||||
// SSE route: /api/idevent
|
||||
app.get("/api/idevent", (req, res) => {
|
||||
|
||||
31
web/src/lib/Day.ts
Normal file
31
web/src/lib/Day.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
export type Day = number;
|
||||
|
||||
export interface AttendanceDay {
|
||||
date: Day,
|
||||
ids: string[],
|
||||
}
|
||||
|
||||
export function dayToDate(day: Day): Date {
|
||||
const SEC_PER_DAY = 86_400;
|
||||
|
||||
return new Date(day * SEC_PER_DAY * 1000);
|
||||
}
|
||||
|
||||
export async function fetchDay(day: Day): Promise<AttendanceDay> {
|
||||
let res = await fetch("/api/day?" + (new URLSearchParams({ day: day.toString() }).toString()));
|
||||
|
||||
let json = await res.json();
|
||||
|
||||
return json;
|
||||
}
|
||||
|
||||
export async function fetchDays(from: Date, to: Date): Promise<Day[]> {
|
||||
let q = new URLSearchParams({ from: (from.getTime() / 1000).toString(), to: (to.getTime() / 1000).toString() });
|
||||
|
||||
let res = await fetch("/api/days?" + q);
|
||||
|
||||
let json = await res.json();
|
||||
|
||||
return json;
|
||||
}
|
||||
|
||||
70
web/src/lib/ExportModal.svelte
Normal file
70
web/src/lib/ExportModal.svelte
Normal file
@@ -0,0 +1,70 @@
|
||||
<script lang="ts">
|
||||
import Modal from "./Modal.svelte";
|
||||
|
||||
let { onSubmitted }: { onSubmitted?: (from: Date, to: Date) => void } = $props();
|
||||
|
||||
let modal: Modal;
|
||||
|
||||
let fromDate: string | undefined = $state();
|
||||
let toDate: string | undefined = $state();
|
||||
|
||||
export function open() {
|
||||
modal.open();
|
||||
}
|
||||
|
||||
function onsubmit(e: SubmitEvent) {
|
||||
if (!fromDate || !toDate){
|
||||
e.preventDefault();
|
||||
return;
|
||||
}
|
||||
|
||||
let from = new Date(fromDate);
|
||||
let to = new Date(toDate);
|
||||
|
||||
onSubmitted?.(from,to);
|
||||
}
|
||||
</script>
|
||||
|
||||
<Modal bind:this={modal}>
|
||||
<form method="dialog" {onsubmit} class="flex flex-col">
|
||||
<label class="form-row">
|
||||
<span>Von:</span>
|
||||
<input type="date" class="form-input" bind:value={fromDate} />
|
||||
</label>
|
||||
|
||||
<label class="form-row">
|
||||
<span>Bis:</span>
|
||||
<input type="date" class="form-input" bind:value={toDate} />
|
||||
</label>
|
||||
|
||||
<div class="flex justify-end mt-3">
|
||||
<button
|
||||
type="reset"
|
||||
class="mr-5 px-2 py-1 bg-red-500 rounded-2xl shadow-md"
|
||||
onclick={() => {
|
||||
modal.close();
|
||||
|
||||
fromDate = undefined;
|
||||
toDate = undefined;
|
||||
}}>Abbrechen</button
|
||||
>
|
||||
<button
|
||||
type="submit"
|
||||
class="px-2 py-1 bg-indigo-600 rounded-2xl shadow-md hover:bg-indigo-700 transition"
|
||||
>Export CSV</button
|
||||
>
|
||||
</div>
|
||||
</form>
|
||||
</Modal>
|
||||
|
||||
<style scoped>
|
||||
@reference "../app.css";
|
||||
|
||||
.form-row {
|
||||
@apply flex justify-between;
|
||||
}
|
||||
|
||||
.form-input {
|
||||
@apply ml-10;
|
||||
}
|
||||
</style>
|
||||
106
web/src/lib/csv.ts
Normal file
106
web/src/lib/csv.ts
Normal file
@@ -0,0 +1,106 @@
|
||||
interface CSVOptions {
|
||||
delimiter?: string; // default: ","
|
||||
includeHeader?: boolean; // default: true for object input, false for array-of-arrays unless headers provided
|
||||
headerOrder?: string[]; // explicit ordering for columns that should come first
|
||||
eol?: string; // default: "\r\n"
|
||||
includeBOM?: boolean; // default: false (useful for Excel)
|
||||
nullString?: string; // default: "" (how to render null/undefined)
|
||||
}
|
||||
|
||||
type RowObject = Record<string, any>;
|
||||
type InputRows = RowObject[];
|
||||
|
||||
export function generateCSVString(input: InputRows, opts: CSVOptions = {}): string {
|
||||
const {
|
||||
delimiter = ",",
|
||||
includeHeader,
|
||||
headerOrder,
|
||||
eol = "\r\n",
|
||||
includeBOM = false,
|
||||
nullString = "",
|
||||
} = opts;
|
||||
|
||||
// Check if the value need quoting
|
||||
// Usually not needed in our use case but still in case
|
||||
const needsQuoting = (s: string) =>
|
||||
s.includes(delimiter) || s.includes('"') || s.includes("\n") || s.includes("\r") || /^\s|\s$/.test(s);
|
||||
|
||||
// Transform the value of a cell in a SAFE string
|
||||
const escapeCell = (raw: any): string => {
|
||||
if (raw === null || raw === undefined) return nullString;
|
||||
|
||||
let s = defaultStringify(raw);
|
||||
|
||||
// Replace quotes
|
||||
if (s.includes('"')) s = s.replace(/"/g, '""');
|
||||
|
||||
return needsQuoting(s) ? `"${s}"` : s;
|
||||
};
|
||||
|
||||
// Transform the value of a cell into a string
|
||||
function defaultStringify(v: any): string {
|
||||
if (v === null || v === undefined) return nullString;
|
||||
if (v instanceof Date) return v.toLocaleDateString();
|
||||
if (typeof v === "boolean") return v ? "X" : "";
|
||||
|
||||
if (typeof v === "object") {
|
||||
// CHeck if array and join with "|"
|
||||
if (Array.isArray(v)) return v.map(item => (item === null || item === undefined ? nullString : String(item))).join("|");
|
||||
|
||||
// If all fails parse it via json
|
||||
// Should also not happen in our use case
|
||||
try { return JSON.stringify(v); } catch { return String(v); }
|
||||
}
|
||||
return String(v);
|
||||
}
|
||||
|
||||
const objs = (input as RowObject[]) || [];
|
||||
|
||||
// Derive headers in the order keys are first encountered (fixed: no reduce)
|
||||
const seen = new Set<string>();
|
||||
const derivedOrder: string[] = [];
|
||||
for (const obj of objs) {
|
||||
if (!obj || typeof obj !== "object") continue;
|
||||
for (const k of Object.keys(obj)) {
|
||||
if (!seen.has(k)) {
|
||||
seen.add(k);
|
||||
derivedOrder.push(k);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Apply headerOrder if provided: put listed columns first (in the order provided),
|
||||
// then append the remaining derived headers in their derived order.
|
||||
let finalHeaders = derivedOrder.slice();
|
||||
if (Array.isArray(headerOrder) && headerOrder.length > 0) {
|
||||
const headSet = new Set(headerOrder);
|
||||
const first = headerOrder.filter(h => seen.has(h)); // keep only headers that actually exist
|
||||
const rest = derivedOrder.filter(h => !headSet.has(h));
|
||||
finalHeaders = first.concat(rest);
|
||||
}
|
||||
|
||||
const shouldIncludeHeader = typeof includeHeader === "boolean" ? includeHeader : finalHeaders.length > 0;
|
||||
const rowsOut: string[] = [];
|
||||
|
||||
if (shouldIncludeHeader && finalHeaders.length) {
|
||||
rowsOut.push(finalHeaders.map(h => escapeCell(h)).join(delimiter));
|
||||
}
|
||||
|
||||
// Parse every row
|
||||
for (const obj of objs) {
|
||||
|
||||
// Check if obj is null or somthing else we can't convert
|
||||
if (!obj || typeof obj !== "object") {
|
||||
|
||||
// produce empty row with same number of columns
|
||||
rowsOut.push(finalHeaders.map(() => escapeCell(null)).join(delimiter));
|
||||
continue;
|
||||
}
|
||||
|
||||
// For every header check if a value on our row exist and print it
|
||||
const row = finalHeaders.map(col => escapeCell(col in obj ? (obj as any)[col] : null)).join(delimiter);
|
||||
rowsOut.push(row);
|
||||
}
|
||||
|
||||
return (includeBOM ? "\uFEFF" : "") + rowsOut.join(eol);
|
||||
}
|
||||
53
web/src/lib/exporting.ts
Normal file
53
web/src/lib/exporting.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import { generateCSVString } from "./csv";
|
||||
import { dayToDate, fetchDay, fetchDays, type AttendanceDay, type Day } from "./Day";
|
||||
import type { IDMap } from "./IDMapping";
|
||||
|
||||
interface CSVRow {
|
||||
ID: string
|
||||
Vorname: string
|
||||
Nachname: string
|
||||
[key: string]: string | boolean
|
||||
}
|
||||
|
||||
function prepareRows(mapping: IDMap, days: AttendanceDay[]): CSVRow[] {
|
||||
let csvData: CSVRow[] = [];
|
||||
|
||||
const allIDs = Object.keys(mapping);
|
||||
|
||||
for (const id of allIDs) {
|
||||
const name = mapping[id];
|
||||
const row: CSVRow = {
|
||||
ID: id,
|
||||
Vorname: name.first,
|
||||
Nachname: name.last,
|
||||
};
|
||||
|
||||
for (const day of days) {
|
||||
const dayKey = dayToDate(day.date).toLocaleDateString();
|
||||
row[dayKey] = day.ids.includes(id);
|
||||
}
|
||||
|
||||
csvData.push(row);
|
||||
}
|
||||
|
||||
return csvData;
|
||||
}
|
||||
|
||||
async function getDays(from: Date, to: Date): Promise<AttendanceDay[]> {
|
||||
const recordedDays: Day[] = await fetchDays(from, to);
|
||||
let days: AttendanceDay[] = [];
|
||||
|
||||
for (const day of recordedDays) {
|
||||
days.push(await fetchDay(day))
|
||||
}
|
||||
|
||||
return days;
|
||||
}
|
||||
|
||||
export async function generateCSVFile(from: Date, to: Date, mapping: IDMap): Promise<string> {
|
||||
const days = await getDays(from, to);
|
||||
const rows = prepareRows(mapping, days);
|
||||
const csvString = generateCSVString(rows);
|
||||
|
||||
return csvString;
|
||||
}
|
||||
Reference in New Issue
Block a user