mirror of
https://github.com/Djeeberjr/fw-anwesenheit.git
synced 2026-05-01 02:59: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 express from "express";
|
||||||
import bodyParser from "body-parser";
|
import bodyParser from "body-parser";
|
||||||
|
|
||||||
|
import mockData from "./data.json" with {type: "json"};
|
||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
const port = 3000;
|
const port = 3000;
|
||||||
|
|
||||||
app.use(bodyParser.json());
|
const SECS_IN_DAY = 86_400;
|
||||||
|
|
||||||
let mappings = [
|
app.use(bodyParser.json());
|
||||||
[
|
|
||||||
"123456789ABC",
|
|
||||||
{
|
|
||||||
first: "Feuerwehrman",
|
|
||||||
last: "Sam",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
];
|
|
||||||
|
|
||||||
function generateRandomId() {
|
function generateRandomId() {
|
||||||
const chars = "ABCDEF0123456789";
|
const chars = "ABCDEF0123456789";
|
||||||
@@ -27,7 +21,7 @@ function generateRandomId() {
|
|||||||
|
|
||||||
// GET /api/mapping
|
// GET /api/mapping
|
||||||
app.get("/api/mapping", (req, res) => {
|
app.get("/api/mapping", (req, res) => {
|
||||||
res.json(mappings);
|
res.json(mockData.mapping);
|
||||||
});
|
});
|
||||||
|
|
||||||
// POST /api/mapping
|
// POST /api/mapping
|
||||||
@@ -45,11 +39,45 @@ app.post("/api/mapping", (req, res) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Add new mapping
|
// Add new mapping
|
||||||
mappings.push([id, name]);
|
mockData.mappings.push([id, name]);
|
||||||
|
|
||||||
res.status(201).send("");
|
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
|
// SSE route: /api/idevent
|
||||||
app.get("/api/idevent", (req, res) => {
|
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