Compare commits

..
33 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
michael 737c868376 Update 2026-08-05 16:14:43 +02:00
michael b01fc1655b Add detailed statistics help 2026-08-05 16:08:12 +02:00
michael f8574e5f39 Refine help UX and monthly close layout 2026-08-05 15:56:09 +02:00
michael 3b6f35e31a Add FAQ guide and contextual help links 2026-08-05 15:46:06 +02:00
michael 33f0170927 Persist monthly close acknowledgement filter 2026-08-05 15:37:56 +02:00
michael 8e4f07dcf8 Refine statistics Teamspace display 2026-08-05 15:34:47 +02:00
michael 84057771fe Allow undoing Teamspace acknowledgements 2026-08-05 15:27:45 +02:00
michael 20bc04778d Refine monthly close cleanup 2026-08-05 15:21:55 +02:00
michael ca5e015065 Move month close workflow 2026-08-05 15:05:25 +02:00
michael 4b35dfe55e Refine statistics comparisons 2026-08-05 14:44:56 +02:00
michael eafd718e64 Add monthly statistics dashboard 2026-08-05 14:25:08 +02:00
michael cfc4ef45a8 Show ticket Teamspace totals 2026-08-05 14:11:30 +02:00
25 changed files with 4707 additions and 1146 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
+1 -4
View File
@@ -1,8 +1,5 @@
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
DB_POOL_SIZE=10
DB_POOL_SIZE=10
+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 });
+1046 -325
View File
File diff suppressed because it is too large Load Diff
+126 -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';");
@@ -295,6 +369,16 @@ export async function migrate() {
);
`);
await query(`
CREATE TABLE IF NOT EXISTS ticket_day_billing_acknowledgements (
ticket_id BIGINT NOT NULL REFERENCES tickets(id) ON DELETE CASCADE,
user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
day DATE NOT NULL,
acknowledged_at TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (ticket_id, user_id, day)
);
`);
await ensureClosureOwnership("ticket_month_closures", "month", ["ticket_id", "month"], adminId);
await ensureClosureOwnership("month_closures", "month", ["month"], adminId);
await ensureClosureOwnership("ticket_day_closures", "day", ["ticket_id", "day"], adminId);
@@ -309,6 +393,7 @@ export async function migrate() {
await query("CREATE INDEX IF NOT EXISTS idx_sessions_billing_status ON sessions(billing_status);");
await query("CREATE INDEX IF NOT EXISTS idx_ticket_day_closures_day ON ticket_day_closures(day);");
await query("CREATE INDEX IF NOT EXISTS idx_ticket_day_billings_user_day ON ticket_day_billings(user_id, day);");
await query("CREATE INDEX IF NOT EXISTS idx_ticket_day_billing_ack_user_day ON ticket_day_billing_acknowledgements(user_id, day);");
await query("CREATE INDEX IF NOT EXISTS idx_recurring_billings_user_active ON recurring_billings(user_id, active);");
await query("UPDATE recurring_billings SET active = true WHERE active = false;");
await query(`
-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.
+503 -74
View File
@@ -1,7 +1,12 @@
import {
BarChart3,
CheckCircle2,
ClipboardCheck,
Command,
ChartNoAxesCombined,
CircleHelp,
FileText,
GripVertical,
LayoutGrid,
LogOut,
Moon,
@@ -15,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";
@@ -31,6 +37,7 @@ import {
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Separator } from "@/components/ui/separator";
import { HelpLink } from "@/components/HelpLink";
import {
Sidebar,
SidebarContent,
@@ -49,18 +56,75 @@ 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";
import { FaqPage } from "./views/FaqPage";
import { LoginPage } from "./views/LoginPage";
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 =
| {
type: "item";
href: string;
label: string;
icon: typeof Timer;
active: boolean;
}
| {
type: "separator";
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+)$/);
@@ -89,6 +153,18 @@ function routeFromPath(pathname: string) {
return { page: "analysis" as const };
}
if (pathname.startsWith("/statistics")) {
return { page: "statistics" as const };
}
if (pathname.startsWith("/faq")) {
return { page: "faq" as const };
}
if (pathname.startsWith("/monthly-close")) {
return { page: "monthly-close" as const };
}
if (pathname.startsWith("/recurring")) {
return { page: "recurring" as const };
}
@@ -190,6 +266,119 @@ function QuickTimerStarter({ className, inputId, onStartTimer }: QuickTimerStart
<span className="hidden sm:inline">Timer starten</span>
<span className="sm:hidden">Start</span>
</Button>
<HelpLink anchor="timer" label="Hilfe zum schnellen Timerstart" className="hidden md:inline-flex" />
</form>
);
}
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>
);
}
@@ -312,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) {
@@ -342,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;
}
@@ -368,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;
}
@@ -434,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)) {
@@ -486,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">
@@ -494,19 +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 = [
{ href: "/timer", label: "Timer", icon: Timer, active: path.startsWith("/timer") || path === "/" },
{ href: "/analysis", label: "Auswertung", icon: BarChart3, active: path.startsWith("/analysis") },
{ href: "/recurring", label: "Fixe Abrechnung", icon: Repeat, active: path.startsWith("/recurring") },
{ href: "/profile", label: "Profil", icon: UserCog, active: path.startsWith("/profile") },
const navItems: NavMenuEntry[] = [
...(currentUser.role === "admin"
? [{ href: "/admin/users", label: "Benutzer", icon: Shield, active: path.startsWith("/admin/users") }]
: []),
? [
{ 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;
@@ -524,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>
@@ -534,62 +885,124 @@ 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) => (
<SidebarMenuItem key={item.href}>
<SidebarMenuButton asChild tooltip={item.label} isActive={item.active}>
<a href={item.href} onClick={navHandler(item.href)}>
<item.icon />
<span>{item.label}</span>
</a>
</SidebarMenuButton>
</SidebarMenuItem>
))}
{navItems.map((item) =>
item.type === "separator" ? (
<SidebarMenuItem key={item.key} className="py-1 group-data-[collapsible=icon]:hidden">
<Separator />
</SidebarMenuItem>
) : (
<SidebarMenuItem key={item.href}>
<SidebarMenuButton asChild tooltip={item.label} isActive={item.active}>
<a href={item.href} onClick={navHandler(item.href)}>
<item.icon />
<span>{item.label}</span>
</a>
</SidebarMenuButton>
</SidebarMenuItem>
)
)}
</SidebarMenu>
</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 Sessions findest du in Tages- und Monatsansicht.</p>
</div>
<p className="text-muted-foreground">Offene Bewertungen findest du im Monatsabschluss.</p>
</div> : null}
<SidebarMenu>
<SidebarMenuItem>
<SidebarMenuButton tooltip="Worklog">
@@ -615,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"
@@ -638,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>
@@ -670,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-[1180px]">
{route.page === "timer" ? (
<div className="mx-auto w-full max-w-screen-2xl">
{!isAdmin && route.page === "timer" ? (
<TimerPage
timers={timers}
setTimers={setTimers}
@@ -684,11 +1103,14 @@ export function App() {
tick={tick}
/>
) : null}
{route.page === "analysis" ? <AnalysisPage 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}
@@ -697,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>
+98 -28
View File
@@ -1,5 +1,4 @@
import type {
AdminSession,
AdminUser,
AuthUser,
BillingStatus,
@@ -7,9 +6,10 @@ import type {
PeriodOverview,
PeriodType,
RecurringBilling,
StatisticsOverview,
TicketMeta,
TicketPeriod,
UserRole,
TicketSearchResult,
WorkType
} from "./types";
@@ -33,6 +33,12 @@ type DeleteSessionResult = {
};
};
export type RestoreValidationResult = {
valid: boolean;
checkedTables: number;
issues: string[];
};
export class ApiError extends Error {
constructor(
message: string,
@@ -90,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");
}
@@ -98,7 +129,6 @@ export function createAdminUser(payload: {
username: string;
displayName: string;
password: string;
role: UserRole;
active: boolean;
}) {
return request<{ user: AdminUser }>("/api/admin/users", {
@@ -113,7 +143,6 @@ export function updateAdminUser(
username: string;
displayName: string;
password?: string;
role: UserRole;
active: boolean;
}
) {
@@ -123,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 = "") {
@@ -213,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",
@@ -246,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: {
@@ -283,6 +342,13 @@ export function updateTicketDayBilling(ticketId: string, day: string, billedMinu
});
}
export function acknowledgeMissingTicketDayBilling(ticketId: string, day: string, acknowledged = true) {
return request(`/api/tickets/${ticketId}/day-billings/${day}/acknowledgement`, {
method: "PATCH",
body: JSON.stringify({ acknowledged })
});
}
export function getMonthOverview(month: string) {
return getPeriodOverview("month", month);
}
@@ -299,6 +365,10 @@ export function getPeriodOverview(type: PeriodType, period: string) {
return request<PeriodOverview>(`/api/periods/${periodPath(type)}/${period}/overview`);
}
export function getStatisticsOverview(month: string) {
return request<StatisticsOverview>(`/api/statistics/months/${month}`);
}
export function getTicketPeriod(type: PeriodType, period: string, ticketId: string) {
return request<TicketPeriod>(`/api/periods/${periodPath(type)}/${period}/tickets/${ticketId}`);
}
+44
View File
@@ -0,0 +1,44 @@
import { CircleHelp } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
import { getHelpSection } from "../help-content";
type HelpLinkProps = {
anchor: string;
label?: string;
className?: string;
};
export function HelpLink({ anchor, label = "Hilfe öffnen", className }: HelpLinkProps) {
const section = getHelpSection(anchor);
return (
<Dialog>
<DialogTrigger asChild>
<Button type="button" variant="ghost" size="icon-sm" className={className} title={label} aria-label={label}>
<CircleHelp className="size-4" />
</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle>{section.title}</DialogTitle>
<DialogDescription>{section.description}</DialogDescription>
</DialogHeader>
<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>
<DialogFooter>
<Button asChild variant="secondary">
<a href={`/faq#${section.id}`}>Ausführliche FAQ öffnen</a>
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
+485
View File
@@ -0,0 +1,485 @@
export type HelpSection = {
id: string;
title: string;
description: string;
quickItems: string[];
details: string[];
workflow?: string[];
diagram?: string[];
};
export const helpSections: HelpSection[] = [
{
id: "grundprinzip",
title: "Grundprinzip",
description: "TicketTracker ist die Arbeitszeiterfassung neben Ticketsystem und CRM. Die App misst Arbeit, bewertet Sessions und macht Monatsabschlüsse kontrollierbar.",
quickItems: [
"Sessions sind die getrackten oder nachgetragenen Arbeitszeiten.",
"Teamspace-Werte sind Tageswerte pro Ticket, nicht einzelne Sessions.",
"Abgeschlossen wird nur der Monat."
],
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 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",
description: "Timer erfassen laufende Arbeit an Tickets. Es können mehrere Timer vorbereitet sein, aber immer nur einer läuft aktiv.",
quickItems: [
"Ticketnummer eingeben und Timer starten.",
"Aktivieren eines Timers pausiert alle anderen Timer automatisch.",
"Beim Beenden stoppt die Zeit sofort; Details werden danach gespeichert."
],
details: [
"Neue Timer werden mit einer Ticketnummer im Format Ticket#XXXXXX gestartet.",
"Alle laufenden und pausierten Timer bleiben in der Seitenleiste sichtbar, damit du auf jeder Unterseite sehen kannst, was vorbereitet oder aktiv ist.",
"Wenn ein anderer Timer aktiviert wird, pausiert TicketTracker die übrigen Timer automatisch. Dadurch kann nie versehentlich auf zwei Tickets gleichzeitig Zeit laufen.",
"Beim Klick auf Beenden wird die Stoppuhr sofort angehalten. Das Speichern der Tätigkeit findet danach statt und verlängert die gemessene Zeit nicht.",
"Existiert das Ticket bereits, übernimmt TicketTracker Organisation und Art. Dann muss beim Beenden nur noch die Tätigkeit eingetragen werden.",
"Existiert das Ticket noch nicht, müssen Organisation, Tätigkeit und Art angegeben werden.",
"Pausen werden von der Gesamtdauer abgezogen. Die gespeicherte Zeit wird auf volle Minuten gerundet.",
"Der Schnellstart im Kopfbereich funktioniert auf allen Unterseiten und nutzt dieselbe Timerlogik."
]
},
{
id: "session-nachtragen",
title: "Session Nachtragen",
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.",
"Tätigkeiten sind mehrzeilig."
],
details: [
"Beim Nachtragen wird aus Datum, Startzeit und Endzeit automatisch die Dauer berechnet.",
"Das Ende muss nach dem Beginn liegen. Übernacht-Sessions sind aktuell nicht vorgesehen und sollten als getrennte Einträge gepflegt werden.",
"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.",
"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."
]
},
{
id: "ticketdaten",
title: "Ticketdaten",
description: "Ticketdaten sind Stammdaten für ein Ticket und können nachträglich korrigiert werden.",
quickItems: [
"Ticketnummer, Organisation und Art sind bearbeitbar.",
"Organisationen kommen aus der lokalen Zammad-Synchronisation.",
"Die Art wiederkehrender Sessions kommt aus der Regel."
],
details: [
"Ticketnummern können korrigiert werden, falls beim Starten oder Nachtragen ein falscher Bezug gewählt wurde.",
"Die Organisation wird immer über die Suchliste gewählt. Dadurch sind spätere Auswertungen pro Organisation zuverlässig.",
"Änderungen an Organisation und Art werden auf vorhandene manuelle Sessions dieses Tickets übernommen.",
"Bei wiederkehrenden Sessions ist die Art nicht direkt an der Session änderbar. Sie wird ausschließlich über die fixe Abrechnungsregel gesteuert.",
"Wenn ein Ticket bereits durch einen anderen Benutzer bekannt ist, können dessen Ticketdaten beim Beenden eines Timers übernommen werden. Die Session selbst bleibt trotzdem deinem Benutzer zugeordnet."
]
},
{
id: "organisationen",
title: "Organisationen",
description: "Organisationen kommen aus Zammad und bilden die Grundlage für saubere Kunden- und Monatsauswertungen.",
quickItems: [
"Nur Admins synchronisieren Organisationen.",
"Freitext ist danach nicht mehr möglich.",
"Gespeichert werden Zammad-ID, Name und Zeitstempel."
],
details: [
"Im Adminbereich werden Zammad-URL und API-Key gespeichert. Der API-Key wird danach nicht mehr im Klartext angezeigt.",
"Beim Sync fragt TicketTracker die Organisationen aus Zammad ab und schreibt sie in die lokale Datenbank.",
"Neue Organisationen werden angelegt, geänderte Namen werden aktualisiert.",
"Organisationen, die in Zammad nicht mehr vorhanden sind, werden aus der lokalen Liste entfernt beziehungsweise von vorhandenen Tickets und Sessions gelöst.",
"Alte Sessions oder Tickets mit Freitext-Organisationen müssen manuell korrigiert werden, indem eine Organisation aus der Liste gewählt wird.",
"Die bewusst schlanke Organisationstabelle enthält interne ID, Zammad-ID, Name sowie Erstell- und Änderungszeitpunkte."
]
},
{
id: "auswertung",
title: "Auswertung",
description: "Die Auswertung ist die Arbeitsliste für Monat und Tag: Tickets öffnen, Sessions prüfen, sortieren und bewerten.",
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.",
"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.",
"Ü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."
]
},
{
id: "ticketdetail",
title: "Ticketdetailansicht",
description: "Die Ticketdetailansicht ist die Bearbeitungsfläche für Sessions, Bewertungen und Teamspace-Tageswerte eines Tickets.",
quickItems: [
"Sessions sind nach Tag gruppiert.",
"Teamspace wird pro Ticket und Tag gespeichert.",
"Monats- und Tagesansicht verwenden denselben Teamspace-Wert."
],
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 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."
]
},
{
id: "sessions-bewerten",
title: "Sessions Bewerten",
description: "Bewertungen bestimmen, ob eine Session abgerechnet wurde oder bewusst nicht abrechenbar ist.",
quickItems: [
"Jede Session braucht genau eine Bewertung.",
"Abgerechnet und Nicht abrechenbar können durch erneuten Klick zurückgenommen werden.",
"Offene Bewertungen blockieren den Monatsabschluss."
],
details: [
"Abgerechnet bedeutet: Die Session wurde für die Abrechnung beziehungsweise im CRM berücksichtigt.",
"Nicht abrechenbar bedeutet: Die Session gehört zum Arbeitsnachweis, wird aber nicht abgerechnet.",
"Ein erneuter Klick auf den aktiven Bewertungsbutton entfernt die Bewertung und macht die Session wieder offen.",
"Nicht abrechenbare Sessions zählen als bewertet. Sie blockieren den Monatsabschluss nicht.",
"Wenn ein Monat abgeschlossen ist, sind Bewertungen gesperrt. Öffne den Monat zuerst wieder, wenn du etwas korrigieren musst.",
"Die offenen Bewertungen findest du gebündelt im Monatsabschluss."
]
},
{
id: "sessions-bearbeiten",
title: "Sessions Bearbeiten und Löschen",
description: "Sessions können korrigiert, gelöscht und bei wiederkehrenden Einträgen als Ausnahme behandelt werden.",
quickItems: [
"Bearbeiten läuft über das Stift-Icon.",
"Löschen läuft über das Papierkorb-Icon.",
"Wiederkehrende gelöschte Sessions werden nicht neu erzeugt."
],
details: [
"Beim Bearbeiten sind Datum, Von-Uhrzeit, Bis-Uhrzeit, Organisation und Tätigkeit änderbar.",
"Die Art ist bei manuellen Sessions änderbar. Bei wiederkehrenden Sessions ist sie gesperrt und kommt aus der Regel.",
"Die Dauer wird aus Von und Bis neu berechnet und wieder auf Minuten gerundet.",
"Beim Löschen wird geprüft, ob dadurch ein Ticket für dich oder komplett leer wird. Leere Tickets werden entfernt.",
"Wenn die letzte Session eines Tickets gelöscht wird, springt die Ansicht zurück zur Auswertung.",
"Wiederkehrend erzeugte Sessions werden beim Löschen als Ausnahme gespeichert. Dadurch erscheinen sie beim nächsten Öffnen des Monats nicht wieder automatisch.",
"Wenn eine wiederkehrende Leistung nur an einem anderen Tag erledigt wurde, wird die erzeugte Session bearbeitet beziehungsweise verschoben."
]
},
{
id: "monatsabschluss",
title: "Monatsabschluss",
description: "Der Monatsabschluss ist die Kontrollseite für offene Bewertungen und Teamspace-Punkte vor dem Abschluss.",
quickItems: [
"Bewertungen sind Pflicht für den Abschluss.",
"Tage ohne Teamspace-Wert können zur Kenntnis genommen werden.",
"Der Monat kann wieder geöffnet werden."
],
details: [
"Ein Monat kann abgeschlossen werden, sobald alle Sessions dieses Monats bewertet sind.",
"Tage ohne Teamspace-Wert blockieren den Abschluss nicht automatisch. Sie sind eine Prüfliste für CRM-Abgleich und Nacharbeit.",
"Wenn eine Session bewusst nicht in Teamspace eingetragen wird, kann der fehlende Tageswert zur Kenntnis genommen werden.",
"Zur Kenntnis genommene Einträge können über die Checkbox Zur Kenntnis anzeigen wieder eingeblendet werden.",
"Die Kenntnisnahme kann pro Eintrag über Zurücknehmen rückgängig gemacht werden.",
"Die Einstellung, ob zur Kenntnis genommene Einträge angezeigt werden, bleibt im Browser gespeichert.",
"Offene Bewertungen werden nach Ticket gruppiert. Ein Klick auf Ticket öffnen führt zur Bewertung im Ticket.",
"Es gibt keinen Tagesabschluss. Tagesansichten dienen nur der Kontrolle."
]
},
{
id: "fixe-abrechnungen",
title: "Fixe Abrechnungen",
description: "Fixe Abrechnungen erzeugen wiederkehrende Sessions für planbare regelmäßige Leistungen.",
quickItems: [
"Jeder Benutzer verwaltet eigene fixe Abrechnungen.",
"Ticket ist optional; ohne Ticket entsteht Fix#ID.",
"Feiertage in Bayern werden übersprungen."
],
details: [
"Fixe Abrechnungen sind für regelmäßige Leistungen gedacht, zum Beispiel jeden Donnerstag vier Stunden oder mehrere Wochentage mit unterschiedlichen Dauern.",
"Das Ticketfeld darf leer bleiben. Dann gruppiert TicketTracker die erzeugten Sessions automatisch unter Fix#ID.",
"Die Organisation, Art und Tätigkeit kommen aus der Regel und werden auf erzeugte Sessions angewendet.",
"Beim Muster Wochentage werden einzelne Slots mit Wochentag, Startzeit und Dauer gepflegt.",
"Beim Muster Alle X Wochen zählt das Gültig-von-Datum als Startpunkt. Das Intervall läuft wochenweise.",
"Gültig bis ist optional. Wird es später gesetzt oder geändert, werden erzeugte Sessions außerhalb der Gültigkeit entfernt.",
"Bayerische Feiertage werden bei der automatischen Anlage ausgelassen.",
"Einzelne erzeugte Sessions können wegen Urlaub, Krankheit oder Feiertagsverschiebung gelöscht oder verschoben werden."
]
},
{
id: "statistiken",
title: "Statistiken",
description: "Statistiken vergleichen abgerechnete Session-Zeit, offene Bewertungen und Teamspace-Werte.",
quickItems: [
"Abgerechnet und Teamspace haben getrennte Farben.",
"Positive oder gleiche Differenz ist grün.",
"Negative Differenz ist rot."
],
details: [
"Die Statistikseite betrachtet immer einen Monat.",
"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: Abgerechnet",
description: "Diese Kennzahl zeigt die abrechenbare Session-Zeit im gewählten Monat.",
quickItems: [
"Summe aller Sessions mit Bewertung Abgerechnet.",
"Zeigt zusätzlich die abgerechnete Sessionanzahl.",
"Basiswert für den Vergleich mit Teamspace."
],
details: [
"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."
]
},
{
id: "statistik-teamspace",
title: "Statistik: Teamspace",
description: "Diese Kennzahl zeigt, wie viele Stunden für die Ticket-Tage im CRM beziehungsweise Teamspace eingetragen wurden.",
quickItems: [
"Summe aller Teamspace-Tageswerte im Monat.",
"Positive oder gleiche Differenz ist grün.",
"Negative Differenz ist rot."
],
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 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-offen",
title: "Statistik: Offen",
description: "Diese Kennzahl zeigt Sessions, die noch keine Bewertung haben.",
quickItems: [
"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 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."
]
},
{
id: "statistik-verlauf",
title: "Statistik: Monatsverlauf und Qualität",
description: "Diese Card verbindet Tagesverlauf, Teamspace-Linie und reine Statuswerte.",
quickItems: [
"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 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."
]
},
{
id: "statistik-organisationen",
title: "Statistik pro Organisation",
description: "Diese Card zeigt Aufwand und Teamspace-Abgleich je Kunde beziehungsweise Organisation.",
quickItems: [
"Gruppiert nach Organisation.",
"Zeigt Sessions, Zeiten und Differenz.",
"Die Liste ist paginiert."
],
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 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."
]
},
{
id: "statistik-typen",
title: "Statistik: Typen",
description: "Diese Card trennt den Monatsaufwand nach Support und Consulting.",
quickItems: [
"Vergleicht Support und Consulting.",
"Zeigt Sessions, Zeiten und Teamspace-Differenz.",
"Zeigt zusätzlich Bewertungsstatus je Typ."
],
details: [
"Die Typenstatistik gruppiert Sessions nach Art.",
"Support und Consulting werden getrennt summiert.",
"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."
]
},
{
id: "statistik-hotspots",
title: "Ticket-Hotspots",
description: "Diese Card zeigt die größten Zeitblöcke des Monats nach Ticket.",
quickItems: [
"Sortiert nach größtem Zeitaufwand.",
"Zeigt Sessions, Teamspace und Differenz.",
"Öffnen führt direkt zur Ticketdetailansicht."
],
details: [
"Ticket-Hotspots helfen, die größten Aufwandstreiber im Monat schnell zu erkennen.",
"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."
]
},
{
id: "profil",
title: "Profil",
description: "Im Profil pflegt jeder Benutzer die eigenen Zugangsdaten.",
quickItems: [
"Benutzername und voller Name sind änderbar.",
"Passwortänderung benötigt das aktuelle Passwort.",
"Der volle Name wird in der Oberfläche angezeigt."
],
details: [
"Der Benutzername wird beim Login verwendet und muss eindeutig sein.",
"Der volle Name erscheint in der App, zum Beispiel im Kopfbereich oder im Adminbereich.",
"Wenn du dein Passwort ändern willst, musst du das aktuelle Passwort sowie das neue Passwort zweimal eingeben.",
"Bleiben die Passwortfelder leer, werden nur Benutzername und voller Name gespeichert.",
"Admins können Benutzer ebenfalls verwalten, aber jeder Benutzer kann die eigenen Daten jederzeit selbst pflegen."
]
},
{
id: "admin",
title: "Adminbereich",
description: "Der feste Adminbereich verwaltet Benutzer, Zammad-Sync und restore-fähige Datenbankexporte.",
quickItems: [
"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 und Aktivstatus. Eine Rollenauswahl gibt es nicht.",
"Admins können bestehende Benutzer bearbeiten, Passwörter setzen und Benutzer deaktivieren.",
"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."
]
}
];
export function getHelpSection(anchor: string) {
return helpSections.find((section) => section.id === anchor) ?? helpSections[0];
}
+123 -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[];
@@ -191,3 +185,109 @@ export type TicketPeriod = {
export type MonthOverview = PeriodOverview;
export type TicketMonth = TicketPeriod;
export type StatisticsDailyBucket = {
day: string;
sessions: number;
total_minutes: number;
billed_minutes: number;
non_billable_minutes: number;
open_minutes: number;
billed_sessions: number;
non_billable_sessions: number;
open_sessions: number;
crm_billed_minutes: number;
};
export type StatisticsGroup = {
organization_id?: string | null;
organization_name?: string;
work_type?: WorkType;
tickets: number;
sessions: number;
total_minutes: number;
billed_minutes: number;
non_billable_minutes: number;
open_minutes: number;
billed_sessions: number;
non_billable_sessions: number;
open_sessions: number;
crm_billed_minutes: number;
crm_delta_minutes: number;
};
export type StatisticsTicket = {
ticket_id: string;
ticket_number: string;
organization_id: string | null;
organization_name: string;
active_days: number;
sessions: number;
total_minutes: number;
billed_minutes: number;
non_billable_minutes: number;
open_minutes: number;
billed_sessions: number;
non_billable_sessions: number;
open_sessions: number;
crm_billed_minutes: number;
crm_delta_minutes: number;
};
export type StatisticsOpenSession = {
id: string;
ticket_id: string;
ticket_number: string;
organization_name: string;
activity: string;
started_at: string;
rounded_minutes: number;
};
export type StatisticsCrmDay = {
ticket_id: string;
ticket_number: string;
organization_name: string;
day: string;
tracked_minutes: number;
crm_billed_minutes: number;
delta_minutes?: number;
acknowledged?: boolean;
acknowledged_at?: string | null;
};
export type StatisticsOverview = {
periodType: "month";
period: string;
closed: boolean;
closedAt: string | null;
totals: {
tickets: number;
sessions: number;
minutes: number;
billedMinutes: number;
nonBillableMinutes: number;
openMinutes: number;
billedSessions: number;
nonBillableSessions: number;
openSessions: number;
recurringMinutes: number;
manualMinutes: number;
recurringSessions: number;
manualSessions: number;
averageSessionMinutes: number;
activeDays: number;
crmBilledMinutes: number;
crmDeltaMinutes: number;
};
dailySeries: StatisticsDailyBucket[];
organizations: StatisticsGroup[];
workTypes: StatisticsGroup[];
tickets: StatisticsTicket[];
attention: {
openSessions: StatisticsOpenSession[];
missingCrmDays: StatisticsCrmDay[];
acknowledgedMissingCrmDays: StatisticsCrmDay[];
crmMismatches: StatisticsCrmDay[];
};
};
+252 -441
View File
@@ -1,114 +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 { OrganizationSelect } from "@/components/OrganizationSelect";
import { HelpLink } from "@/components/HelpLink";
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"
@@ -156,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) {
@@ -180,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
});
@@ -195,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);
@@ -326,63 +212,233 @@ 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>
<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>
<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>
<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>
<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">
<div className="flex items-center gap-2">
<DatabaseZap className="size-5 text-muted-foreground" />
<CardTitle>Data-Sync</CardTitle>
<div className="flex items-start justify-between gap-3">
<div>
<CardTitle>Neuer Benutzer</CardTitle>
<CardDescription>Neue Benutzer sehen später nur ihre eigenen Sessions und Abschlüsse.</CardDescription>
</div>
<HelpLink anchor="admin" label="Hilfe zur Benutzerverwaltung" />
</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
/>
</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"}
</Button>
<Button type="submit" disabled={syncingOrganizations}>
<DatabaseZap className="size-4" />
{syncingOrganizations ? "Synchronisiert..." : "Organisationen synchronisieren"}
</Button>
</form>
</CardContent>
</Card>
<Card>
<CardHeader className="p-4">
<CardTitle>Neuer Benutzer</CardTitle>
<CardDescription>Neue Benutzer sehen später nur ihre eigenen Sessions und Abschlüsse.</CardDescription>
</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 />
@@ -396,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 })} />
@@ -419,129 +467,16 @@ export function AdminUsersPage({ currentUser }: { currentUser: AuthUser }) {
</CardContent>
</Card>
<Card>
<CardHeader className="p-4">
<div className="flex items-center gap-2">
<CalendarPlus className="size-5 text-muted-foreground" />
<CardTitle>Session nachtragen</CardTitle>
</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>
<HelpLink anchor="admin" label="Hilfe zu vorhandenen Benutzern" />
</div>
<Badge variant="outline">{currentUser.display_name}</Badge>
</CardHeader>
<CardContent className="px-4 pb-4">
<div className="hidden xl:block">
@@ -559,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}>
@@ -569,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>
@@ -609,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">
@@ -617,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"
@@ -637,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}>
@@ -651,117 +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>
</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>
);
}
+56 -117
View File
@@ -1,4 +1,4 @@
import { CheckCircle2, ChevronLeft, ChevronRight, CircleAlert, Clock3, ExternalLink, LockOpen, 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";
@@ -10,7 +10,8 @@ import { Input } from "@/components/ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { CopyTicketButton } from "@/components/CopyTicketButton";
import { closePeriod, getPeriodOverview, reopenPeriod } from "../api";
import { HelpLink } from "@/components/HelpLink";
import { downloadMonthExport, getPeriodOverview } from "../api";
import { currentDay, currentMonth, formatDateTime, formatMinutes } from "../format";
import type { PeriodOverview, PeriodType, TicketSummary } from "../types";
@@ -304,9 +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 [closing, setClosing] = useState(false);
const [reopening, setReopening] = useState(false);
const [showOpen, setShowOpen] = useState(false);
const [exportingMonth, setExportingMonth] = useState(false);
const [ticketViewSettings, setTicketViewSettings] = useState<AnalysisTicketViewSettings>(() => readStoredTicketViewSettings());
const loadRequestId = useRef(0);
@@ -362,7 +361,6 @@ export function AnalysisPage({ onNavigate }: AnalysisPageProps) {
function switchPeriodType(nextType: PeriodType) {
setPeriodType(nextType);
setPeriod(nextType === "month" ? currentMonth() : currentDay());
setShowOpen(false);
}
function changePeriodBy(delta: number) {
@@ -381,53 +379,45 @@ export function AnalysisPage({ onNavigate }: AnalysisPageProps) {
setPeriod(`${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}-${String(date.getDate()).padStart(2, "0")}`);
}
setShowOpen(false);
}
async function finishPeriod() {
setClosing(true);
try {
await closePeriod(periodType, period);
toast.success(`${periodType === "month" ? "Monat" : "Tag"} abgeschlossen`, {
description: `${period} ist abgeschlossen.`
});
await load();
} catch (error) {
toast.error(`${periodType === "month" ? "Monat" : "Tag"} noch nicht abschließbar`, {
description: error instanceof Error ? error.message : "Bitte offene Sessions prüfen."
});
setShowOpen(true);
await load();
} finally {
setClosing(false);
async function exportMonth() {
if (periodType !== "month") {
return;
}
}
async function openPeriodAgain() {
setReopening(true);
setExportingMonth(true);
try {
await reopenPeriod(periodType, period);
toast.success(`${periodType === "month" ? "Monat" : "Tag"} wieder geöffnet`, {
description: `${period} kann wieder bearbeitet werden.`
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
});
await load();
} catch (error) {
toast.error(`${periodType === "month" ? "Monat" : "Tag"} konnte nicht geöffnet werden`, {
toast.error("Monatsexport fehlgeschlagen", {
description: error instanceof Error ? error.message : "Unbekannter Fehler"
});
} finally {
setReopening(false);
setExportingMonth(false);
}
}
const totals = overview?.totals;
const periodLabel = periodType === "month" ? "Monat" : "Tag";
const supportsPeriodClosure = periodType === "month";
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(() => {
@@ -499,7 +489,7 @@ export function AnalysisPage({ onNavigate }: AnalysisPageProps) {
<div>
<h2 className="text-2xl font-semibold tracking-normal">Auswertung</h2>
<p className="text-sm text-muted-foreground">
{supportsPeriodClosure ? "Tickets prüfen, Sessions markieren und Monat abschließen." : "Sessions des Tages prüfen und bewerten."}
{periodType === "month" ? "Tickets prüfen, Sessions bewerten und Zeiten kontrollieren." : "Sessions des Tages prüfen und bewerten."}
</p>
</div>
<div className="grid gap-2 sm:grid-cols-[auto_minmax(220px,300px)] sm:items-end">
@@ -527,20 +517,29 @@ export function AnalysisPage({ onNavigate }: AnalysisPageProps) {
className="h-9"
type={periodType === "month" ? "month" : "date"}
value={period}
onChange={(event) => {
setPeriod(event.currentTarget.value);
setShowOpen(false);
}}
onChange={(event) => setPeriod(event.currentTarget.value)}
/>
<Button type="button" size="icon" variant="secondary" className="h-9 w-9" onClick={() => changePeriodBy(1)} aria-label={`${periodLabel} vor`}>
<ChevronRight className="size-4" />
</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">
@@ -560,12 +559,15 @@ 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>
<Badge variant="outline">{formatMinutes(totals?.minutes ?? 0)}</Badge>
<div className="flex items-center gap-2">
<Badge variant="outline">{formatMinutes(totals?.minutes ?? 0)}</Badge>
<HelpLink anchor="auswertung" label="Hilfe zur Auswertung" />
</div>
</CardHeader>
<CardContent className="px-4 pb-4">
<div className="h-44 w-full overflow-hidden rounded-md border bg-background">
@@ -633,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" />
@@ -643,73 +645,15 @@ export function AnalysisPage({ onNavigate }: AnalysisPageProps) {
</CardContent>
</Card>
{supportsPeriodClosure && overview?.closed ? (
<Alert variant="success" className="flex items-center gap-2 py-3">
<CheckCircle2 className="size-4 shrink-0" />
<span>Dieser Monat wurde am {formatDateTime(overview.closedAt!)} abgeschlossen.</span>
</Alert>
) : null}
{overview && overview.openSessions.length > 0 ? (
<Alert variant="warning" className="flex flex-col gap-2 py-3 sm:flex-row sm:items-center sm:justify-between">
<div className="flex items-center gap-2">
<CircleAlert className="size-4 shrink-0" />
<span>Es gibt noch {overview.openSessions.length} Session(s) ohne Auswahl.</span>
</div>
<Button variant="ghost" size="sm" onClick={() => setShowOpen((value) => !value)}>
Offene Sessions prüfen
</Button>
</Alert>
) : null}
{showOpen && overview?.openSessions.length ? (
<Card>
<CardHeader className="p-4">
<CardTitle>Nicht bearbeitete Sessions</CardTitle>
<CardDescription>Diese Einträge blockieren den Monatsabschluss.</CardDescription>
</CardHeader>
<CardContent className="space-y-2 px-4 pb-4">
{overview.openSessions.map((session) => (
<div key={session.id} className="grid gap-2 rounded-md border p-3 sm:grid-cols-[1fr_auto] sm:items-center">
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-2">
<span className="font-medium">{session.ticket_number}</span>
<Badge variant="outline">{formatMinutes(session.rounded_minutes)}</Badge>
</div>
<p className="truncate text-sm text-muted-foreground">{session.customer_name}</p>
<p className="text-sm">{session.activity}</p>
</div>
<Button variant="secondary" size="sm" onClick={() => onNavigate(`/analysis/${periodType}/${period}/tickets/${session.ticket_id}`)}>
Öffnen
<ExternalLink className="size-4" />
</Button>
</div>
))}
</CardContent>
</Card>
) : null}
<Card>
<CardHeader className="gap-3 p-4 sm:flex-row sm:items-center sm:justify-between sm:space-y-0">
<div>
<CardTitle>Tickets im Zeitraum</CardTitle>
<CardDescription>
{supportsPeriodClosure ? "Sessions prüfen und danach den Monat abschließen." : "Tagesansicht ohne eigenen Abschluss."}
{periodType === "month" ? "Sessions prüfen, bewerten und bei Bedarf Tickets öffnen." : "Tagesansicht ohne eigenen Abschluss."}
</CardDescription>
</div>
{supportsPeriodClosure ? (
overview?.closed ? (
<Button size="sm" variant="secondary" disabled={reopening} onClick={openPeriodAgain}>
<LockOpen className="size-4" />
{reopening ? "Öffnet..." : "Monat wieder öffnen"}
</Button>
) : (
<Button size="sm" variant={overview?.canClose ? "default" : "secondary"} disabled={!overview?.canClose || closing} onClick={finishPeriod}>
<CheckCircle2 className="size-4" />
{closing ? "Schließt..." : overview?.canClose ? "Monat abschließen" : "Noch nicht abschließbar"}
</Button>
)
) : null}
<HelpLink anchor="auswertung" label="Hilfe zu Tickets im Zeitraum" />
</CardHeader>
<CardContent className="px-4 pb-4">
<div className="mb-4 rounded-md border bg-muted/20 p-3">
@@ -723,6 +667,7 @@ export function AnalysisPage({ onNavigate }: AnalysisPageProps) {
</p>
</div>
{activeFilterCount > 0 ? <Badge variant="outline">{activeFilterCount} aktiv</Badge> : null}
<HelpLink anchor="auswertung" label="Hilfe zu Filtern und Sortierung" />
</div>
<Button
type="button"
@@ -848,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>
@@ -860,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>
@@ -888,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>
@@ -934,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
+124
View File
@@ -0,0 +1,124 @@
import { ArrowUp, BookOpenCheck } from "lucide-react";
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 (
<div id="top" className="space-y-5">
<div className="flex flex-col gap-3 lg:flex-row lg:items-end lg:justify-between">
<div>
<h2 className="text-2xl font-semibold tracking-normal">FAQ und Anleitung</h2>
<p className="text-sm text-muted-foreground">Ausführliche Anleitung für Timer, Auswertung, Teamspace, fixe Abrechnungen, Monatsabschluss und Adminfunktionen.</p>
</div>
<Badge variant="outline">{helpSections.length} Abschnitt(e)</Badge>
</div>
<Card>
<CardHeader className="p-4">
<div className="flex items-center gap-2">
<BookOpenCheck className="size-5 text-muted-foreground" />
<CardTitle>Schnellnavigation</CardTitle>
</div>
<CardDescription>Die Fragezeichen in der App öffnen eine Kurzfassung. Von dort gelangst du direkt zum passenden ausführlichen Abschnitt.</CardDescription>
</CardHeader>
<CardContent className="flex flex-wrap gap-2 px-4 pb-4">
{helpSections.map((section) => (
<Button key={section.id} asChild variant="secondary" size="sm">
<a href={`#${section.id}`}>{section.title}</a>
</Button>
))}
</CardContent>
</Card>
<div className="grid gap-3 xl:grid-cols-2">
{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={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>
{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" />
Nach oben
</a>
</Button>
</div>
</CardContent>
</Card>
);
})}
</div>
</div>
);
}
+448
View File
@@ -0,0 +1,448 @@
import {
CheckCircle2,
ChevronLeft,
ChevronRight,
Clock3,
ExternalLink,
FileWarning,
Lock,
LockOpen,
Sparkles,
} from "lucide-react";
import { 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";
import { Checkbox } from "@/components/ui/checkbox";
import { Input } from "@/components/ui/input";
import { CopyTicketButton } from "@/components/CopyTicketButton";
import { HelpLink } from "@/components/HelpLink";
import { acknowledgeMissingTicketDayBilling, closePeriod, getStatisticsOverview, reopenPeriod } from "../api";
import { currentMonth, formatDate, formatDateTime, formatMinutes } from "../format";
import type { StatisticsCrmDay, StatisticsOpenSession, StatisticsOverview } from "../types";
type MonthlyClosePageProps = {
onNavigate: (to: string) => void;
};
const trackedTextClass = "text-sky-700 dark:text-sky-300";
const teamspaceTextClass = "text-emerald-700 dark:text-emerald-300";
const showAcknowledgedMissingCrmStorageKey = "tickettracker.monthlyClose.showAcknowledgedMissingCrm";
function formatSignedMinutes(minutes: number) {
if (minutes === 0) {
return "ausgeglichen";
}
return `${minutes > 0 ? "+" : "-"}${formatMinutes(Math.abs(minutes))}`;
}
function formatTeamspaceDelta(minutes: number) {
return formatSignedMinutes(minutes);
}
function StatusCard({ label, value, detail, tone }: { label: string; value: string | number; detail: string; tone?: "ok" | "warn" | "neutral" }) {
const valueClassName = tone === "ok" ? "text-emerald-700 dark:text-emerald-300" : tone === "warn" ? "text-amber-700 dark:text-amber-300" : "";
return (
<Card>
<CardContent className="p-3">
<p className="text-xs font-medium text-muted-foreground">{label}</p>
<p className={`text-xl font-semibold tracking-normal ${valueClassName}`}>{value}</p>
<p className="text-xs text-muted-foreground">{detail}</p>
</CardContent>
</Card>
);
}
type OpenTicketGroup = {
ticketId: string;
ticketNumber: string;
organizationName: string;
openCount: number;
totalMinutes: number;
};
function groupOpenSessions(sessions: StatisticsOpenSession[]) {
const groups = new Map<string, OpenTicketGroup>();
for (const session of sessions) {
const existing = groups.get(session.ticket_id);
if (existing) {
existing.openCount += 1;
existing.totalMinutes += session.rounded_minutes;
continue;
}
groups.set(session.ticket_id, {
ticketId: session.ticket_id,
ticketNumber: session.ticket_number,
organizationName: session.organization_name,
openCount: 1,
totalMinutes: session.rounded_minutes
});
}
return Array.from(groups.values()).sort((left, right) => left.ticketNumber.localeCompare(right.ticketNumber, "de", { numeric: true, sensitivity: "base" }));
}
function OpenTicketItem({ group, month, onNavigate }: { group: OpenTicketGroup; month: string; onNavigate: (to: string) => void }) {
return (
<div className="flex min-h-36 flex-col justify-between gap-3 rounded-md border bg-background p-3">
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-2">
<span className="font-medium">{group.ticketNumber}</span>
<CopyTicketButton ticketNumber={group.ticketNumber} />
<Badge variant="warning">{group.openCount} offen</Badge>
</div>
<p className="truncate text-sm text-muted-foreground">{group.organizationName}</p>
<p className={`text-sm font-medium ${trackedTextClass}`}>{formatMinutes(group.totalMinutes)} offen zu bewerten</p>
</div>
<div className="flex justify-end">
<Button variant="secondary" size="sm" onClick={() => onNavigate(`/analysis/month/${month}/tickets/${group.ticketId}`)}>
Ticket öffnen
<ExternalLink className="size-4" />
</Button>
</div>
</div>
);
}
function CrmDayItem({
item,
busy,
onSetAcknowledgement,
onNavigate
}: {
item: StatisticsCrmDay;
busy: boolean;
onSetAcknowledgement: (item: StatisticsCrmDay, acknowledged: boolean) => void;
onNavigate: (to: string) => void;
}) {
const isAcknowledged = Boolean(item.acknowledged);
return (
<div className={`flex min-h-36 flex-col justify-between gap-3 rounded-md border bg-background p-3 ${isAcknowledged ? "opacity-75" : ""}`}>
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-2">
<span className="font-medium">{item.ticket_number}</span>
<CopyTicketButton ticketNumber={item.ticket_number} />
<Badge variant="outline">{formatDate(`${item.day}T00:00:00`)}</Badge>
<Badge variant={isAcknowledged ? "success" : "warning"}>{isAcknowledged ? "zur Kenntnis" : "offen"}</Badge>
</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}>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>
</div>
<div className="flex flex-wrap justify-end gap-2">
<Button variant={isAcknowledged ? "secondary" : "ghost"} size="sm" disabled={busy} onClick={() => onSetAcknowledgement(item, !isAcknowledged)}>
{isAcknowledged ? "Zurücknehmen" : "Zur Kenntnis"}
</Button>
<Button variant="secondary" size="sm" onClick={() => onNavigate(`/analysis/day/${item.day}/tickets/${item.ticket_id}`)}>
Tag öffnen
<ExternalLink className="size-4" />
</Button>
</div>
</div>
);
}
export function MonthlyClosePage({ onNavigate }: MonthlyClosePageProps) {
const [month, setMonth] = useState(currentMonth());
const [stats, setStats] = useState<StatisticsOverview | null>(null);
const [loading, setLoading] = useState(false);
const [closing, setClosing] = useState(false);
const [reopening, setReopening] = useState(false);
const [acknowledgingKeys, setAcknowledgingKeys] = useState<Set<string>>(() => new Set());
const [showAcknowledgedMissingCrm, setShowAcknowledgedMissingCrm] = useState(false);
const [showAcknowledgedMissingCrmLoaded, setShowAcknowledgedMissingCrmLoaded] = useState(false);
const loadRequestId = useRef(0);
async function load() {
const requestId = loadRequestId.current + 1;
loadRequestId.current = requestId;
setLoading(true);
try {
const result = await getStatisticsOverview(month);
if (requestId === loadRequestId.current) {
setStats(result);
}
} catch (error) {
if (requestId === loadRequestId.current) {
toast.error("Monatsabschluss konnte nicht geladen werden", {
description: error instanceof Error ? error.message : "Unbekannter Fehler"
});
}
} finally {
if (requestId === loadRequestId.current) {
setLoading(false);
}
}
}
useEffect(() => {
void load();
}, [month]);
useEffect(() => {
setShowAcknowledgedMissingCrm(window.localStorage.getItem(showAcknowledgedMissingCrmStorageKey) === "true");
setShowAcknowledgedMissingCrmLoaded(true);
}, []);
useEffect(() => {
if (showAcknowledgedMissingCrmLoaded) {
window.localStorage.setItem(showAcknowledgedMissingCrmStorageKey, String(showAcknowledgedMissingCrm));
}
}, [showAcknowledgedMissingCrm, showAcknowledgedMissingCrmLoaded]);
function changeMonthBy(delta: number) {
const [year, monthNumber] = month.split("-").map(Number);
const date = Number.isFinite(year) && Number.isFinite(monthNumber) ? new Date(year, monthNumber - 1, 1) : new Date();
date.setMonth(date.getMonth() + delta);
setMonth(`${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}`);
}
async function finishMonth() {
setClosing(true);
try {
await closePeriod("month", month);
toast.success("Monat abgeschlossen", {
description: `${month} ist abgeschlossen.`
});
await load();
} catch (error) {
toast.error("Monat noch nicht abschließbar", {
description: error instanceof Error ? error.message : "Bitte offene Bewertungen prüfen."
});
await load();
} finally {
setClosing(false);
}
}
async function reopenMonth() {
setReopening(true);
try {
await reopenPeriod("month", month);
toast.success("Monat wieder geöffnet", {
description: `${month} kann wieder bearbeitet werden.`
});
await load();
} catch (error) {
toast.error("Monat konnte nicht geöffnet werden", {
description: error instanceof Error ? error.message : "Unbekannter Fehler"
});
} finally {
setReopening(false);
}
}
async function setMissingCrmAcknowledgement(item: StatisticsCrmDay, acknowledged: boolean) {
const key = `${item.ticket_id}:${item.day}`;
setAcknowledgingKeys((current) => new Set(current).add(key));
try {
await acknowledgeMissingTicketDayBilling(item.ticket_id, item.day, acknowledged);
toast.success(acknowledged ? "Teamspace-Prüfung abgehakt" : "Kenntnisnahme zurückgenommen", {
description: `${item.ticket_number} am ${formatDate(`${item.day}T00:00:00`)}`
});
await load();
} catch (error) {
toast.error(acknowledged ? "Konnte nicht abgehakt werden" : "Konnte nicht zurückgenommen werden", {
description: error instanceof Error ? error.message : "Unbekannter Fehler"
});
} finally {
setAcknowledgingKeys((current) => {
const next = new Set(current);
next.delete(key);
return next;
});
}
}
const openSessions = stats?.attention.openSessions ?? [];
const missingCrmDays = stats?.attention.missingCrmDays ?? [];
const acknowledgedMissingCrmDays = stats?.attention.acknowledgedMissingCrmDays ?? [];
const openTicketGroups = useMemo(() => groupOpenSessions(openSessions), [openSessions]);
const visibleMissingCrmDays = useMemo(() => {
const days = showAcknowledgedMissingCrm ? [...missingCrmDays, ...acknowledgedMissingCrmDays] : [...missingCrmDays];
return days.sort((left, right) => left.day.localeCompare(right.day) || left.ticket_number.localeCompare(right.ticket_number, "de", { numeric: true, sensitivity: "base" }));
}, [acknowledgedMissingCrmDays, missingCrmDays, showAcknowledgedMissingCrm]);
const canClose = Boolean(stats && !stats.closed && stats.totals.sessions > 0 && openSessions.length === 0);
const checklist = useMemo(
() => [
{
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, canClose, openSessions.length, openTicketGroups.length, missingCrmDays.length, stats?.closed]
);
return (
<div className="space-y-5">
<div className="flex flex-col gap-3 xl:flex-row xl:items-end xl:justify-between">
<div>
<h2 className="text-2xl font-semibold tracking-normal">Monatsabschluss</h2>
<p className="text-sm text-muted-foreground">Offene Bewertungen, Teamspace-Abgleich und der eigentliche Monatsabschluss.</p>
</div>
<div className="grid gap-2 sm:grid-cols-[minmax(220px,300px)_auto] sm:items-end">
<div className="space-y-1">
<div className="flex items-center justify-between gap-2">
<label className="text-xs font-medium text-muted-foreground" htmlFor="close-month">Monat</label>
{loading ? <span className="text-xs text-muted-foreground">lädt...</span> : null}
</div>
<div className="grid grid-cols-[2.25rem_minmax(0,1fr)_2.25rem] gap-1">
<Button type="button" size="icon" variant="secondary" className="h-9 w-9" onClick={() => changeMonthBy(-1)} aria-label="Monat zurück">
<ChevronLeft className="size-4" />
</Button>
<Input id="close-month" className="h-9" type="month" value={month} onChange={(event) => setMonth(event.currentTarget.value)} />
<Button type="button" size="icon" variant="secondary" className="h-9 w-9" onClick={() => changeMonthBy(1)} aria-label="Monat vor">
<ChevronRight className="size-4" />
</Button>
</div>
</div>
{stats?.closed ? (
<Button size="sm" variant="secondary" disabled={reopening} onClick={reopenMonth}>
<LockOpen className="size-4" />
{reopening ? "Öffnet..." : "Monat öffnen"}
</Button>
) : (
<Button size="sm" disabled={!canClose || closing} onClick={finishMonth}>
<Lock className="size-4" />
{closing ? "Schließt..." : "Monat abschließen"}
</Button>
)}
</div>
</div>
{stats?.closed ? (
<Alert variant="success" className="flex items-center gap-2 py-3">
<CheckCircle2 className="size-4 shrink-0" />
<span>Dieser Monat wurde am {formatDateTime(stats.closedAt!)} abgeschlossen.</span>
</Alert>
) : 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)} 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"} />
<StatusCard label="Status" value={stats?.closed ? "geschlossen" : canClose ? "bereit" : "offen"} detail={stats?.closed ? "Monat ist gesperrt" : canClose ? "kann abgeschlossen werden" : "Prüfpunkte bearbeiten"} tone={stats?.closed || canClose ? "ok" : "warn"} />
</div>
<Card>
<CardHeader className="p-4">
<div className="flex items-start justify-between gap-3">
<div>
<CardTitle>Checkliste</CardTitle>
<CardDescription>Bewertungen sind Pflicht. Teamspace-Punkte helfen beim sauberen CRM-Abgleich.</CardDescription>
</div>
<HelpLink anchor="monatsabschluss" label="Hilfe zum Monatsabschluss" />
</div>
</CardHeader>
<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.ok ? "success" : "warning"}>{item.value}</Badge>
</div>
<p className="text-xs text-muted-foreground">{item.detail}</p>
</div>
))}
</CardContent>
</Card>
<Card>
<CardHeader className="p-4">
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div>
<CardTitle>Aufräumen</CardTitle>
<CardDescription>Alles, was für diesen Monat noch Aufmerksamkeit braucht.</CardDescription>
</div>
<div className="flex flex-wrap items-center gap-2">
<div className="inline-flex h-8 items-center gap-2 rounded-md border bg-background px-3 text-sm">
<Checkbox
checked={showAcknowledgedMissingCrm}
aria-label="Zur Kenntnis genommene Teamspace-Tage anzeigen"
onCheckedChange={(checked) => setShowAcknowledgedMissingCrm(checked === true)}
/>
<span>Zur Kenntnis anzeigen ({acknowledgedMissingCrmDays.length})</span>
</div>
<HelpLink anchor="monatsabschluss" label="Hilfe zu offenen Bewertungen und Teamspace-Prüfpunkten" />
</div>
</div>
</CardHeader>
<CardContent className="grid gap-4 px-4 pb-4 xl:grid-cols-2">
<section className="space-y-2">
<div className="flex min-h-8 items-center gap-2 text-sm font-medium">
<FileWarning className="size-4 text-muted-foreground" />
Offene Bewertungen
</div>
{openTicketGroups.map((group) => (
<OpenTicketItem key={group.ticketId} group={group} month={month} onNavigate={onNavigate} />
))}
{openTicketGroups.length === 0 ? <p className="rounded-md border bg-muted/20 p-3 text-sm text-muted-foreground">Keine offenen Bewertungen.</p> : null}
</section>
<section className="space-y-2">
<div className="flex min-h-8 items-center gap-2 text-sm font-medium">
<Sparkles className="size-4 text-muted-foreground" />
Tage ohne Teamspace-Wert
</div>
{visibleMissingCrmDays.map((item) => (
<CrmDayItem
key={`${item.ticket_id}-${item.day}-missing`}
item={item}
busy={acknowledgingKeys.has(`${item.ticket_id}:${item.day}`)}
onSetAcknowledgement={setMissingCrmAcknowledgement}
onNavigate={onNavigate}
/>
))}
{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 abrechenbaren Tage haben einen Teamspace-Wert."}
</p>
) : null}
</section>
</CardContent>
</Card>
{stats && stats.totals.sessions === 0 ? (
<Card>
<CardContent className="grid place-items-center gap-2 p-8 text-center">
<Clock3 className="size-8 text-muted-foreground" />
<div>
<p className="font-medium">Keine Sessions in diesem Monat</p>
<p className="text-sm text-muted-foreground">Ein Monatsabschluss ist erst sinnvoll, wenn Sessions vorhanden sind.</p>
</div>
</CardContent>
</Card>
) : null}
</div>
);
}
+7 -3
View File
@@ -4,6 +4,7 @@ import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { HelpLink } from "@/components/HelpLink";
import { updateCurrentUser } from "../api";
import type { AuthUser } from "../types";
@@ -74,9 +75,12 @@ export function ProfilePage({ currentUser, onUserUpdated }: ProfilePageProps) {
<Card>
<CardHeader className="p-4">
<div className="flex items-center gap-2">
<UserCircle className="size-5 text-muted-foreground" />
<CardTitle>Accountdaten</CardTitle>
<div className="flex items-start justify-between gap-3">
<div className="flex items-center gap-2">
<UserCircle className="size-5 text-muted-foreground" />
<CardTitle>Accountdaten</CardTitle>
</div>
<HelpLink anchor="profil" label="Hilfe zum Profil" />
</div>
<CardDescription>Der Benutzername wird beim Login verwendet. Dein voller Name wird in der App angezeigt.</CardDescription>
</CardHeader>
+14 -5
View File
@@ -6,6 +6,7 @@ import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { HelpLink } from "@/components/HelpLink";
import { OrganizationSelect } from "@/components/OrganizationSelect";
import { createRecurringBilling, deleteRecurringBilling, getRecurringBillings, updateRecurringBilling } from "../api";
import { currentDay, formatMinutes } from "../format";
@@ -248,9 +249,12 @@ export function RecurringBillingsPage() {
<Card className="overflow-hidden">
<CardHeader className="border-b bg-muted/30 p-4">
<div className="flex items-center gap-2">
<Repeat className="size-5 text-muted-foreground" />
<CardTitle>{editingBillingId ? "Regel bearbeiten" : "Neue Regel"}</CardTitle>
<div className="flex items-start justify-between gap-3">
<div className="flex items-center gap-2">
<Repeat className="size-5 text-muted-foreground" />
<CardTitle>{editingBillingId ? "Regel bearbeiten" : "Neue Regel"}</CardTitle>
</div>
<HelpLink anchor="fixe-abrechnungen" label="Hilfe zu fixen Abrechnungen" />
</div>
<CardDescription>Ticket ist optional. Leer bedeutet automatische Gruppierung als Fix#ID.</CardDescription>
</CardHeader>
@@ -447,8 +451,13 @@ export function RecurringBillingsPage() {
<Card className="overflow-hidden">
<CardHeader className="border-b bg-muted/30 p-4">
<CardTitle>Regeln</CardTitle>
<CardDescription>Erzeugte Einträge können in der Auswertung verschoben oder gelöscht werden.</CardDescription>
<div className="flex items-start justify-between gap-3">
<div>
<CardTitle>Regeln</CardTitle>
<CardDescription>Erzeugte Einträge können in der Auswertung verschoben oder gelöscht werden.</CardDescription>
</div>
<HelpLink anchor="fixe-abrechnungen" label="Hilfe zu erzeugten fixen Sessions" />
</div>
</CardHeader>
<CardContent className="space-y-2 p-4">
{recurringBillings.map((billing) => (
+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>
);
}
+746
View File
@@ -0,0 +1,746 @@
import {
ChevronLeft,
ChevronRight,
CircleAlert,
Clock3,
ExternalLink,
RotateCcw,
Ticket,
TrendingUp,
} from "lucide-react";
import type { LucideIcon } from "lucide-react";
import { useEffect, useMemo, useRef, useState } from "react";
import type { ReactNode } from "react";
import { toast } from "sonner";
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 { Pagination, PaginationContent, PaginationItem, PaginationNext, PaginationPrevious } from "@/components/ui/pagination";
import { Progress } from "@/components/ui/progress";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { HelpLink } from "@/components/HelpLink";
import { getStatisticsOverview } from "../api";
import { currentMonth, formatDate, formatMinutes } from "../format";
import type { StatisticsDailyBucket, StatisticsGroup, StatisticsOverview, StatisticsTicket, WorkType } from "../types";
type StatisticsPageProps = {
onNavigate: (to: string) => void;
};
const trackedTextClass = "text-sky-700 dark:text-sky-300";
const teamspaceTextClass = "text-fuchsia-700 dark:text-fuchsia-300";
const trackedFillClass = "fill-sky-500/70 dark:fill-sky-400/65";
const teamspaceStrokeClass = "text-fuchsia-600 dark:text-fuchsia-300";
const trackedDotClass = "bg-sky-500 dark:bg-sky-400";
const teamspaceDotClass = "bg-fuchsia-500 dark:bg-fuchsia-400";
const openTextClass = "text-amber-700 dark:text-amber-300";
const organizationPageSize = 8;
function workTypeLabel(workType: WorkType | undefined) {
if (workType === "support") {
return "Support";
}
if (workType === "consulting") {
return "Consulting";
}
return "Ohne Typ";
}
function percentage(value: number, total: number) {
if (total <= 0) {
return 0;
}
return Math.max(0, Math.min(100, Math.round((value / total) * 100)));
}
function formatSignedMinutes(minutes: number) {
if (minutes === 0) {
return "ausgeglichen";
}
return `${minutes > 0 ? "+" : "-"}${formatMinutes(Math.abs(minutes))}`;
}
function formatTeamspaceDelta(minutes: number) {
return formatSignedMinutes(minutes);
}
function deltaTextClass(minutes: number) {
return minutes < 0 ? "text-red-700 dark:text-red-300" : "text-emerald-700 dark:text-emerald-300";
}
function DeltaBadge({ minutes }: { minutes: number }) {
return (
<Badge variant={minutes < 0 ? "destructive" : "success"}>
{formatTeamspaceDelta(minutes)}
</Badge>
);
}
function deltaDescription(minutes: number) {
if (minutes > 0) {
return "Teamspace liegt über der abgerechneten Zeit";
}
if (minutes < 0) {
return "Teamspace liegt unter der abgerechneten Zeit";
}
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}`}>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>
);
}
function ColorLegend() {
return (
<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}>Abgerechnet</span>
</span>
<span className="inline-flex items-center gap-1.5">
<span className={`h-0.5 w-4 ${teamspaceDotClass}`} />
<span className={teamspaceTextClass}>Teamspace</span>
</span>
<span className="inline-flex items-center gap-1.5">
<span className="size-2 rounded-full bg-emerald-500 dark:bg-emerald-400" />
<span className="text-emerald-700 dark:text-emerald-300">Differenz plus oder ausgeglichen</span>
</span>
<span className="inline-flex items-center gap-1.5">
<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-amber-500 dark:bg-amber-400" />
<span className={openTextClass}>offen</span>
</span>
</div>
);
}
function formatAxisMinutes(value: number) {
const minutes = Math.round(value);
if (minutes <= 0) {
return "0";
}
if (minutes >= 60) {
return `${new Intl.NumberFormat("de-DE", { maximumFractionDigits: minutes < 600 ? 1 : 0 }).format(minutes / 60)} h`;
}
return `${minutes} min`;
}
function niceCeilMinutes(value: number) {
if (value <= 30) {
return Math.max(5, Math.ceil(value / 5) * 5);
}
if (value <= 120) {
return Math.ceil(value / 15) * 15;
}
if (value <= 480) {
return Math.ceil(value / 30) * 30;
}
return Math.ceil(value / 60) * 60;
}
function buildChartPath(points: Array<{ x: number; y: number }>) {
if (points.length === 0) {
return "";
}
if (points.length === 1) {
return `M ${points[0].x - 18} ${points[0].y} L ${points[0].x + 18} ${points[0].y}`;
}
return points.map((point, index) => `${index === 0 ? "M" : "L"} ${point.x} ${point.y}`).join(" ");
}
function buildMonthSeries(month: string, buckets: StatisticsDailyBucket[]) {
const series = new Map(buckets.map((bucket) => [bucket.day, bucket]));
const [year, monthNumber] = month.split("-").map(Number);
const daysInMonth = Number.isFinite(year) && Number.isFinite(monthNumber) ? new Date(year, monthNumber, 0).getDate() : 31;
return Array.from({ length: daysInMonth }).map((_, index) => {
const day = String(index + 1).padStart(2, "0");
const key = `${month}-${day}`;
const bucket = series.get(key);
return {
day: key,
label: day,
sessions: bucket?.sessions ?? 0,
totalMinutes: bucket?.total_minutes ?? 0,
billedMinutes: bucket?.billed_minutes ?? 0,
openMinutes: bucket?.open_minutes ?? 0,
billedSessions: bucket?.billed_sessions ?? 0,
openSessions: bucket?.open_sessions ?? 0,
crmBilledMinutes: bucket?.crm_billed_minutes ?? 0
};
});
}
function DailyEffortChart({ month, buckets }: { month: string; buckets: StatisticsDailyBucket[] }) {
const series = buildMonthSeries(month, buckets);
const width = 900;
const height = 220;
const padding = { top: 18, right: 20, bottom: 36, left: 64 };
const innerWidth = width - padding.left - padding.right;
const innerHeight = height - padding.top - padding.bottom;
const maxMinutes = niceCeilMinutes(Math.max(...series.map((bucket) => Math.max(bucket.totalMinutes, bucket.crmBilledMinutes)), 1));
const ticks = Array.from({ length: 5 }).map((_, index) => maxMinutes - (index * maxMinutes) / 4);
const labelStep = Math.max(1, Math.ceil(series.length / 9));
const barWidth = Math.max(5, Math.min(16, innerWidth / Math.max(series.length, 1) / 2));
const points = series.map((bucket, index) => {
const x = series.length === 1 ? padding.left + innerWidth / 2 : padding.left + (index * innerWidth) / (series.length - 1);
return {
bucket,
x,
totalBarHeight: bucket.totalMinutes > 0 ? Math.max(2, (bucket.totalMinutes / maxMinutes) * innerHeight) : 0,
crmY: padding.top + (1 - bucket.crmBilledMinutes / maxMinutes) * innerHeight
};
});
const crmPath = buildChartPath(points.filter((point) => point.bucket.crmBilledMinutes > 0).map((point) => ({ x: point.x, y: point.crmY })));
return (
<div className="h-56 w-full overflow-hidden rounded-md border bg-background">
{series.some((bucket) => bucket.totalMinutes > 0 || bucket.crmBilledMinutes > 0) ? (
<svg viewBox={`0 0 ${width} ${height}`} className="h-full w-full">
{ticks.map((tick) => {
const y = padding.top + (1 - tick / maxMinutes) * innerHeight;
return (
<g key={tick}>
<line x1={padding.left} x2={width - padding.right} y1={y} y2={y} stroke="currentColor" className="text-muted/70" />
<text x={padding.left - 9} y={y + 4} textAnchor="end" className="fill-muted-foreground text-[11px]">
{formatAxisMinutes(tick)}
</text>
</g>
);
})}
<line x1={padding.left} x2={padding.left} y1={padding.top} y2={padding.top + innerHeight} stroke="currentColor" className="text-muted-foreground/70" />
{points.map((point) => {
const y = padding.top + innerHeight - point.totalBarHeight;
return point.totalBarHeight > 0 ? (
<rect key={point.bucket.day} x={point.x - barWidth / 2} y={y} width={barWidth} height={point.totalBarHeight} rx="4" className={trackedFillClass} />
) : null;
})}
{crmPath ? <path d={crmPath} fill="none" stroke="currentColor" strokeWidth="2.5" className={teamspaceStrokeClass} /> : null}
{points
.filter((point) => point.bucket.crmBilledMinutes > 0)
.map((point) => (
<circle key={`${point.bucket.day}-crm`} cx={point.x} cy={point.crmY} r="3" className={`fill-background ${teamspaceStrokeClass}`} stroke="currentColor" strokeWidth="2" />
))}
{points.map((point, index) =>
index % labelStep === 0 || index === points.length - 1 ? (
<text key={`${point.bucket.day}-label`} x={point.x} y={height - 11} textAnchor="middle" className="fill-muted-foreground text-[11px]">
{point.bucket.label}
</text>
) : null
)}
</svg>
) : (
<div className="grid h-full place-items-center text-sm text-muted-foreground">Keine Statistikdaten für diesen Monat.</div>
)}
</div>
);
}
function MetricCard({
label,
value,
detail,
icon: Icon,
valueClassName = "",
helpAnchor,
helpLabel
}: {
label: string;
value: string | number;
detail?: ReactNode;
icon: LucideIcon;
valueClassName?: string;
helpAnchor?: string;
helpLabel?: string;
}) {
return (
<Card className="relative">
{helpAnchor ? <HelpLink anchor={helpAnchor} label={helpLabel ?? `Hilfe zu ${label}`} className="absolute right-2 top-2" /> : null}
<CardContent className="flex items-center gap-3 p-3 pr-10">
<div className="grid size-8 shrink-0 place-items-center rounded-lg border bg-muted/40 text-muted-foreground">
<Icon className="size-3.5" />
</div>
<div className="min-w-0">
<p className="text-xs font-medium text-muted-foreground">{label}</p>
<p className={`truncate text-xl font-semibold tracking-normal ${valueClassName}`}>{value}</p>
{detail ? <div className="text-xs text-muted-foreground">{detail}</div> : null}
</div>
</CardContent>
</Card>
);
}
function OrganizationRow({ organization, maxMinutes, totalMinutes }: { organization: StatisticsGroup; maxMinutes: number; totalMinutes: number }) {
const monthlyShare = percentage(organization.total_minutes, totalMinutes);
return (
<TableRow>
<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.billed_sessions} abgerechnet
</div>
</TableCell>
<TableCell>{organization.billed_sessions}</TableCell>
<TableCell>
<TimeComparison trackedMinutes={organization.total_minutes} crmMinutes={organization.crm_billed_minutes} />
</TableCell>
<TableCell>
<DeltaBadge minutes={organization.crm_delta_minutes} />
</TableCell>
<TableCell className="min-w-[150px]">
<Progress value={percentage(organization.total_minutes, maxMinutes)} />
<div className="mt-1 text-xs text-muted-foreground">{monthlyShare}% vom Monat</div>
</TableCell>
</TableRow>
);
}
function TicketHotspot({ ticket, month, onNavigate }: { ticket: StatisticsTicket; month: string; onNavigate: (to: string) => void }) {
return (
<div className="grid gap-2 rounded-md border bg-background p-3 sm:grid-cols-[1fr_auto] sm:items-center">
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-2">
<span className="font-semibold">{ticket.ticket_number}</span>
{ticket.open_sessions > 0 ? <Badge variant="warning">{ticket.open_sessions} offen</Badge> : <Badge variant="success">bewertet</Badge>}
</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}>Abgerechnet {formatMinutes(ticket.total_minutes)}</span>
<span className={teamspaceTextClass}>Teamspace {formatMinutes(ticket.crm_billed_minutes)}</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>
</div>
<Button variant="secondary" size="sm" onClick={() => onNavigate(`/analysis/month/${month}/tickets/${ticket.ticket_id}`)}>
Öffnen
<ExternalLink className="size-4" />
</Button>
</div>
);
}
export function StatisticsPage({ onNavigate }: StatisticsPageProps) {
const [month, setMonth] = useState(currentMonth());
const [stats, setStats] = useState<StatisticsOverview | null>(null);
const [loading, setLoading] = useState(false);
const [organizationPage, setOrganizationPage] = useState(1);
const loadRequestId = useRef(0);
async function load() {
const requestId = loadRequestId.current + 1;
loadRequestId.current = requestId;
setLoading(true);
try {
const result = await getStatisticsOverview(month);
if (requestId === loadRequestId.current) {
setStats(result);
}
} catch (error) {
if (requestId === loadRequestId.current) {
toast.error("Statistiken konnten nicht geladen werden", {
description: error instanceof Error ? error.message : "Unbekannter Fehler"
});
}
} finally {
if (requestId === loadRequestId.current) {
setLoading(false);
}
}
}
useEffect(() => {
void load();
}, [month]);
useEffect(() => {
function refreshVisible() {
if (document.visibilityState === "visible") {
void load();
}
}
const interval = window.setInterval(refreshVisible, 60_000);
window.addEventListener("focus", refreshVisible);
document.addEventListener("visibilitychange", refreshVisible);
return () => {
window.clearInterval(interval);
window.removeEventListener("focus", refreshVisible);
document.removeEventListener("visibilitychange", refreshVisible);
};
}, [month]);
function changeMonthBy(delta: number) {
const [year, monthNumber] = month.split("-").map(Number);
const date = Number.isFinite(year) && Number.isFinite(monthNumber) ? new Date(year, monthNumber - 1, 1) : new Date();
date.setMonth(date.getMonth() + delta);
setMonth(`${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}`);
}
const totals = stats?.totals;
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;
const strongestDay = useMemo(() => {
return [...(stats?.dailySeries ?? [])].sort((left, right) => right.total_minutes - left.total_minutes)[0] ?? null;
}, [stats?.dailySeries]);
const organizationPageCount = Math.max(1, Math.ceil((stats?.organizations.length ?? 0) / organizationPageSize));
const safeOrganizationPage = Math.min(organizationPage, organizationPageCount);
const organizationStart = (safeOrganizationPage - 1) * organizationPageSize;
const visibleOrganizations = (stats?.organizations ?? []).slice(organizationStart, organizationStart + organizationPageSize);
const organizationRangeStart = stats?.organizations.length ? organizationStart + 1 : 0;
const organizationRangeEnd = Math.min(organizationStart + organizationPageSize, stats?.organizations.length ?? 0);
useEffect(() => {
setOrganizationPage(1);
}, [month]);
useEffect(() => {
setOrganizationPage((current) => Math.min(current, organizationPageCount));
}, [organizationPageCount]);
return (
<div className="space-y-5">
<div className="flex flex-col gap-3 xl:flex-row xl:items-end xl:justify-between">
<div>
<h2 className="text-2xl font-semibold tracking-normal">Statistiken</h2>
<p className="text-sm text-muted-foreground">Monatswerte, Teamspace-Abgleich, Organisationen und offene Nacharbeit.</p>
</div>
<div className="grid gap-1 sm:w-[300px]">
<div className="flex items-center justify-between gap-2">
<label className="text-xs font-medium text-muted-foreground" htmlFor="statistics-month">Monat</label>
{loading ? <span className="text-xs text-muted-foreground">lädt...</span> : null}
</div>
<div className="grid grid-cols-[2.25rem_minmax(0,1fr)_2.25rem] gap-1">
<Button type="button" size="icon" variant="secondary" className="h-9 w-9" onClick={() => changeMonthBy(-1)} aria-label="Monat zurück">
<ChevronLeft className="size-4" />
</Button>
<Input id="statistics-month" className="h-9" type="month" value={month} onChange={(event) => setMonth(event.currentTarget.value)} />
<Button type="button" size="icon" variant="secondary" className="h-9 w-9" onClick={() => changeMonthBy(1)} aria-label="Monat vor">
<ChevronRight className="size-4" />
</Button>
</div>
</div>
</div>
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-3">
<MetricCard
label="Abgerechnet"
value={formatMinutes(totals?.minutes ?? 0)}
detail={`${billedSessions} Session(s), ${totals?.tickets ?? 0} Ticket(s)`}
icon={Clock3}
valueClassName={trackedTextClass}
helpAnchor="statistik-getrackt"
helpLabel="Hilfe zur Kennzahl Abgerechnet"
/>
<MetricCard
label="Teamspace"
value={formatMinutes(totals?.crmBilledMinutes ?? 0)}
detail={<span className={deltaTextClass(totals?.crmDeltaMinutes ?? 0)}>Differenz {formatTeamspaceDelta(totals?.crmDeltaMinutes ?? 0)}</span>}
icon={TrendingUp}
valueClassName={teamspaceTextClass}
helpAnchor="statistik-teamspace"
helpLabel="Hilfe zur Kennzahl Teamspace"
/>
<MetricCard
label="Offen"
value={formatMinutes(openMinutes)}
detail={
<div className="space-y-0.5">
<div>{openSessions} Session(s)</div>
<div>wird nicht kalkuliert</div>
</div>
}
icon={CircleAlert}
valueClassName={openTextClass}
helpAnchor="statistik-offen"
helpLabel="Hilfe zur Kennzahl Offen"
/>
</div>
<Card>
<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>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>
<HelpLink anchor="statistik-verlauf" label="Hilfe zu Monatsverlauf und Abrechnungsqualität" />
</div>
</CardHeader>
<CardContent className="grid gap-4 px-4 pb-4 xl:grid-cols-[1.55fr_1fr]">
<div className="space-y-3">
<DailyEffortChart month={month} buckets={stats?.dailySeries ?? []} />
<ColorLegend />
{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">{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={trackedTextClass}>Abgerechnet: {billedSessions} Session(s), {formatMinutes(billedMinutes)}</div>
<div className={openTextClass}>Offen: {openSessions} Session(s), {formatMinutes(openMinutes)}</div>
</div>
</div>
<div className="space-y-2 rounded-md border bg-muted/20 p-3">
<div className="flex items-start justify-between gap-3">
<div>
<p className="text-sm font-medium">Teamspace-Abgleich</p>
<p className="text-xs text-muted-foreground">{deltaDescription(totals?.crmDeltaMinutes ?? 0)}</p>
</div>
<DeltaBadge minutes={totals?.crmDeltaMinutes ?? 0} />
</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">
<div className="rounded-md border bg-muted/20 p-3">
<p className="text-muted-foreground">Manuell</p>
<p className={`font-semibold ${trackedTextClass}`}>{formatMinutes(totals?.manualMinutes ?? 0)}</p>
<p className="text-xs text-muted-foreground">{totals?.manualSessions ?? 0} Session(s)</p>
</div>
<div className="rounded-md border bg-muted/20 p-3">
<p className="text-muted-foreground">Fix</p>
<p className={`font-semibold ${trackedTextClass}`}>{formatMinutes(totals?.recurringMinutes ?? 0)}</p>
<p className="text-xs text-muted-foreground">{totals?.recurringSessions ?? 0} Session(s)</p>
</div>
<div className="rounded-md border bg-muted/20 p-3">
<p className="text-muted-foreground">Aktive Tage</p>
<p className="font-semibold">{totals?.activeDays ?? 0}</p>
</div>
<div className="rounded-md border bg-muted/20 p-3">
<p className="text-muted-foreground">Schnitt</p>
<p className="font-semibold">{formatMinutes(totals?.averageSessionMinutes ?? 0)}</p>
</div>
</div>
{topOrganization ? (
<div className="rounded-md border bg-muted/20 p-3 text-sm">
<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}>Abgerechnet {formatMinutes(topOrganization.total_minutes)}</span>
<span className={teamspaceTextClass}>Teamspace {formatMinutes(topOrganization.crm_billed_minutes)}</span>
</p>
</div>
) : null}
</div>
</CardContent>
</Card>
<div className="grid gap-3 xl:grid-cols-[1.45fr_0.8fr]">
<Card>
<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 an der abgerechneten Monatszeit.</CardDescription>
</div>
<div className="flex items-center gap-2">
<Badge variant="outline">
{organizationRangeStart}-{organizationRangeEnd} von {stats?.organizations.length ?? 0}
</Badge>
<HelpLink anchor="statistik-organisationen" label="Hilfe zur Organisationsstatistik" />
</div>
</CardHeader>
<CardContent className="px-4 pb-4">
<div className="hidden md:block">
<Table>
<TableHeader>
<TableRow>
<TableHead>Organisation</TableHead>
<TableHead>Abgerechnet</TableHead>
<TableHead>Zeiten</TableHead>
<TableHead>Differenz</TableHead>
<TableHead>Anteil abgerechnet</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{visibleOrganizations.map((organization) => (
<OrganizationRow
key={organization.organization_id ?? organization.organization_name}
organization={organization}
maxMinutes={maxOrganizationMinutes}
totalMinutes={totals?.minutes ?? 0}
/>
))}
</TableBody>
</Table>
</div>
<div className="space-y-2 md:hidden">
{visibleOrganizations.map((organization) => (
<div key={organization.organization_id ?? organization.organization_name} className="space-y-2 rounded-md border bg-background p-3">
<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.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}>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>
</div>
</div>
))}
</div>
{(stats?.organizations.length ?? 0) > organizationPageSize ? (
<Pagination className="mt-4 justify-end">
<PaginationContent>
<PaginationItem>
<PaginationPrevious
href="#"
text="Zurück"
aria-disabled={safeOrganizationPage <= 1}
className={safeOrganizationPage <= 1 ? "pointer-events-none opacity-50" : undefined}
onClick={(event) => {
event.preventDefault();
setOrganizationPage((current) => Math.max(1, current - 1));
}}
/>
</PaginationItem>
<PaginationItem>
<span className="flex h-9 items-center px-3 text-sm text-muted-foreground">
Seite {safeOrganizationPage} von {organizationPageCount}
</span>
</PaginationItem>
<PaginationItem>
<PaginationNext
href="#"
text="Weiter"
aria-disabled={safeOrganizationPage >= organizationPageCount}
className={safeOrganizationPage >= organizationPageCount ? "pointer-events-none opacity-50" : undefined}
onClick={(event) => {
event.preventDefault();
setOrganizationPage((current) => Math.min(organizationPageCount, current + 1));
}}
/>
</PaginationItem>
</PaginationContent>
</Pagination>
) : null}
{stats && stats.organizations.length === 0 ? <p className="py-8 text-center text-sm text-muted-foreground">Keine Organisationen im gewählten Monat.</p> : null}
</CardContent>
</Card>
<Card>
<CardHeader className="p-4">
<div className="flex items-start justify-between gap-3">
<div>
<CardTitle>Typen</CardTitle>
<CardDescription>Support und Consulting im direkten Vergleich.</CardDescription>
</div>
<HelpLink anchor="statistik-typen" label="Hilfe zu Typenstatistiken" />
</div>
</CardHeader>
<CardContent className="space-y-3 px-4 pb-4">
{(stats?.workTypes ?? []).map((group) => (
<div key={group.work_type ?? "none"} className="space-y-2 rounded-md border bg-background p-3">
<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.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}>Abgerechnet {formatMinutes(group.total_minutes)}</span>
<span className={teamspaceTextClass}>Teamspace {formatMinutes(group.crm_billed_minutes)}</span>
</div>
<div className="grid grid-cols-2 gap-2 text-xs text-muted-foreground">
<span>Abr. {group.billed_sessions} / {formatMinutes(group.billed_minutes)}</span>
<span>Offen {group.open_sessions} / {formatMinutes(group.open_minutes)}</span>
</div>
</div>
))}
{stats && stats.workTypes.length === 0 ? <p className="py-8 text-center text-sm text-muted-foreground">Noch keine Typdaten vorhanden.</p> : null}
</CardContent>
</Card>
</div>
<Card>
<CardHeader className="gap-3 p-4 sm:flex-row sm:items-start sm:justify-between sm:space-y-0">
<div>
<CardTitle>Ticket-Hotspots</CardTitle>
<CardDescription>Die größten Zeitblöcke im Monat.</CardDescription>
</div>
<div className="flex items-center gap-2">
<Button variant="secondary" size="sm" onClick={() => onNavigate("/analysis")}>
Auswertung öffnen
<ExternalLink className="size-4" />
</Button>
<HelpLink anchor="statistik-hotspots" label="Hilfe zu Ticket-Hotspots" />
</div>
</CardHeader>
<CardContent className="space-y-2 px-4 pb-4">
{(stats?.tickets ?? []).map((ticket) => (
<TicketHotspot key={ticket.ticket_id} ticket={ticket} month={month} onNavigate={onNavigate} />
))}
{stats && stats.tickets.length === 0 ? <p className="py-8 text-center text-sm text-muted-foreground">Keine Tickets im gewählten Monat.</p> : null}
</CardContent>
</Card>
{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 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" />
Aktuellen Monat zeigen
</Button>
</CardContent>
</Card>
) : null}
</div>
);
}
+82 -12
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";
@@ -8,6 +9,7 @@ import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } f
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { CopyTicketButton } from "@/components/CopyTicketButton";
import { HelpLink } from "@/components/HelpLink";
import { OrganizationSelect } from "@/components/OrganizationSelect";
import {
createSession,
@@ -27,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 = {
@@ -139,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);
@@ -205,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]
});
}
@@ -228,6 +249,30 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate }: T
return Array.from(groups.values());
}, [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) {
@@ -490,10 +535,21 @@ 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">
<CardTitle>Ticketdaten</CardTitle>
<CardDescription>Änderungen an Organisation und Art werden auf alle vorhandenen Sessions dieses Tickets übernommen.</CardDescription>
<div className="flex items-start justify-between gap-3">
<div>
<CardTitle>Ticketdaten</CardTitle>
<CardDescription>Änderungen an Organisation und Art werden auf alle vorhandenen Sessions dieses Tickets übernommen.</CardDescription>
</div>
<HelpLink anchor="ticketdaten" label="Hilfe zu Ticketdaten" />
</div>
</CardHeader>
<CardContent className="px-4 pb-4">
<form className="grid gap-3 lg:grid-cols-[180px_minmax(220px,1fr)_180px_auto] lg:items-end" onSubmit={saveTicketData}>
@@ -542,9 +598,16 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate }: T
<CardTitle>Session-Einträge</CardTitle>
<CardDescription>Wähle für jede Session genau eine Bewertung aus oder lösche falsche Einträge.</CardDescription>
</div>
<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="font-semibold">{formatMinutes(periodTotalMinutes)}</span>
<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} 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">
<span className="text-muted-foreground">{periodType === "month" ? "Teamspace gesamt" : "Teamspace Tag"}</span>
<span className="font-semibold">{formatMinutes(periodTeamspaceMinutes)}</span>
</div>
<HelpLink anchor="ticketdetail" label="Hilfe zur Ticketdetailansicht" />
</div>
</CardHeader>
<CardContent className="px-4 pb-4">
@@ -554,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
@@ -650,8 +715,13 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate }: T
</Card>
<Card>
<CardHeader className="p-4">
<CardTitle>Session nachtragen</CardTitle>
<CardDescription>Neue Zeit direkt diesem Ticket zuordnen.</CardDescription>
<div className="flex items-start justify-between gap-3">
<div>
<CardTitle>Session nachtragen</CardTitle>
<CardDescription>Neue Zeit direkt diesem Ticket zuordnen.</CardDescription>
</div>
<HelpLink anchor="session-nachtragen" label="Hilfe zum Nachtragen von Sessions" />
</div>
</CardHeader>
<CardContent className="px-4 pb-4">
<form className="space-y-4" onSubmit={submitTicketSession}>
+30 -13
View File
@@ -8,6 +8,7 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/com
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { HelpLink } from "@/components/HelpLink";
import { OrganizationSelect } from "@/components/OrganizationSelect";
import { createSession, lookupTicket } from "../api";
import { formatTimer } from "../format";
@@ -300,8 +301,13 @@ export function TimerPage({ timers, setTimers, selectedTimerId, setSelectedTimer
<Card className="overflow-hidden">
<CardHeader className="border-b bg-muted/30 p-4">
<CardTitle>Neuen Timer starten</CardTitle>
<CardDescription>Ein neuer Timer startet sofort und pausiert alle anderen Timer automatisch.</CardDescription>
<div className="flex items-start justify-between gap-3">
<div>
<CardTitle>Neuen Timer starten</CardTitle>
<CardDescription>Ein neuer Timer startet sofort und pausiert alle anderen Timer automatisch.</CardDescription>
</div>
<HelpLink anchor="timer" label="Hilfe zu Timern" />
</div>
</CardHeader>
<CardContent className="grid gap-3 p-4 sm:grid-cols-[minmax(220px,320px)_auto] sm:items-end">
<div className="space-y-2">
@@ -312,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") {
@@ -330,14 +337,19 @@ export function TimerPage({ timers, setTimers, selectedTimerId, setSelectedTimer
<Card className="overflow-hidden">
<CardHeader className="border-b bg-muted/30 p-4">
<CardTitle>{selectedTimer ? selectedTimer.ticketNumber : "Keine Session ausgewählt"}</CardTitle>
<CardDescription>
{selectedTimer
? selectedTimer.organizationName
? `${selectedTimer.organizationName}${selectedTimer.workType ? ` · ${selectedTimer.workType === "support" ? "Support" : "Consulting"}` : ""}`
: "Beim Umschalten wird dieser Timer aktiviert und alle anderen werden pausiert."
: "Starte einen Timer, um eine Session zu erfassen."}
</CardDescription>
<div className="flex items-start justify-between gap-3">
<div>
<CardTitle>{selectedTimer ? selectedTimer.ticketNumber : "Keine Session ausgewählt"}</CardTitle>
<CardDescription>
{selectedTimer
? selectedTimer.organizationName
? `${selectedTimer.organizationName}${selectedTimer.workType ? ` · ${selectedTimer.workType === "support" ? "Support" : "Consulting"}` : ""}`
: "Beim Umschalten wird dieser Timer aktiviert und alle anderen werden pausiert."
: "Starte einen Timer, um eine Session zu erfassen."}
</CardDescription>
</div>
<HelpLink anchor="timer" label="Hilfe zum Timerstatus" />
</div>
</CardHeader>
<CardContent className="space-y-4 p-4">
<div className="rounded-lg border bg-background p-4 sm:p-5">
@@ -374,8 +386,13 @@ export function TimerPage({ timers, setTimers, selectedTimerId, setSelectedTimer
<Card className="overflow-hidden">
<CardHeader className="border-b bg-muted/30 p-4">
<CardTitle>Session nachtragen</CardTitle>
<CardDescription>Vergessene Zeiten für deinen eigenen Account manuell erfassen.</CardDescription>
<div className="flex items-start justify-between gap-3">
<div>
<CardTitle>Session nachtragen</CardTitle>
<CardDescription>Vergessene Zeiten für deinen eigenen Account manuell erfassen.</CardDescription>
</div>
<HelpLink anchor="session-nachtragen" label="Hilfe zum Nachtragen von Sessions" />
</div>
</CardHeader>
<CardContent className="p-4">
<form className="space-y-4" onSubmit={submitManualSession}>
@@ -509,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">