Compare commits

..
21 Commits
Author SHA1 Message Date
michael a760f423a4 Use Zammad IDs for organization links 2026-08-11 14:47:11 +02:00
michael dcc115a82c Add global ticket search 2026-08-11 07:57:58 +02:00
michael 4390f9a7da Allow manual ordering of opened ticket links 2026-08-06 08:39:35 +02:00
michael 7d47a05bff Add persistent opened ticket navigation 2026-08-06 08:32:49 +02:00
michael 05c842e406 Validate backups before restore 2026-08-06 08:28:01 +02:00
michael 72229cf14c Add setup and restore flow 2026-08-06 08:22:33 +02:00
michael 19e4621aeb Restrict admin to system management 2026-08-06 08:07:26 +02:00
michael 0bef65ab75 Document monthly workflow in FAQ 2026-08-06 07:43:40 +02:00
michael e527576ed6 Remove non-billable fragments from statistics 2026-08-05 22:46:39 +02:00
michael ac71eb88c0 Hide non-billable column in analysis table 2026-08-05 22:44:16 +02:00
michael eff8823371 Show non-billable status without statistic hours 2026-08-05 22:40:47 +02:00
michael 7d4d373863 Revert "Remove non-billable data from statistics"
This reverts commit 971d493015.
2026-08-05 22:38:14 +02:00
michael bc184fef56 Revert "Hide non-billable values in analysis"
This reverts commit ccb859ad49.
2026-08-05 22:38:14 +02:00
michael ccb859ad49 Hide non-billable values in analysis 2026-08-05 22:35:49 +02:00
michael 7f71c23232 Revert "Fix Next static asset serving"
This reverts commit 964bd44a82.
2026-08-05 22:03:49 +02:00
michael 964bd44a82 Fix Next static asset serving 2026-08-05 21:33:19 +02:00
michael 971d493015 Remove non-billable data from statistics 2026-08-05 21:17:47 +02:00
michael 8fef5980db Adjust non-billable statistics color 2026-08-05 20:56:01 +02:00
michael cf7b33f331 Exclude unresolved time from statistics 2026-08-05 20:51:59 +02:00
michael 2aec561f3c Use billable time for Teamspace comparisons 2026-08-05 17:06:34 +02:00
michael 76bc438b4e Update user guide documentation 2026-08-05 16:19:29 +02:00
22 changed files with 2439 additions and 1239 deletions
-4
View File
@@ -4,9 +4,5 @@ APP_PORT=3910
DATABASE_URL=postgresql://tickettracker:change-me@10.10.10.23:5432/tickettracker
DB_POOL_SIZE=10
ADMIN_USERNAME=admin
ADMIN_PASSWORD=change-me-now
ADMIN_DISPLAY_NAME=Admin
COOKIE_SECURE=false
SESSION_MAX_AGE_MS=1209600000
-4
View File
@@ -6,9 +6,5 @@ POSTGRES_USER=tickettracker
POSTGRES_PASSWORD=change-me-db-password
DB_POOL_SIZE=10
ADMIN_USERNAME=admin
ADMIN_PASSWORD=change-me-now
ADMIN_DISPLAY_NAME=Admin
COOKIE_SECURE=false
SESSION_MAX_AGE_MS=1209600000
-3
View File
@@ -1,7 +1,4 @@
DATABASE_URL=postgresql://tickettracker:change-me@10.10.10.23:5432/tickettracker
ADMIN_USERNAME=admin
ADMIN_PASSWORD=change-me-now
ADMIN_DISPLAY_NAME=Admin
APP_PORT=3910
TICKETTRACKER_IMAGE=git.d-razz.de/michael/tickettracker:latest
COOKIE_SECURE=false
+9
View File
@@ -155,6 +155,15 @@ export function requireAdmin(req: Request, res: Response, next: NextFunction) {
next();
}
export function requireUser(req: Request, res: Response, next: NextFunction) {
if (req.user?.role !== "user") {
res.status(403).json({ error: "Dieser Bereich ist nur für Benutzerkonten vorgesehen" });
return;
}
next();
}
export function currentUser(req: Request) {
if (!req.user) {
throw Object.assign(new Error("Nicht angemeldet"), { status: 401 });
+616 -343
View File
File diff suppressed because it is too large Load Diff
+115 -41
View File
@@ -1,42 +1,25 @@
import { query } from "./db.js";
import { hashPassword } from "./auth.js";
async function ensureMigrationAdmin() {
const username = process.env.ADMIN_USERNAME?.trim() || "admin";
const password = process.env.ADMIN_PASSWORD ?? "admin";
const displayName = process.env.ADMIN_DISPLAY_NAME?.trim() || "Admin";
const existingUsers = await query<{ count: string }>("SELECT COUNT(*) AS count FROM users;");
if (Number(existingUsers.rows[0]?.count ?? 0) === 0) {
await query(
`
INSERT INTO users (username, display_name, password_hash, role)
VALUES ($1, $2, $3, 'admin');
`,
[username, displayName, await hashPassword(password)]
);
if (!process.env.ADMIN_PASSWORD) {
console.warn("Initialer Admin wurde mit admin/admin angelegt. Bitte Passwort über ADMIN_PASSWORD setzen oder im Adminbereich ändern.");
}
}
async function findMigrationAdmin() {
const adminResult = await query<{ id: string }>("SELECT id FROM users WHERE role = 'admin' ORDER BY id ASC LIMIT 1;");
if (adminResult.rows.length === 0) {
throw new Error("At least one admin user is required");
}
return adminResult.rows[0].id;
return adminResult.rows[0]?.id ?? null;
}
async function ensureClosureOwnership(table: string, column: string, primaryKeyColumns: string[], adminId: string) {
async function ensureClosureOwnership(table: string, column: string, primaryKeyColumns: string[], adminId: string | null) {
await query(`ALTER TABLE ${table} ADD COLUMN IF NOT EXISTS user_id BIGINT REFERENCES users(id);`);
await query(`UPDATE ${table} SET user_id = $1 WHERE user_id IS NULL;`, [adminId]);
await query(`ALTER TABLE ${table} ALTER COLUMN user_id SET NOT NULL;`);
await query(`ALTER TABLE ${table} DROP CONSTRAINT IF EXISTS ${table}_pkey;`);
await query(`ALTER TABLE ${table} ADD CONSTRAINT ${table}_pkey PRIMARY KEY (${primaryKeyColumns.join(", ")}, user_id);`);
if (adminId) {
await query(`UPDATE ${table} SET user_id = $1 WHERE user_id IS NULL;`, [adminId]);
}
await query(`
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM ${table} WHERE user_id IS NULL) THEN
ALTER TABLE ${table} ALTER COLUMN user_id SET NOT NULL;
ALTER TABLE ${table} DROP CONSTRAINT IF EXISTS ${table}_pkey;
ALTER TABLE ${table} ADD CONSTRAINT ${table}_pkey PRIMARY KEY (${primaryKeyColumns.join(", ")}, user_id);
END IF;
END $$;
`);
await query(`CREATE INDEX IF NOT EXISTS idx_${table}_${column}_user ON ${table}(${column}, user_id);`);
}
@@ -74,7 +57,13 @@ export async function migrate() {
await query("CREATE INDEX IF NOT EXISTS idx_auth_sessions_user ON auth_sessions(user_id);");
await query("CREATE INDEX IF NOT EXISTS idx_auth_sessions_expires ON auth_sessions(expires_at);");
const adminId = await ensureMigrationAdmin();
const adminId = await findMigrationAdmin();
if (adminId) {
await query("UPDATE users SET role = 'user', updated_at = now() WHERE role = 'admin' AND id <> $1;", [adminId]);
}
await query("CREATE UNIQUE INDEX IF NOT EXISTS idx_users_single_admin ON users ((role)) WHERE role = 'admin';");
await query(`
CREATE TABLE IF NOT EXISTS app_settings (
@@ -108,7 +97,7 @@ export async function migrate() {
CREATE TABLE IF NOT EXISTS tickets (
id BIGSERIAL PRIMARY KEY,
ticket_number TEXT NOT NULL UNIQUE,
organization_id BIGINT REFERENCES organizations(id),
organization_id BIGINT REFERENCES organizations(zammad_id),
customer_name TEXT,
work_type TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
@@ -118,7 +107,7 @@ export async function migrate() {
`);
await query("ALTER TABLE tickets ADD COLUMN IF NOT EXISTS customer_name TEXT;");
await query("ALTER TABLE tickets ADD COLUMN IF NOT EXISTS organization_id BIGINT REFERENCES organizations(id);");
await query("ALTER TABLE tickets ADD COLUMN IF NOT EXISTS organization_id BIGINT REFERENCES organizations(zammad_id);");
await query("ALTER TABLE tickets ADD COLUMN IF NOT EXISTS work_type TEXT;");
await query(`
ALTER TABLE tickets
@@ -141,7 +130,7 @@ export async function migrate() {
CREATE TABLE IF NOT EXISTS sessions (
id BIGSERIAL PRIMARY KEY,
ticket_id BIGINT NOT NULL REFERENCES tickets(id) ON DELETE CASCADE,
organization_id BIGINT REFERENCES organizations(id),
organization_id BIGINT REFERENCES organizations(zammad_id),
customer_name TEXT NOT NULL,
activity TEXT NOT NULL,
work_type TEXT NOT NULL,
@@ -162,20 +151,29 @@ export async function migrate() {
`);
await query("ALTER TABLE sessions ADD COLUMN IF NOT EXISTS user_id BIGINT REFERENCES users(id);");
await query("ALTER TABLE sessions ADD COLUMN IF NOT EXISTS organization_id BIGINT REFERENCES organizations(id);");
await query("ALTER TABLE sessions ADD COLUMN IF NOT EXISTS organization_id BIGINT REFERENCES organizations(zammad_id);");
await query("ALTER TABLE sessions ADD COLUMN IF NOT EXISTS recurring_billing_id BIGINT;");
await query("ALTER TABLE sessions ADD COLUMN IF NOT EXISTS recurring_billing_slot_id BIGINT;");
await query("ALTER TABLE sessions ADD COLUMN IF NOT EXISTS recurring_occurrence_date DATE;");
await query("ALTER TABLE sessions ADD COLUMN IF NOT EXISTS recurring_occurrence_key TEXT;");
await query("UPDATE sessions SET user_id = $1 WHERE user_id IS NULL;", [adminId]);
await query("ALTER TABLE sessions ALTER COLUMN user_id SET NOT NULL;");
if (adminId) {
await query("UPDATE sessions SET user_id = $1 WHERE user_id IS NULL;", [adminId]);
}
await query(`
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM sessions WHERE user_id IS NULL) THEN
ALTER TABLE sessions ALTER COLUMN user_id SET NOT NULL;
END IF;
END $$;
`);
await query(`
CREATE TABLE IF NOT EXISTS recurring_billings (
id BIGSERIAL PRIMARY KEY,
user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
ticket_number TEXT,
organization_id BIGINT NOT NULL REFERENCES organizations(id),
organization_id BIGINT NOT NULL REFERENCES organizations(zammad_id),
activity TEXT NOT NULL,
work_type TEXT NOT NULL,
recurrence_type TEXT NOT NULL,
@@ -193,6 +191,82 @@ export async function migrate() {
);
`);
await query(`
ALTER TABLE tickets DROP CONSTRAINT IF EXISTS tickets_organization_id_fkey;
ALTER TABLE sessions DROP CONSTRAINT IF EXISTS sessions_organization_id_fkey;
ALTER TABLE recurring_billings DROP CONSTRAINT IF EXISTS recurring_billings_organization_id_fkey;
`);
await query(`
UPDATE tickets t
SET organization_id = o.zammad_id
FROM organizations o
WHERE t.organization_id = o.id
AND NOT EXISTS (
SELECT 1
FROM organizations current_org
WHERE current_org.zammad_id = t.organization_id
);
`);
await query(`
UPDATE sessions s
SET organization_id = o.zammad_id
FROM organizations o
WHERE s.organization_id = o.id
AND NOT EXISTS (
SELECT 1
FROM organizations current_org
WHERE current_org.zammad_id = s.organization_id
);
`);
await query(`
UPDATE recurring_billings rb
SET organization_id = o.zammad_id
FROM organizations o
WHERE rb.organization_id = o.id
AND NOT EXISTS (
SELECT 1
FROM organizations current_org
WHERE current_org.zammad_id = rb.organization_id
);
`);
await query(`
UPDATE tickets
SET organization_id = NULL
WHERE organization_id IS NOT NULL
AND NOT EXISTS (
SELECT 1
FROM organizations o
WHERE o.zammad_id = tickets.organization_id
);
`);
await query(`
UPDATE sessions
SET organization_id = NULL
WHERE organization_id IS NOT NULL
AND NOT EXISTS (
SELECT 1
FROM organizations o
WHERE o.zammad_id = sessions.organization_id
);
`);
await query(`
ALTER TABLE tickets
ADD CONSTRAINT tickets_organization_id_fkey
FOREIGN KEY (organization_id) REFERENCES organizations(zammad_id);
ALTER TABLE sessions
ADD CONSTRAINT sessions_organization_id_fkey
FOREIGN KEY (organization_id) REFERENCES organizations(zammad_id);
ALTER TABLE recurring_billings
ADD CONSTRAINT recurring_billings_organization_id_fkey
FOREIGN KEY (organization_id) REFERENCES organizations(zammad_id);
`);
await query("ALTER TABLE recurring_billings ALTER COLUMN ticket_number DROP NOT NULL;");
await query("ALTER TABLE recurring_billings DROP CONSTRAINT IF EXISTS recurring_billings_ticket_number_format;");
await query("UPDATE recurring_billings SET recurrence_type = 'every_n_weeks' WHERE recurrence_type = 'every_n_days';");
-3
View File
@@ -31,9 +31,6 @@ services:
API_PROXY_TARGET: http://127.0.0.1:3001
SERVE_FRONTEND: "false"
NEXT_TELEMETRY_DISABLED: "1"
ADMIN_USERNAME: ${ADMIN_USERNAME:-admin}
ADMIN_PASSWORD: ${ADMIN_PASSWORD:-}
ADMIN_DISPLAY_NAME: ${ADMIN_DISPLAY_NAME:-Admin}
SESSION_MAX_AGE_MS: ${SESSION_MAX_AGE_MS:-1209600000}
COOKIE_SECURE: ${COOKIE_SECURE:-false}
DB_POOL_SIZE: ${DB_POOL_SIZE:-10}
-3
View File
@@ -12,9 +12,6 @@ services:
API_PROXY_TARGET: http://127.0.0.1:3001
SERVE_FRONTEND: "false"
NEXT_TELEMETRY_DISABLED: "1"
ADMIN_USERNAME: ${ADMIN_USERNAME:-admin}
ADMIN_PASSWORD: ${ADMIN_PASSWORD:-}
ADMIN_DISPLAY_NAME: ${ADMIN_DISPLAY_NAME:-Admin}
SESSION_MAX_AGE_MS: ${SESSION_MAX_AGE_MS:-1209600000}
COOKIE_SECURE: ${COOKIE_SECURE:-false}
DB_POOL_SIZE: ${DB_POOL_SIZE:-10}
+11 -2
View File
@@ -14,7 +14,6 @@ cp .env.docker-external.example .env.docker-external
```bash
DATABASE_URL=postgresql://tickettracker:PASSWORT@10.10.10.23:5432/tickettracker
ADMIN_PASSWORD=ein-sicheres-passwort
TICKETTRACKER_IMAGE=git.d-razz.de/michael/tickettracker:latest
APP_PORT=3910
```
@@ -37,7 +36,6 @@ cp .env.docker-postgres.example .env.docker-postgres
```bash
POSTGRES_PASSWORD=ein-sicheres-db-passwort
ADMIN_PASSWORD=ein-sicheres-admin-passwort
TICKETTRACKER_IMAGE=git.d-razz.de/michael/tickettracker:latest
APP_PORT=3910
```
@@ -50,6 +48,17 @@ docker compose --env-file .env.docker-postgres -f compose.postgres.yml up -d
Die Datenbankdaten liegen im Docker-Volume `tickettracker_postgres_data`.
## Erstes Setup
Admin-Zugangsdaten werden nicht per `.env` gesetzt. Nach dem ersten Start oeffnest du TicketTracker im Browser und landest automatisch auf der Setup-Seite.
Dort gibt es zwei Wege:
- `Admin anlegen`: festen Admin direkt erstellen und anmelden.
- `Backup einspielen`: ein TicketTracker-SQL-Backup hochladen, Struktur pruefen und danach einspielen. Danach meldest du dich mit einem Benutzer aus dem Backup an.
Ein Restore ersetzt den kompletten Systemstand. Vor dem Einspielen wird geprueft, ob Dump-Struktur und aktuelle Datenbankstruktur zusammenpassen. Danach werden alle TicketTracker-Tabellen geleert; anschliessend gelten ausschliesslich Benutzer, Passwoerter, Sessions, Settings, Organisationen, fixe Abrechnungen und Abschluesse aus dem Backup.
## Image bauen und in Gitea pushen
Einmal am Docker/Gitea-Registry anmelden:
+258 -44
View File
@@ -1,37 +1,85 @@
# TicketTracker Benutzeranleitung
Diese Anleitung beschreibt die Nutzung von TicketTracker im Arbeitsalltag: Zeiten erfassen, Sessions bewerten, CRM-Zeiten eintragen, fixe Abrechnungen verwalten und Monatsauswertungen abschliessen.
Diese Anleitung beschreibt die Nutzung von TicketTracker im Arbeitsalltag: Zeiten erfassen, Sessions bewerten, Teamspace-Zeiten eintragen, fixe Abrechnungen verwalten, Statistiken lesen und den Monatsabschluss durchfuehren.
## Erstes Setup
Wenn noch kein Admin existiert, zeigt TicketTracker beim Aufruf automatisch die Setup-Seite.
Dort gibt es zwei Moeglichkeiten:
- `Admin anlegen`: festen Admin mit Benutzername, vollem Namen und Passwort erstellen. Danach ist der Admin direkt angemeldet.
- `Backup einspielen`: ein TicketTracker-SQL-Backup aus einem frueheren Systemstand hochladen, Struktur pruefen und danach einspielen. Danach meldet man sich mit einem Benutzer aus dem Backup an.
Ein Restore ersetzt den kompletten Systemstand. Vor dem Einspielen wird geprueft, ob die Struktur des Dumps zur aktuellen Datenbank passt. Fehlen Felder oder enthaelt der Dump unbekannte Felder, wird eine Fehlermeldung angezeigt und der Restore kann nicht gestartet werden. Beim eigentlichen Restore werden alle TicketTracker-Tabellen geleert. Danach gilt ausschliesslich der Stand des Backups, inklusive aller Benutzer, Passwoerter, Sessions, Einstellungen, Organisationen, fixen Abrechnungen und Abschluesse.
## Grundprinzip
TicketTracker ersetzt nicht das Ticketsystem oder CRM. Die App dient dazu, Arbeitszeiten sauber zu messen, auszuwerten und fuer die spaetere Abrechnung vorzubereiten.
TicketTracker ersetzt nicht das Ticketsystem und nicht das CRM. Die App ist die Arbeitszeiterfassung und Auswertung daneben.
Es gibt drei Zeitebenen:
Es gibt drei zentrale Zeitebenen:
- Session-Zeit: Die gemessene oder manuell erfasste Arbeitszeit einer einzelnen Taetigkeit.
- Tages-CRM-Zeit: Die Zeit, die fuer ein Ticket an einem konkreten Tag wirklich im CRM eingetragen wurde.
- Monatsabschluss: Ein Ticket wird fuer einen Monat geschlossen, wenn alle Sessions dieses Tickets im Monat bewertet sind.
- Teamspace-Zeit: Die Zeit, die fuer ein Ticket an einem konkreten Tag wirklich im CRM beziehungsweise Teamspace eingetragen wurde.
- Monatsabschluss: Der Abschluss eines ganzen Monats, sobald alle Sessions des Monats bewertet sind.
## Anmeldung
Wichtig: Tickets werden nicht geschlossen. Tickets dienen nur als Gruppierung fuer Sessions. Abgeschlossen wird ausschliesslich der Monat.
## Hilfe und FAQ
Die App hat eine eigene Unterseite `FAQ`. Dort steht die ausfuehrliche Bedienungsanleitung mit Abschnitten zu Timer, Auswertung, Teamspace, Monatsabschluss, Statistiken, fixen Abrechnungen, Profil und Adminbereich.
An vielen Stellen in der Oberflaeche gibt es ein Fragezeichen-Icon. Ein Klick darauf oeffnet ein kurzes Hilfe-Modal zur jeweiligen Card oder Funktion. Im Modal gibt es einen Link zum passenden ausfuehrlichen FAQ-Abschnitt.
## Anmeldung und Navigation
1. App im Browser oeffnen.
2. Mit Benutzername und Passwort anmelden.
3. Nach der Anmeldung ist die Seitenleiste sichtbar.
Normale Benutzer sehen ihre eigenen Zeiten, Auswertungen, fixen Abrechnungen und ihr Profil. Administratoren sehen zusaetzlich den Adminbereich.
Normale Benutzer sehen ihre eigenen Zeiten, Auswertungen, fixen Abrechnungen und ihr Profil. Der feste Admin sieht nur Administration, Profil und FAQ.
Die Navigation ist in Workflows gegliedert:
- Session starten
- Auswertung
- Monatsabschluss
- Statistiken
- Fixe Abrechnung
- Profil
- Administration, nur fuer Admin
- FAQ
Nach `Auswertung`, nach `Monatsabschluss` und vor `FAQ` trennt eine Linie die Bereiche optisch.
## Globaler Timer-Schnellstart
Oben im Header gibt es auf allen Unterseiten ein Ticket-Eingabefeld fuer den schnellen Timerstart.
1. Ticketnummer im Format `Ticket#XXXXXX` eingeben.
2. `Timer starten` klicken.
3. Der neue Timer startet sofort.
Wenn fuer diese Ticketnummer bereits ein Timer existiert, wird dieser vorhandene Timer aktiviert. Alle anderen Timer werden automatisch pausiert.
Wenn zu der Ticketnummer bereits Ticketdaten vorhanden sind, zeigt der Timer auch die Organisation an.
## Timer
Die Seite `Timer` ist fuer laufende Arbeit an Tickets gedacht.
1. Ticketnummer im Format `Ticket#XXXXXX` eingeben.
2. `Session beginnen` klicken.
2. `Timer starten` klicken.
3. Der Timer startet.
4. Bei Bedarf kann der Timer pausiert, fortgesetzt, beendet oder zurueckgesetzt werden.
4. Bei Bedarf kann der Timer pausiert, aktiviert, beendet oder entfernt werden.
Es koennen mehrere Timer vorbereitet sein, aber immer nur ein Timer laeuft aktiv. Wird ein anderer Timer gestartet oder fortgesetzt, werden alle anderen laufenden Timer automatisch pausiert.
Es koennen mehrere Timer vorbereitet sein, aber immer nur ein Timer laeuft aktiv. Wird ein anderer Timer aktiviert, werden alle anderen Timer automatisch pausiert.
Beim Klick auf `Beenden` stoppt der Timer sofort. Danach wird die Session fertig gespeichert:
Alle laufenden und pausierten Timer stehen in der Seitenleiste. Dadurch bleiben sie auf allen Unterseiten sichtbar.
Beim Klick auf `Beenden` stoppt der Timer sofort. Das spaetere Ausfuellen des Modals verlaengert die gemessene Zeit nicht.
Beim Speichern nach dem Beenden gilt:
- Existiert das Ticket bereits, werden Organisation und Art vom Ticket uebernommen.
- Dann muss nur noch die Taetigkeit eingetragen werden.
@@ -41,9 +89,9 @@ Die gemessene Zeit wird auf Minuten gerundet. Pausen werden von der Gesamtdauer
## Session Nachtragen
Sessions koennen auch manuell nachgetragen werden, zum Beispiel wenn ein Timer vergessen wurde.
Sessions koennen manuell nachgetragen werden, zum Beispiel wenn ein Timer vergessen wurde.
Normale Benutzer koennen eigene Sessions nachtragen. Administratoren koennen Sessions fuer andere Benutzer nachtragen.
Normale Benutzer koennen eigene Sessions nachtragen. Der feste Admin erfasst keine Sessions.
Beim Nachtragen werden angegeben:
@@ -51,11 +99,16 @@ Beim Nachtragen werden angegeben:
- Organisation
- Art: Support oder Consulting
- Datum
- Von- und Bis-Uhrzeit
- Von-Uhrzeit
- Bis-Uhrzeit
- Taetigkeit
Das Taetigkeitsfeld ist mehrzeilig. Zeilenumbrueche bleiben in der Auswertung erhalten.
Das Ende muss nach dem Beginn liegen. Die Dauer wird automatisch berechnet und auf Minuten gerundet.
In der Ticketdetailansicht gibt es ebenfalls `Session nachtragen`. Dort wird die neue Session direkt dem geoeffneten Ticket zugeordnet.
## Ticketdaten Bearbeiten
In der Ticketdetailansicht koennen Ticketdaten nachtraeglich korrigiert werden:
@@ -64,11 +117,17 @@ In der Ticketdetailansicht koennen Ticketdaten nachtraeglich korrigiert werden:
- Organisation
- Art
Aenderungen an Organisation und Art werden auf vorhandene manuelle Sessions des Tickets uebernommen. Bei wiederkehrend erzeugten Sessions bleibt die Art aus der Regel erhalten und kann nur ueber die fixe Abrechnung geaendert werden.
Aenderungen an Organisation und Art werden auf vorhandene manuelle Sessions des Tickets uebernommen.
Bei wiederkehrend erzeugten Sessions bleibt die Art aus der Regel erhalten und kann nur ueber die fixe Abrechnung geaendert werden.
Wenn ein Ticket bereits durch einen anderen Benutzer existiert, koennen die Ticketdaten beim Beenden eines Timers uebernommen werden. Die Session selbst bleibt trotzdem dem angemeldeten Benutzer zugeordnet.
## Organisationen
Organisationen werden aus Zammad synchronisiert. Danach koennen Organisationen in TicketTracker nur noch aus der Liste ausgewaehlt werden, nicht mehr frei als Text eingetragen werden.
Organisationen werden aus Zammad synchronisiert. Danach koennen Organisationen in TicketTracker nur noch aus der lokalen Liste ausgewaehlt werden. Freitext ist nicht mehr moeglich.
Der Grund: Nur mit eindeutigen Organisationen sind saubere Auswertungen pro Kunde ueber mehrere Monate moeglich.
Bereits vorhandene alte Freitexte muessen bei Bedarf manuell korrigiert werden, indem das Ticket oder die Session bearbeitet und eine Organisation aus der Liste ausgewaehlt wird.
@@ -79,21 +138,24 @@ Die Seite `Auswertung` hat zwei Ansichten:
- Monat
- Tag
Mit den Pfeilen neben dem Datumsfeld kann ein Monat oder Tag vor- und zurueckgeschaltet werden. Die Daten aktualisieren sich automatisch.
Mit den Pfeilen neben dem Datumsfeld kann ein Monat oder Tag vor- und zurueckgeschaltet werden. Die Daten aktualisieren sich automatisch. Ein separater Aktualisieren-Button ist nicht notwendig.
Die oberen Karten zeigen:
- Tickets: Anzahl der Tickets im Zeitraum
- Sessions: Anzahl der Sessions im Zeitraum
- Zeit: Summe der erfassten Session-Zeit, plus CRM-Zeit
- Abgerechnet: Summe der Sessions mit Bewertung `Abgerechnet` und, sofern vorhanden, die dazugehoerige Teamspace-Zeit als Vergleichswert
- Nicht abrechenbar: Summe der Sessions mit Bewertung `Nicht abrechenbar`
- Offen: Anzahl unbewerteter Sessions
Der Graph `Gesamtaufwand` zeigt den Aufwand im Zeitraum:
Der Graph zeigt abgerechneten Aufwand im Zeitraum:
- In der Monatsansicht pro Tag
- In der Tagesansicht pro Stunde
## Tickets Im Zeitraum
In der Monatsansicht kann ueber `Monat exportieren` eine CSV-Datei fuer den eigenen Benutzer heruntergeladen werden. Der Export enthaelt alle eigenen Sessions des Monats mit Ticket, Organisation, Art, Datum, Von/Bis, Bewertung, Session-Minuten, Teamspace-Minuten und Taetigkeit. Er ist fuer Kontrolle, Ablage oder Weiterverarbeitung gedacht.
## Tickets im Zeitraum
Unter `Tickets im Zeitraum` werden alle Tickets angezeigt, die im gewaehlten Zeitraum Sessions haben.
@@ -103,9 +165,8 @@ Pro Ticket sind sichtbar:
- Organisation
- Art
- Anzahl Sessions
- Zeit
- Abgerechnete Zeit
- Anzahl abgerechneter Sessions
- Anzahl nicht abrechenbarer Sessions
- Status
Neben der Ticketnummer gibt es ein Copy-Icon. Ein Klick kopiert die Ticketnummer in die Zwischenablage.
@@ -115,6 +176,33 @@ Der Status bedeutet:
- `offen`: Es gibt noch unbewertete Sessions.
- `bewertet`: Alle Sessions dieses Tickets im gewaehlten Zeitraum sind bewertet.
### Filtern, Gruppieren und Sortieren
Die Ticketliste kann gefiltert, gruppiert und sortiert werden.
Filter:
- Suche nach Ticketnummer oder Organisation
- Typ
- Organisation
- Status
Gruppierung:
- Keine
- Typ
- Organisation
- Status
Sortierung:
- Ticket
- Typ
- Organisation
- Status
Die Einstellungen bleiben im Browser gespeichert, auch nach Aktualisierung oder Browserneustart. Mit `Zuruecksetzen` werden sie auf Standard gesetzt.
## Ticketdetailansicht
Ein Ticket wird aus der Auswertung heraus geoeffnet.
@@ -122,24 +210,34 @@ Ein Ticket wird aus der Auswertung heraus geoeffnet.
In der Ticketdetailansicht sieht man:
- Ticketnummer mit Copy-Icon
- Abschlussstatus
- Status des Monats
- Ticketdaten
- Session-Eintraege nach Tagen gruppiert
- Teamspace-Wert pro Ticket und Tag
- Moeglichkeit, direkt eine Session zu diesem Ticket nachzutragen
Geoeffnete Tickets erscheinen in der Seitenleiste unter `Geoeffnete Tickets`. Die Liste bleibt nach Aktualisierung und Browserneustart erhalten. Mit einem Klick auf die Ticketnummer gelangt man direkt zur Ticketdetailansicht zurueck. Die Reihenfolge bleibt beim Anklicken unveraendert und kann nur per Drag and Drop angepasst werden. Der Schliessen-Button entfernt das Ticket aus der Seitenleiste. Wenn im aktuell geoeffneten Ticket noch ungespeicherte Werte vorhanden sind, muss zuerst gespeichert werden.
Die Sessions sind pro Tag gruppiert. In jeder Tagesgruppe stehen:
- Tagesdatum
- `Tag gesamt`: Summe der erfassten Session-Zeit dieses Tages
- `CRM`: Stunden, die fuer dieses Ticket an diesem Tag wirklich im CRM eingetragen wurden
- `Tag abgerechnet`: Summe der als `Abgerechnet` bewerteten Session-Zeit dieses Tages
- `Nicht`: Summe der als `Nicht abrechenbar` bewerteten Session-Zeit dieses Tages
- `Offen`: Summe der noch unbewerteten Session-Zeit dieses Tages
- `Teamspace`: Stunden, die fuer dieses Ticket an diesem Tag wirklich im CRM eingetragen wurden
Das CRM-Feld wird in Stunden eingetragen, zum Beispiel:
Das Teamspace-Feld wird in Stunden eingetragen, zum Beispiel:
- `1`
- `1,5`
- `2.25`
- `0,75`
Ein leeres CRM-Feld entfernt den gespeicherten CRM-Wert fuer diesen Ticket-Tag. Der Wert ist derselbe in Monats- und Tagesansicht.
Der Teamspace-Wert wird nicht waehrend der Eingabe gespeichert. Sobald sich der Wert geaendert hat, erscheint ein Speichern-Button. Erst dieser Button speichert den Wert.
Ein leeres Teamspace-Feld entfernt den gespeicherten Teamspace-Wert fuer diesen Ticket-Tag.
Der Wert ist derselbe in Monats- und Tagesansicht. Wenn er in einer Ansicht gespeichert wird, ist er in der anderen Ansicht identisch gesetzt.
## Sessions Bewerten
@@ -150,6 +248,8 @@ Jede Session kann bewertet werden als:
Ein erneuter Klick auf den bereits gewaehlten Zustand nimmt die Bewertung zurueck. Dadurch wird die Session wieder offen.
Nicht abrechenbare Sessions gelten als bewertet. Sie blockieren den Monatsabschluss nicht.
Es gibt keinen Ticketabschluss. Tickets dienen nur als Gruppierung fuer Sessions. Abgeschlossen wird ausschliesslich der Monat.
Wenn der Monat bereits abgeschlossen ist, muss er zuerst wieder geoeffnet werden, bevor Bewertungen geaendert werden.
@@ -169,6 +269,8 @@ Bearbeitbar sind:
Bei wiederkehrenden Sessions ist die Art gesperrt. Sie kommt aus der Regel der fixen Abrechnung.
Von und Bis beziehen sich nur auf die Uhrzeit. Das Datum ist ein eigenes Feld.
## Sessions Loeschen
Eine Session kann ueber das Papierkorb-Icon geloescht werden.
@@ -177,13 +279,30 @@ Wird die letzte Session eines Tickets geloescht, wird das Ticket entfernt. Die A
Bei wiederkehrenden Sessions wird das Loeschen als Ausnahme gespeichert. Dadurch wird die Session fuer diesen Tag nicht automatisch wieder neu erzeugt.
## Zeitraum Abschliessen
Wenn eine wiederkehrende Leistung an einem anderen Tag erbracht wurde, kann die erzeugte Session bearbeitet beziehungsweise verschoben werden.
## Monatsabschluss
Der Monatsabschluss ist eine eigene Unterseite.
Ein Monat kann abgeschlossen werden, wenn:
- Alle Sessions im Monat bewertet sind.
Einen separaten Tagesabschluss gibt es nicht. Die Tagesansicht ist nur eine gefilterte Kontrolle einzelner Tage. Bewertungen und CRM-Tageswerte wirken in Monats- und Tagesansicht auf dieselben Daten, abgeschlossen wird aber ausschliesslich der Monat.
Einen separaten Tagesabschluss gibt es nicht. Die Tagesansicht ist nur eine gefilterte Kontrolle einzelner Tage. Bewertungen und Teamspace-Tageswerte wirken in Monats- und Tagesansicht auf dieselben Daten, abgeschlossen wird aber ausschliesslich der Monat.
Die Seite `Monatsabschluss` enthaelt:
- Checkliste
- Offene Bewertungen
- Tage ohne Teamspace-Wert
- Button zum Monat abschliessen oder wieder oeffnen
Offene Bewertungen werden nach Ticket gruppiert. Mit `Ticket oeffnen` gelangt man direkt zur Bewertung.
Tage ohne Teamspace-Wert sind eine Pruefliste. Wenn ein Tag bewusst keinen Teamspace-Wert bekommen soll, kann der Eintrag mit `Zur Kenntnis` abgehakt werden.
Zur Kenntnis genommene Eintraege koennen ueber die Checkbox `Zur Kenntnis anzeigen` wieder eingeblendet werden. Diese Anzeigeeinstellung bleibt im Browser gespeichert. Ein abgehakter Eintrag kann mit `Zuruecknehmen` wieder offen gemacht werden.
## Fixe Abrechnungen
@@ -208,12 +327,96 @@ Eine fixe Abrechnung enthaelt:
Wenn keine Ticketnummer hinterlegt ist, wird automatisch `Fix#ID` verwendet.
Muster:
- `Wochentage`: Es werden einzelne Slots mit Wochentag, Startzeit und Dauer gepflegt.
- `Alle X Wochen`: Das Gueltig-von-Datum ist der Startpunkt. Das Intervall laeuft wochenweise.
Bayerische Feiertage werden bei der automatischen Erzeugung beruecksichtigt. Fuer Feiertage werden keine Sessions erzeugt.
Fixe Abrechnungen koennen bearbeitet werden. Wenn Gueltigkeiten oder Slots geaendert werden, werden nicht mehr passende erzeugte Sessions entfernt oder angepasst.
Fix erzeugte einzelne Sessions koennen geloescht werden, zum Beispiel wegen Urlaub oder Krankheit. Diese Loeschung wird als Ausnahme gespeichert, damit die Session nicht beim naechsten Aufruf wieder automatisch erscheint.
## Statistiken
Die Seite `Statistiken` betrachtet immer einen Monat. Sie dient als Ueberblick fuer abgerechnete Zeiten, nicht abrechenbare Arbeit, Teamspace-Vergleich, Bewertungsstand und Auffaelligkeiten.
### Kennzahlen
`Abgerechnet` zeigt die Summe aller Sessions mit Bewertung `Abgerechnet`. Nicht abrechenbare und offene Sessions sind darin nicht enthalten. Dieser Wert ist die Basis fuer den Vergleich mit Teamspace.
Alle Statistik- und Vergleichswerte verwenden nur abgerechnete Zeiten. Nicht ausgewaehlte/offene und nicht abrechenbare Sessions werden nicht in Relationen, Graphen, Teamspace-Differenzen, Hotspots, Manuell/Fix-Werte oder Durchschnittswerte eingerechnet.
`Teamspace` zeigt die Summe aller Teamspace-Tageswerte im Monat. Die Differenz wird immer so berechnet:
```text
Teamspace-Zeit minus abgerechnete Session-Zeit
```
Das bedeutet:
- Positive Differenz: In Teamspace steht mehr Zeit als abgerechnet wurde.
- Negative Differenz: In Teamspace steht weniger Zeit als abgerechnet wurde.
- Ausgeglichen: Beide Werte sind gleich.
Farben:
- Abgerechnet: blau
- Teamspace: fuchsia
- Positive oder ausgeglichene Differenz: gruen
- Negative Differenz: rot
- Nicht abrechenbar: indigo
- Offen: gelb/orange
`Nicht abrechenbar` zeigt Sessions, die bewusst nicht fakturiert werden. In den Statistiken wird dazu kein Stundenwert ausgegeben; diese Sessions werden nicht mit Teamspace verglichen.
`Offen` zeigt Sessions ohne Bewertung. Diese blockieren den Monatsabschluss.
### Monatsverlauf und Abrechnungsqualitaet
Diese Card kombiniert den Tagesverlauf und die Bewertungsqualitaet.
- Balken zeigen abgerechnete Session-Zeit.
- Die Linie zeigt Teamspace-Zeit.
- Der Bewertungsstand trennt abgerechnete, nicht abrechenbare und offene Sessions.
- Der Teamspace-Abgleich zeigt die Gesamtdifferenz.
- Manuell und Fix trennen manuell erfasste abrechenbare Sessions von automatisch erzeugten abrechenbaren Sessions.
### Statistik pro Organisation
Diese Card gruppiert den Aufwand nach Organisation.
Sichtbar sind:
- Organisation
- Sessions
- Zeiten: Abgerechnet, nicht abrechenbar und Teamspace
- Differenz
- Anteil an der abgerechneten Monatszeit
Bei vielen Organisationen wird paginiert.
### Typen
Die Typen-Card trennt Support und Consulting.
Sie zeigt je Typ:
- Tickets
- Sessions
- Abgerechnete Session-Zeit
- Nicht abrechenbare Session-Zeit
- Teamspace-Zeit
- Differenz
- Anzahl abgerechnet, nicht abrechenbar und offen
### Ticket-Hotspots
Ticket-Hotspots zeigen die groessten Zeitbloecke des Monats nach Ticket.
Sie helfen, die groessten Aufwandstreiber schnell zu finden. Mit `Oeffnen` gelangt man direkt in die Ticketdetailansicht des Monats.
## Profil
Unter `Profil` kann jeder Benutzer seine eigenen Daten pflegen:
@@ -222,21 +425,27 @@ Unter `Profil` kann jeder Benutzer seine eigenen Daten pflegen:
- Voller Name
- Passwort
Passwortaenderungen erfordern das aktuelle Passwort.
Passwortaenderungen erfordern das aktuelle Passwort. Bleiben die Passwortfelder leer, werden nur Benutzername und voller Name gespeichert.
## Adminbereich
Administratoren koennen:
Der feste Admin ist nur fuer die Systemverwaltung vorgesehen. Er kann:
- Benutzer anlegen
- Benutzer bearbeiten
- Passwoerter fuer Benutzer setzen
- Sessions aller Benutzer sehen
- Sessions anderen Benutzern zuweisen
- Sessions fuer Benutzer nachtragen
- Zammad-Organisationen synchronisieren
- Restore-faehige SQL-Backups herunterladen
Bei der Auswertung normaler Benutzer werden nur die Sessions des angemeldeten Benutzers beruecksichtigt. Ticketdaten koennen trotzdem von Sessions anderer Benutzer uebernommen werden, wenn dasselbe Ticket schon existiert.
Neue Benutzer erhalten automatisch die Rolle `User`. Eine Rollenauswahl gibt es nicht. Der feste Admin kann nicht deaktiviert oder zur User-Rolle herabgestuft werden.
Der Admin sieht keine Timer, keine Auswertungen, keine Statistiken, keine fixen Abrechnungen und keinen Monatsabschluss. Diese Bereiche sind nur fuer normale Benutzer vorgesehen.
Der Datenbankexport im Adminbereich erzeugt eine SQL-Datei fuer einen kompletten Restore. Fuer einen Umzug wird auf dem Zielsystem zuerst TicketTracker gestartet, damit die Migrationen die Tabellen anlegen. Danach wird die SQL-Datei mit `psql` in die Ziel-Datenbank eingespielt.
Der Export enthaelt alle Tabellen, die fuer den Stand des Systems notwendig sind: Benutzer, Einstellungen, Organisationen, Tickets, Sessions, Teamspace-Werte, fixe Abrechnungen, Ausnahmen und Monatsabschluesse. Er ist kein reiner CSV-Auszug, sondern ein Restore-Backup.
Im laufenden System kann der Admin ein SQL-Backup ebenfalls im Adminbereich wieder einspielen. Nach dem Hochladen muss zuerst die Strukturpruefung erfolgreich durchlaufen. Erst danach wird der Restore-Button freigegeben. Vor dem Restore erscheint ein Hinweis, weil alle aktuellen Daten ersetzt werden. Nach dem Restore ist eine neue Anmeldung mit den Zugangsdaten aus dem Backup notwendig.
## Zammad Sync
@@ -251,21 +460,26 @@ Gespeichert werden:
- Name
- Zeitstempel
Neue Organisationen werden hinzugefuegt, geaenderte Namen aktualisiert und nicht mehr vorhandene Organisationen entfernt beziehungsweise von bestehenden Tickets geloest.
Neue Organisationen werden hinzugefuegt, geaenderte Namen aktualisiert und nicht mehr vorhandene Organisationen entfernt beziehungsweise von bestehenden Tickets und Sessions geloest.
## Typischer Monatsablauf
1. Im Alltag Sessions per Timer oder Nachtragen erfassen.
2. Am Monatsende `Auswertung` oeffnen.
3. Monat auswaehlen.
4. Offene Sessions pruefen.
5. Pro Ticket jede Session als `Abgerechnet` oder `Nicht abrechenbar` markieren.
6. Pro Tag und Ticket die wirklich ins CRM eingetragene Zeit im Feld `CRM` pflegen.
7. Wenn keine Sessions im Monat mehr offen sind, Monat abschliessen.
2. Optional fixe Abrechnungen pflegen, damit wiederkehrende Sessions automatisch erzeugt werden.
3. Am Monatsende `Auswertung` oeffnen.
4. Monat auswaehlen.
5. Offene Sessions pruefen.
6. Pro Ticket jede Session als `Abgerechnet` oder `Nicht abrechenbar` markieren.
7. Pro Tag und Ticket die wirklich in Teamspace eingetragene Zeit im Feld `Teamspace` pflegen.
8. `Monatsabschluss` oeffnen.
9. Offene Bewertungen abarbeiten.
10. Tage ohne Teamspace-Wert pruefen oder zur Kenntnis nehmen.
11. Wenn keine Sessions im Monat mehr offen sind, Monat abschliessen.
## Hinweise
- Ticketnummern koennen ueber das Copy-Icon schnell in die Zwischenablage kopiert werden.
- Die App aktualisiert Auswertungen automatisch beim Wechsel von Monat oder Tag.
- Eine zurueckgenommene Bewertung macht eine Session wieder offen und kann Abschluesse wieder oeffnen.
- CRM-Zeiten sind Tageswerte pro Ticket und Benutzer. Sie sind nicht an einzelne Sessions gebunden.
- Eine zurueckgenommene Bewertung macht eine Session wieder offen und kann den Monatsabschluss wieder verhindern.
- Teamspace-Zeiten sind Tageswerte pro Ticket und Benutzer. Sie sind nicht an einzelne Sessions gebunden.
- Fragezeichen-Icons oeffnen kurze Hilfen direkt in der aktuellen Ansicht.
+450 -70
View File
@@ -5,6 +5,8 @@ import {
Command,
ChartNoAxesCombined,
CircleHelp,
FileText,
GripVertical,
LayoutGrid,
LogOut,
Moon,
@@ -18,8 +20,9 @@ import {
Timer,
UserCog,
UserCircle,
X,
} from "lucide-react";
import { FormEvent, MouseEvent, useEffect, useMemo, useState } from "react";
import { FormEvent, MouseEvent, useCallback, useEffect, useMemo, useState } from "react";
import { useShallow } from "zustand/react/shallow";
import { toast } from "sonner";
@@ -53,8 +56,8 @@ import {
import { usePreferencesStore } from "@/stores/preferences/preferences-provider";
import { cn } from "./lib/utils";
import { ApiError, getCurrentUser, logout, lookupTicket } from "./api";
import { formatTimer } from "./format";
import { ApiError, getCurrentUser, getSetupStatus, logout, lookupTicket, searchTickets } from "./api";
import { formatDateTime, formatMinutes, formatTimer } from "./format";
import { activeElapsedMs, pauseEntry, readStoredTimers, resumeEntry, storageKeyForUser, ticketPattern, type TimerEntry } from "./timers";
import { AdminUsersPage } from "./views/AdminUsersPage";
import { AnalysisPage } from "./views/AnalysisPage";
@@ -64,10 +67,11 @@ import { MonthlyClosePage } from "./views/MonthlyClosePage";
import { ProfilePage } from "./views/ProfilePage";
import { RecurringBillingsPage } from "./views/RecurringBillingsPage";
import { StatisticsPage } from "./views/StatisticsPage";
import { SetupPage } from "./views/SetupPage";
import { TicketDetailPage } from "./views/TicketDetailPage";
import { TimerPage } from "./views/TimerPage";
import type { AuthUser, PeriodType } from "./types";
import type { Dispatch, SetStateAction } from "react";
import type { AuthUser, PeriodType, TicketSearchResult } from "./types";
import type { Dispatch, DragEvent, SetStateAction } from "react";
type NavMenuEntry =
| {
@@ -82,6 +86,46 @@ type NavMenuEntry =
key: string;
};
type OpenTicketNavItem = {
key: string;
ticketId: string;
ticketNumber: string;
periodType: PeriodType;
period: string;
path: string;
dirty: boolean;
};
function openTicketsStorageKey(userId: string) {
return `tickettracker.openTickets.${userId}`;
}
function readOpenTickets(userId: string): OpenTicketNavItem[] {
try {
const raw = localStorage.getItem(openTicketsStorageKey(userId));
if (!raw) {
return [];
}
const parsed = JSON.parse(raw) as Partial<OpenTicketNavItem>[];
return parsed
.filter((item) => item.key && item.ticketId && item.ticketNumber && item.periodType && item.period && item.path)
.map((item) => ({
key: String(item.key),
ticketId: String(item.ticketId),
ticketNumber: String(item.ticketNumber),
periodType: item.periodType === "day" ? "day" : "month",
period: String(item.period),
path: String(item.path),
dirty: false
}));
} catch {
return [];
}
}
function routeFromPath(pathname: string) {
const periodTicketMatch = pathname.match(/^\/analysis\/(month|day)\/([^/]+)\/tickets\/(\d+)$/);
@@ -227,6 +271,118 @@ function QuickTimerStarter({ className, inputId, onStartTimer }: QuickTimerStart
);
}
type HeaderTicketSearchProps = {
className?: string;
onNavigate: (to: string) => void;
};
function HeaderTicketSearch({ className, onNavigate }: HeaderTicketSearchProps) {
const [query, setQuery] = useState("");
const [results, setResults] = useState<TicketSearchResult[]>([]);
const [open, setOpen] = useState(false);
const [loading, setLoading] = useState(false);
const trimmedQuery = query.trim();
useEffect(() => {
if (trimmedQuery.length < 2) {
setResults([]);
setLoading(false);
return;
}
let cancelled = false;
setLoading(true);
const timeout = window.setTimeout(async () => {
try {
const result = await searchTickets(trimmedQuery);
if (!cancelled) {
setResults(result.tickets);
}
} catch {
if (!cancelled) {
setResults([]);
}
} finally {
if (!cancelled) {
setLoading(false);
}
}
}, 220);
return () => {
cancelled = true;
window.clearTimeout(timeout);
};
}, [trimmedQuery]);
function openTicket(ticket: TicketSearchResult) {
onNavigate(`/analysis/month/${ticket.latest_period}/tickets/${ticket.ticket_id}`);
setQuery("");
setResults([]);
setOpen(false);
}
function submit(event: FormEvent) {
event.preventDefault();
if (results[0]) {
openTicket(results[0]);
}
}
return (
<form className={cn("relative min-w-0", className)} onSubmit={submit}>
<label className="sr-only" htmlFor="global-ticket-search">
Tickets suchen
</label>
<Search className="pointer-events-none absolute left-2.5 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
<Input
id="global-ticket-search"
className="h-8 bg-background pl-8"
placeholder="Ticket, Kunde, Tätigkeit"
value={query}
onFocus={() => setOpen(true)}
onBlur={() => window.setTimeout(() => setOpen(false), 120)}
onChange={(event) => {
setQuery(event.currentTarget.value);
setOpen(true);
}}
/>
{open && trimmedQuery.length >= 2 ? (
<div className="absolute left-0 top-[calc(100%+0.35rem)] z-50 w-[min(28rem,calc(100vw-2rem))] overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-lg">
<div className="max-h-80 overflow-y-auto py-1">
{loading ? <p className="px-3 py-2 text-sm text-muted-foreground">Sucht...</p> : null}
{!loading && results.length === 0 ? <p className="px-3 py-2 text-sm text-muted-foreground">Keine Tickets gefunden.</p> : null}
{!loading
? results.map((ticket) => (
<button
key={`${ticket.latest_period}:${ticket.ticket_id}`}
type="button"
className="flex w-full cursor-pointer flex-col gap-1 px-3 py-2 text-left hover:bg-accent hover:text-accent-foreground"
onMouseDown={(event) => {
event.preventDefault();
openTicket(ticket);
}}
>
<span className="flex min-w-0 items-center justify-between gap-3">
<span className="truncate text-sm font-medium">{ticket.ticket_number}</span>
<span className="shrink-0 text-xs text-muted-foreground">{formatMinutes(ticket.total_minutes)}</span>
</span>
<span className="truncate text-xs text-muted-foreground">
{ticket.organization_name || ticket.customer_name || "Keine Organisation"} · {ticket.session_count} Session(s) · zuletzt {formatDateTime(ticket.latest_started_at)}
</span>
</button>
))
: null}
</div>
</div>
) : null}
</form>
);
}
type SidebarTimersProps = {
timers: TimerEntry[];
selectedTimerId: string | null;
@@ -345,15 +501,27 @@ export function App() {
const { path, route, navHandler, navigate } = useAppRouter();
const [currentUser, setCurrentUser] = useState<AuthUser | null>(null);
const [authLoading, setAuthLoading] = useState(true);
const [setupRequired, setSetupRequired] = useState(false);
const [timers, setTimers] = useState<TimerEntry[]>([]);
const [timersLoaded, setTimersLoaded] = useState(false);
const [timerOwnerId, setTimerOwnerId] = useState<string | null>(null);
const [selectedTimerId, setSelectedTimerId] = useState<string | null>(null);
const [tick, setTick] = useState(Date.now());
const [openTickets, setOpenTickets] = useState<OpenTicketNavItem[]>([]);
const [draggedOpenTicketKey, setDraggedOpenTicketKey] = useState<string | null>(null);
useEffect(() => {
async function loadUser() {
try {
const setup = await getSetupStatus();
if (setup.required) {
setSetupRequired(true);
setCurrentUser(null);
return;
}
setSetupRequired(false);
const result = await getCurrentUser();
setCurrentUser(result.user);
} catch (error) {
@@ -375,16 +543,38 @@ export function App() {
setSelectedTimerId(null);
setTimerOwnerId(null);
setTimersLoaded(false);
setOpenTickets([]);
return;
}
if (currentUser.role === "admin") {
setTimers([]);
setSelectedTimerId(null);
setTimerOwnerId(currentUser.id);
setTimersLoaded(true);
setOpenTickets([]);
return;
}
setTimers(readStoredTimers(currentUser.id));
setOpenTickets(readOpenTickets(currentUser.id));
setTimerOwnerId(currentUser.id);
setTimersLoaded(true);
}, [currentUser]);
useEffect(() => {
if (!currentUser || !timersLoaded || timerOwnerId !== currentUser.id) {
if (!currentUser || currentUser.role === "admin") {
return;
}
localStorage.setItem(
openTicketsStorageKey(currentUser.id),
JSON.stringify(openTickets.map((ticket) => ({ ...ticket, dirty: false }))),
);
}, [currentUser, openTickets]);
useEffect(() => {
if (!currentUser || currentUser.role === "admin" || !timersLoaded || timerOwnerId !== currentUser.id) {
return;
}
@@ -401,7 +591,7 @@ export function App() {
}, [currentUser, timers, selectedTimerId, timersLoaded, timerOwnerId]);
useEffect(() => {
if (!currentUser || !timersLoaded || timerOwnerId !== currentUser.id) {
if (!currentUser || currentUser.role === "admin" || !timersLoaded || timerOwnerId !== currentUser.id) {
return;
}
@@ -467,7 +657,26 @@ export function App() {
return () => window.clearInterval(interval);
}, []);
useEffect(() => {
if (!currentUser || currentUser.role !== "admin") {
return;
}
if (route.page === "admin-users" || route.page === "profile" || route.page === "faq") {
return;
}
navigate("/admin/users");
}, [currentUser, route.page]);
async function startQuickTimer(rawTicketNumber: string) {
if (currentUser?.role === "admin") {
toast.error("Admin erfasst keine Sessions", {
description: "Der Adminbereich ist nur für die Systemverwaltung vorgesehen."
});
return false;
}
const ticketNumber = rawTicketNumber.trim();
if (!ticketPattern.test(ticketNumber)) {
@@ -519,6 +728,83 @@ export function App() {
navigate("/timer");
}
const updateOpenTicket = useCallback((ticket: OpenTicketNavItem) => {
setOpenTickets((current) => {
const existingIndex = current.findIndex((item) => item.key === ticket.key);
if (existingIndex >= 0) {
return current.map((item, index) => (index === existingIndex ? ticket : item));
}
return [...current, ticket].slice(-12);
});
}, []);
function moveOpenTicket(draggedKey: string, targetKey: string) {
if (draggedKey === targetKey) {
return;
}
setOpenTickets((current) => {
const draggedIndex = current.findIndex((item) => item.key === draggedKey);
const targetIndex = current.findIndex((item) => item.key === targetKey);
if (draggedIndex < 0 || targetIndex < 0) {
return current;
}
const next = [...current];
const [dragged] = next.splice(draggedIndex, 1);
next.splice(targetIndex, 0, dragged);
return next;
});
}
function startOpenTicketDrag(event: DragEvent<HTMLDivElement>, ticket: OpenTicketNavItem) {
setDraggedOpenTicketKey(ticket.key);
event.dataTransfer.effectAllowed = "move";
event.dataTransfer.setData("text/plain", ticket.key);
}
function overOpenTicket(event: DragEvent<HTMLDivElement>, ticket: OpenTicketNavItem) {
const draggedKey = draggedOpenTicketKey || event.dataTransfer.getData("text/plain");
if (!draggedKey || draggedKey === ticket.key) {
return;
}
event.preventDefault();
event.dataTransfer.dropEffect = "move";
}
function dropOpenTicket(event: DragEvent<HTMLDivElement>, ticket: OpenTicketNavItem) {
event.preventDefault();
const draggedKey = draggedOpenTicketKey || event.dataTransfer.getData("text/plain");
if (draggedKey) {
moveOpenTicket(draggedKey, ticket.key);
}
setDraggedOpenTicketKey(null);
}
function closeOpenTicket(ticket: OpenTicketNavItem) {
const current = openTickets.find((item) => item.key === ticket.key) ?? ticket;
if (current.dirty) {
toast.warning("Ticket hat ungespeicherte Änderungen", {
description: "Bitte speichere zuerst die offenen Werte im Ticket, bevor du es aus der Navigation schließt."
});
return;
}
setOpenTickets((items) => items.filter((item) => item.key !== ticket.key));
if (path === ticket.path) {
navigate("/analysis");
}
}
if (authLoading) {
return (
<main className="grid min-h-screen place-items-center bg-background p-4 text-sm text-muted-foreground">
@@ -527,26 +813,51 @@ export function App() {
);
}
if (setupRequired) {
return (
<SetupPage
onAdminCreated={(user) => {
setSetupRequired(false);
setCurrentUser(user);
navigate("/admin/users");
}}
onRestoreComplete={() => {
setSetupRequired(false);
setCurrentUser(null);
navigate("/");
}}
/>
);
}
if (!currentUser) {
return <LoginPage onLogin={setCurrentUser} />;
}
const navItems: NavMenuEntry[] = [
{ type: "item", href: "/timer", label: "Timer", icon: Timer, active: path.startsWith("/timer") || path === "/" },
{ type: "item", href: "/analysis", label: "Auswertung", icon: BarChart3, active: path.startsWith("/analysis") },
{ type: "separator", key: "after-analysis" },
{ type: "item", href: "/monthly-close", label: "Monatsabschluss", icon: ClipboardCheck, active: path.startsWith("/monthly-close") },
{ type: "separator", key: "after-monthly-close" },
{ type: "item", href: "/statistics", label: "Statistiken", icon: ChartNoAxesCombined, active: path.startsWith("/statistics") },
{ type: "item", href: "/recurring", label: "Fixe Abrechnung", icon: Repeat, active: path.startsWith("/recurring") },
{ type: "separator", key: "before-profile" },
{ type: "item", href: "/profile", label: "Profil", icon: UserCog, active: path.startsWith("/profile") },
...(currentUser.role === "admin"
? [{ type: "item" as const, href: "/admin/users", label: "Benutzer", icon: Shield, active: path.startsWith("/admin/users") }]
: []),
{ type: "separator", key: "before-faq" },
{ type: "item", href: "/faq", label: "FAQ", icon: CircleHelp, active: path.startsWith("/faq") },
? [
{ type: "item" as const, href: "/admin/users", label: "Administration", icon: Shield, active: path.startsWith("/admin/users") },
{ type: "separator" as const, key: "before-profile" },
{ type: "item" as const, href: "/profile", label: "Profil", icon: UserCog, active: path.startsWith("/profile") },
{ type: "separator" as const, key: "before-faq" },
{ type: "item" as const, href: "/faq", label: "FAQ", icon: CircleHelp, active: path.startsWith("/faq") }
]
: [
{ type: "item" as const, href: "/timer", label: "Session starten", icon: Timer, active: path.startsWith("/timer") || path === "/" },
{ type: "item" as const, href: "/analysis", label: "Auswertung", icon: BarChart3, active: path.startsWith("/analysis") },
{ type: "separator" as const, key: "after-analysis" },
{ type: "item" as const, href: "/monthly-close", label: "Monatsabschluss", icon: ClipboardCheck, active: path.startsWith("/monthly-close") },
{ type: "separator" as const, key: "after-monthly-close" },
{ type: "item" as const, href: "/statistics", label: "Statistiken", icon: ChartNoAxesCombined, active: path.startsWith("/statistics") },
{ type: "item" as const, href: "/recurring", label: "Fixe Abrechnung", icon: Repeat, active: path.startsWith("/recurring") },
{ type: "separator" as const, key: "before-profile" },
{ type: "item" as const, href: "/profile", label: "Profil", icon: UserCog, active: path.startsWith("/profile") },
{ type: "separator" as const, key: "before-faq" },
{ type: "item" as const, href: "/faq", label: "FAQ", icon: CircleHelp, active: path.startsWith("/faq") }
])
];
const isAdmin = currentUser.role === "admin";
const runningTimer = timers.find((timer) => timer.phase === "running") ?? null;
const runningElapsedSeconds = runningTimer ? Math.floor(activeElapsedMs(runningTimer, tick) / 1000) : 0;
@@ -564,7 +875,7 @@ export function App() {
<SidebarMenu>
<SidebarMenuItem>
<SidebarMenuButton asChild>
<a href="/timer" onClick={navHandler("/timer")}>
<a href={isAdmin ? "/admin/users" : "/timer"} onClick={navHandler(isAdmin ? "/admin/users" : "/timer")}>
<Command />
<span className="font-semibold text-base">TicketTracker</span>
</a>
@@ -574,36 +885,40 @@ export function App() {
</SidebarHeader>
<SidebarContent>
<SidebarGroup>
<SidebarGroupContent className="flex flex-col gap-2">
<SidebarMenu>
<SidebarMenuItem className="flex items-center gap-2">
<SidebarMenuButton
asChild
tooltip="Session starten"
className="min-w-8 bg-primary text-primary-foreground duration-200 ease-linear hover:bg-primary/90 hover:text-primary-foreground active:bg-primary/90 active:text-primary-foreground"
>
<a href="/timer" onClick={navHandler("/timer")}>
<Timer />
<span>Session starten</span>
</a>
</SidebarMenuButton>
</SidebarMenuItem>
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
{!isAdmin ? (
<>
<SidebarGroup>
<SidebarGroupContent className="flex flex-col gap-2">
<SidebarMenu>
<SidebarMenuItem className="flex items-center gap-2">
<SidebarMenuButton
asChild
tooltip="Session starten"
className="min-w-8 bg-primary text-primary-foreground duration-200 ease-linear hover:bg-primary/90 hover:text-primary-foreground active:bg-primary/90 active:text-primary-foreground"
>
<a href="/timer" onClick={navHandler("/timer")}>
<Timer />
<span>Session starten</span>
</a>
</SidebarMenuButton>
</SidebarMenuItem>
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
<SidebarTimers
timers={timers}
selectedTimerId={selectedTimerId}
setSelectedTimerId={setSelectedTimerId}
setTimers={setTimers}
tick={tick}
onOpenTimer={() => navigate("/timer")}
/>
<SidebarTimers
timers={timers}
selectedTimerId={selectedTimerId}
setSelectedTimerId={setSelectedTimerId}
setTimers={setTimers}
tick={tick}
onOpenTimer={() => navigate("/timer")}
/>
</>
) : null}
<SidebarGroup>
<SidebarGroupLabel>Workflows</SidebarGroupLabel>
<SidebarGroupLabel>{isAdmin ? "Verwaltung" : "Workflow"}</SidebarGroupLabel>
<SidebarGroupContent>
<SidebarMenu>
{navItems.map((item) =>
@@ -626,16 +941,68 @@ export function App() {
</SidebarGroupContent>
</SidebarGroup>
{!isAdmin && openTickets.length > 0 ? (
<SidebarGroup className="pt-0 group-data-[collapsible=icon]:hidden">
<SidebarGroupLabel className="h-6 px-2 text-xs">Geöffnete Tickets</SidebarGroupLabel>
<SidebarGroupContent>
<SidebarMenu>
{openTickets.map((ticket) => {
const isActive = path === ticket.path;
return (
<SidebarMenuItem key={ticket.key}>
<div
draggable
onDragStart={(event) => startOpenTicketDrag(event, ticket)}
onDragOver={(event) => overOpenTicket(event, ticket)}
onDrop={(event) => dropOpenTicket(event, ticket)}
onDragEnd={() => setDraggedOpenTicketKey(null)}
className={cn(
"mx-2 flex cursor-grab items-center gap-1 rounded-md px-2 py-1.5 active:cursor-grabbing",
isActive ? "bg-primary text-primary-foreground" : "hover:bg-sidebar-accent hover:text-sidebar-accent-foreground",
draggedOpenTicketKey === ticket.key && "opacity-60",
)}
>
<GripVertical className="size-3.5 shrink-0 text-muted-foreground" />
<a
href={ticket.path}
className="flex min-w-0 flex-1 items-center gap-2 text-sm"
title={`${ticket.ticketNumber} (${ticket.periodType === "month" ? "Monat" : "Tag"} ${ticket.period})`}
onClick={navHandler(ticket.path)}
>
<span className={cn("size-2 shrink-0 rounded-full", ticket.dirty ? "bg-amber-400" : isActive ? "bg-primary-foreground" : "bg-muted-foreground/70")} />
<FileText className="size-4 shrink-0" />
<span className="truncate">{ticket.ticketNumber}</span>
</a>
<Button
type="button"
size="icon"
variant="ghost"
className={cn("size-6 shrink-0", isActive ? "hover:bg-primary-foreground/15 hover:text-primary-foreground" : "")}
onClick={() => closeOpenTicket(ticket)}
aria-label={`${ticket.ticketNumber} schließen`}
>
<X className="size-3.5" />
</Button>
</div>
</SidebarMenuItem>
);
})}
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
) : null}
</SidebarContent>
<SidebarFooter>
<div className="mx-2 rounded-lg border bg-card p-3 text-sm shadow-xs group-data-[collapsible=icon]:hidden">
{!isAdmin ? <div className="mx-2 rounded-lg border bg-card p-3 text-sm shadow-xs group-data-[collapsible=icon]:hidden">
<div className="mb-2 flex items-center gap-2 font-medium">
<CheckCircle2 className="size-4 text-muted-foreground" />
Abschluss
</div>
<p className="text-muted-foreground">Offene Bewertungen findest du im Monatsabschluss.</p>
</div>
</div> : null}
<SidebarMenu>
<SidebarMenuItem>
<SidebarMenuButton tooltip="Worklog">
@@ -661,10 +1028,14 @@ export function App() {
<div className="flex min-w-0 items-center gap-2">
<SidebarTrigger className="-ml-1" />
<Separator orientation="vertical" className="mx-1 data-[orientation=vertical]:h-4 data-[orientation=vertical]:self-center" />
<div className="hidden h-8 min-w-[160px] items-center gap-2 rounded-md px-2 text-sm text-muted-foreground lg:flex">
<Search className="size-4" />
<span>{currentUser.display_name}</span>
</div>
{!isAdmin ? (
<HeaderTicketSearch className="hidden w-72 lg:block" onNavigate={navigate} />
) : (
<div className="hidden h-8 min-w-[160px] items-center gap-2 rounded-md px-2 text-sm text-muted-foreground lg:flex">
<Search className="size-4" />
<span>{currentUser.display_name}</span>
</div>
)}
{runningTimer ? (
<button
type="button"
@@ -684,11 +1055,13 @@ export function App() {
<span className="truncate text-sm font-semibold">TicketTracker</span>
</div>
</div>
<QuickTimerStarter
inputId="quick-ticket-number-desktop"
className="mx-3 hidden max-w-md flex-1 md:flex"
onStartTimer={startQuickTimer}
/>
{!isAdmin ? (
<QuickTimerStarter
inputId="quick-ticket-number-desktop"
className="mx-3 hidden max-w-md flex-1 md:flex"
onStartTimer={startQuickTimer}
/>
) : null}
<div className="flex items-center gap-2">
<ThemeSwitcher />
<DropdownMenu>
@@ -716,12 +1089,12 @@ export function App() {
</div>
</div>
</header>
<div className="border-b bg-background/95 px-3 py-2 md:hidden">
{!isAdmin ? <div className="border-b bg-background/95 px-3 py-2 md:hidden">
<QuickTimerStarter inputId="quick-ticket-number-mobile" onStartTimer={startQuickTimer} />
</div>
</div> : null}
<main className="min-h-0 min-w-0 flex-1 overflow-x-hidden p-3 sm:p-4 md:p-5">
<div className="mx-auto w-full max-w-screen-2xl">
{route.page === "timer" ? (
{!isAdmin && route.page === "timer" ? (
<TimerPage
timers={timers}
setTimers={setTimers}
@@ -730,14 +1103,14 @@ export function App() {
tick={tick}
/>
) : null}
{route.page === "analysis" ? <AnalysisPage onNavigate={navigate} /> : null}
{route.page === "monthly-close" ? <MonthlyClosePage onNavigate={navigate} /> : null}
{route.page === "statistics" ? <StatisticsPage onNavigate={navigate} /> : null}
{route.page === "recurring" ? <RecurringBillingsPage /> : null}
{!isAdmin && route.page === "analysis" ? <AnalysisPage onNavigate={navigate} /> : null}
{!isAdmin && route.page === "monthly-close" ? <MonthlyClosePage onNavigate={navigate} /> : null}
{!isAdmin && route.page === "statistics" ? <StatisticsPage onNavigate={navigate} /> : null}
{!isAdmin && route.page === "recurring" ? <RecurringBillingsPage /> : null}
{route.page === "faq" ? <FaqPage /> : null}
{route.page === "profile" ? <ProfilePage currentUser={currentUser} onUserUpdated={setCurrentUser} /> : null}
{route.page === "admin-users" && currentUser.role === "admin" ? <AdminUsersPage currentUser={currentUser} /> : null}
{route.page === "admin-users" && currentUser.role !== "admin" ? (
{route.page === "admin-users" && isAdmin ? <AdminUsersPage currentUser={currentUser} /> : null}
{route.page === "admin-users" && !isAdmin ? (
<TimerPage
timers={timers}
setTimers={setTimers}
@@ -746,9 +1119,16 @@ export function App() {
tick={tick}
/>
) : null}
{route.page === "ticket" ? (
<TicketDetailPage periodType={route.periodType} period={route.period} ticketId={route.ticketId} onNavigate={navigate} />
{!isAdmin && route.page === "ticket" ? (
<TicketDetailPage
periodType={route.periodType}
period={route.period}
ticketId={route.ticketId}
onNavigate={navigate}
onOpenTicketChange={updateOpenTicket}
/>
) : null}
{isAdmin && route.page !== "admin-users" && route.page !== "profile" && route.page !== "faq" ? <AdminUsersPage currentUser={currentUser} /> : null}
</div>
</main>
</SidebarInset>
+86 -28
View File
@@ -1,5 +1,4 @@
import type {
AdminSession,
AdminUser,
AuthUser,
BillingStatus,
@@ -10,7 +9,7 @@ import type {
StatisticsOverview,
TicketMeta,
TicketPeriod,
UserRole,
TicketSearchResult,
WorkType
} from "./types";
@@ -34,6 +33,12 @@ type DeleteSessionResult = {
};
};
export type RestoreValidationResult = {
valid: boolean;
checkedTables: number;
issues: string[];
};
export class ApiError extends Error {
constructor(
message: string,
@@ -91,6 +96,31 @@ export function updateCurrentUser(payload: {
});
}
export function getSetupStatus() {
return request<{ required: boolean }>("/api/setup/status");
}
export function createSetupAdmin(payload: { username: string; displayName: string; password: string }) {
return request<{ user: AuthUser }>("/api/setup/admin", {
method: "POST",
body: JSON.stringify(payload)
});
}
export function restoreSetupBackup(sql: string) {
return request<{ ok: boolean }>("/api/setup/restore", {
method: "POST",
body: JSON.stringify({ sql })
});
}
export function validateSetupBackup(sql: string) {
return request<RestoreValidationResult>("/api/setup/restore/validate", {
method: "POST",
body: JSON.stringify({ sql })
});
}
export function getAdminUsers() {
return request<{ users: AdminUser[] }>("/api/admin/users");
}
@@ -99,7 +129,6 @@ export function createAdminUser(payload: {
username: string;
displayName: string;
password: string;
role: UserRole;
active: boolean;
}) {
return request<{ user: AdminUser }>("/api/admin/users", {
@@ -114,7 +143,6 @@ export function updateAdminUser(
username: string;
displayName: string;
password?: string;
role: UserRole;
active: boolean;
}
) {
@@ -124,8 +152,56 @@ export function updateAdminUser(
});
}
export function getAdminSessions() {
return request<{ sessions: AdminSession[] }>("/api/admin/sessions");
export async function downloadDatabaseExport() {
const response = await fetch("/api/admin/export/database", {
credentials: "same-origin"
});
if (!response.ok) {
const body = await response.json().catch(() => ({}));
throw new ApiError(body.error ?? "Export fehlgeschlagen", response.status);
}
const contentDisposition = response.headers.get("content-disposition") ?? "";
const filenameMatch = contentDisposition.match(/filename="([^"]+)"/);
return {
blob: await response.blob(),
filename: filenameMatch?.[1] ?? `tickettracker-backup-${new Date().toISOString().replace(/[:.]/g, "-")}.sql`
};
}
export function restoreDatabaseBackup(sql: string) {
return request<{ ok: boolean }>("/api/admin/restore/database", {
method: "POST",
body: JSON.stringify({ sql })
});
}
export function validateDatabaseBackup(sql: string) {
return request<RestoreValidationResult>("/api/admin/restore/database/validate", {
method: "POST",
body: JSON.stringify({ sql })
});
}
export async function downloadMonthExport(month: string) {
const response = await fetch(`/api/export/months/${month}`, {
credentials: "same-origin"
});
if (!response.ok) {
const body = await response.json().catch(() => ({}));
throw new ApiError(body.error ?? "Export fehlgeschlagen", response.status);
}
const contentDisposition = response.headers.get("content-disposition") ?? "";
const filenameMatch = contentDisposition.match(/filename="([^"]+)"/);
return {
blob: await response.blob(),
filename: filenameMatch?.[1] ?? `tickettracker-monat-${month}.csv`
};
}
export function getOrganizations(search = "") {
@@ -214,28 +290,6 @@ export function deleteRecurringBilling(billingId: string) {
});
}
export function createAdminSession(payload: {
userId: string;
ticketNumber: string;
organizationId: string;
activity: string;
workType: WorkType;
startedAt: string;
endedAt: string;
}) {
return request<{ session: AdminSession }>("/api/admin/sessions", {
method: "POST",
body: JSON.stringify(payload)
});
}
export function reassignAdminSession(sessionId: string, userId: string) {
return request<{ session: AdminSession }>(`/api/admin/sessions/${sessionId}/owner`, {
method: "PATCH",
body: JSON.stringify({ userId })
});
}
export function createSession(payload: CreateSessionPayload) {
return request("/api/sessions", {
method: "POST",
@@ -247,6 +301,10 @@ export function lookupTicket(ticketNumber: string) {
return request<{ ticket: TicketMeta | null }>(`/api/tickets/lookup?ticketNumber=${encodeURIComponent(ticketNumber)}`);
}
export function searchTickets(query: string) {
return request<{ tickets: TicketSearchResult[] }>(`/api/tickets/search?q=${encodeURIComponent(query)}`);
}
export function updateTicket(
ticketId: string,
payload: {
+140 -70
View File
@@ -4,6 +4,8 @@ export type HelpSection = {
description: string;
quickItems: string[];
details: string[];
workflow?: string[];
diagram?: string[];
};
export const helpSections: HelpSection[] = [
@@ -18,13 +20,87 @@ export const helpSections: HelpSection[] = [
],
details: [
"TicketTracker ersetzt nicht das Ticketsystem. Ticketnummern dienen als Bezug zur Arbeit, die eigentliche Dokumentation bleibt im vorhandenen Ticketsystem.",
"TicketTracker ersetzt auch nicht das CRM. Die CRM- beziehungsweise Teamspace-Zeit wird als Kontrollwert gepflegt, damit getrackte Zeiten und eingetragene Abrechnung gegenübergestellt werden können.",
"TicketTracker ersetzt auch nicht das CRM. Die CRM- beziehungsweise Teamspace-Zeit wird als Kontrollwert gepflegt, damit abgerechnete Zeiten und eingetragene Abrechnung gegenübergestellt werden können.",
"Eine Session ist immer einem Benutzer zugeordnet. In den normalen Auswertungen sieht jeder Benutzer nur seine eigenen Sessions.",
"Ticketdaten wie Organisation und Art können aus bestehenden Tickets übernommen werden, auch wenn ein anderer Benutzer das Ticket schon bearbeitet hat.",
"Tickets werden nicht abgeschlossen. Sie gruppieren Sessions. Relevant für den Abschluss ist nur, ob alle Sessions eines Monats bewertet sind.",
"Der Monatsabschluss sperrt den Monat gegen versehentliche Änderungen. Muss doch noch etwas korrigiert werden, kann der Monat wieder geöffnet werden."
]
},
{
id: "monatsworkflow",
title: "Monatsworkflow",
description: "Der empfohlene Ablauf vom Start einer Arbeitssession bis zum sauberen Monatsabschluss.",
quickItems: [
"Während des Monats: Ticketnummer eingeben, Timer starten, Session beenden und Tätigkeit dokumentieren.",
"Vergessene Zeiten: Session manuell auf dem Ticket oder als neues Ticket nachtragen.",
"Am Monatsende: offene Bewertungen bearbeiten, Teamspace-Tageswerte pflegen, Kontrolle durchführen und Monat schließen."
],
details: [
"Der normale Einstieg ist das Ticketfeld im Kopfbereich oder die Seite Session starten. Dort gibst du die Ticketnummer ein und startest den Timer mit Timer starten.",
"Während der Arbeit läuft genau ein Timer aktiv. Wechselst du zu einem anderen Timer, pausiert TicketTracker die übrigen Timer automatisch.",
"Wenn die Arbeit erledigt ist, beendest du den Timer. Die Zeit stoppt sofort beim Klick auf Beenden; das anschließende Ausfüllen der Tätigkeit zählt nicht mehr zur Sessionzeit.",
"Im Beenden-Dialog trägst du die Tätigkeit ein. Wenn das Ticket bereits bekannt ist, übernimmt TicketTracker Organisation und Art automatisch. Bei neuen Tickets wählst du Organisation und Art zusätzlich aus.",
"Vergessene Sessions trägst du auf der Seite Session starten im Bereich Session nachtragen nach. Wenn das Ticket bereits in der Auswertung geöffnet ist, nutzt du in der Ticketdetailansicht ebenfalls Session nachtragen; dann ist die Zuordnung zum Ticket bereits vorbelegt.",
"Unter Auswertung wählst du Monat oder Tag, öffnest ein Ticket über Öffnen und siehst dort alle Sessions nach Tag gruppiert.",
"Im Ticket bewertest du jede Session mit Abgerechnet oder Nicht abrechenbar. Ein erneuter Klick auf den aktiven Status nimmt die Bewertung zurück.",
"Im Ticket steht pro Tag das Feld Teamspace. Dort trägst du die Stunden ein, die du für genau dieses Ticket an genau diesem Tag wirklich im CRM beziehungsweise Teamspace erfasst hast. Nach einer Änderung erscheint der Speichern-Button.",
"Die Teamspace-Zeit ist ein Tageswert pro Ticket und Benutzer. Monats- und Tagesansicht zeigen denselben Wert; er muss also nur einmal gepflegt werden.",
"Am Monatsende öffnest du Monatsabschluss. Dort findest du offene Bewertungen gruppiert nach Ticket, Tage ohne Teamspace-Wert und die Schaltfläche Monat abschließen.",
"Offene Bewertungen sind Pflicht: Öffne das Ticket aus der Liste, bewerte die Sessions und kehre zum Monatsabschluss zurück.",
"Tage ohne Teamspace-Wert sind eine Prüfliste. Wenn die Arbeit wirklich nicht in Teamspace eingetragen werden soll, nutzt du Zur Kenntnis. Mit Zur Kenntnis anzeigen blendest du solche Einträge wieder ein und kannst die Kenntnisnahme zurücknehmen.",
"Kontrolliere vor dem Abschluss die Kennzahl Teamspace im Monatsabschluss. Idealerweise entspricht dieser Wert der Summe, die du im CRM beziehungsweise Teamspace für den Monat eingetragen hast.",
"Wenn alle Sessions bewertet sind und die Teamspace-Kontrolle passt, schließt du den Monat mit Monat abschließen. Danach sind Bewertungen und Änderungen für den Monat geschützt, bis der Monat wieder geöffnet wird."
],
workflow: [
"Ticketfeld im Kopfbereich oder Session starten öffnen.",
"Ticketnummer eingeben und Timer starten.",
"Arbeit erledigen; bei Bedarf zwischen vorbereiteten Timern wechseln.",
"Timer beenden und Tätigkeit speichern.",
"Vergessene Zeiten über Session nachtragen ergänzen.",
"Auswertung öffnen, Monat wählen und Tickets prüfen.",
"Ticket öffnen, Sessions pro Tag bewerten und Teamspace-Tageswerte speichern.",
"Monatsabschluss öffnen, offene Bewertungen und Teamspace-Prüfpunkte abarbeiten.",
"Teamspace-Summe mit dem CRM vergleichen.",
"Monat abschließen."
],
diagram: [
"Ticketnummer eingeben",
"Timer starten",
"Arbeit am Ticket erledigen",
"Timer beenden",
"Tätigkeit eintragen",
"Session wird dem Ticket und Benutzer gespeichert",
"Optional: vergessene Session nachtragen",
"Auswertung öffnen und Ticket prüfen",
"Sessions bewerten",
"Teamspace pro Ticket-Tag eintragen",
"Monatsabschluss öffnen",
"Offene Bewertungen und Teamspace-Prüfpunkte klären",
"Teamspace-Summe mit CRM vergleichen",
"Monat abschließen"
]
},
{
id: "erstes-setup",
title: "Erstes Setup",
description: "Wenn noch kein Admin existiert, startet TicketTracker mit einer Setup-Seite.",
quickItems: [
"Admin direkt im Browser anlegen.",
"Alternativ ein TicketTracker-SQL-Backup prüfen und einspielen.",
"Nach Restore gelten ausschließlich die Daten aus dem Backup."
],
details: [
"Admin-Zugangsdaten werden nicht mehr über .env gesetzt.",
"Beim ersten Start ohne Admin zeigt TicketTracker automatisch die Setup-Seite.",
"Über Admin anlegen erstellst du den festen Admin und wirst direkt angemeldet.",
"Über Backup einspielen lädst du ein restore-fähiges SQL-Backup aus TicketTracker hoch.",
"Nach dem Hochladen prüft TicketTracker die Dump-Struktur gegen die aktuelle Datenbankstruktur.",
"Der Restore-Button wird erst aktiv, wenn keine Felder fehlen und keine unbekannten Felder enthalten sind.",
"Beim Restore werden zuerst alle TicketTracker-Tabellen geleert. Danach gelten Benutzer, Passwörter, Sessions, Teamspace-Werte, Settings und Abschlüsse aus dem Backup.",
"Nach einem Restore meldest du dich mit einem Benutzer aus dem Backup an."
]
},
{
id: "timer",
title: "Timer",
@@ -51,7 +127,7 @@ export const helpSections: HelpSection[] = [
description: "Nachtragen ist für vergessene Zeiten gedacht, wenn kein Timer gelaufen ist.",
quickItems: [
"Datum, Von, Bis und Tätigkeit eintragen.",
"Benutzer tragen eigene Sessions nach; Admins können Benutzer auswählen.",
"Benutzer tragen eigene Sessions nach.",
"Tätigkeiten sind mehrzeilig."
],
details: [
@@ -60,7 +136,6 @@ export const helpSections: HelpSection[] = [
"Die Organisation muss aus der synchronisierten Organisationsliste gewählt werden. Freitext ist absichtlich gesperrt.",
"Die Tätigkeit ist eine Textarea. Zeilenumbrüche bleiben erhalten und werden in der Ticketdetailansicht wieder ausgegeben.",
"Nachgetragene Sessions starten immer offen. Sie müssen später als Abgerechnet oder Nicht abrechenbar bewertet werden.",
"Admins können im Adminbereich zusätzlich bestimmen, welchem Benutzer die nachgetragene Session gehört.",
"In der Ticketdetailansicht kann eine Session direkt zum geöffneten Ticket nachgetragen werden. Ticketnummer und Stammdaten sind dort schon vorbelegt beziehungsweise aus dem Ticket abgeleitet."
]
},
@@ -106,14 +181,17 @@ export const helpSections: HelpSection[] = [
quickItems: [
"Monat oder Tag wählen; die Ansicht aktualisiert automatisch.",
"Tickets können gefiltert, gruppiert und sortiert werden.",
"Die Einstellungen bleiben nach Refresh und Browserneustart erhalten."
"Die Einstellungen bleiben nach Refresh und Browserneustart erhalten.",
"Monate können als CSV exportiert werden."
],
details: [
"Die Monatsansicht zeigt alle Tickets, die im gewählten Monat mindestens eine Session haben.",
"Die Tagesansicht zeigt dieselben Datenlogiken auf einen einzelnen Tag gefiltert. Es gibt keinen Tagesabschluss.",
"Die Pfeile neben der Zeitraum-Eingabe springen jeweils einen Monat oder einen Tag vor beziehungsweise zurück.",
"Der Gesamtaufwand-Graph zeigt den getrackten Aufwand im Zeitraum. In der Monatsansicht pro Tag, in der Tagesansicht pro Stunde.",
"Die Ticketliste kann nach Typ, Organisation oder Status gruppiert werden. Gruppenüberschriften zeigen Anzahl, Zeit und offene Bewertungen.",
"Über Monat exportieren lädst du eine CSV-Datei deiner eigenen Sessions dieses Monats herunter.",
"Der CSV-Export enthält Ticket, Organisation, Art, Datum, Von/Bis, Bewertung, Session-Minuten, Teamspace-Minuten und Tätigkeit.",
"Der Graph zeigt abgerechnete Zeit im Zeitraum. In der Monatsansicht pro Tag, in der Tagesansicht pro Stunde.",
"Die Ticketliste kann nach Typ, Organisation oder Status gruppiert werden. Gruppenüberschriften zeigen Anzahl, abgerechnete Zeit und offene Bewertungen.",
"Filter und Sortierungen bleiben im Browser gespeichert und können über Zurücksetzen wieder auf Standard gestellt werden.",
"Das Copy-Icon neben der Ticketnummer kopiert die Nummer direkt in die Zwischenablage."
]
@@ -129,11 +207,15 @@ export const helpSections: HelpSection[] = [
],
details: [
"Oben steht die Ticketnummer mit Copy-Icon, damit sie schnell in andere Systeme übernommen werden kann.",
"Die Session-Einträge sind nach Kalendertagen gruppiert. Pro Tagesgruppe steht die Summe der getrackten Zeit.",
"Die Session-Einträge sind nach Kalendertagen gruppiert. Pro Tagesgruppe steht die Summe der abgerechneten Zeit.",
"Nicht abrechenbare und offene Zeiten werden neben der abgerechneten Tageszeit separat angezeigt.",
"Das Teamspace-Feld wird als Stundenwert gepflegt, zum Beispiel 1, 1,5 oder 2.25.",
"Während der Eingabe wird der Teamspace-Wert nicht sofort gespeichert. Sobald eine Änderung vorliegt, erscheint ein Speichern-Button.",
"Ein leeres Teamspace-Feld entfernt den gespeicherten Teamspace-Wert für diesen Ticket-Tag.",
"Der Teamspace-Wert hängt am Ticket, Benutzer und Tag. Deshalb ist er in Monats- und Tagesansicht identisch.",
"Geöffnete Tickets werden in der Seitenleiste unter Geöffnete Tickets abgelegt und bleiben nach Refresh oder Browserneustart erhalten.",
"Die Reihenfolge bleibt beim Anklicken stabil und kann nur per Drag and Drop geändert werden.",
"Der Schließen-Button in der Seitenleiste entfernt ein Ticket nur, wenn im aktuell geöffneten Ticket keine ungespeicherten Werte mehr vorhanden sind.",
"Sessions können bewertet, bearbeitet oder gelöscht werden. Gelöschte letzte Sessions können dazu führen, dass ein leeres Ticket entfernt wird."
]
},
@@ -217,38 +299,39 @@ export const helpSections: HelpSection[] = [
{
id: "statistiken",
title: "Statistiken",
description: "Statistiken vergleichen getrackte Arbeit, bewertete Sessions und Teamspace-Werte.",
description: "Statistiken vergleichen abgerechnete Session-Zeit, offene Bewertungen und Teamspace-Werte.",
quickItems: [
"Sessions und Teamspace haben getrennte Farben.",
"Abgerechnet und Teamspace haben getrennte Farben.",
"Positive oder gleiche Differenz ist grün.",
"Negative Differenz ist rot."
],
details: [
"Die Statistikseite betrachtet immer einen Monat.",
"Getrackt zeigt die Summe aller Session-Zeiten und die Anzahl der Sessions und Tickets.",
"Teamspace zeigt, wie viel Zeit für dieselben Ticket-Tage im CRM eingetragen wurde.",
"Eine positive Differenz bedeutet: In Teamspace steht mehr Zeit als in den Sessions getrackt wurde.",
"Eine negative Differenz bedeutet: In Teamspace steht weniger Zeit als getrackt wurde.",
"Bewertet umfasst sowohl Abgerechnet als auch Nicht abrechenbar. Offen sind nur Sessions ohne Bewertung.",
"Der Monatsverlauf zeigt getrackte Session-Zeit als Balken und Teamspace als Linie.",
"Abgerechnet zeigt nur Sessions mit der Bewertung Abgerechnet. Offene Zeiten sind dort nicht enthalten.",
"Alle Statistik- und Vergleichswerte nutzen nur abgerechnete Zeiten.",
"Teamspace zeigt, wie viel Zeit für dieselben abrechenbaren Ticket-Tage im CRM eingetragen wurde.",
"Eine positive Differenz bedeutet: In Teamspace steht mehr Zeit als abgerechnet wurde.",
"Eine negative Differenz bedeutet: In Teamspace steht weniger Zeit als abgerechnet wurde.",
"Offene Sessions werden nicht in Relationen, Graphen, Teamspace-Differenzen, Hotspots, Manuell/Fix-Werte oder Durchschnittswerte eingerechnet.",
"Der Monatsverlauf zeigt abgerechnete Session-Zeit als Balken und Teamspace als Linie.",
"Organisationen, Typen und Ticket-Hotspots helfen dabei, Auffälligkeiten im Monat schnell zu finden."
]
},
{
id: "statistik-getrackt",
title: "Statistik: Getrackt",
description: "Diese Kennzahl zeigt die gesamte erfasste Session-Zeit im gewählten Monat.",
title: "Statistik: Abgerechnet",
description: "Diese Kennzahl zeigt die abrechenbare Session-Zeit im gewählten Monat.",
quickItems: [
"Summe aller getrackten und nachgetragenen Sessions.",
"Zeigt zusätzlich Session- und Ticketanzahl.",
"Summe aller Sessions mit Bewertung Abgerechnet.",
"Zeigt zusätzlich die abgerechnete Sessionanzahl.",
"Basiswert für den Vergleich mit Teamspace."
],
details: [
"Getrackt umfasst alle Sessions des angemeldeten Benutzers im gewählten Monat.",
"Dabei ist egal, ob die Session bereits bewertet wurde oder noch offen ist.",
"Auch automatisch erzeugte Sessions aus fixen Abrechnungen zählen hier mit.",
"Die Ticketanzahl zeigt, auf wie viele unterschiedliche Tickets sich die Sessions verteilen.",
"Dieser Wert ist die Ausgangsbasis für Teamspace-Differenzen: Teamspace minus getrackte Session-Zeit ergibt die Differenz."
"Abgerechnet umfasst nur Sessions des angemeldeten Benutzers, die aktiv als Abgerechnet markiert wurden.",
"Offene Sessions werden aus diesem Wert herausgehalten.",
"Auch automatisch erzeugte Sessions aus fixen Abrechnungen zählen hier mit, sobald sie als Abgerechnet markiert wurden.",
"Es werden keine offenen Zeiten in diesen Wert eingerechnet.",
"Dieser Wert ist die Ausgangsbasis für Teamspace-Differenzen: Teamspace minus abgerechnete Session-Zeit ergibt die Differenz."
]
},
{
@@ -263,40 +346,24 @@ export const helpSections: HelpSection[] = [
details: [
"Teamspace-Werte werden pro Ticket und Tag gepflegt, nicht pro einzelner Session.",
"Die Statistik summiert alle Teamspace-Werte des Monats für den angemeldeten Benutzer.",
"Eine positive Differenz bedeutet, dass in Teamspace mehr Zeit steht als in den Sessions getrackt wurde.",
"Eine negative Differenz bedeutet, dass in Teamspace weniger Zeit steht als getrackt wurde.",
"Eine positive Differenz bedeutet, dass in Teamspace mehr Zeit steht als abgerechnet wurde.",
"Eine negative Differenz bedeutet, dass in Teamspace weniger Zeit steht als abgerechnet wurde.",
"Offene Sessions fließen nicht in diese Differenz ein.",
"Fehlende Teamspace-Werte können im Monatsabschluss geprüft und bei Bedarf zur Kenntnis genommen werden."
]
},
{
id: "statistik-bewertet",
title: "Statistik: Bewertet",
description: "Diese Kennzahl zeigt, wie viel der erfassten Arbeit bereits fachlich eingeordnet wurde.",
quickItems: [
"Abgerechnet und Nicht abrechenbar zählen beide als bewertet.",
"Der Prozentwert bezieht sich auf die Sessionanzahl.",
"Bewertete Sessions blockieren den Monatsabschluss nicht."
],
details: [
"Bewertet ist die Summe aus Abgerechnet und Nicht abrechenbar.",
"Nicht abrechenbare Sessions fallen also nicht aus der Qualität heraus, sondern gelten als bewusst entschieden.",
"Die Zeit zeigt die Summe der bewerteten Session-Minuten.",
"Die Prozentwerte werden zusammen mit den offenen Sessions gerundet, sodass bewertet und offen zusammen 100 Prozent ergeben.",
"Wenn eine Bewertung zurückgenommen wird, wandert die Session wieder in Offen."
]
},
{
id: "statistik-offen",
title: "Statistik: Offen",
description: "Diese Kennzahl zeigt Sessions, die noch keine Bewertung haben.",
quickItems: [
"Offen bedeutet: weder Abgerechnet noch Nicht abrechenbar.",
"Offen bedeutet: noch keine Bewertung.",
"Offene Sessions blockieren den Monatsabschluss.",
"Die Klärung erfolgt in der Ticketdetailansicht."
],
details: [
"Offen umfasst alle Sessions des Monats ohne Bewertung.",
"Diese Sessions müssen vor dem Monatsabschluss entweder als Abgerechnet oder Nicht abrechenbar markiert werden.",
"Diese Sessions müssen vor dem Monatsabschluss bewertet werden.",
"Die offene Zeit zeigt, wie viele Minuten noch bewertet werden müssen.",
"Der Monatsabschluss listet offene Bewertungen gruppiert nach Ticket, damit du direkt in die passende Ticketdetailansicht springen kannst.",
"Wird eine Bewertung zurückgenommen, steigt die offene Anzahl wieder."
@@ -305,19 +372,19 @@ export const helpSections: HelpSection[] = [
{
id: "statistik-verlauf",
title: "Statistik: Monatsverlauf und Qualität",
description: "Diese Card verbindet Tagesverlauf, Teamspace-Linie, Bewertungsstand und Qualitätswerte.",
description: "Diese Card verbindet Tagesverlauf, Teamspace-Linie und reine Statuswerte.",
quickItems: [
"Balken zeigen getrackte Sessions.",
"Balken zeigen abgerechnete Sessions.",
"Die Linie zeigt Teamspace-Werte.",
"Der rechte Bereich zeigt Bewertung und Teamspace-Abgleich."
],
details: [
"Der Monatsverlauf zeigt jeden Tag des Monats. Tage ohne Sessions bleiben leer, damit die Monatsstruktur sichtbar bleibt.",
"Die blauen Balken stehen für getrackte Session-Zeit.",
"Die grüne Linie steht für Teamspace-Zeit, sofern für den Tag Werte gepflegt wurden.",
"Der Bewertungsstand vergleicht bewertete und offene Sessions.",
"Der Teamspace-Abgleich zeigt die Gesamtdifferenz zwischen CRM-Werten und getrackter Session-Zeit.",
"Manuell und Fix trennen selbst erfasste Sessions von automatisch erzeugten Sessions aus fixen Abrechnungen."
"Die blauen Balken stehen für abgerechnete Session-Zeit.",
"Die fuchsiafarbene Linie steht für Teamspace-Zeit, sofern für den Tag Werte gepflegt wurden.",
"Der Bewertungsstand weist Abgerechnet und Offen getrennt aus.",
"Der Teamspace-Abgleich zeigt die Gesamtdifferenz zwischen CRM-Werten und abgerechneter Session-Zeit.",
"Manuell und Fix trennen selbst erfasste abrechenbare Sessions von automatisch erzeugten abrechenbaren Sessions aus fixen Abrechnungen."
]
},
{
@@ -332,9 +399,9 @@ export const helpSections: HelpSection[] = [
details: [
"Die Organisationsstatistik fasst alle Tickets und Sessions einer Organisation im gewählten Monat zusammen.",
"Sessions zeigt die Anzahl der einzelnen Arbeitseinträge.",
"Zeiten vergleicht getrackte Session-Zeit mit Teamspace-Zeit.",
"Die Differenz wird als Teamspace minus Sessions berechnet.",
"Der Anteil am Monat zeigt, wie groß der getrackte Aufwand dieser Organisation im Verhältnis zur größten Organisation im Monat ist.",
"Zeiten vergleicht abgerechnete Session-Zeit mit Teamspace-Zeit.",
"Die Differenz wird als Teamspace minus abgerechnete Zeit berechnet.",
"Der Anteil am Monat zeigt, wie groß die abgerechnete Zeit dieser Organisation im Verhältnis zur größten Organisation im Monat ist.",
"Bei vielen Organisationen wird die Liste paginiert, damit die Seite kompakt bleibt."
]
},
@@ -350,9 +417,9 @@ export const helpSections: HelpSection[] = [
details: [
"Die Typenstatistik gruppiert Sessions nach Art.",
"Support und Consulting werden getrennt summiert.",
"Die Zeitwerte zeigen getrackte Sessions und Teamspace für den jeweiligen Typ.",
"Die Differenz folgt derselben Logik wie überall: Teamspace minus getrackte Sessions.",
"Die Zeile Abr., Nicht und Offen zeigt, wie viele Sessions dieses Typs bereits bewertet oder noch offen sind."
"Die Zeitwerte zeigen abgerechnete Sessions und Teamspace für den jeweiligen Typ.",
"Die Differenz folgt derselben Logik wie überall: Teamspace minus abgerechnete Session-Zeit.",
"Die Zeile Abr. und Offen zeigt, wie viele Sessions dieses Typs bereits abgerechnet oder noch offen sind."
]
},
{
@@ -366,8 +433,8 @@ export const helpSections: HelpSection[] = [
],
details: [
"Ticket-Hotspots helfen, die größten Aufwandstreiber im Monat schnell zu erkennen.",
"Die Liste zeigt die wichtigsten Tickets nach getrackter Zeit.",
"Pro Ticket siehst du Organisation, Sessionanzahl, aktive Tage, getrackte Zeit, Teamspace-Zeit und Differenz.",
"Die Liste zeigt die wichtigsten Tickets nach abgerechneter Zeit.",
"Pro Ticket siehst du Organisation, Sessionanzahl, aktive abrechenbare Tage, abgerechnete Zeit, Teamspace-Zeit und Differenz.",
"Offene Tickets werden mit einem Warnstatus angezeigt, damit Bewertungen nachgezogen werden können.",
"Über Öffnen gelangst du direkt in die Ticketdetailansicht des Monats."
]
@@ -392,20 +459,23 @@ export const helpSections: HelpSection[] = [
{
id: "admin",
title: "Adminbereich",
description: "Der Adminbereich verwaltet Benutzer, Session-Zuordnung, manuelle Admin-Nachträge und Zammad-Sync.",
description: "Der feste Adminbereich verwaltet Benutzer, Zammad-Sync und restore-fähige Datenbankexporte.",
quickItems: [
"Admins legen Benutzer an und bearbeiten Rollen.",
"Sessions können anderen Benutzern zugewiesen werden.",
"Zammad-Organisationen werden über Data-Sync aktualisiert."
"Neue Benutzer erhalten automatisch die Rolle User.",
"Der Admin erfasst keine Sessions und sieht keine Auswertungen.",
"SQL-Backups sind für einen kompletten Restore gedacht."
],
details: [
"Neue Benutzer bekommen Benutzername, vollen Namen, Passwort, Rolle und Aktivstatus.",
"Neue Benutzer bekommen Benutzername, vollen Namen, Passwort und Aktivstatus. Eine Rollenauswahl gibt es nicht.",
"Admins können bestehende Benutzer bearbeiten, Passwörter setzen und Benutzer deaktivieren.",
"Der eigene Admin-Zugang kann nicht versehentlich deaktiviert oder zur normalen User-Rolle herabgestuft werden.",
"In der Session-Zuordnung sehen Admins die letzten Sessions inklusive Besitzer und können einzelne Sessions umverteilen.",
"Beim Umverteilen werden die betroffenen Monatsabschlüsse für alten und neuen Besitzer wieder geöffnet, damit Bewertungen und Auswertungen konsistent bleiben.",
"Admins können Sessions für andere Benutzer nachtragen, wenn Zeiten vergessen wurden.",
"Der Data-Sync speichert Zammad-Zugangsdaten und synchronisiert Organisationen in die lokale Datenbank."
"Der feste Admin-Zugang kann nicht deaktiviert oder zur normalen User-Rolle herabgestuft werden.",
"Der Data-Sync speichert Zammad-Zugangsdaten und synchronisiert Organisationen in die lokale Datenbank.",
"Der Datenbankexport erzeugt eine SQL-Datei mit Benutzern, Einstellungen, Organisationen, Tickets, Sessions, Teamspace-Werten, fixen Abrechnungen und Abschlüssen.",
"Der Restore im Adminbereich prüft zuerst, ob die Dump-Struktur zur aktuellen Datenbank passt.",
"Der Restore-Button wird erst nach erfolgreicher Prüfung aktiv.",
"Beim Einspielen leert TicketTracker zuerst alle Tabellen und spielt danach den Backup-Stand ein.",
"Nach einem Restore ist eine neue Anmeldung mit den Zugangsdaten aus dem Backup notwendig.",
"Für einen Restore lässt du auf dem Zielsystem zuerst die TicketTracker-Migrationen laufen und spielst danach die SQL-Datei mit psql ein."
]
}
];
+17 -23
View File
@@ -16,29 +16,6 @@ export type AdminUser = AuthUser & {
updated_at: string;
};
export type AdminSession = {
id: string;
ticket_id: string;
user_id: string;
owner_username: string;
owner_display_name: string;
ticket_number: string;
organization_id: string | null;
organization_name: string | null;
customer_name: string;
activity: string;
work_type: WorkType;
started_at: string;
ended_at: string;
duration_seconds: number;
rounded_minutes: number;
billing_status: BillingStatus;
created_at: string;
recurring_billing_id: string | null;
recurring_billing_slot_id: string | null;
recurring_occurrence_date: string | null;
};
export type Organization = {
id: string;
zammad_id: string;
@@ -84,6 +61,19 @@ export type TicketMeta = {
work_type: WorkType | null;
};
export type TicketSearchResult = {
ticket_id: string;
ticket_number: string;
organization_id: string | null;
organization_name: string | null;
customer_name: string | null;
work_type: WorkType | null;
latest_started_at: string;
latest_period: string;
session_count: number;
total_minutes: number;
};
export type TicketSummary = {
id: string;
ticket_number: string;
@@ -93,6 +83,8 @@ export type TicketSummary = {
work_type: WorkType | null;
session_count: number;
total_minutes: number;
non_billable_minutes: number;
open_minutes: number;
open_count: number;
ticket_open_count: number;
billed_count: number;
@@ -134,6 +126,8 @@ export type PeriodOverview = {
sessions: number;
minutes: number;
crmBilledMinutes: number;
nonBillableMinutes: number;
openMinutes: number;
openSessions: number;
};
tickets: TicketSummary[];
+237 -442
View File
@@ -1,115 +1,84 @@
import { FormEvent, useEffect, useState } from "react";
import { CalendarPlus, DatabaseZap, Save, Shield, Shuffle, UserPlus } from "lucide-react";
import { ChangeEvent, FormEvent, useEffect, useState } from "react";
import { CheckCircle2, DatabaseBackup, DatabaseZap, Save, Shield, Upload, UserPlus } from "lucide-react";
import { toast } from "sonner";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Switch } from "@/components/ui/switch";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { Textarea } from "@/components/ui/textarea";
import { HelpLink } from "@/components/HelpLink";
import { OrganizationSelect } from "@/components/OrganizationSelect";
import {
createAdminSession,
createAdminUser,
getAdminSessions,
downloadDatabaseExport,
getAdminUsers,
getZammadSettings,
reassignAdminSession,
restoreDatabaseBackup,
saveZammadSettings,
syncZammadOrganizations,
updateAdminUser
updateAdminUser,
validateDatabaseBackup,
type RestoreValidationResult
} from "../api";
import { currentDay, formatDateTime, formatMinutes } from "../format";
import type { AdminSession, AdminUser, AuthUser, UserRole, WorkType } from "../types";
import type { AdminUser, AuthUser } from "../types";
type UserFormState = {
username: string;
displayName: string;
password: string;
role: UserRole;
active: boolean;
};
type ManualSessionFormState = {
userId: string;
ticketNumber: string;
organizationId: string;
organizationName: string | null;
activity: string;
workType: WorkType;
day: string;
startTime: string;
endTime: string;
};
const emptyForm: UserFormState = {
username: "",
displayName: "",
password: "",
role: "user",
active: true
};
function emptyManualSession(): ManualSessionFormState {
return {
userId: "",
ticketNumber: "",
organizationId: "",
organizationName: null,
activity: "",
workType: "support",
day: currentDay(),
startTime: "09:00",
endTime: "09:30"
};
}
function formFromUser(user: AdminUser): UserFormState {
return {
username: user.username,
displayName: user.display_name,
password: "",
role: user.role,
active: user.active
};
}
export function AdminUsersPage({ currentUser }: { currentUser: AuthUser }) {
const [users, setUsers] = useState<AdminUser[]>([]);
const [sessions, setSessions] = useState<AdminSession[]>([]);
const [forms, setForms] = useState<Record<string, UserFormState>>({});
const [sessionOwners, setSessionOwners] = useState<Record<string, string>>({});
const [newUser, setNewUser] = useState<UserFormState>(emptyForm);
const [manualSession, setManualSession] = useState<ManualSessionFormState>(() => emptyManualSession());
const [zammadBaseUrl, setZammadBaseUrl] = useState("");
const [zammadApiKey, setZammadApiKey] = useState("");
const [hasZammadApiKey, setHasZammadApiKey] = useState(false);
const [syncingOrganizations, setSyncingOrganizations] = useState(false);
const [savingZammadSettings, setSavingZammadSettings] = useState(false);
const [exportingDatabase, setExportingDatabase] = useState(false);
const [restoreSql, setRestoreSql] = useState("");
const [restoreValidation, setRestoreValidation] = useState<RestoreValidationResult | null>(null);
const [validatingRestore, setValidatingRestore] = useState(false);
const [restoringDatabase, setRestoringDatabase] = useState(false);
const [loading, setLoading] = useState(false);
const [savingId, setSavingId] = useState<string | null>(null);
const [movingId, setMovingId] = useState<string | null>(null);
const [creating, setCreating] = useState(false);
const [creatingSession, setCreatingSession] = useState(false);
async function load(options: { preserveEdits?: boolean } = {}) {
setLoading(true);
try {
const [usersResult, sessionsResult, zammadSettingsResult] = await Promise.all([getAdminUsers(), getAdminSessions(), getZammadSettings()]);
const [usersResult, zammadSettingsResult] = await Promise.all([getAdminUsers(), getZammadSettings()]);
setUsers(usersResult.users);
setSessions(sessionsResult.sessions);
if (!options.preserveEdits) {
setZammadBaseUrl(zammadSettingsResult.settings.baseUrl);
setHasZammadApiKey(zammadSettingsResult.settings.hasApiKey);
}
setForms((current) =>
Object.fromEntries(usersResult.users.map((user) => [user.id, options.preserveEdits ? current[user.id] ?? formFromUser(user) : formFromUser(user)]))
);
setSessionOwners((current) =>
Object.fromEntries(sessionsResult.sessions.map((session) => [session.id, options.preserveEdits ? current[session.id] ?? session.user_id : session.user_id]))
);
} catch (error) {
toast.error("Adminbereich konnte nicht geladen werden", {
description: error instanceof Error ? error.message : "Unbekannter Fehler"
@@ -157,7 +126,9 @@ export function AdminUsersPage({ currentUser }: { currentUser: AuthUser }) {
try {
await createAdminUser(newUser);
toast.success("Benutzer angelegt");
toast.success("Benutzer angelegt", {
description: "Neue Accounts erhalten automatisch die Rolle User."
});
setNewUser(emptyForm);
await load();
} catch (error) {
@@ -181,7 +152,6 @@ export function AdminUsersPage({ currentUser }: { currentUser: AuthUser }) {
await updateAdminUser(user.id, {
username: form.username,
displayName: form.displayName,
role: form.role,
active: form.active,
password: form.password || undefined
});
@@ -196,91 +166,6 @@ export function AdminUsersPage({ currentUser }: { currentUser: AuthUser }) {
}
}
async function moveSession(session: AdminSession) {
const nextUserId = sessionOwners[session.id] ?? session.user_id;
if (nextUserId === session.user_id) {
toast.info("Keine Änderung", {
description: "Diese Session gehört bereits diesem Benutzer."
});
return;
}
setMovingId(session.id);
try {
await reassignAdminSession(session.id, nextUserId);
toast.success("Session umverteilt", {
description: "Betroffene Tages- und Monatsabschlüsse wurden wieder geöffnet."
});
await load();
} catch (error) {
toast.error("Session konnte nicht umverteilt werden", {
description: error instanceof Error ? error.message : "Unbekannter Fehler"
});
} finally {
setMovingId(null);
}
}
const activeUsers = users.filter((user) => user.active);
const manualSessionUserId = manualSession.userId || activeUsers[0]?.id || "";
async function createManualSession(event: FormEvent) {
event.preventDefault();
if (!manualSessionUserId) {
toast.error("Kein aktiver Benutzer vorhanden");
return;
}
if (!manualSession.organizationId) {
toast.error("Organisation wählen");
return;
}
const startedAt = new Date(`${manualSession.day}T${manualSession.startTime}:00`);
const endedAt = new Date(`${manualSession.day}T${manualSession.endTime}:00`);
if (Number.isNaN(startedAt.getTime()) || Number.isNaN(endedAt.getTime())) {
toast.error("Datum oder Uhrzeit prüfen");
return;
}
if (endedAt <= startedAt) {
toast.error("Ende muss nach Beginn liegen");
return;
}
setCreatingSession(true);
try {
await createAdminSession({
userId: manualSessionUserId,
ticketNumber: manualSession.ticketNumber,
organizationId: manualSession.organizationId,
activity: manualSession.activity,
workType: manualSession.workType,
startedAt: startedAt.toISOString(),
endedAt: endedAt.toISOString()
});
toast.success("Session nachgetragen", {
description: "Der Eintrag ist in der Auswertung des gewählten Benutzers offen."
});
setManualSession({
...emptyManualSession(),
userId: manualSessionUserId,
day: manualSession.day
});
await load();
} catch (error) {
toast.error("Session konnte nicht nachgetragen werden", {
description: error instanceof Error ? error.message : "Unbekannter Fehler"
});
} finally {
setCreatingSession(false);
}
}
async function syncOrganizations(event: FormEvent) {
event.preventDefault();
setSyncingOrganizations(true);
@@ -327,58 +212,220 @@ export function AdminUsersPage({ currentUser }: { currentUser: AuthUser }) {
}
}
async function exportDatabase() {
setExportingDatabase(true);
try {
const result = await downloadDatabaseExport();
const url = window.URL.createObjectURL(result.blob);
const link = document.createElement("a");
link.href = url;
link.download = result.filename;
document.body.append(link);
link.click();
link.remove();
window.URL.revokeObjectURL(url);
toast.success("Datenbankexport erstellt", {
description: result.filename
});
} catch (error) {
toast.error("Datenbankexport fehlgeschlagen", {
description: error instanceof Error ? error.message : "Unbekannter Fehler"
});
} finally {
setExportingDatabase(false);
}
}
async function readRestoreFile(event: ChangeEvent<HTMLInputElement>) {
const file = event.currentTarget.files?.[0];
if (!file) {
return;
}
const sql = await file.text();
setRestoreSql(sql);
setRestoreValidation(null);
await validateRestore(sql);
}
async function validateRestore(sql = restoreSql) {
if (!sql.trim()) {
setRestoreValidation(null);
return;
}
setValidatingRestore(true);
try {
const result = await validateDatabaseBackup(sql);
setRestoreValidation(result);
if (result.valid) {
toast.success("Backup-Struktur passt", {
description: `${result.checkedTables} Tabelle(n) geprüft.`
});
} else {
toast.error("Backup-Struktur passt nicht", {
description: result.issues[0] ?? "Unbekannter Strukturfehler"
});
}
} catch (error) {
setRestoreValidation({
valid: false,
checkedTables: 0,
issues: [error instanceof Error ? error.message : "Unbekannter Fehler"]
});
toast.error("Backup-Prüfung fehlgeschlagen", {
description: error instanceof Error ? error.message : "Unbekannter Fehler"
});
} finally {
setValidatingRestore(false);
}
}
async function restoreDatabase(event: FormEvent) {
event.preventDefault();
if (!restoreValidation?.valid) {
toast.error("Backup zuerst prüfen", {
description: "Der Restore kann erst nach erfolgreicher Strukturprüfung ausgeführt werden."
});
return;
}
if (!window.confirm("Restore wirklich einspielen? Alle aktuellen TicketTracker-Daten werden durch den Stand des Backups ersetzt.")) {
return;
}
setRestoringDatabase(true);
try {
await restoreDatabaseBackup(restoreSql);
toast.success("Restore eingespielt", {
description: "Melde dich jetzt mit einem Benutzer aus dem Backup neu an."
});
window.location.href = "/";
} catch (error) {
toast.error("Restore fehlgeschlagen", {
description: error instanceof Error ? error.message : "Unbekannter Fehler"
});
} finally {
setRestoringDatabase(false);
}
}
const regularUserCount = users.filter((user) => user.role === "user").length;
return (
<div className="space-y-4">
<div>
<h2 className="text-xl font-semibold tracking-normal sm:text-2xl">Benutzer</h2>
<p className="text-sm text-muted-foreground">Accounts verwalten, Besitzer von Sessions prüfen und Einträge umverteilen.</p>
<h2 className="text-xl font-semibold tracking-normal sm:text-2xl">Administration</h2>
<p className="text-sm text-muted-foreground">Systemverwaltung für Benutzer, Zammad-Organisationen und restore-fähige Backups.</p>
</div>
<Card>
<CardHeader className="p-4">
<div className="flex items-start justify-between gap-3">
<div className="flex items-center gap-2">
<DatabaseZap className="size-5 text-muted-foreground" />
<CardTitle>Data-Sync</CardTitle>
<div className="grid min-w-0 gap-4 2xl:grid-cols-[minmax(0,1.2fr)_minmax(340px,0.8fr)]">
<Card className="min-w-0">
<CardHeader className="p-4">
<div className="flex items-start justify-between gap-3">
<div className="flex items-center gap-2">
<DatabaseZap className="size-5 text-muted-foreground" />
<CardTitle>Data-Sync</CardTitle>
</div>
<HelpLink anchor="organisationen" label="Hilfe zum Zammad-Sync" />
</div>
<HelpLink anchor="organisationen" label="Hilfe zum Zammad-Sync" />
</div>
<CardDescription>Zammad-Zugang speichern und Organisationen in die lokale TicketTracker-Datenbank übernehmen.</CardDescription>
</CardHeader>
<CardContent className="px-4 pb-4">
<form className="grid gap-3 lg:grid-cols-[minmax(220px,1fr)_minmax(220px,1fr)_auto_auto] lg:items-end" onSubmit={syncOrganizations}>
<div className="space-y-2">
<label className="text-sm font-medium" htmlFor="zammad-base-url">Zammad URL</label>
<Input
id="zammad-base-url"
placeholder="https://zammad.example.de"
value={zammadBaseUrl}
onChange={(event) => setZammadBaseUrl(event.currentTarget.value)}
required
/>
<CardDescription>Zammad-Zugang speichern und Organisationen in die lokale TicketTracker-Datenbank übernehmen.</CardDescription>
</CardHeader>
<CardContent className="px-4 pb-4">
<form className="grid gap-3 xl:grid-cols-2" onSubmit={syncOrganizations}>
<div className="space-y-2">
<label className="text-sm font-medium" htmlFor="zammad-base-url">Zammad URL</label>
<Input
id="zammad-base-url"
placeholder="https://zammad.example.de"
value={zammadBaseUrl}
onChange={(event) => setZammadBaseUrl(event.currentTarget.value)}
required
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium" htmlFor="zammad-api-key">API-Key</label>
<Input
id="zammad-api-key"
type="password"
placeholder={hasZammadApiKey ? "••••••••••••••••" : ""}
value={zammadApiKey}
onChange={(event) => setZammadApiKey(event.currentTarget.value)}
/>
</div>
<div className="flex flex-wrap gap-2 xl:col-span-2">
<Button type="button" variant="secondary" disabled={savingZammadSettings} onClick={() => void saveZammadAccess()}>
<Save className="size-4" />
{savingZammadSettings ? "Speichert..." : "Zugang speichern"}
</Button>
<Button type="submit" disabled={syncingOrganizations}>
<DatabaseZap className="size-4" />
{syncingOrganizations ? "Synchronisiert..." : "Organisationen synchronisieren"}
</Button>
</div>
</form>
</CardContent>
</Card>
<Card className="min-w-0">
<CardHeader className="p-4">
<div className="flex items-start justify-between gap-3">
<div className="flex items-center gap-2">
<DatabaseBackup className="size-5 text-muted-foreground" />
<CardTitle>Backup und Restore</CardTitle>
</div>
<HelpLink anchor="admin" label="Hilfe zu Backup und Restore" />
</div>
<div className="space-y-2">
<label className="text-sm font-medium" htmlFor="zammad-api-key">API-Key</label>
<Input
id="zammad-api-key"
type="password"
placeholder={hasZammadApiKey ? "Gespeicherter API-Key wird verwendet" : ""}
value={zammadApiKey}
onChange={(event) => setZammadApiKey(event.currentTarget.value)}
/>
{hasZammadApiKey ? <p className="text-xs text-muted-foreground">API-Key ist gespeichert. Leer lassen, um ihn weiter zu verwenden.</p> : null}
</div>
<Button type="button" variant="secondary" disabled={savingZammadSettings} onClick={() => void saveZammadAccess()}>
<Save className="size-4" />
{savingZammadSettings ? "Speichert..." : "Zugang speichern"}
<CardDescription>SQL-Backup herunterladen oder einen kompletten Systemstand wiederherstellen.</CardDescription>
</CardHeader>
<CardContent className="space-y-3 px-4 pb-4">
<p className="text-sm text-muted-foreground">
Enthält Benutzer, Einstellungen, Organisationen, Tickets, Sessions, Teamspace-Werte, fixe Abrechnungen und Abschlüsse.
</p>
<Button className="w-full" disabled={exportingDatabase} onClick={() => void exportDatabase()}>
<DatabaseBackup className="size-4" />
{exportingDatabase ? "Exportiert..." : "SQL-Backup herunterladen"}
</Button>
<Button type="submit" disabled={syncingOrganizations}>
<DatabaseZap className="size-4" />
{syncingOrganizations ? "Synchronisiert..." : "Organisationen synchronisieren"}
</Button>
</form>
</CardContent>
</Card>
<form className="space-y-3 rounded-md border bg-muted/20 p-3" onSubmit={restoreDatabase}>
<Alert variant="warning">
<Upload className="size-4" />
<AlertTitle>Restore ersetzt alles</AlertTitle>
<AlertDescription>
Vor dem Einspielen werden alle Tabellen geleert. Danach gilt nur noch der Stand des Backups, inklusive aller User, Passwörter, Sessions und Settings.
</AlertDescription>
</Alert>
<Input type="file" accept=".sql,text/sql,text/plain" onChange={(event) => void readRestoreFile(event)} />
{restoreValidation ? (
<Alert variant={restoreValidation.valid ? "success" : "destructive"}>
{restoreValidation.valid ? <CheckCircle2 className="size-4" /> : <Upload className="size-4" />}
<AlertTitle>{restoreValidation.valid ? "Strukturprüfung erfolgreich" : "Strukturprüfung fehlgeschlagen"}</AlertTitle>
<AlertDescription>
{restoreValidation.valid
? `${restoreValidation.checkedTables} Tabelle(n) passen zum aktuellen Datenbankschema.`
: restoreValidation.issues.slice(0, 4).join(" ")}
</AlertDescription>
</Alert>
) : null}
<div className="grid gap-2 sm:grid-cols-2">
<Button type="button" variant="secondary" disabled={validatingRestore || !restoreSql.trim()} onClick={() => void validateRestore()}>
<CheckCircle2 className="size-4" />
{validatingRestore ? "Prüft..." : "Struktur prüfen"}
</Button>
<Button type="submit" variant="destructive" disabled={restoringDatabase || !restoreValidation?.valid}>
<Upload className="size-4" />
{restoringDatabase ? "Spielt ein..." : "SQL-Backup einspielen"}
</Button>
</div>
</form>
</CardContent>
</Card>
</div>
<Card>
<CardHeader className="p-4">
@@ -391,7 +438,7 @@ export function AdminUsersPage({ currentUser }: { currentUser: AuthUser }) {
</div>
</CardHeader>
<CardContent className="px-4 pb-4">
<form className="grid gap-3 lg:grid-cols-[160px_minmax(180px,1fr)_160px_130px_auto_auto] lg:items-end" onSubmit={createUser}>
<form className="grid gap-3 lg:grid-cols-[160px_minmax(180px,1fr)_160px_110px_auto] lg:items-end" onSubmit={createUser}>
<div className="space-y-2">
<label className="text-sm font-medium" htmlFor="new-username">Benutzername</label>
<Input id="new-username" value={newUser.username} onChange={(event) => setNewUser({ ...newUser, username: event.currentTarget.value })} required />
@@ -405,16 +452,8 @@ export function AdminUsersPage({ currentUser }: { currentUser: AuthUser }) {
<Input id="new-password" type="password" value={newUser.password} onChange={(event) => setNewUser({ ...newUser, password: event.currentTarget.value })} required />
</div>
<div className="space-y-2">
<label className="text-sm font-medium" htmlFor="new-role">Rolle</label>
<select
id="new-role"
className="h-8 w-full rounded-lg border border-input bg-background px-2 text-sm focus-visible:outline-none focus-visible:ring-3 focus-visible:ring-ring/50"
value={newUser.role}
onChange={(event) => setNewUser({ ...newUser, role: event.currentTarget.value as UserRole })}
>
<option value="user">User</option>
<option value="admin">Admin</option>
</select>
<span className="text-sm font-medium">Rolle</span>
<Badge variant="secondary" className="flex h-8 w-full items-center justify-center">User</Badge>
</div>
<label className="flex h-8 items-center gap-2 text-sm">
<Switch checked={newUser.active} onCheckedChange={(active) => setNewUser({ ...newUser, active })} />
@@ -428,130 +467,11 @@ export function AdminUsersPage({ currentUser }: { currentUser: AuthUser }) {
</CardContent>
</Card>
<Card>
<CardHeader className="p-4">
<div className="flex items-start justify-between gap-3">
<div className="flex items-center gap-2">
<CalendarPlus className="size-5 text-muted-foreground" />
<CardTitle>Session nachtragen</CardTitle>
</div>
<HelpLink anchor="session-nachtragen" label="Hilfe zum Nachtragen für Benutzer" />
</div>
<CardDescription>Vergessene Zeiten manuell erfassen und direkt einem Benutzer zuweisen.</CardDescription>
</CardHeader>
<CardContent className="px-4 pb-4">
<form className="space-y-4" onSubmit={createManualSession}>
<div className="grid gap-3 lg:grid-cols-[160px_minmax(180px,1fr)_150px_150px]">
<div className="space-y-2">
<label className="text-sm font-medium" htmlFor="manual-ticket-number">Ticket</label>
<Input
id="manual-ticket-number"
placeholder="Ticket#123456"
value={manualSession.ticketNumber}
onChange={(event) => setManualSession({ ...manualSession, ticketNumber: event.currentTarget.value })}
required
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium" htmlFor="manual-organization">Organisation</label>
<OrganizationSelect
value={manualSession.organizationId}
selectedName={manualSession.organizationName}
onChange={(organization) =>
setManualSession({
...manualSession,
organizationId: organization.id,
organizationName: organization.name
})
}
required
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium" htmlFor="manual-work-type">Art</label>
<select
id="manual-work-type"
className="h-8 w-full rounded-lg border border-input bg-background px-2 text-sm focus-visible:outline-none focus-visible:ring-3 focus-visible:ring-ring/50"
value={manualSession.workType}
onChange={(event) => setManualSession({ ...manualSession, workType: event.currentTarget.value as WorkType })}
>
<option value="support">Support</option>
<option value="consulting">Consulting</option>
</select>
</div>
<div className="space-y-2">
<label className="text-sm font-medium" htmlFor="manual-user">Benutzer</label>
<select
id="manual-user"
className="h-8 w-full rounded-lg border border-input bg-background px-2 text-sm focus-visible:outline-none focus-visible:ring-3 focus-visible:ring-ring/50"
value={manualSessionUserId}
onChange={(event) => setManualSession({ ...manualSession, userId: event.currentTarget.value })}
required
>
{activeUsers.map((user) => (
<option key={user.id} value={user.id}>
{user.display_name}
</option>
))}
</select>
</div>
</div>
<div className="grid gap-3 lg:grid-cols-[160px_120px_120px_minmax(220px,1fr)_auto] lg:items-end">
<div className="space-y-2">
<label className="text-sm font-medium" htmlFor="manual-day">Datum</label>
<Input
id="manual-day"
type="date"
value={manualSession.day}
onChange={(event) => setManualSession({ ...manualSession, day: event.currentTarget.value })}
required
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium" htmlFor="manual-start">Von</label>
<Input
id="manual-start"
type="time"
value={manualSession.startTime}
onChange={(event) => setManualSession({ ...manualSession, startTime: event.currentTarget.value })}
required
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium" htmlFor="manual-end">Bis</label>
<Input
id="manual-end"
type="time"
value={manualSession.endTime}
onChange={(event) => setManualSession({ ...manualSession, endTime: event.currentTarget.value })}
required
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium" htmlFor="manual-activity">Tätigkeit</label>
<Textarea
id="manual-activity"
className="min-h-20"
value={manualSession.activity}
onChange={(event) => setManualSession({ ...manualSession, activity: event.currentTarget.value })}
required
/>
</div>
<Button type="submit" disabled={creatingSession || activeUsers.length === 0}>
<CalendarPlus className="size-4" />
{creatingSession ? "Speichert..." : "Nachtragen"}
</Button>
</div>
</form>
</CardContent>
</Card>
<Card>
<CardHeader className="gap-3 p-4 sm:flex-row sm:items-center sm:justify-between sm:space-y-0">
<div>
<CardTitle>Vorhandene Benutzer</CardTitle>
<CardDescription>{loading ? "Lädt..." : `${users.length} Account(s)`}</CardDescription>
<CardDescription>{loading ? "Lädt..." : `${regularUserCount} User, 1 fester Admin`}</CardDescription>
</div>
<div className="flex items-center gap-2">
<Badge variant="outline">{currentUser.display_name}</Badge>
@@ -574,6 +494,7 @@ export function AdminUsersPage({ currentUser }: { currentUser: AuthUser }) {
<TableBody>
{users.map((user) => {
const form = forms[user.id] ?? formFromUser(user);
const isFixedAdmin = user.role === "admin";
return (
<TableRow key={user.id}>
@@ -584,18 +505,11 @@ export function AdminUsersPage({ currentUser }: { currentUser: AuthUser }) {
<Input className="h-8" value={form.displayName} onChange={(event) => updateForm(user.id, { displayName: event.currentTarget.value })} />
</TableCell>
<TableCell>
<select
className="h-8 w-full rounded-lg border border-input bg-background px-2 text-sm focus-visible:outline-none focus-visible:ring-3 focus-visible:ring-ring/50"
value={form.role}
onChange={(event) => updateForm(user.id, { role: event.currentTarget.value as UserRole })}
>
<option value="user">User</option>
<option value="admin">Admin</option>
</select>
<Badge variant={isFixedAdmin ? "default" : "secondary"}>{isFixedAdmin ? "Fester Admin" : "User"}</Badge>
</TableCell>
<TableCell>
<label className="flex items-center gap-2 text-sm">
<Switch checked={form.active} onCheckedChange={(active) => updateForm(user.id, { active })} />
<Switch checked={form.active} disabled={isFixedAdmin} onCheckedChange={(active) => updateForm(user.id, { active })} />
{form.active ? "aktiv" : "inaktiv"}
</label>
</TableCell>
@@ -624,6 +538,7 @@ export function AdminUsersPage({ currentUser }: { currentUser: AuthUser }) {
<div className="space-y-2 xl:hidden">
{users.map((user) => {
const form = forms[user.id] ?? formFromUser(user);
const isFixedAdmin = user.role === "admin";
return (
<div key={user.id} className="space-y-3 rounded-md border bg-background p-3">
@@ -632,18 +547,10 @@ export function AdminUsersPage({ currentUser }: { currentUser: AuthUser }) {
<Shield className="size-4 text-muted-foreground" />
<span className="font-medium">{user.username}</span>
</div>
<Badge variant={form.role === "admin" ? "default" : "secondary"}>{form.role === "admin" ? "Admin" : "User"}</Badge>
<Badge variant={isFixedAdmin ? "default" : "secondary"}>{isFixedAdmin ? "Fester Admin" : "User"}</Badge>
</div>
<Input value={form.username} onChange={(event) => updateForm(user.id, { username: event.currentTarget.value })} />
<Input value={form.displayName} onChange={(event) => updateForm(user.id, { displayName: event.currentTarget.value })} />
<select
className="h-9 w-full rounded-lg border border-input bg-background px-2 text-sm focus-visible:outline-none focus-visible:ring-3 focus-visible:ring-ring/50"
value={form.role}
onChange={(event) => updateForm(user.id, { role: event.currentTarget.value as UserRole })}
>
<option value="user">User</option>
<option value="admin">Admin</option>
</select>
<Input
type="password"
placeholder="Passwort unverändert lassen"
@@ -652,7 +559,7 @@ export function AdminUsersPage({ currentUser }: { currentUser: AuthUser }) {
/>
<div className="flex items-center justify-between gap-2">
<label className="flex items-center gap-2 text-sm">
<Switch checked={form.active} onCheckedChange={(active) => updateForm(user.id, { active })} />
<Switch checked={form.active} disabled={isFixedAdmin} onCheckedChange={(active) => updateForm(user.id, { active })} />
{form.active ? "aktiv" : "inaktiv"}
</label>
<Button size="sm" onClick={() => void saveUser(user)} disabled={savingId === user.id}>
@@ -666,118 +573,6 @@ export function AdminUsersPage({ currentUser }: { currentUser: AuthUser }) {
</div>
</CardContent>
</Card>
<Card>
<CardHeader className="gap-3 p-4 sm:flex-row sm:items-center sm:justify-between sm:space-y-0">
<div>
<CardTitle>Session-Zuordnung</CardTitle>
<CardDescription>{loading ? "Lädt..." : `${sessions.length} letzte Session(s), inklusive Besitzer.`}</CardDescription>
</div>
<HelpLink anchor="admin" label="Hilfe zur Session-Zuordnung" />
</CardHeader>
<CardContent className="px-4 pb-4">
<div className="hidden xl:block">
<Table>
<TableHeader>
<TableRow>
<TableHead>Besitzer</TableHead>
<TableHead>Ticket</TableHead>
<TableHead>Organisation</TableHead>
<TableHead>Tätigkeit</TableHead>
<TableHead>Beginn</TableHead>
<TableHead>Zeit</TableHead>
<TableHead>Zuordnen zu</TableHead>
<TableHead />
</TableRow>
</TableHeader>
<TableBody>
{sessions.map((session) => (
<TableRow key={session.id}>
<TableCell>
<div className="font-medium">{session.owner_display_name}</div>
<div className="text-xs text-muted-foreground">{session.owner_username}</div>
</TableCell>
<TableCell className="font-semibold">{session.ticket_number}</TableCell>
<TableCell>{session.customer_name}</TableCell>
<TableCell className="max-w-xs whitespace-normal">{session.activity}</TableCell>
<TableCell className="whitespace-nowrap">{formatDateTime(session.started_at)}</TableCell>
<TableCell>{formatMinutes(session.rounded_minutes)}</TableCell>
<TableCell>
<select
className="h-8 w-full min-w-40 rounded-lg border border-input bg-background px-2 text-sm focus-visible:outline-none focus-visible:ring-3 focus-visible:ring-ring/50"
value={sessionOwners[session.id] ?? session.user_id}
onChange={(event) =>
setSessionOwners((current) => ({
...current,
[session.id]: event.currentTarget.value
}))
}
>
{activeUsers.map((user) => (
<option key={user.id} value={user.id}>
{user.display_name}
</option>
))}
</select>
</TableCell>
<TableCell className="text-right">
<Button size="sm" onClick={() => void moveSession(session)} disabled={movingId === session.id}>
<Shuffle className="size-4" />
{movingId === session.id ? "Speichert..." : "Umverteilen"}
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
<div className="space-y-2 xl:hidden">
{sessions.map((session) => (
<div key={session.id} className="space-y-3 rounded-md border bg-background p-3">
<div className="flex items-start justify-between gap-2">
<div>
<p className="font-semibold">{session.ticket_number}</p>
<p className="text-sm text-muted-foreground">{session.customer_name}</p>
</div>
<Badge variant="outline">{formatMinutes(session.rounded_minutes)}</Badge>
</div>
<div className="rounded-md bg-muted/40 p-2 text-sm">
<p className="font-medium">{session.owner_display_name}</p>
<p className="text-muted-foreground">{session.owner_username} · {formatDateTime(session.started_at)}</p>
</div>
<p className="text-sm">{session.activity}</p>
<div className="grid gap-2 sm:grid-cols-[1fr_auto]">
<select
className="h-9 w-full rounded-lg border border-input bg-background px-2 text-sm focus-visible:outline-none focus-visible:ring-3 focus-visible:ring-ring/50"
value={sessionOwners[session.id] ?? session.user_id}
onChange={(event) =>
setSessionOwners((current) => ({
...current,
[session.id]: event.currentTarget.value
}))
}
>
{activeUsers.map((user) => (
<option key={user.id} value={user.id}>
{user.display_name}
</option>
))}
</select>
<Button size="sm" onClick={() => void moveSession(session)} disabled={movingId === session.id}>
<Shuffle className="size-4" />
Umverteilen
</Button>
</div>
</div>
))}
</div>
{sessions.length === 0 ? (
<p className="py-8 text-center text-sm text-muted-foreground">Noch keine Sessions vorhanden.</p>
) : null}
</CardContent>
</Card>
</div>
);
}
+59 -21
View File
@@ -1,7 +1,8 @@
import { ChevronLeft, ChevronRight, Clock3, ExternalLink, RotateCcw, SlidersHorizontal, Ticket, UserCheck, Users } from "lucide-react";
import { ChevronLeft, ChevronRight, Clock3, Download, ExternalLink, RotateCcw, SlidersHorizontal, Ticket, UserCheck, Users } from "lucide-react";
import type { LucideIcon } from "lucide-react";
import { Fragment, useEffect, useMemo, useRef, useState } from "react";
import { toast } from "sonner";
import { Alert } from "@/components/ui/alert";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
@@ -10,8 +11,8 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { CopyTicketButton } from "@/components/CopyTicketButton";
import { HelpLink } from "@/components/HelpLink";
import { getPeriodOverview } from "../api";
import { currentDay, currentMonth, formatMinutes } from "../format";
import { downloadMonthExport, getPeriodOverview } from "../api";
import { currentDay, currentMonth, formatDateTime, formatMinutes } from "../format";
import type { PeriodOverview, PeriodType, TicketSummary } from "../types";
type AnalysisPageProps = {
@@ -304,6 +305,7 @@ export function AnalysisPage({ onNavigate }: AnalysisPageProps) {
const [period, setPeriod] = useState(currentMonth());
const [overview, setOverview] = useState<PeriodOverview | null>(null);
const [loading, setLoading] = useState(false);
const [exportingMonth, setExportingMonth] = useState(false);
const [ticketViewSettings, setTicketViewSettings] = useState<AnalysisTicketViewSettings>(() => readStoredTicketViewSettings());
const loadRequestId = useRef(0);
@@ -379,13 +381,43 @@ export function AnalysisPage({ onNavigate }: AnalysisPageProps) {
}
async function exportMonth() {
if (periodType !== "month") {
return;
}
setExportingMonth(true);
try {
const { blob, filename } = await downloadMonthExport(period);
const url = window.URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = filename;
document.body.appendChild(link);
link.click();
link.remove();
window.URL.revokeObjectURL(url);
toast.success("Monatsexport erstellt", {
description: filename
});
} catch (error) {
toast.error("Monatsexport fehlgeschlagen", {
description: error instanceof Error ? error.message : "Unbekannter Fehler"
});
} finally {
setExportingMonth(false);
}
}
const totals = overview?.totals;
const periodLabel = periodType === "month" ? "Monat" : "Tag";
const metricCards: Array<{ label: string; value: string | number; detail?: string; icon: LucideIcon }> = [
{ label: "Tickets", value: totals?.tickets ?? 0, icon: Ticket },
{ label: "Sessions", value: totals?.sessions ?? 0, icon: Users },
{ label: "Zeit", value: formatMinutes(totals?.minutes ?? 0), detail: `Teamspace ${formatMinutes(totals?.crmBilledMinutes ?? 0)}`, icon: Clock3 },
{ label: "Offen", value: totals?.openSessions ?? 0, icon: UserCheck }
{ label: "Abgerechnet", value: formatMinutes(totals?.minutes ?? 0), detail: `Teamspace ${formatMinutes(totals?.crmBilledMinutes ?? 0)}`, icon: Clock3 },
{ label: "Nicht abrechenbar", value: formatMinutes(totals?.nonBillableMinutes ?? 0), icon: UserCheck },
{ label: "Offen", value: totals?.openSessions ?? 0, detail: formatMinutes(totals?.openMinutes ?? 0), icon: UserCheck }
];
const tickets = overview?.tickets ?? [];
const organizationOptions = useMemo(() => {
@@ -492,10 +524,22 @@ export function AnalysisPage({ onNavigate }: AnalysisPageProps) {
</Button>
</div>
</div>
{periodType === "month" ? (
<Button type="button" variant="secondary" className="sm:col-span-2" disabled={exportingMonth} onClick={() => void exportMonth()}>
<Download className="size-4" />
{exportingMonth ? "Exportiert..." : "Monat exportieren"}
</Button>
) : null}
</div>
</div>
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
{periodType === "month" && overview?.closed ? (
<Alert variant="success" className="py-3 text-sm">
Dieser Monat wurde am {formatDateTime(overview.closedAt!)} abgeschlossen. Bewertungen und Änderungen sind geschützt, bis der Monat im Monatsabschluss wieder geöffnet wird.
</Alert>
) : null}
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-5">
{metricCards.map(({ label, value, detail, icon: Icon }) => (
<Card key={label}>
<CardContent className="flex items-center gap-3 p-3">
@@ -515,9 +559,9 @@ export function AnalysisPage({ onNavigate }: AnalysisPageProps) {
<Card>
<CardHeader className="gap-3 p-4 sm:flex-row sm:items-center sm:justify-between sm:space-y-0">
<div>
<CardTitle>Gesamtaufwand</CardTitle>
<CardTitle>Abgerechneter Aufwand</CardTitle>
<CardDescription>
{periodType === "month" ? "Aufwand pro Tag im ausgewählten Monat." : "Aufwand pro Stunde am ausgewählten Tag."}
{periodType === "month" ? "Abrechenbare Zeit pro Tag im ausgewählten Monat." : "Abrechenbare Zeit pro Stunde am ausgewählten Tag."}
</CardDescription>
</div>
<div className="flex items-center gap-2">
@@ -591,7 +635,7 @@ export function AnalysisPage({ onNavigate }: AnalysisPageProps) {
<div className="mt-3 flex flex-wrap gap-3 text-xs text-muted-foreground">
<span className="inline-flex items-center gap-1.5">
<span className="size-2 rounded-sm bg-neutral-300 dark:bg-neutral-700" />
Minuten
Abgerechnet
</span>
<span className="inline-flex items-center gap-1.5">
<span className="h-0.5 w-4 bg-foreground" />
@@ -749,9 +793,8 @@ export function AnalysisPage({ onNavigate }: AnalysisPageProps) {
<TableHead>Organisation</TableHead>
<TableHead>Art</TableHead>
<TableHead>Sessions</TableHead>
<TableHead>Zeit</TableHead>
<TableHead>Abgerechnet</TableHead>
<TableHead>Nicht abrechenbar</TableHead>
<TableHead>Zeit abgerechnet</TableHead>
<TableHead>Sessions abgerechnet</TableHead>
<TableHead>Status</TableHead>
<TableHead />
</TableRow>
@@ -761,7 +804,7 @@ export function AnalysisPage({ onNavigate }: AnalysisPageProps) {
<Fragment key={section.key}>
{ticketViewSettings.groupBy !== "none" ? (
<TableRow className={`${sectionIndex > 0 ? "border-t-[12px] border-t-background" : ""} border-b-2 border-border bg-muted/70 hover:bg-muted/70`}>
<TableCell colSpan={9} className="py-0">
<TableCell colSpan={8} className="py-0">
<div className="flex flex-wrap items-center justify-between gap-3 rounded-md border border-border/80 bg-card px-3 py-2 shadow-sm">
<div className="flex min-w-0 items-center gap-2">
<span className="rounded-sm bg-primary px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-wide text-primary-foreground">Gruppe</span>
@@ -789,7 +832,6 @@ export function AnalysisPage({ onNavigate }: AnalysisPageProps) {
<TableCell>{ticket.session_count}</TableCell>
<TableCell>{formatMinutes(ticket.total_minutes)}</TableCell>
<TableCell>{ticket.billed_count}</TableCell>
<TableCell>{ticket.non_billable_count}</TableCell>
<TableCell>
<TicketStatus ticket={ticket} />
</TableCell>
@@ -835,19 +877,15 @@ export function AnalysisPage({ onNavigate }: AnalysisPageProps) {
</div>
<TicketStatus ticket={ticket} />
</div>
<div className="grid grid-cols-3 gap-2 text-sm">
<div className="grid grid-cols-2 gap-2 text-sm">
<div className="rounded-md bg-muted/50 p-2">
<p className="text-muted-foreground">Zeit</p>
<p className="text-muted-foreground">Abr.</p>
<p className="font-medium">{formatMinutes(ticket.total_minutes)}</p>
</div>
<div className="rounded-md bg-muted/50 p-2">
<p className="text-muted-foreground">Abr.</p>
<p className="text-muted-foreground">Sessions</p>
<p className="font-medium">{ticket.billed_count}</p>
</div>
<div className="rounded-md bg-muted/50 p-2">
<p className="text-muted-foreground">Nicht</p>
<p className="font-medium">{ticket.non_billable_count}</p>
</div>
</div>
<Button className="w-full" size="sm" variant="secondary" onClick={() => onNavigate(`/analysis/${periodType}/${period}/tickets/${ticket.id}`)}>
Öffnen
+70 -26
View File
@@ -4,6 +4,41 @@ import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { helpSections } from "../help-content";
import { cn } from "../lib/utils";
function WorkflowList({ items }: { items: string[] }) {
return (
<div>
<p className="mb-2 text-sm font-medium">Empfohlener Ablauf</p>
<ol className="space-y-2 text-sm text-muted-foreground">
{items.map((item, index) => (
<li key={item} className="flex gap-2">
<span className="grid size-5 shrink-0 place-items-center rounded-full bg-primary/10 text-xs font-semibold text-primary">{index + 1}</span>
<span>{item}</span>
</li>
))}
</ol>
</div>
);
}
function FlowDiagram({ items }: { items: string[] }) {
return (
<div>
<p className="mb-2 text-sm font-medium">Ablaufdiagramm</p>
<div className="overflow-x-auto rounded-md border bg-muted/20 p-3">
<div className="flex min-w-[520px] flex-col items-center gap-2 text-center text-xs text-muted-foreground">
{items.map((item, index) => (
<div key={item} className="contents">
<div className="w-full max-w-[360px] rounded-md border bg-background px-3 py-2 font-medium text-foreground">{item}</div>
{index < items.length - 1 ? <div className="text-muted-foreground"></div> : null}
</div>
))}
</div>
</div>
</div>
);
}
export function FaqPage() {
return (
@@ -34,36 +69,44 @@ export function FaqPage() {
</Card>
<div className="grid gap-3 xl:grid-cols-2">
{helpSections.map((section) => (
<Card key={section.id} id={section.id} className="scroll-mt-16">
{helpSections.map((section) => {
const isWide = section.id === "grundprinzip" || section.id === "monatsworkflow";
const isWorkflow = section.id === "monatsworkflow";
return (
<Card key={section.id} id={section.id} className={cn("scroll-mt-16", isWide ? "xl:col-span-2" : "")}>
<CardHeader className="p-4">
<CardTitle>{section.title}</CardTitle>
<CardDescription>{section.description}</CardDescription>
</CardHeader>
<CardContent className="space-y-4 px-4 pb-4">
<div className="rounded-md border bg-muted/20 p-3">
<p className="mb-2 text-sm font-medium">Kurzfassung</p>
<ul className="space-y-2 text-sm text-muted-foreground">
{section.quickItems.map((item) => (
<li key={item} className="flex gap-2">
<span className="mt-2 size-1.5 shrink-0 rounded-full bg-primary" />
<span>{item}</span>
</li>
))}
</ul>
<CardContent className={cn("px-4 pb-4", isWorkflow ? "grid gap-5 lg:grid-cols-[minmax(0,1fr)_minmax(320px,0.9fr)]" : "space-y-4")}>
<div className="space-y-4">
<div className="rounded-md border bg-muted/20 p-3">
<p className="mb-2 text-sm font-medium">Kurzfassung</p>
<ul className="space-y-2 text-sm text-muted-foreground">
{section.quickItems.map((item) => (
<li key={item} className="flex gap-2">
<span className="mt-2 size-1.5 shrink-0 rounded-full bg-primary" />
<span>{item}</span>
</li>
))}
</ul>
</div>
<div>
<p className="mb-2 text-sm font-medium">Details</p>
<ul className="space-y-2 text-sm text-muted-foreground">
{section.details.map((item) => (
<li key={item} className="flex gap-2">
<span className="mt-2 size-1.5 shrink-0 rounded-full bg-primary/70" />
<span>{item}</span>
</li>
))}
</ul>
</div>
{section.workflow ? <WorkflowList items={section.workflow} /> : null}
</div>
<div>
<p className="mb-2 text-sm font-medium">Details</p>
<ul className="space-y-2 text-sm text-muted-foreground">
{section.details.map((item) => (
<li key={item} className="flex gap-2">
<span className="mt-2 size-1.5 shrink-0 rounded-full bg-primary/70" />
<span>{item}</span>
</li>
))}
</ul>
</div>
<div className="flex justify-end">
{section.diagram ? <FlowDiagram items={section.diagram} /> : null}
<div className={cn("flex justify-end", isWorkflow ? "lg:col-span-2" : "")}>
<Button asChild variant="ghost" size="sm">
<a href="#top">
<ArrowUp className="size-4" />
@@ -73,7 +116,8 @@ export function FaqPage() {
</div>
</CardContent>
</Card>
))}
);
})}
</div>
</div>
);
+25 -9
View File
@@ -136,7 +136,7 @@ function CrmDayItem({
</div>
<p className="truncate text-sm text-muted-foreground">{item.organization_name}</p>
<p className="flex flex-wrap gap-x-3 gap-y-1 text-xs">
<span className={trackedTextClass}>Sessions {formatMinutes(item.tracked_minutes)}</span>
<span className={trackedTextClass}>Abgerechnet {formatMinutes(item.tracked_minutes)}</span>
<span className={teamspaceTextClass}>Teamspace {formatMinutes(item.crm_billed_minutes)}</span>
{typeof item.delta_minutes === "number" ? <span className="text-muted-foreground">Differenz {formatTeamspaceDelta(item.delta_minutes)}</span> : null}
</p>
@@ -280,10 +280,26 @@ export function MonthlyClosePage({ onNavigate }: MonthlyClosePageProps) {
const canClose = Boolean(stats && !stats.closed && stats.totals.sessions > 0 && openSessions.length === 0);
const checklist = useMemo(
() => [
{ label: "Offene Bewertungen", count: openSessions.length, detail: `${openTicketGroups.length} Ticket(s)`, blocker: true },
{ label: "Tage ohne Teamspace-Wert", count: missingCrmDays.length, detail: `${acknowledgedMissingCrmDays.length} zur Kenntnis genommen`, blocker: false }
{
label: "Offene Bewertungen",
ok: openSessions.length === 0,
value: openSessions.length,
detail: openSessions.length === 0 ? "Alle Sessions sind bewertet." : `${openTicketGroups.length} Ticket(s) bearbeiten`
},
{
label: "Teamspace geprüft",
ok: missingCrmDays.length === 0,
value: missingCrmDays.length,
detail: missingCrmDays.length === 0 ? "Keine offenen Teamspace-Prüfpunkte." : `${acknowledgedMissingCrmDays.length} zur Kenntnis genommen`
},
{
label: "Monat abschließbar",
ok: Boolean(stats?.closed || canClose),
value: stats?.closed ? "geschlossen" : canClose ? "bereit" : "offen",
detail: stats?.closed ? "Monat ist gesperrt." : canClose ? "Kann abgeschlossen werden." : "Bewertungen prüfen."
}
],
[acknowledgedMissingCrmDays.length, openSessions.length, openTicketGroups.length, missingCrmDays.length]
[acknowledgedMissingCrmDays.length, canClose, openSessions.length, openTicketGroups.length, missingCrmDays.length, stats?.closed]
);
return (
@@ -331,7 +347,7 @@ export function MonthlyClosePage({ onNavigate }: MonthlyClosePageProps) {
) : null}
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-5">
<StatusCard label="Sessions" value={stats?.totals.sessions ?? 0} detail={`${formatMinutes(stats?.totals.minutes ?? 0)} getrackt`} />
<StatusCard label="Sessions" value={stats?.totals.sessions ?? 0} detail={`${formatMinutes(stats?.totals.minutes ?? 0)} abgerechnet`} />
<StatusCard label="Teamspace" value={formatMinutes(stats?.totals.crmBilledMinutes ?? 0)} detail={`Differenz ${formatTeamspaceDelta(stats?.totals.crmDeltaMinutes ?? 0)}`} />
<StatusCard label="Offene Bewertungen" value={openSessions.length} detail={`${openTicketGroups.length} Ticket(s)`} tone={openSessions.length > 0 ? "warn" : "ok"} />
<StatusCard label="Ohne Teamspace" value={missingCrmDays.length} detail={`${acknowledgedMissingCrmDays.length} zur Kenntnis genommen`} tone={missingCrmDays.length > 0 ? "warn" : "ok"} />
@@ -348,14 +364,14 @@ export function MonthlyClosePage({ onNavigate }: MonthlyClosePageProps) {
<HelpLink anchor="monatsabschluss" label="Hilfe zum Monatsabschluss" />
</div>
</CardHeader>
<CardContent className="grid gap-2 px-4 pb-4 md:grid-cols-2">
<CardContent className="grid gap-2 px-4 pb-4 md:grid-cols-3">
{checklist.map((item) => (
<div key={item.label} className="rounded-md border bg-background p-3">
<div className="mb-2 flex items-center justify-between gap-2">
<span className="text-sm font-medium">{item.label}</span>
<Badge variant={item.count > 0 ? "warning" : "success"}>{item.count}</Badge>
<Badge variant={item.ok ? "success" : "warning"}>{item.value}</Badge>
</div>
<p className="text-xs text-muted-foreground">{item.blocker ? "Muss erledigt sein." : item.detail}</p>
<p className="text-xs text-muted-foreground">{item.detail}</p>
</div>
))}
</CardContent>
@@ -409,7 +425,7 @@ export function MonthlyClosePage({ onNavigate }: MonthlyClosePageProps) {
))}
{visibleMissingCrmDays.length === 0 ? (
<p className="rounded-md border bg-muted/20 p-3 text-sm text-muted-foreground">
{acknowledgedMissingCrmDays.length > 0 ? "Alle offenen Punkte sind zur Kenntnis genommen." : "Alle getrackten Tage haben einen Teamspace-Wert."}
{acknowledgedMissingCrmDays.length > 0 ? "Alle offenen Punkte sind zur Kenntnis genommen." : "Alle abrechenbaren Tage haben einen Teamspace-Wert."}
</p>
) : null}
</section>
+244
View File
@@ -0,0 +1,244 @@
import { ChangeEvent, FormEvent, useState } from "react";
import { CheckCircle2, DatabaseBackup, KeyRound, Upload } from "lucide-react";
import { toast } from "sonner";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Textarea } from "@/components/ui/textarea";
import { createSetupAdmin, restoreSetupBackup, validateSetupBackup, type RestoreValidationResult } from "../api";
import type { AuthUser } from "../types";
type SetupPageProps = {
onAdminCreated: (user: AuthUser) => void;
onRestoreComplete: () => void;
};
export function SetupPage({ onAdminCreated, onRestoreComplete }: SetupPageProps) {
const [username, setUsername] = useState("admin");
const [displayName, setDisplayName] = useState("Admin");
const [password, setPassword] = useState("");
const [passwordRepeat, setPasswordRepeat] = useState("");
const [restoreSql, setRestoreSql] = useState("");
const [restoreValidation, setRestoreValidation] = useState<RestoreValidationResult | null>(null);
const [creatingAdmin, setCreatingAdmin] = useState(false);
const [validatingRestore, setValidatingRestore] = useState(false);
const [restoring, setRestoring] = useState(false);
async function createAdmin(event: FormEvent) {
event.preventDefault();
if (password !== passwordRepeat) {
toast.error("Passwörter stimmen nicht überein");
return;
}
setCreatingAdmin(true);
try {
const result = await createSetupAdmin({ username, displayName, password });
toast.success("Admin angelegt");
onAdminCreated(result.user);
} catch (error) {
toast.error("Admin konnte nicht angelegt werden", {
description: error instanceof Error ? error.message : "Unbekannter Fehler"
});
} finally {
setCreatingAdmin(false);
}
}
async function readBackupFile(event: ChangeEvent<HTMLInputElement>) {
const file = event.currentTarget.files?.[0];
if (!file) {
return;
}
const sql = await file.text();
setRestoreSql(sql);
setRestoreValidation(null);
await validateRestore(sql);
}
async function validateRestore(sql = restoreSql) {
if (!sql.trim()) {
setRestoreValidation(null);
return;
}
setValidatingRestore(true);
try {
const result = await validateSetupBackup(sql);
setRestoreValidation(result);
if (result.valid) {
toast.success("Backup-Struktur passt", {
description: `${result.checkedTables} Tabelle(n) geprüft.`
});
} else {
toast.error("Backup-Struktur passt nicht", {
description: result.issues[0] ?? "Unbekannter Strukturfehler"
});
}
} catch (error) {
setRestoreValidation({
valid: false,
checkedTables: 0,
issues: [error instanceof Error ? error.message : "Unbekannter Fehler"]
});
toast.error("Backup-Prüfung fehlgeschlagen", {
description: error instanceof Error ? error.message : "Unbekannter Fehler"
});
} finally {
setValidatingRestore(false);
}
}
async function restoreBackup(event: FormEvent) {
event.preventDefault();
if (!restoreValidation?.valid) {
toast.error("Backup zuerst prüfen", {
description: "Der Restore kann erst nach erfolgreicher Strukturprüfung ausgeführt werden."
});
return;
}
setRestoring(true);
try {
await restoreSetupBackup(restoreSql);
toast.success("Backup eingespielt", {
description: "Melde dich jetzt mit einem Benutzer aus dem Backup an."
});
onRestoreComplete();
} catch (error) {
toast.error("Restore fehlgeschlagen", {
description: error instanceof Error ? error.message : "Unbekannter Fehler"
});
} finally {
setRestoring(false);
}
}
return (
<main className="min-h-screen bg-background p-4">
<div className="mx-auto flex min-h-[calc(100vh-2rem)] w-full max-w-4xl flex-col justify-center gap-4">
<div>
<h1 className="text-2xl font-semibold tracking-normal">TicketTracker Setup</h1>
<p className="text-sm text-muted-foreground">Es wurde noch kein Admin gefunden. Lege einen Admin an oder spiele ein bestehendes Backup ein.</p>
</div>
<Tabs defaultValue="admin" className="w-full">
<TabsList className="grid w-full grid-cols-2">
<TabsTrigger value="admin">Admin anlegen</TabsTrigger>
<TabsTrigger value="restore">Backup einspielen</TabsTrigger>
</TabsList>
<TabsContent value="admin">
<Card>
<CardHeader>
<div className="flex items-center gap-2">
<KeyRound className="size-5 text-muted-foreground" />
<CardTitle>Festen Admin erstellen</CardTitle>
</div>
<CardDescription>Dieser Account ist nur für Systemverwaltung, Benutzer, Zammad-Sync und Backups vorgesehen.</CardDescription>
</CardHeader>
<CardContent>
<form className="grid gap-4 sm:grid-cols-2" onSubmit={createAdmin}>
<div className="space-y-2">
<label className="text-sm font-medium" htmlFor="setup-username">Benutzername</label>
<Input id="setup-username" value={username} onChange={(event) => setUsername(event.currentTarget.value)} autoComplete="username" autoFocus required />
</div>
<div className="space-y-2">
<label className="text-sm font-medium" htmlFor="setup-display-name">Voller Name</label>
<Input id="setup-display-name" value={displayName} onChange={(event) => setDisplayName(event.currentTarget.value)} required />
</div>
<div className="space-y-2">
<label className="text-sm font-medium" htmlFor="setup-password">Passwort</label>
<Input id="setup-password" type="password" value={password} onChange={(event) => setPassword(event.currentTarget.value)} autoComplete="new-password" minLength={6} required />
</div>
<div className="space-y-2">
<label className="text-sm font-medium" htmlFor="setup-password-repeat">Passwort wiederholen</label>
<Input id="setup-password-repeat" type="password" value={passwordRepeat} onChange={(event) => setPasswordRepeat(event.currentTarget.value)} autoComplete="new-password" minLength={6} required />
</div>
<Button className="sm:col-span-2" type="submit" disabled={creatingAdmin}>
<KeyRound className="size-4" />
{creatingAdmin ? "Erstellt..." : "Admin erstellen und anmelden"}
</Button>
</form>
</CardContent>
</Card>
</TabsContent>
<TabsContent value="restore">
<Card>
<CardHeader>
<div className="flex items-center gap-2">
<DatabaseBackup className="size-5 text-muted-foreground" />
<CardTitle>Backup einspielen</CardTitle>
</div>
<CardDescription>Nutze ein SQL-Backup aus dem TicketTracker-Adminbereich.</CardDescription>
</CardHeader>
<CardContent>
<form className="space-y-4" onSubmit={restoreBackup}>
<Alert variant="warning">
<Upload className="size-4" />
<AlertTitle>Restore ersetzt den kompletten Systemstand</AlertTitle>
<AlertDescription>
Vor dem Einspielen werden alle TicketTracker-Tabellen geleert. Danach gilt ausschließlich der Stand des Backups, inklusive aller Benutzer,
Passwörter, Sessions, Teamspace-Werte, Einstellungen und Abschlüsse.
</AlertDescription>
</Alert>
<div className="space-y-2">
<label className="text-sm font-medium" htmlFor="setup-restore-file">SQL-Datei</label>
<Input id="setup-restore-file" type="file" accept=".sql,text/sql,text/plain" onChange={(event) => void readBackupFile(event)} />
</div>
<div className="space-y-2">
<label className="text-sm font-medium" htmlFor="setup-restore-sql">Backup-Inhalt</label>
<Textarea
id="setup-restore-sql"
className="min-h-44 font-mono text-xs"
value={restoreSql}
onChange={(event) => {
setRestoreSql(event.currentTarget.value);
setRestoreValidation(null);
}}
placeholder="SQL-Backup hier einfügen oder Datei auswählen"
required
/>
</div>
{restoreValidation ? (
<Alert variant={restoreValidation.valid ? "success" : "destructive"}>
{restoreValidation.valid ? <CheckCircle2 className="size-4" /> : <Upload className="size-4" />}
<AlertTitle>{restoreValidation.valid ? "Strukturprüfung erfolgreich" : "Strukturprüfung fehlgeschlagen"}</AlertTitle>
<AlertDescription>
{restoreValidation.valid
? `${restoreValidation.checkedTables} Tabelle(n) passen zum aktuellen Datenbankschema.`
: restoreValidation.issues.slice(0, 4).join(" ")}
</AlertDescription>
</Alert>
) : null}
<div className="grid gap-2 sm:grid-cols-2">
<Button type="button" variant="secondary" disabled={validatingRestore || !restoreSql.trim()} onClick={() => void validateRestore()}>
<CheckCircle2 className="size-4" />
{validatingRestore ? "Prüft..." : "Struktur prüfen"}
</Button>
<Button type="submit" variant="destructive" disabled={restoring || !restoreValidation?.valid}>
<DatabaseBackup className="size-4" />
{restoring ? "Spielt ein..." : "Backup einspielen"}
</Button>
</div>
</form>
</CardContent>
</Card>
</TabsContent>
</Tabs>
</div>
</main>
);
}
+42 -95
View File
@@ -4,7 +4,6 @@ import {
CircleAlert,
Clock3,
ExternalLink,
ListChecks,
RotateCcw,
Ticket,
TrendingUp,
@@ -31,12 +30,11 @@ type StatisticsPageProps = {
};
const trackedTextClass = "text-sky-700 dark:text-sky-300";
const teamspaceTextClass = "text-emerald-700 dark:text-emerald-300";
const teamspaceTextClass = "text-fuchsia-700 dark:text-fuchsia-300";
const trackedFillClass = "fill-sky-500/70 dark:fill-sky-400/65";
const teamspaceStrokeClass = "text-emerald-600 dark:text-emerald-300";
const teamspaceStrokeClass = "text-fuchsia-600 dark:text-fuchsia-300";
const trackedDotClass = "bg-sky-500 dark:bg-sky-400";
const teamspaceDotClass = "bg-emerald-500 dark:bg-emerald-400";
const evaluatedTextClass = "text-violet-700 dark:text-violet-300";
const teamspaceDotClass = "bg-fuchsia-500 dark:bg-fuchsia-400";
const openTextClass = "text-amber-700 dark:text-amber-300";
const organizationPageSize = 8;
@@ -60,32 +58,6 @@ function percentage(value: number, total: number) {
return Math.max(0, Math.min(100, Math.round((value / total) * 100)));
}
function roundedPercentages(values: number[]) {
const total = values.reduce((sum, value) => sum + value, 0);
if (total <= 0) {
return values.map(() => 0);
}
const raw = values.map((value) => (value / total) * 100);
const floors = raw.map(Math.floor);
let remainder = 100 - floors.reduce((sum, value) => sum + value, 0);
const order = raw
.map((value, index) => ({ index, fraction: value - Math.floor(value) }))
.sort((left, right) => right.fraction - left.fraction);
for (const item of order) {
if (remainder <= 0) {
break;
}
floors[item.index] += 1;
remainder -= 1;
}
return floors;
}
function formatSignedMinutes(minutes: number) {
if (minutes === 0) {
return "ausgeglichen";
@@ -112,20 +84,20 @@ function DeltaBadge({ minutes }: { minutes: number }) {
function deltaDescription(minutes: number) {
if (minutes > 0) {
return "Teamspace liegt über den Sessions";
return "Teamspace liegt über der abgerechneten Zeit";
}
if (minutes < 0) {
return "Teamspace liegt unter den Sessions";
return "Teamspace liegt unter der abgerechneten Zeit";
}
return "Teamspace und Sessions sind ausgeglichen";
return "Teamspace und abgerechnete Zeit sind ausgeglichen";
}
function TimeComparison({ trackedMinutes, crmMinutes, sessions }: { trackedMinutes: number; crmMinutes: number; sessions?: number }) {
return (
<div className="space-y-0.5 text-sm">
<div className={`font-semibold ${trackedTextClass}`}>Sessions {formatMinutes(trackedMinutes)}</div>
<div className={`font-semibold ${trackedTextClass}`}>Abgerechnet {formatMinutes(trackedMinutes)}</div>
<div className={`font-semibold ${teamspaceTextClass}`}>Teamspace {formatMinutes(crmMinutes)}</div>
{typeof sessions === "number" ? <div className="text-xs text-muted-foreground">{sessions} Session(s)</div> : null}
</div>
@@ -137,7 +109,7 @@ function ColorLegend() {
<div className="flex flex-wrap gap-x-4 gap-y-2 rounded-md border bg-muted/20 p-3 text-xs text-muted-foreground">
<span className="inline-flex items-center gap-1.5">
<span className={`size-2 rounded-sm ${trackedDotClass}`} />
<span className={trackedTextClass}>Sessions</span>
<span className={trackedTextClass}>Abgerechnet</span>
</span>
<span className="inline-flex items-center gap-1.5">
<span className={`h-0.5 w-4 ${teamspaceDotClass}`} />
@@ -151,10 +123,6 @@ function ColorLegend() {
<span className="size-2 rounded-full bg-red-500 dark:bg-red-400" />
<span className="text-red-700 dark:text-red-300">Differenz minus</span>
</span>
<span className="inline-flex items-center gap-1.5">
<span className="size-2 rounded-full bg-violet-500 dark:bg-violet-400" />
<span className={evaluatedTextClass}>bewertet</span>
</span>
<span className="inline-flex items-center gap-1.5">
<span className="size-2 rounded-full bg-amber-500 dark:bg-amber-400" />
<span className={openTextClass}>offen</span>
@@ -221,10 +189,8 @@ function buildMonthSeries(month: string, buckets: StatisticsDailyBucket[]) {
sessions: bucket?.sessions ?? 0,
totalMinutes: bucket?.total_minutes ?? 0,
billedMinutes: bucket?.billed_minutes ?? 0,
nonBillableMinutes: bucket?.non_billable_minutes ?? 0,
openMinutes: bucket?.open_minutes ?? 0,
billedSessions: bucket?.billed_sessions ?? 0,
nonBillableSessions: bucket?.non_billable_sessions ?? 0,
openSessions: bucket?.open_sessions ?? 0,
crmBilledMinutes: bucket?.crm_billed_minutes ?? 0
};
@@ -341,10 +307,10 @@ function OrganizationRow({ organization, maxMinutes, totalMinutes }: { organizat
<TableCell className="max-w-[320px] whitespace-normal">
<div className="font-medium">{organization.organization_name ?? "Keine Organisation"}</div>
<div className="text-xs text-muted-foreground">
{organization.tickets} Ticket(s) · {organization.sessions} Session(s)
{organization.tickets} Ticket(s) · {organization.billed_sessions} abgerechnet
</div>
</TableCell>
<TableCell>{organization.sessions}</TableCell>
<TableCell>{organization.billed_sessions}</TableCell>
<TableCell>
<TimeComparison trackedMinutes={organization.total_minutes} crmMinutes={organization.crm_billed_minutes} />
</TableCell>
@@ -369,9 +335,9 @@ function TicketHotspot({ ticket, month, onNavigate }: { ticket: StatisticsTicket
</div>
<p className="truncate text-sm text-muted-foreground">{ticket.organization_name ?? "Keine Organisation"}</p>
<div className="mt-1 flex flex-wrap gap-x-3 gap-y-1 text-xs">
<span className={trackedTextClass}>Sessions {formatMinutes(ticket.total_minutes)}</span>
<span className={trackedTextClass}>Abgerechnet {formatMinutes(ticket.total_minutes)}</span>
<span className={teamspaceTextClass}>Teamspace {formatMinutes(ticket.crm_billed_minutes)}</span>
<span className="text-muted-foreground">{ticket.sessions} Session(s)</span>
<span className="text-muted-foreground">{ticket.billed_sessions} abgerechnet</span>
<span>{ticket.active_days} Tag(e)</span>
<span className={deltaTextClass(ticket.crm_delta_minutes)}>{formatTeamspaceDelta(ticket.crm_delta_minutes)}</span>
</div>
@@ -445,10 +411,10 @@ export function StatisticsPage({ onNavigate }: StatisticsPageProps) {
}
const totals = stats?.totals;
const evaluatedSessions = (totals?.billedSessions ?? 0) + (totals?.nonBillableSessions ?? 0);
const evaluatedMinutes = (totals?.billedMinutes ?? 0) + (totals?.nonBillableMinutes ?? 0);
const [evaluatedSessionShare, openSessionShare] = roundedPercentages([evaluatedSessions, totals?.openSessions ?? 0]);
const [evaluatedTimeShare, openTimeShare] = roundedPercentages([evaluatedMinutes, totals?.openMinutes ?? 0]);
const billedSessions = totals?.billedSessions ?? 0;
const billedMinutes = totals?.billedMinutes ?? 0;
const openSessions = totals?.openSessions ?? 0;
const openMinutes = totals?.openMinutes ?? 0;
const crmCoverage = percentage(totals?.crmBilledMinutes ?? 0, totals?.minutes ?? 0);
const maxOrganizationMinutes = Math.max(...(stats?.organizations ?? []).map((organization) => organization.total_minutes), 1);
const topOrganization = stats?.organizations[0] ?? null;
@@ -494,15 +460,15 @@ export function StatisticsPage({ onNavigate }: StatisticsPageProps) {
</div>
</div>
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-3">
<MetricCard
label="Getrackt"
label="Abgerechnet"
value={formatMinutes(totals?.minutes ?? 0)}
detail={`${totals?.sessions ?? 0} Session(s), ${totals?.tickets ?? 0} Ticket(s)`}
detail={`${billedSessions} Session(s), ${totals?.tickets ?? 0} Ticket(s)`}
icon={Clock3}
valueClassName={trackedTextClass}
helpAnchor="statistik-getrackt"
helpLabel="Hilfe zur Kennzahl Getrackt"
helpLabel="Hilfe zur Kennzahl Abgerechnet"
/>
<MetricCard
label="Teamspace"
@@ -513,27 +479,13 @@ export function StatisticsPage({ onNavigate }: StatisticsPageProps) {
helpAnchor="statistik-teamspace"
helpLabel="Hilfe zur Kennzahl Teamspace"
/>
<MetricCard
label="Bewertet"
value={formatMinutes(evaluatedMinutes)}
detail={
<div className="space-y-0.5">
<div>{evaluatedSessions} von {totals?.sessions ?? 0} Session(s), {evaluatedSessionShare}%</div>
<div>{evaluatedTimeShare}% der Zeit · Abr. {totals?.billedSessions ?? 0} · Nicht {totals?.nonBillableSessions ?? 0}</div>
</div>
}
icon={ListChecks}
valueClassName={evaluatedTextClass}
helpAnchor="statistik-bewertet"
helpLabel="Hilfe zur Kennzahl Bewertet"
/>
<MetricCard
label="Offen"
value={formatMinutes(totals?.openMinutes ?? 0)}
value={formatMinutes(openMinutes)}
detail={
<div className="space-y-0.5">
<div>{totals?.openSessions ?? 0} von {totals?.sessions ?? 0} Session(s), {openSessionShare}%</div>
<div>{openTimeShare}% der Zeit</div>
<div>{openSessions} Session(s)</div>
<div>wird nicht kalkuliert</div>
</div>
}
icon={CircleAlert}
@@ -547,7 +499,7 @@ export function StatisticsPage({ onNavigate }: StatisticsPageProps) {
<CardHeader className="gap-3 p-4 sm:flex-row sm:items-start sm:justify-between sm:space-y-0">
<div>
<CardTitle>Monatsverlauf und Abrechnungsqualität</CardTitle>
<CardDescription>Getrackter Aufwand pro Tag, Teamspace-Linie und Bewertungsstand in einem Überblick.</CardDescription>
<CardDescription>Abgerechnete Zeit pro Tag, Teamspace-Linie und Bewertungsstand in einem Überblick.</CardDescription>
</div>
<div className="flex items-center gap-2">
<Badge variant="outline">{crmCoverage}% Teamspace-Abdeckung</Badge>
@@ -558,22 +510,18 @@ export function StatisticsPage({ onNavigate }: StatisticsPageProps) {
<div className="space-y-3">
<DailyEffortChart month={month} buckets={stats?.dailySeries ?? []} />
<ColorLegend />
{strongestDay ? <p className="text-xs text-muted-foreground">Stärkster Tag: {formatDate(`${strongestDay.day}T00:00:00`)} mit {formatMinutes(strongestDay.total_minutes)}</p> : null}
{strongestDay ? <p className="text-xs text-muted-foreground">Stärkster abrechenbarer Tag: {formatDate(`${strongestDay.day}T00:00:00`)} mit {formatMinutes(strongestDay.total_minutes)}</p> : null}
</div>
<div className="space-y-4">
<div className="space-y-2 rounded-md border bg-muted/20 p-3">
<div className="flex items-center justify-between gap-3 text-sm">
<span className="text-muted-foreground">Bewertungsstand</span>
<span className="font-medium">{evaluatedSessionShare}% / {openSessionShare}%</span>
</div>
<div className="flex h-2 overflow-hidden rounded-full bg-muted">
<div className="bg-violet-500 dark:bg-violet-400" style={{ width: `${evaluatedSessionShare}%` }} />
<div className="bg-amber-500 dark:bg-amber-400" style={{ width: `${openSessionShare}%` }} />
<span className="font-medium">{billedSessions} abrechenbar</span>
</div>
<div className="grid gap-2 text-xs sm:grid-cols-2 xl:grid-cols-1 2xl:grid-cols-2">
<div className={evaluatedTextClass}>Bewertet: {evaluatedSessions} Session(s), {formatMinutes(evaluatedMinutes)}</div>
<div className={openTextClass}>Offen: {totals?.openSessions ?? 0} Session(s), {formatMinutes(totals?.openMinutes ?? 0)}</div>
<div className={trackedTextClass}>Abgerechnet: {billedSessions} Session(s), {formatMinutes(billedMinutes)}</div>
<div className={openTextClass}>Offen: {openSessions} Session(s), {formatMinutes(openMinutes)}</div>
</div>
</div>
@@ -585,8 +533,8 @@ export function StatisticsPage({ onNavigate }: StatisticsPageProps) {
</div>
<DeltaBadge minutes={totals?.crmDeltaMinutes ?? 0} />
</div>
<TimeComparison trackedMinutes={totals?.minutes ?? 0} crmMinutes={totals?.crmBilledMinutes ?? 0} sessions={totals?.sessions ?? 0} />
<div className="text-xs text-muted-foreground">Teamspace-Abdeckung: {crmCoverage}% der getrackten Session-Zeit</div>
<TimeComparison trackedMinutes={totals?.minutes ?? 0} crmMinutes={totals?.crmBilledMinutes ?? 0} sessions={totals?.billedSessions ?? 0} />
<div className="text-xs text-muted-foreground">Teamspace-Abdeckung: {crmCoverage}% der abgerechneten Zeit</div>
</div>
<div className="grid grid-cols-2 gap-2 text-sm">
@@ -614,7 +562,7 @@ export function StatisticsPage({ onNavigate }: StatisticsPageProps) {
<p className="text-muted-foreground">Top Organisation</p>
<p className="truncate font-semibold">{topOrganization.organization_name}</p>
<p className="flex flex-wrap gap-x-3 gap-y-1 text-xs">
<span className={trackedTextClass}>Sessions {formatMinutes(topOrganization.total_minutes)}</span>
<span className={trackedTextClass}>Abgerechnet {formatMinutes(topOrganization.total_minutes)}</span>
<span className={teamspaceTextClass}>Teamspace {formatMinutes(topOrganization.crm_billed_minutes)}</span>
</p>
</div>
@@ -628,7 +576,7 @@ export function StatisticsPage({ onNavigate }: StatisticsPageProps) {
<CardHeader className="gap-3 p-4 sm:flex-row sm:items-start sm:justify-between sm:space-y-0">
<div>
<CardTitle>Statistik pro Organisation</CardTitle>
<CardDescription>Kundenaufwand inklusive Teamspace-Differenz und Anteil am getrackten Monatsaufwand.</CardDescription>
<CardDescription>Kundenaufwand inklusive Teamspace-Differenz und Anteil an der abgerechneten Monatszeit.</CardDescription>
</div>
<div className="flex items-center gap-2">
<Badge variant="outline">
@@ -643,10 +591,10 @@ export function StatisticsPage({ onNavigate }: StatisticsPageProps) {
<TableHeader>
<TableRow>
<TableHead>Organisation</TableHead>
<TableHead>Sessions</TableHead>
<TableHead>Abgerechnet</TableHead>
<TableHead>Zeiten</TableHead>
<TableHead>Differenz</TableHead>
<TableHead>Anteil am Monat</TableHead>
<TableHead>Anteil abgerechnet</TableHead>
</TableRow>
</TableHeader>
<TableBody>
@@ -667,13 +615,13 @@ export function StatisticsPage({ onNavigate }: StatisticsPageProps) {
<div className="flex items-start justify-between gap-2">
<div className="min-w-0">
<p className="truncate font-medium">{organization.organization_name ?? "Keine Organisation"}</p>
<p className="text-xs text-muted-foreground">{organization.tickets} Ticket(s) · {organization.sessions} Session(s)</p>
<p className="text-xs text-muted-foreground">{organization.tickets} Ticket(s) · {organization.billed_sessions} abgerechnet</p>
</div>
<DeltaBadge minutes={organization.crm_delta_minutes} />
</div>
<Progress value={percentage(organization.total_minutes, maxOrganizationMinutes)} />
<div className="flex flex-wrap gap-x-3 gap-y-1 text-xs">
<span className={trackedTextClass}>Sessions {formatMinutes(organization.total_minutes)}</span>
<span className={trackedTextClass}>Abgerechnet {formatMinutes(organization.total_minutes)}</span>
<span className={teamspaceTextClass}>Teamspace {formatMinutes(organization.crm_billed_minutes)}</span>
<span className="text-muted-foreground">{percentage(organization.total_minutes, totals?.minutes ?? 0)}% vom Monat</span>
<span className={deltaTextClass(organization.crm_delta_minutes)}>{formatTeamspaceDelta(organization.crm_delta_minutes)}</span>
@@ -736,18 +684,17 @@ export function StatisticsPage({ onNavigate }: StatisticsPageProps) {
<div className="flex items-center justify-between gap-3">
<div>
<p className="font-semibold">{workTypeLabel(group.work_type)}</p>
<p className="text-xs text-muted-foreground">{group.tickets} Ticket(s), {group.sessions} Session(s)</p>
<p className="text-xs text-muted-foreground">{group.tickets} Ticket(s), {group.billed_sessions} abgerechnet</p>
</div>
<DeltaBadge minutes={group.crm_delta_minutes} />
</div>
<Progress value={percentage(group.total_minutes, totals?.minutes ?? 0)} />
<div className="flex flex-wrap gap-x-3 gap-y-1 text-xs">
<span className={trackedTextClass}>Sessions {formatMinutes(group.total_minutes)}</span>
<span className={trackedTextClass}>Abgerechnet {formatMinutes(group.total_minutes)}</span>
<span className={teamspaceTextClass}>Teamspace {formatMinutes(group.crm_billed_minutes)}</span>
</div>
<div className="grid grid-cols-3 gap-2 text-xs text-muted-foreground">
<div className="grid grid-cols-2 gap-2 text-xs text-muted-foreground">
<span>Abr. {group.billed_sessions} / {formatMinutes(group.billed_minutes)}</span>
<span>Nicht {group.non_billable_sessions} / {formatMinutes(group.non_billable_minutes)}</span>
<span>Offen {group.open_sessions} / {formatMinutes(group.open_minutes)}</span>
</div>
</div>
@@ -779,13 +726,13 @@ export function StatisticsPage({ onNavigate }: StatisticsPageProps) {
</CardContent>
</Card>
{stats && stats.totals.sessions === 0 ? (
{stats && stats.totals.billedSessions === 0 ? (
<Card>
<CardContent className="grid place-items-center gap-2 p-8 text-center">
<Ticket className="size-8 text-muted-foreground" />
<div>
<p className="font-medium">Keine Sessions in diesem Monat</p>
<p className="text-sm text-muted-foreground">Sobald Sessions vorhanden sind, füllt sich die Statistik automatisch.</p>
<p className="font-medium">Keine abgerechneten Sessions in diesem Monat</p>
<p className="text-sm text-muted-foreground">Offene Sessions werden separat gezeigt und fließen nicht in Statistiken oder Vergleiche ein.</p>
</div>
<Button variant="secondary" size="sm" onClick={() => setMonth(currentMonth())}>
<RotateCcw className="size-4" />
+57 -6
View File
@@ -1,6 +1,7 @@
import { ArrowLeft, Pencil, Save, Trash2 } from "lucide-react";
import { FormEvent, useEffect, useMemo, useState } from "react";
import { toast } from "sonner";
import { Alert } from "@/components/ui/alert";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
@@ -28,6 +29,15 @@ type TicketDetailPageProps = {
period: string;
ticketId: string;
onNavigate: (to: string) => void;
onOpenTicketChange?: (ticket: {
key: string;
ticketId: string;
ticketNumber: string;
periodType: PeriodType;
period: string;
path: string;
dirty: boolean;
}) => void;
};
type TicketSessionFormState = {
@@ -140,7 +150,7 @@ function isPartialHoursInput(value: string) {
return /^\d*(?:[,.]\d*)?$/.test(value.trim());
}
export function TicketDetailPage({ periodType, period, ticketId, onNavigate }: TicketDetailPageProps) {
export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onOpenTicketChange }: TicketDetailPageProps) {
const [data, setData] = useState<TicketPeriod | null>(null);
const [loadingId, setLoadingId] = useState<string | null>(null);
const [deletingId, setDeletingId] = useState<string | null>(null);
@@ -206,22 +216,32 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate }: T
const savedDayBillingInput = (day: string) => formatHoursInput(dayBillingMap.get(day));
const isDayBillingDirty = (day: string) => (dayBillingInputs[day] ?? "") !== savedDayBillingInput(day);
const sessionGroups = useMemo(() => {
const groups = new Map<string, { key: string; label: string; totalMinutes: number; sessions: SessionEntry[] }>();
const groups = new Map<
string,
{ key: string; label: string; totalMinutes: number; nonBillableMinutes: number; openMinutes: number; sessions: SessionEntry[] }
>();
for (const session of data?.sessions ?? []) {
const key = sessionDayKey(session.started_at);
const group = groups.get(key);
const billedMinutes = session.billing_status === "billed" ? session.rounded_minutes : 0;
const nonBillableMinutes = session.billing_status === "non_billable" ? session.rounded_minutes : 0;
const openMinutes = session.billing_status === null ? session.rounded_minutes : 0;
if (group) {
group.sessions.push(session);
group.totalMinutes += session.rounded_minutes;
group.totalMinutes += billedMinutes;
group.nonBillableMinutes += nonBillableMinutes;
group.openMinutes += openMinutes;
continue;
}
groups.set(key, {
key,
label: formatDate(session.started_at),
totalMinutes: session.rounded_minutes,
totalMinutes: billedMinutes,
nonBillableMinutes,
openMinutes,
sessions: [session]
});
}
@@ -230,6 +250,29 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate }: T
}, [data?.sessions]);
const periodTotalMinutes = sessionGroups.reduce((sum, group) => sum + group.totalMinutes, 0);
const periodTeamspaceMinutes = Array.from(dayBillingMap.values()).reduce((sum, minutes) => sum + Number(minutes ?? 0), 0);
const ticketDataDirty = Boolean(
ticket &&
(editTicketNumber !== ticket.ticket_number ||
editOrganizationId !== (ticket.organization_id ?? "") ||
editWorkType !== (ticket.work_type ?? "support"))
);
const hasUnsavedChanges = ticketDataDirty || sessionGroups.some((group) => isDayBillingDirty(group.key));
useEffect(() => {
if (!ticket) {
return;
}
onOpenTicketChange?.({
key: `${periodType}:${period}:${ticket.id}`,
ticketId: ticket.id,
ticketNumber: ticket.ticket_number,
periodType,
period,
path: `/analysis/${periodType}/${period}/tickets/${ticket.id}`,
dirty: hasUnsavedChanges
});
}, [ticket, periodType, period, hasUnsavedChanges, onOpenTicketChange]);
async function setBilling(sessionId: string, billingStatus: BillingStatus) {
if (isPeriodClosed && billingStatus !== null) {
@@ -492,6 +535,12 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate }: T
</Badge>
</div>
{periodType === "month" && data?.closed ? (
<Alert variant="success" className="py-3 text-sm">
Dieser Monat ist abgeschlossen. Bewertungen und Änderungen sind geschützt, bis du den Monat im Monatsabschluss wieder öffnest.
</Alert>
) : null}
<Card>
<CardHeader className="p-4">
<div className="flex items-start justify-between gap-3">
@@ -551,7 +600,7 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate }: T
</div>
<div className="flex flex-wrap items-center gap-2">
<div className="flex items-center gap-2 rounded-md border bg-muted/30 px-3 py-2 text-sm">
<span className="text-muted-foreground">{periodLabel} gesamt</span>
<span className="text-muted-foreground">{periodLabel} abgerechnet</span>
<span className="font-semibold">{formatMinutes(periodTotalMinutes)}</span>
</div>
<div className="flex items-center gap-2 rounded-md border bg-muted/30 px-3 py-2 text-sm">
@@ -568,7 +617,9 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate }: T
<div className="flex flex-col gap-2 border-b bg-muted/40 px-3 py-2 sm:flex-row sm:items-center sm:justify-between">
<p className="font-medium">{group.label}</p>
<div className="flex flex-wrap items-center gap-2">
<Badge variant="outline">Tag gesamt {formatMinutes(group.totalMinutes)}</Badge>
<Badge variant="outline">Tag abgerechnet {formatMinutes(group.totalMinutes)}</Badge>
{group.nonBillableMinutes > 0 ? <Badge variant="secondary">Nicht {formatMinutes(group.nonBillableMinutes)}</Badge> : null}
{group.openMinutes > 0 ? <Badge variant="warning">Offen {formatMinutes(group.openMinutes)}</Badge> : null}
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<label htmlFor={`crm-billing-${group.key}`}>Teamspace</label>
<Input
+2 -1
View File
@@ -318,6 +318,7 @@ export function TimerPage({ timers, setTimers, selectedTimerId, setSelectedTimer
id="ticket-number"
placeholder="Ticket#123456"
value={ticketNumber}
autoFocus
onChange={(event) => setTicketNumber(event.currentTarget.value)}
onKeyDown={(event) => {
if (event.key === "Enter") {
@@ -525,7 +526,7 @@ export function TimerPage({ timers, setTimers, selectedTimerId, setSelectedTimer
<label className="text-sm font-medium" htmlFor="activity">
Tätigkeit
</label>
<Textarea id="activity" value={activity} onChange={(event) => setActivity(event.currentTarget.value)} required />
<Textarea id="activity" value={activity} onChange={(event) => setActivity(event.currentTarget.value)} autoFocus required />
</div>
{finished?.ticket?.organization_id && finished.ticket.work_type ? null : (
<div className="space-y-2">