Compare commits

...
21 Commits
Author SHA1 Message Date
michael a760f423a4 Use Zammad IDs for organization links 2026-08-11 14:47:11 +02:00
michael dcc115a82c Add global ticket search 2026-08-11 07:57:58 +02:00
michael 4390f9a7da Allow manual ordering of opened ticket links 2026-08-06 08:39:35 +02:00
michael 7d47a05bff Add persistent opened ticket navigation 2026-08-06 08:32:49 +02:00
michael 05c842e406 Validate backups before restore 2026-08-06 08:28:01 +02:00
michael 72229cf14c Add setup and restore flow 2026-08-06 08:22:33 +02:00
michael 19e4621aeb Restrict admin to system management 2026-08-06 08:07:26 +02:00
michael 0bef65ab75 Document monthly workflow in FAQ 2026-08-06 07:43:40 +02:00
michael e527576ed6 Remove non-billable fragments from statistics 2026-08-05 22:46:39 +02:00
michael ac71eb88c0 Hide non-billable column in analysis table 2026-08-05 22:44:16 +02:00
michael eff8823371 Show non-billable status without statistic hours 2026-08-05 22:40:47 +02:00
michael 7d4d373863 Revert "Remove non-billable data from statistics"
This reverts commit 971d493015.
2026-08-05 22:38:14 +02:00
michael bc184fef56 Revert "Hide non-billable values in analysis"
This reverts commit ccb859ad49.
2026-08-05 22:38:14 +02:00
michael ccb859ad49 Hide non-billable values in analysis 2026-08-05 22:35:49 +02:00
michael 7f71c23232 Revert "Fix Next static asset serving"
This reverts commit 964bd44a82.
2026-08-05 22:03:49 +02:00
michael 964bd44a82 Fix Next static asset serving 2026-08-05 21:33:19 +02:00
michael 971d493015 Remove non-billable data from statistics 2026-08-05 21:17:47 +02:00
michael 8fef5980db Adjust non-billable statistics color 2026-08-05 20:56:01 +02:00
michael cf7b33f331 Exclude unresolved time from statistics 2026-08-05 20:51:59 +02:00
michael 2aec561f3c Use billable time for Teamspace comparisons 2026-08-05 17:06:34 +02:00
michael 76bc438b4e Update user guide documentation 2026-08-05 16:19:29 +02:00
22 changed files with 2439 additions and 1239 deletions
-4
View File
@@ -4,9 +4,5 @@ APP_PORT=3910
DATABASE_URL=postgresql://tickettracker:change-me@10.10.10.23:5432/tickettracker DATABASE_URL=postgresql://tickettracker:change-me@10.10.10.23:5432/tickettracker
DB_POOL_SIZE=10 DB_POOL_SIZE=10
ADMIN_USERNAME=admin
ADMIN_PASSWORD=change-me-now
ADMIN_DISPLAY_NAME=Admin
COOKIE_SECURE=false COOKIE_SECURE=false
SESSION_MAX_AGE_MS=1209600000 SESSION_MAX_AGE_MS=1209600000
-4
View File
@@ -6,9 +6,5 @@ POSTGRES_USER=tickettracker
POSTGRES_PASSWORD=change-me-db-password POSTGRES_PASSWORD=change-me-db-password
DB_POOL_SIZE=10 DB_POOL_SIZE=10
ADMIN_USERNAME=admin
ADMIN_PASSWORD=change-me-now
ADMIN_DISPLAY_NAME=Admin
COOKIE_SECURE=false COOKIE_SECURE=false
SESSION_MAX_AGE_MS=1209600000 SESSION_MAX_AGE_MS=1209600000
-3
View File
@@ -1,7 +1,4 @@
DATABASE_URL=postgresql://tickettracker:change-me@10.10.10.23:5432/tickettracker 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 APP_PORT=3910
TICKETTRACKER_IMAGE=git.d-razz.de/michael/tickettracker:latest TICKETTRACKER_IMAGE=git.d-razz.de/michael/tickettracker:latest
COOKIE_SECURE=false COOKIE_SECURE=false
+9
View File
@@ -155,6 +155,15 @@ export function requireAdmin(req: Request, res: Response, next: NextFunction) {
next(); 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) { export function currentUser(req: Request) {
if (!req.user) { if (!req.user) {
throw Object.assign(new Error("Nicht angemeldet"), { status: 401 }); throw Object.assign(new Error("Nicht angemeldet"), { status: 401 });
+616 -343
View File
File diff suppressed because it is too large Load Diff
+113 -39
View File
@@ -1,42 +1,25 @@
import { query } from "./db.js"; 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;"); const adminResult = await query<{ id: string }>("SELECT id FROM users WHERE role = 'admin' ORDER BY id ASC LIMIT 1;");
return adminResult.rows[0]?.id ?? null;
if (adminResult.rows.length === 0) {
throw new Error("At least one admin user is required");
} }
return adminResult.rows[0].id; async function ensureClosureOwnership(table: string, column: string, primaryKeyColumns: string[], adminId: string | null) {
}
async function ensureClosureOwnership(table: string, column: string, primaryKeyColumns: string[], adminId: string) {
await query(`ALTER TABLE ${table} ADD COLUMN IF NOT EXISTS user_id BIGINT REFERENCES users(id);`); await query(`ALTER TABLE ${table} ADD COLUMN IF NOT EXISTS user_id BIGINT REFERENCES users(id);`);
if (adminId) {
await query(`UPDATE ${table} SET user_id = $1 WHERE user_id IS NULL;`, [adminId]); 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(`
await query(`ALTER TABLE ${table} ADD CONSTRAINT ${table}_pkey PRIMARY KEY (${primaryKeyColumns.join(", ")}, user_id);`); 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);`); 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_user ON auth_sessions(user_id);");
await query("CREATE INDEX IF NOT EXISTS idx_auth_sessions_expires ON auth_sessions(expires_at);"); 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(` await query(`
CREATE TABLE IF NOT EXISTS app_settings ( CREATE TABLE IF NOT EXISTS app_settings (
@@ -108,7 +97,7 @@ export async function migrate() {
CREATE TABLE IF NOT EXISTS tickets ( CREATE TABLE IF NOT EXISTS tickets (
id BIGSERIAL PRIMARY KEY, id BIGSERIAL PRIMARY KEY,
ticket_number TEXT NOT NULL UNIQUE, ticket_number TEXT NOT NULL UNIQUE,
organization_id BIGINT REFERENCES organizations(id), organization_id BIGINT REFERENCES organizations(zammad_id),
customer_name TEXT, customer_name TEXT,
work_type TEXT, work_type TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(), 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 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 ADD COLUMN IF NOT EXISTS work_type TEXT;");
await query(` await query(`
ALTER TABLE tickets ALTER TABLE tickets
@@ -141,7 +130,7 @@ export async function migrate() {
CREATE TABLE IF NOT EXISTS sessions ( CREATE TABLE IF NOT EXISTS sessions (
id BIGSERIAL PRIMARY KEY, id BIGSERIAL PRIMARY KEY,
ticket_id BIGINT NOT NULL REFERENCES tickets(id) ON DELETE CASCADE, 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, customer_name TEXT NOT NULL,
activity TEXT NOT NULL, activity TEXT NOT NULL,
work_type 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 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_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_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_date DATE;");
await query("ALTER TABLE sessions ADD COLUMN IF NOT EXISTS recurring_occurrence_key TEXT;"); await query("ALTER TABLE sessions ADD COLUMN IF NOT EXISTS recurring_occurrence_key TEXT;");
if (adminId) {
await query("UPDATE sessions SET user_id = $1 WHERE user_id IS NULL;", [adminId]); 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;"); }
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(` await query(`
CREATE TABLE IF NOT EXISTS recurring_billings ( CREATE TABLE IF NOT EXISTS recurring_billings (
id BIGSERIAL PRIMARY KEY, id BIGSERIAL PRIMARY KEY,
user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE, user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
ticket_number TEXT, ticket_number TEXT,
organization_id BIGINT NOT NULL REFERENCES organizations(id), organization_id BIGINT NOT NULL REFERENCES organizations(zammad_id),
activity TEXT NOT NULL, activity TEXT NOT NULL,
work_type TEXT NOT NULL, work_type TEXT NOT NULL,
recurrence_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 ALTER COLUMN ticket_number DROP NOT NULL;");
await query("ALTER TABLE recurring_billings DROP CONSTRAINT IF EXISTS recurring_billings_ticket_number_format;"); 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';"); await query("UPDATE recurring_billings SET recurrence_type = 'every_n_weeks' WHERE recurrence_type = 'every_n_days';");
-3
View File
@@ -31,9 +31,6 @@ services:
API_PROXY_TARGET: http://127.0.0.1:3001 API_PROXY_TARGET: http://127.0.0.1:3001
SERVE_FRONTEND: "false" SERVE_FRONTEND: "false"
NEXT_TELEMETRY_DISABLED: "1" 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} SESSION_MAX_AGE_MS: ${SESSION_MAX_AGE_MS:-1209600000}
COOKIE_SECURE: ${COOKIE_SECURE:-false} COOKIE_SECURE: ${COOKIE_SECURE:-false}
DB_POOL_SIZE: ${DB_POOL_SIZE:-10} DB_POOL_SIZE: ${DB_POOL_SIZE:-10}
-3
View File
@@ -12,9 +12,6 @@ services:
API_PROXY_TARGET: http://127.0.0.1:3001 API_PROXY_TARGET: http://127.0.0.1:3001
SERVE_FRONTEND: "false" SERVE_FRONTEND: "false"
NEXT_TELEMETRY_DISABLED: "1" 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} SESSION_MAX_AGE_MS: ${SESSION_MAX_AGE_MS:-1209600000}
COOKIE_SECURE: ${COOKIE_SECURE:-false} COOKIE_SECURE: ${COOKIE_SECURE:-false}
DB_POOL_SIZE: ${DB_POOL_SIZE:-10} DB_POOL_SIZE: ${DB_POOL_SIZE:-10}
+11 -2
View File
@@ -14,7 +14,6 @@ cp .env.docker-external.example .env.docker-external
```bash ```bash
DATABASE_URL=postgresql://tickettracker:PASSWORT@10.10.10.23:5432/tickettracker 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 TICKETTRACKER_IMAGE=git.d-razz.de/michael/tickettracker:latest
APP_PORT=3910 APP_PORT=3910
``` ```
@@ -37,7 +36,6 @@ cp .env.docker-postgres.example .env.docker-postgres
```bash ```bash
POSTGRES_PASSWORD=ein-sicheres-db-passwort POSTGRES_PASSWORD=ein-sicheres-db-passwort
ADMIN_PASSWORD=ein-sicheres-admin-passwort
TICKETTRACKER_IMAGE=git.d-razz.de/michael/tickettracker:latest TICKETTRACKER_IMAGE=git.d-razz.de/michael/tickettracker:latest
APP_PORT=3910 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`. 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 ## Image bauen und in Gitea pushen
Einmal am Docker/Gitea-Registry anmelden: Einmal am Docker/Gitea-Registry anmelden:
+258 -44
View File
@@ -1,37 +1,85 @@
# TicketTracker Benutzeranleitung # 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 ## 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. - 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. - Teamspace-Zeit: Die Zeit, die fuer ein Ticket an einem konkreten Tag wirklich im CRM beziehungsweise Teamspace eingetragen wurde.
- Monatsabschluss: Ein Ticket wird fuer einen Monat geschlossen, wenn alle Sessions dieses Tickets im Monat bewertet sind. - 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. 1. App im Browser oeffnen.
2. Mit Benutzername und Passwort anmelden. 2. Mit Benutzername und Passwort anmelden.
3. Nach der Anmeldung ist die Seitenleiste sichtbar. 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 ## Timer
Die Seite `Timer` ist fuer laufende Arbeit an Tickets gedacht. Die Seite `Timer` ist fuer laufende Arbeit an Tickets gedacht.
1. Ticketnummer im Format `Ticket#XXXXXX` eingeben. 1. Ticketnummer im Format `Ticket#XXXXXX` eingeben.
2. `Session beginnen` klicken. 2. `Timer starten` klicken.
3. Der Timer startet. 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. - Existiert das Ticket bereits, werden Organisation und Art vom Ticket uebernommen.
- Dann muss nur noch die Taetigkeit eingetragen werden. - 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 ## 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: Beim Nachtragen werden angegeben:
@@ -51,11 +99,16 @@ Beim Nachtragen werden angegeben:
- Organisation - Organisation
- Art: Support oder Consulting - Art: Support oder Consulting
- Datum - Datum
- Von- und Bis-Uhrzeit - Von-Uhrzeit
- Bis-Uhrzeit
- Taetigkeit - Taetigkeit
Das Taetigkeitsfeld ist mehrzeilig. Zeilenumbrueche bleiben in der Auswertung erhalten. 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 ## Ticketdaten Bearbeiten
In der Ticketdetailansicht koennen Ticketdaten nachtraeglich korrigiert werden: In der Ticketdetailansicht koennen Ticketdaten nachtraeglich korrigiert werden:
@@ -64,11 +117,17 @@ In der Ticketdetailansicht koennen Ticketdaten nachtraeglich korrigiert werden:
- Organisation - Organisation
- Art - 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
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. 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 - Monat
- Tag - 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: Die oberen Karten zeigen:
- Tickets: Anzahl der Tickets im Zeitraum - Tickets: Anzahl der Tickets im Zeitraum
- Sessions: Anzahl der Sessions 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 - 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 Monatsansicht pro Tag
- In der Tagesansicht pro Stunde - 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. Unter `Tickets im Zeitraum` werden alle Tickets angezeigt, die im gewaehlten Zeitraum Sessions haben.
@@ -103,9 +165,8 @@ Pro Ticket sind sichtbar:
- Organisation - Organisation
- Art - Art
- Anzahl Sessions - Anzahl Sessions
- Zeit - Abgerechnete Zeit
- Anzahl abgerechneter Sessions - Anzahl abgerechneter Sessions
- Anzahl nicht abrechenbarer Sessions
- Status - Status
Neben der Ticketnummer gibt es ein Copy-Icon. Ein Klick kopiert die Ticketnummer in die Zwischenablage. 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. - `offen`: Es gibt noch unbewertete Sessions.
- `bewertet`: Alle Sessions dieses Tickets im gewaehlten Zeitraum sind bewertet. - `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 ## Ticketdetailansicht
Ein Ticket wird aus der Auswertung heraus geoeffnet. 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: In der Ticketdetailansicht sieht man:
- Ticketnummer mit Copy-Icon - Ticketnummer mit Copy-Icon
- Abschlussstatus - Status des Monats
- Ticketdaten - Ticketdaten
- Session-Eintraege nach Tagen gruppiert - Session-Eintraege nach Tagen gruppiert
- Teamspace-Wert pro Ticket und Tag
- Moeglichkeit, direkt eine Session zu diesem Ticket nachzutragen - 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: Die Sessions sind pro Tag gruppiert. In jeder Tagesgruppe stehen:
- Tagesdatum - Tagesdatum
- `Tag gesamt`: Summe der erfassten Session-Zeit dieses Tages - `Tag abgerechnet`: Summe der als `Abgerechnet` bewerteten Session-Zeit dieses Tages
- `CRM`: Stunden, die fuer dieses Ticket an diesem Tag wirklich im CRM eingetragen wurden - `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`
- `1,5` - `1,5`
- `2.25` - `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 ## 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. 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. 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. 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. 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 ## Sessions Loeschen
Eine Session kann ueber das Papierkorb-Icon geloescht werden. 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. 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: Ein Monat kann abgeschlossen werden, wenn:
- Alle Sessions im Monat bewertet sind. - 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 ## Fixe Abrechnungen
@@ -208,12 +327,96 @@ Eine fixe Abrechnung enthaelt:
Wenn keine Ticketnummer hinterlegt ist, wird automatisch `Fix#ID` verwendet. 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. 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. 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. 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 ## Profil
Unter `Profil` kann jeder Benutzer seine eigenen Daten pflegen: Unter `Profil` kann jeder Benutzer seine eigenen Daten pflegen:
@@ -222,21 +425,27 @@ Unter `Profil` kann jeder Benutzer seine eigenen Daten pflegen:
- Voller Name - Voller Name
- Passwort - Passwort
Passwortaenderungen erfordern das aktuelle Passwort. Passwortaenderungen erfordern das aktuelle Passwort. Bleiben die Passwortfelder leer, werden nur Benutzername und voller Name gespeichert.
## Adminbereich ## Adminbereich
Administratoren koennen: Der feste Admin ist nur fuer die Systemverwaltung vorgesehen. Er kann:
- Benutzer anlegen - Benutzer anlegen
- Benutzer bearbeiten - Benutzer bearbeiten
- Passwoerter fuer Benutzer setzen - Passwoerter fuer Benutzer setzen
- Sessions aller Benutzer sehen
- Sessions anderen Benutzern zuweisen
- Sessions fuer Benutzer nachtragen
- Zammad-Organisationen synchronisieren - 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 ## Zammad Sync
@@ -251,21 +460,26 @@ Gespeichert werden:
- Name - Name
- Zeitstempel - 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 ## Typischer Monatsablauf
1. Im Alltag Sessions per Timer oder Nachtragen erfassen. 1. Im Alltag Sessions per Timer oder Nachtragen erfassen.
2. Am Monatsende `Auswertung` oeffnen. 2. Optional fixe Abrechnungen pflegen, damit wiederkehrende Sessions automatisch erzeugt werden.
3. Monat auswaehlen. 3. Am Monatsende `Auswertung` oeffnen.
4. Offene Sessions pruefen. 4. Monat auswaehlen.
5. Pro Ticket jede Session als `Abgerechnet` oder `Nicht abrechenbar` markieren. 5. Offene Sessions pruefen.
6. Pro Tag und Ticket die wirklich ins CRM eingetragene Zeit im Feld `CRM` pflegen. 6. Pro Ticket jede Session als `Abgerechnet` oder `Nicht abrechenbar` markieren.
7. Wenn keine Sessions im Monat mehr offen sind, Monat abschliessen. 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 ## Hinweise
- Ticketnummern koennen ueber das Copy-Icon schnell in die Zwischenablage kopiert werden. - Ticketnummern koennen ueber das Copy-Icon schnell in die Zwischenablage kopiert werden.
- Die App aktualisiert Auswertungen automatisch beim Wechsel von Monat oder Tag. - Die App aktualisiert Auswertungen automatisch beim Wechsel von Monat oder Tag.
- Eine zurueckgenommene Bewertung macht eine Session wieder offen und kann Abschluesse wieder oeffnen. - Eine zurueckgenommene Bewertung macht eine Session wieder offen und kann den Monatsabschluss wieder verhindern.
- CRM-Zeiten sind Tageswerte pro Ticket und Benutzer. Sie sind nicht an einzelne Sessions gebunden. - 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.
+415 -35
View File
@@ -5,6 +5,8 @@ import {
Command, Command,
ChartNoAxesCombined, ChartNoAxesCombined,
CircleHelp, CircleHelp,
FileText,
GripVertical,
LayoutGrid, LayoutGrid,
LogOut, LogOut,
Moon, Moon,
@@ -18,8 +20,9 @@ import {
Timer, Timer,
UserCog, UserCog,
UserCircle, UserCircle,
X,
} from "lucide-react"; } 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 { useShallow } from "zustand/react/shallow";
import { toast } from "sonner"; import { toast } from "sonner";
@@ -53,8 +56,8 @@ import {
import { usePreferencesStore } from "@/stores/preferences/preferences-provider"; import { usePreferencesStore } from "@/stores/preferences/preferences-provider";
import { cn } from "./lib/utils"; import { cn } from "./lib/utils";
import { ApiError, getCurrentUser, logout, lookupTicket } from "./api"; import { ApiError, getCurrentUser, getSetupStatus, logout, lookupTicket, searchTickets } from "./api";
import { formatTimer } from "./format"; import { formatDateTime, formatMinutes, formatTimer } from "./format";
import { activeElapsedMs, pauseEntry, readStoredTimers, resumeEntry, storageKeyForUser, ticketPattern, type TimerEntry } from "./timers"; import { activeElapsedMs, pauseEntry, readStoredTimers, resumeEntry, storageKeyForUser, ticketPattern, type TimerEntry } from "./timers";
import { AdminUsersPage } from "./views/AdminUsersPage"; import { AdminUsersPage } from "./views/AdminUsersPage";
import { AnalysisPage } from "./views/AnalysisPage"; import { AnalysisPage } from "./views/AnalysisPage";
@@ -64,10 +67,11 @@ import { MonthlyClosePage } from "./views/MonthlyClosePage";
import { ProfilePage } from "./views/ProfilePage"; import { ProfilePage } from "./views/ProfilePage";
import { RecurringBillingsPage } from "./views/RecurringBillingsPage"; import { RecurringBillingsPage } from "./views/RecurringBillingsPage";
import { StatisticsPage } from "./views/StatisticsPage"; import { StatisticsPage } from "./views/StatisticsPage";
import { SetupPage } from "./views/SetupPage";
import { TicketDetailPage } from "./views/TicketDetailPage"; import { TicketDetailPage } from "./views/TicketDetailPage";
import { TimerPage } from "./views/TimerPage"; import { TimerPage } from "./views/TimerPage";
import type { AuthUser, PeriodType } from "./types"; import type { AuthUser, PeriodType, TicketSearchResult } from "./types";
import type { Dispatch, SetStateAction } from "react"; import type { Dispatch, DragEvent, SetStateAction } from "react";
type NavMenuEntry = type NavMenuEntry =
| { | {
@@ -82,6 +86,46 @@ type NavMenuEntry =
key: string; 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) { function routeFromPath(pathname: string) {
const periodTicketMatch = pathname.match(/^\/analysis\/(month|day)\/([^/]+)\/tickets\/(\d+)$/); const periodTicketMatch = pathname.match(/^\/analysis\/(month|day)\/([^/]+)\/tickets\/(\d+)$/);
@@ -227,6 +271,118 @@ function QuickTimerStarter({ className, inputId, onStartTimer }: QuickTimerStart
); );
} }
type HeaderTicketSearchProps = {
className?: string;
onNavigate: (to: string) => void;
};
function HeaderTicketSearch({ className, onNavigate }: HeaderTicketSearchProps) {
const [query, setQuery] = useState("");
const [results, setResults] = useState<TicketSearchResult[]>([]);
const [open, setOpen] = useState(false);
const [loading, setLoading] = useState(false);
const trimmedQuery = query.trim();
useEffect(() => {
if (trimmedQuery.length < 2) {
setResults([]);
setLoading(false);
return;
}
let cancelled = false;
setLoading(true);
const timeout = window.setTimeout(async () => {
try {
const result = await searchTickets(trimmedQuery);
if (!cancelled) {
setResults(result.tickets);
}
} catch {
if (!cancelled) {
setResults([]);
}
} finally {
if (!cancelled) {
setLoading(false);
}
}
}, 220);
return () => {
cancelled = true;
window.clearTimeout(timeout);
};
}, [trimmedQuery]);
function openTicket(ticket: TicketSearchResult) {
onNavigate(`/analysis/month/${ticket.latest_period}/tickets/${ticket.ticket_id}`);
setQuery("");
setResults([]);
setOpen(false);
}
function submit(event: FormEvent) {
event.preventDefault();
if (results[0]) {
openTicket(results[0]);
}
}
return (
<form className={cn("relative min-w-0", className)} onSubmit={submit}>
<label className="sr-only" htmlFor="global-ticket-search">
Tickets suchen
</label>
<Search className="pointer-events-none absolute left-2.5 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
<Input
id="global-ticket-search"
className="h-8 bg-background pl-8"
placeholder="Ticket, Kunde, Tätigkeit"
value={query}
onFocus={() => setOpen(true)}
onBlur={() => window.setTimeout(() => setOpen(false), 120)}
onChange={(event) => {
setQuery(event.currentTarget.value);
setOpen(true);
}}
/>
{open && trimmedQuery.length >= 2 ? (
<div className="absolute left-0 top-[calc(100%+0.35rem)] z-50 w-[min(28rem,calc(100vw-2rem))] overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-lg">
<div className="max-h-80 overflow-y-auto py-1">
{loading ? <p className="px-3 py-2 text-sm text-muted-foreground">Sucht...</p> : null}
{!loading && results.length === 0 ? <p className="px-3 py-2 text-sm text-muted-foreground">Keine Tickets gefunden.</p> : null}
{!loading
? results.map((ticket) => (
<button
key={`${ticket.latest_period}:${ticket.ticket_id}`}
type="button"
className="flex w-full cursor-pointer flex-col gap-1 px-3 py-2 text-left hover:bg-accent hover:text-accent-foreground"
onMouseDown={(event) => {
event.preventDefault();
openTicket(ticket);
}}
>
<span className="flex min-w-0 items-center justify-between gap-3">
<span className="truncate text-sm font-medium">{ticket.ticket_number}</span>
<span className="shrink-0 text-xs text-muted-foreground">{formatMinutes(ticket.total_minutes)}</span>
</span>
<span className="truncate text-xs text-muted-foreground">
{ticket.organization_name || ticket.customer_name || "Keine Organisation"} · {ticket.session_count} Session(s) · zuletzt {formatDateTime(ticket.latest_started_at)}
</span>
</button>
))
: null}
</div>
</div>
) : null}
</form>
);
}
type SidebarTimersProps = { type SidebarTimersProps = {
timers: TimerEntry[]; timers: TimerEntry[];
selectedTimerId: string | null; selectedTimerId: string | null;
@@ -345,15 +501,27 @@ export function App() {
const { path, route, navHandler, navigate } = useAppRouter(); const { path, route, navHandler, navigate } = useAppRouter();
const [currentUser, setCurrentUser] = useState<AuthUser | null>(null); const [currentUser, setCurrentUser] = useState<AuthUser | null>(null);
const [authLoading, setAuthLoading] = useState(true); const [authLoading, setAuthLoading] = useState(true);
const [setupRequired, setSetupRequired] = useState(false);
const [timers, setTimers] = useState<TimerEntry[]>([]); const [timers, setTimers] = useState<TimerEntry[]>([]);
const [timersLoaded, setTimersLoaded] = useState(false); const [timersLoaded, setTimersLoaded] = useState(false);
const [timerOwnerId, setTimerOwnerId] = useState<string | null>(null); const [timerOwnerId, setTimerOwnerId] = useState<string | null>(null);
const [selectedTimerId, setSelectedTimerId] = useState<string | null>(null); const [selectedTimerId, setSelectedTimerId] = useState<string | null>(null);
const [tick, setTick] = useState(Date.now()); const [tick, setTick] = useState(Date.now());
const [openTickets, setOpenTickets] = useState<OpenTicketNavItem[]>([]);
const [draggedOpenTicketKey, setDraggedOpenTicketKey] = useState<string | null>(null);
useEffect(() => { useEffect(() => {
async function loadUser() { async function loadUser() {
try { try {
const setup = await getSetupStatus();
if (setup.required) {
setSetupRequired(true);
setCurrentUser(null);
return;
}
setSetupRequired(false);
const result = await getCurrentUser(); const result = await getCurrentUser();
setCurrentUser(result.user); setCurrentUser(result.user);
} catch (error) { } catch (error) {
@@ -375,16 +543,38 @@ export function App() {
setSelectedTimerId(null); setSelectedTimerId(null);
setTimerOwnerId(null); setTimerOwnerId(null);
setTimersLoaded(false); setTimersLoaded(false);
setOpenTickets([]);
return;
}
if (currentUser.role === "admin") {
setTimers([]);
setSelectedTimerId(null);
setTimerOwnerId(currentUser.id);
setTimersLoaded(true);
setOpenTickets([]);
return; return;
} }
setTimers(readStoredTimers(currentUser.id)); setTimers(readStoredTimers(currentUser.id));
setOpenTickets(readOpenTickets(currentUser.id));
setTimerOwnerId(currentUser.id); setTimerOwnerId(currentUser.id);
setTimersLoaded(true); setTimersLoaded(true);
}, [currentUser]); }, [currentUser]);
useEffect(() => { 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; return;
} }
@@ -401,7 +591,7 @@ export function App() {
}, [currentUser, timers, selectedTimerId, timersLoaded, timerOwnerId]); }, [currentUser, timers, selectedTimerId, timersLoaded, timerOwnerId]);
useEffect(() => { useEffect(() => {
if (!currentUser || !timersLoaded || timerOwnerId !== currentUser.id) { if (!currentUser || currentUser.role === "admin" || !timersLoaded || timerOwnerId !== currentUser.id) {
return; return;
} }
@@ -467,7 +657,26 @@ export function App() {
return () => window.clearInterval(interval); 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) { 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(); const ticketNumber = rawTicketNumber.trim();
if (!ticketPattern.test(ticketNumber)) { if (!ticketPattern.test(ticketNumber)) {
@@ -519,6 +728,83 @@ export function App() {
navigate("/timer"); 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) { if (authLoading) {
return ( return (
<main className="grid min-h-screen place-items-center bg-background p-4 text-sm text-muted-foreground"> <main className="grid min-h-screen place-items-center bg-background p-4 text-sm text-muted-foreground">
@@ -527,26 +813,51 @@ export function App() {
); );
} }
if (setupRequired) {
return (
<SetupPage
onAdminCreated={(user) => {
setSetupRequired(false);
setCurrentUser(user);
navigate("/admin/users");
}}
onRestoreComplete={() => {
setSetupRequired(false);
setCurrentUser(null);
navigate("/");
}}
/>
);
}
if (!currentUser) { if (!currentUser) {
return <LoginPage onLogin={setCurrentUser} />; return <LoginPage onLogin={setCurrentUser} />;
} }
const navItems: NavMenuEntry[] = [ const navItems: NavMenuEntry[] = [
{ type: "item", href: "/timer", label: "Timer", icon: Timer, active: path.startsWith("/timer") || path === "/" },
{ type: "item", href: "/analysis", label: "Auswertung", icon: BarChart3, active: path.startsWith("/analysis") },
{ type: "separator", key: "after-analysis" },
{ type: "item", href: "/monthly-close", label: "Monatsabschluss", icon: ClipboardCheck, active: path.startsWith("/monthly-close") },
{ type: "separator", key: "after-monthly-close" },
{ type: "item", href: "/statistics", label: "Statistiken", icon: ChartNoAxesCombined, active: path.startsWith("/statistics") },
{ type: "item", href: "/recurring", label: "Fixe Abrechnung", icon: Repeat, active: path.startsWith("/recurring") },
{ type: "separator", key: "before-profile" },
{ type: "item", href: "/profile", label: "Profil", icon: UserCog, active: path.startsWith("/profile") },
...(currentUser.role === "admin" ...(currentUser.role === "admin"
? [{ type: "item" as const, 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", key: "before-faq" }, { type: "separator" as const, key: "before-profile" },
{ type: "item", href: "/faq", label: "FAQ", icon: CircleHelp, active: path.startsWith("/faq") }, { 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 runningTimer = timers.find((timer) => timer.phase === "running") ?? null;
const runningElapsedSeconds = runningTimer ? Math.floor(activeElapsedMs(runningTimer, tick) / 1000) : 0; const runningElapsedSeconds = runningTimer ? Math.floor(activeElapsedMs(runningTimer, tick) / 1000) : 0;
@@ -564,7 +875,7 @@ export function App() {
<SidebarMenu> <SidebarMenu>
<SidebarMenuItem> <SidebarMenuItem>
<SidebarMenuButton asChild> <SidebarMenuButton asChild>
<a href="/timer" onClick={navHandler("/timer")}> <a href={isAdmin ? "/admin/users" : "/timer"} onClick={navHandler(isAdmin ? "/admin/users" : "/timer")}>
<Command /> <Command />
<span className="font-semibold text-base">TicketTracker</span> <span className="font-semibold text-base">TicketTracker</span>
</a> </a>
@@ -574,6 +885,8 @@ export function App() {
</SidebarHeader> </SidebarHeader>
<SidebarContent> <SidebarContent>
{!isAdmin ? (
<>
<SidebarGroup> <SidebarGroup>
<SidebarGroupContent className="flex flex-col gap-2"> <SidebarGroupContent className="flex flex-col gap-2">
<SidebarMenu> <SidebarMenu>
@@ -601,9 +914,11 @@ export function App() {
tick={tick} tick={tick}
onOpenTimer={() => navigate("/timer")} onOpenTimer={() => navigate("/timer")}
/> />
</>
) : null}
<SidebarGroup> <SidebarGroup>
<SidebarGroupLabel>Workflows</SidebarGroupLabel> <SidebarGroupLabel>{isAdmin ? "Verwaltung" : "Workflow"}</SidebarGroupLabel>
<SidebarGroupContent> <SidebarGroupContent>
<SidebarMenu> <SidebarMenu>
{navItems.map((item) => {navItems.map((item) =>
@@ -626,16 +941,68 @@ export function App() {
</SidebarGroupContent> </SidebarGroupContent>
</SidebarGroup> </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> </SidebarContent>
<SidebarFooter> <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"> <div className="mb-2 flex items-center gap-2 font-medium">
<CheckCircle2 className="size-4 text-muted-foreground" /> <CheckCircle2 className="size-4 text-muted-foreground" />
Abschluss Abschluss
</div> </div>
<p className="text-muted-foreground">Offene Bewertungen findest du im Monatsabschluss.</p> <p className="text-muted-foreground">Offene Bewertungen findest du im Monatsabschluss.</p>
</div> </div> : null}
<SidebarMenu> <SidebarMenu>
<SidebarMenuItem> <SidebarMenuItem>
<SidebarMenuButton tooltip="Worklog"> <SidebarMenuButton tooltip="Worklog">
@@ -661,10 +1028,14 @@ export function App() {
<div className="flex min-w-0 items-center gap-2"> <div className="flex min-w-0 items-center gap-2">
<SidebarTrigger className="-ml-1" /> <SidebarTrigger className="-ml-1" />
<Separator orientation="vertical" className="mx-1 data-[orientation=vertical]:h-4 data-[orientation=vertical]:self-center" /> <Separator orientation="vertical" className="mx-1 data-[orientation=vertical]:h-4 data-[orientation=vertical]:self-center" />
{!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"> <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" /> <Search className="size-4" />
<span>{currentUser.display_name}</span> <span>{currentUser.display_name}</span>
</div> </div>
)}
{runningTimer ? ( {runningTimer ? (
<button <button
type="button" type="button"
@@ -684,11 +1055,13 @@ export function App() {
<span className="truncate text-sm font-semibold">TicketTracker</span> <span className="truncate text-sm font-semibold">TicketTracker</span>
</div> </div>
</div> </div>
{!isAdmin ? (
<QuickTimerStarter <QuickTimerStarter
inputId="quick-ticket-number-desktop" inputId="quick-ticket-number-desktop"
className="mx-3 hidden max-w-md flex-1 md:flex" className="mx-3 hidden max-w-md flex-1 md:flex"
onStartTimer={startQuickTimer} onStartTimer={startQuickTimer}
/> />
) : null}
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<ThemeSwitcher /> <ThemeSwitcher />
<DropdownMenu> <DropdownMenu>
@@ -716,12 +1089,12 @@ export function App() {
</div> </div>
</div> </div>
</header> </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} /> <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"> <main className="min-h-0 min-w-0 flex-1 overflow-x-hidden p-3 sm:p-4 md:p-5">
<div className="mx-auto w-full max-w-screen-2xl"> <div className="mx-auto w-full max-w-screen-2xl">
{route.page === "timer" ? ( {!isAdmin && route.page === "timer" ? (
<TimerPage <TimerPage
timers={timers} timers={timers}
setTimers={setTimers} setTimers={setTimers}
@@ -730,14 +1103,14 @@ export function App() {
tick={tick} tick={tick}
/> />
) : null} ) : null}
{route.page === "analysis" ? <AnalysisPage onNavigate={navigate} /> : null} {!isAdmin && route.page === "analysis" ? <AnalysisPage onNavigate={navigate} /> : null}
{route.page === "monthly-close" ? <MonthlyClosePage onNavigate={navigate} /> : null} {!isAdmin && route.page === "monthly-close" ? <MonthlyClosePage onNavigate={navigate} /> : null}
{route.page === "statistics" ? <StatisticsPage onNavigate={navigate} /> : null} {!isAdmin && route.page === "statistics" ? <StatisticsPage onNavigate={navigate} /> : null}
{route.page === "recurring" ? <RecurringBillingsPage /> : null} {!isAdmin && route.page === "recurring" ? <RecurringBillingsPage /> : null}
{route.page === "faq" ? <FaqPage /> : null} {route.page === "faq" ? <FaqPage /> : null}
{route.page === "profile" ? <ProfilePage currentUser={currentUser} onUserUpdated={setCurrentUser} /> : 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" && isAdmin ? <AdminUsersPage currentUser={currentUser} /> : null}
{route.page === "admin-users" && currentUser.role !== "admin" ? ( {route.page === "admin-users" && !isAdmin ? (
<TimerPage <TimerPage
timers={timers} timers={timers}
setTimers={setTimers} setTimers={setTimers}
@@ -746,9 +1119,16 @@ export function App() {
tick={tick} tick={tick}
/> />
) : null} ) : null}
{route.page === "ticket" ? ( {!isAdmin && route.page === "ticket" ? (
<TicketDetailPage periodType={route.periodType} period={route.period} ticketId={route.ticketId} onNavigate={navigate} /> <TicketDetailPage
periodType={route.periodType}
period={route.period}
ticketId={route.ticketId}
onNavigate={navigate}
onOpenTicketChange={updateOpenTicket}
/>
) : null} ) : null}
{isAdmin && route.page !== "admin-users" && route.page !== "profile" && route.page !== "faq" ? <AdminUsersPage currentUser={currentUser} /> : null}
</div> </div>
</main> </main>
</SidebarInset> </SidebarInset>
+86 -28
View File
@@ -1,5 +1,4 @@
import type { import type {
AdminSession,
AdminUser, AdminUser,
AuthUser, AuthUser,
BillingStatus, BillingStatus,
@@ -10,7 +9,7 @@ import type {
StatisticsOverview, StatisticsOverview,
TicketMeta, TicketMeta,
TicketPeriod, TicketPeriod,
UserRole, TicketSearchResult,
WorkType WorkType
} from "./types"; } from "./types";
@@ -34,6 +33,12 @@ type DeleteSessionResult = {
}; };
}; };
export type RestoreValidationResult = {
valid: boolean;
checkedTables: number;
issues: string[];
};
export class ApiError extends Error { export class ApiError extends Error {
constructor( constructor(
message: string, message: string,
@@ -91,6 +96,31 @@ export function updateCurrentUser(payload: {
}); });
} }
export function getSetupStatus() {
return request<{ required: boolean }>("/api/setup/status");
}
export function createSetupAdmin(payload: { username: string; displayName: string; password: string }) {
return request<{ user: AuthUser }>("/api/setup/admin", {
method: "POST",
body: JSON.stringify(payload)
});
}
export function restoreSetupBackup(sql: string) {
return request<{ ok: boolean }>("/api/setup/restore", {
method: "POST",
body: JSON.stringify({ sql })
});
}
export function validateSetupBackup(sql: string) {
return request<RestoreValidationResult>("/api/setup/restore/validate", {
method: "POST",
body: JSON.stringify({ sql })
});
}
export function getAdminUsers() { export function getAdminUsers() {
return request<{ users: AdminUser[] }>("/api/admin/users"); return request<{ users: AdminUser[] }>("/api/admin/users");
} }
@@ -99,7 +129,6 @@ export function createAdminUser(payload: {
username: string; username: string;
displayName: string; displayName: string;
password: string; password: string;
role: UserRole;
active: boolean; active: boolean;
}) { }) {
return request<{ user: AdminUser }>("/api/admin/users", { return request<{ user: AdminUser }>("/api/admin/users", {
@@ -114,7 +143,6 @@ export function updateAdminUser(
username: string; username: string;
displayName: string; displayName: string;
password?: string; password?: string;
role: UserRole;
active: boolean; active: boolean;
} }
) { ) {
@@ -124,8 +152,56 @@ export function updateAdminUser(
}); });
} }
export function getAdminSessions() { export async function downloadDatabaseExport() {
return request<{ sessions: AdminSession[] }>("/api/admin/sessions"); 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 = "") { export function getOrganizations(search = "") {
@@ -214,28 +290,6 @@ export function deleteRecurringBilling(billingId: string) {
}); });
} }
export function createAdminSession(payload: {
userId: string;
ticketNumber: string;
organizationId: string;
activity: string;
workType: WorkType;
startedAt: string;
endedAt: string;
}) {
return request<{ session: AdminSession }>("/api/admin/sessions", {
method: "POST",
body: JSON.stringify(payload)
});
}
export function reassignAdminSession(sessionId: string, userId: string) {
return request<{ session: AdminSession }>(`/api/admin/sessions/${sessionId}/owner`, {
method: "PATCH",
body: JSON.stringify({ userId })
});
}
export function createSession(payload: CreateSessionPayload) { export function createSession(payload: CreateSessionPayload) {
return request("/api/sessions", { return request("/api/sessions", {
method: "POST", method: "POST",
@@ -247,6 +301,10 @@ export function lookupTicket(ticketNumber: string) {
return request<{ ticket: TicketMeta | null }>(`/api/tickets/lookup?ticketNumber=${encodeURIComponent(ticketNumber)}`); 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( export function updateTicket(
ticketId: string, ticketId: string,
payload: { payload: {
+140 -70
View File
@@ -4,6 +4,8 @@ export type HelpSection = {
description: string; description: string;
quickItems: string[]; quickItems: string[];
details: string[]; details: string[];
workflow?: string[];
diagram?: string[];
}; };
export const helpSections: HelpSection[] = [ export const helpSections: HelpSection[] = [
@@ -18,13 +20,87 @@ export const helpSections: HelpSection[] = [
], ],
details: [ details: [
"TicketTracker ersetzt nicht das Ticketsystem. Ticketnummern dienen als Bezug zur Arbeit, die eigentliche Dokumentation bleibt im vorhandenen Ticketsystem.", "TicketTracker ersetzt nicht das Ticketsystem. Ticketnummern dienen als Bezug zur Arbeit, die eigentliche Dokumentation bleibt im vorhandenen Ticketsystem.",
"TicketTracker ersetzt auch nicht das CRM. Die CRM- beziehungsweise Teamspace-Zeit wird als Kontrollwert gepflegt, damit getrackte Zeiten und eingetragene Abrechnung gegenübergestellt werden können.", "TicketTracker ersetzt auch nicht das CRM. Die CRM- beziehungsweise Teamspace-Zeit wird als Kontrollwert gepflegt, damit abgerechnete Zeiten und eingetragene Abrechnung gegenübergestellt werden können.",
"Eine Session ist immer einem Benutzer zugeordnet. In den normalen Auswertungen sieht jeder Benutzer nur seine eigenen Sessions.", "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.", "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.", "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." "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", id: "timer",
title: "Timer", title: "Timer",
@@ -51,7 +127,7 @@ export const helpSections: HelpSection[] = [
description: "Nachtragen ist für vergessene Zeiten gedacht, wenn kein Timer gelaufen ist.", description: "Nachtragen ist für vergessene Zeiten gedacht, wenn kein Timer gelaufen ist.",
quickItems: [ quickItems: [
"Datum, Von, Bis und Tätigkeit eintragen.", "Datum, Von, Bis und Tätigkeit eintragen.",
"Benutzer tragen eigene Sessions nach; Admins können Benutzer auswählen.", "Benutzer tragen eigene Sessions nach.",
"Tätigkeiten sind mehrzeilig." "Tätigkeiten sind mehrzeilig."
], ],
details: [ details: [
@@ -60,7 +136,6 @@ export const helpSections: HelpSection[] = [
"Die Organisation muss aus der synchronisierten Organisationsliste gewählt werden. Freitext ist absichtlich gesperrt.", "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.", "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.", "Nachgetragene Sessions starten immer offen. Sie müssen später als Abgerechnet oder Nicht abrechenbar bewertet werden.",
"Admins können im Adminbereich zusätzlich bestimmen, welchem Benutzer die nachgetragene Session gehört.",
"In der Ticketdetailansicht kann eine Session direkt zum geöffneten Ticket nachgetragen werden. Ticketnummer und Stammdaten sind dort schon vorbelegt beziehungsweise aus dem Ticket abgeleitet." "In der Ticketdetailansicht kann eine Session direkt zum geöffneten Ticket nachgetragen werden. Ticketnummer und Stammdaten sind dort schon vorbelegt beziehungsweise aus dem Ticket abgeleitet."
] ]
}, },
@@ -106,14 +181,17 @@ export const helpSections: HelpSection[] = [
quickItems: [ quickItems: [
"Monat oder Tag wählen; die Ansicht aktualisiert automatisch.", "Monat oder Tag wählen; die Ansicht aktualisiert automatisch.",
"Tickets können gefiltert, gruppiert und sortiert werden.", "Tickets können gefiltert, gruppiert und sortiert werden.",
"Die Einstellungen bleiben nach Refresh und Browserneustart erhalten." "Die Einstellungen bleiben nach Refresh und Browserneustart erhalten.",
"Monate können als CSV exportiert werden."
], ],
details: [ details: [
"Die Monatsansicht zeigt alle Tickets, die im gewählten Monat mindestens eine Session haben.", "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 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.", "Die Pfeile neben der Zeitraum-Eingabe springen jeweils einen Monat oder einen Tag vor beziehungsweise zurück.",
"Der Gesamtaufwand-Graph zeigt den getrackten Aufwand im Zeitraum. In der Monatsansicht pro Tag, in der Tagesansicht pro Stunde.", "Über Monat exportieren lädst du eine CSV-Datei deiner eigenen Sessions dieses Monats herunter.",
"Die Ticketliste kann nach Typ, Organisation oder Status gruppiert werden. Gruppenüberschriften zeigen Anzahl, Zeit und offene Bewertungen.", "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.", "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." "Das Copy-Icon neben der Ticketnummer kopiert die Nummer direkt in die Zwischenablage."
] ]
@@ -129,11 +207,15 @@ export const helpSections: HelpSection[] = [
], ],
details: [ details: [
"Oben steht die Ticketnummer mit Copy-Icon, damit sie schnell in andere Systeme übernommen werden kann.", "Oben steht die Ticketnummer mit Copy-Icon, damit sie schnell in andere Systeme übernommen werden kann.",
"Die Session-Einträge sind nach Kalendertagen gruppiert. Pro Tagesgruppe steht die Summe der getrackten Zeit.", "Die Session-Einträge sind nach Kalendertagen gruppiert. Pro Tagesgruppe steht die Summe der abgerechneten Zeit.",
"Nicht abrechenbare und offene Zeiten werden neben der abgerechneten Tageszeit separat angezeigt.",
"Das Teamspace-Feld wird als Stundenwert gepflegt, zum Beispiel 1, 1,5 oder 2.25.", "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.", "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.", "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.", "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." "Sessions können bewertet, bearbeitet oder gelöscht werden. Gelöschte letzte Sessions können dazu führen, dass ein leeres Ticket entfernt wird."
] ]
}, },
@@ -217,38 +299,39 @@ export const helpSections: HelpSection[] = [
{ {
id: "statistiken", id: "statistiken",
title: "Statistiken", title: "Statistiken",
description: "Statistiken vergleichen getrackte Arbeit, bewertete Sessions und Teamspace-Werte.", description: "Statistiken vergleichen abgerechnete Session-Zeit, offene Bewertungen und Teamspace-Werte.",
quickItems: [ quickItems: [
"Sessions und Teamspace haben getrennte Farben.", "Abgerechnet und Teamspace haben getrennte Farben.",
"Positive oder gleiche Differenz ist grün.", "Positive oder gleiche Differenz ist grün.",
"Negative Differenz ist rot." "Negative Differenz ist rot."
], ],
details: [ details: [
"Die Statistikseite betrachtet immer einen Monat.", "Die Statistikseite betrachtet immer einen Monat.",
"Getrackt zeigt die Summe aller Session-Zeiten und die Anzahl der Sessions und Tickets.", "Abgerechnet zeigt nur Sessions mit der Bewertung Abgerechnet. Offene Zeiten sind dort nicht enthalten.",
"Teamspace zeigt, wie viel Zeit für dieselben Ticket-Tage im CRM eingetragen wurde.", "Alle Statistik- und Vergleichswerte nutzen nur abgerechnete Zeiten.",
"Eine positive Differenz bedeutet: In Teamspace steht mehr Zeit als in den Sessions getrackt wurde.", "Teamspace zeigt, wie viel Zeit für dieselben abrechenbaren Ticket-Tage im CRM eingetragen wurde.",
"Eine negative Differenz bedeutet: In Teamspace steht weniger Zeit als getrackt wurde.", "Eine positive Differenz bedeutet: In Teamspace steht mehr Zeit als abgerechnet wurde.",
"Bewertet umfasst sowohl Abgerechnet als auch Nicht abrechenbar. Offen sind nur Sessions ohne Bewertung.", "Eine negative Differenz bedeutet: In Teamspace steht weniger Zeit als abgerechnet wurde.",
"Der Monatsverlauf zeigt getrackte Session-Zeit als Balken und Teamspace als Linie.", "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." "Organisationen, Typen und Ticket-Hotspots helfen dabei, Auffälligkeiten im Monat schnell zu finden."
] ]
}, },
{ {
id: "statistik-getrackt", id: "statistik-getrackt",
title: "Statistik: Getrackt", title: "Statistik: Abgerechnet",
description: "Diese Kennzahl zeigt die gesamte erfasste Session-Zeit im gewählten Monat.", description: "Diese Kennzahl zeigt die abrechenbare Session-Zeit im gewählten Monat.",
quickItems: [ quickItems: [
"Summe aller getrackten und nachgetragenen Sessions.", "Summe aller Sessions mit Bewertung Abgerechnet.",
"Zeigt zusätzlich Session- und Ticketanzahl.", "Zeigt zusätzlich die abgerechnete Sessionanzahl.",
"Basiswert für den Vergleich mit Teamspace." "Basiswert für den Vergleich mit Teamspace."
], ],
details: [ details: [
"Getrackt umfasst alle Sessions des angemeldeten Benutzers im gewählten Monat.", "Abgerechnet umfasst nur Sessions des angemeldeten Benutzers, die aktiv als Abgerechnet markiert wurden.",
"Dabei ist egal, ob die Session bereits bewertet wurde oder noch offen ist.", "Offene Sessions werden aus diesem Wert herausgehalten.",
"Auch automatisch erzeugte Sessions aus fixen Abrechnungen zählen hier mit.", "Auch automatisch erzeugte Sessions aus fixen Abrechnungen zählen hier mit, sobald sie als Abgerechnet markiert wurden.",
"Die Ticketanzahl zeigt, auf wie viele unterschiedliche Tickets sich die Sessions verteilen.", "Es werden keine offenen Zeiten in diesen Wert eingerechnet.",
"Dieser Wert ist die Ausgangsbasis für Teamspace-Differenzen: Teamspace minus getrackte Session-Zeit ergibt die Differenz." "Dieser Wert ist die Ausgangsbasis für Teamspace-Differenzen: Teamspace minus abgerechnete Session-Zeit ergibt die Differenz."
] ]
}, },
{ {
@@ -263,40 +346,24 @@ export const helpSections: HelpSection[] = [
details: [ details: [
"Teamspace-Werte werden pro Ticket und Tag gepflegt, nicht pro einzelner Session.", "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.", "Die Statistik summiert alle Teamspace-Werte des Monats für den angemeldeten Benutzer.",
"Eine positive Differenz bedeutet, dass in Teamspace mehr Zeit steht als in den Sessions getrackt wurde.", "Eine positive Differenz bedeutet, dass in Teamspace mehr Zeit steht als abgerechnet wurde.",
"Eine negative Differenz bedeutet, dass in Teamspace weniger Zeit steht als getrackt 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." "Fehlende Teamspace-Werte können im Monatsabschluss geprüft und bei Bedarf zur Kenntnis genommen werden."
] ]
}, },
{
id: "statistik-bewertet",
title: "Statistik: Bewertet",
description: "Diese Kennzahl zeigt, wie viel der erfassten Arbeit bereits fachlich eingeordnet wurde.",
quickItems: [
"Abgerechnet und Nicht abrechenbar zählen beide als bewertet.",
"Der Prozentwert bezieht sich auf die Sessionanzahl.",
"Bewertete Sessions blockieren den Monatsabschluss nicht."
],
details: [
"Bewertet ist die Summe aus Abgerechnet und Nicht abrechenbar.",
"Nicht abrechenbare Sessions fallen also nicht aus der Qualität heraus, sondern gelten als bewusst entschieden.",
"Die Zeit zeigt die Summe der bewerteten Session-Minuten.",
"Die Prozentwerte werden zusammen mit den offenen Sessions gerundet, sodass bewertet und offen zusammen 100 Prozent ergeben.",
"Wenn eine Bewertung zurückgenommen wird, wandert die Session wieder in Offen."
]
},
{ {
id: "statistik-offen", id: "statistik-offen",
title: "Statistik: Offen", title: "Statistik: Offen",
description: "Diese Kennzahl zeigt Sessions, die noch keine Bewertung haben.", description: "Diese Kennzahl zeigt Sessions, die noch keine Bewertung haben.",
quickItems: [ quickItems: [
"Offen bedeutet: weder Abgerechnet noch Nicht abrechenbar.", "Offen bedeutet: noch keine Bewertung.",
"Offene Sessions blockieren den Monatsabschluss.", "Offene Sessions blockieren den Monatsabschluss.",
"Die Klärung erfolgt in der Ticketdetailansicht." "Die Klärung erfolgt in der Ticketdetailansicht."
], ],
details: [ details: [
"Offen umfasst alle Sessions des Monats ohne Bewertung.", "Offen umfasst alle Sessions des Monats ohne Bewertung.",
"Diese Sessions müssen vor dem Monatsabschluss entweder als Abgerechnet oder Nicht abrechenbar markiert werden.", "Diese Sessions müssen vor dem Monatsabschluss bewertet werden.",
"Die offene Zeit zeigt, wie viele Minuten noch bewertet werden müssen.", "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.", "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." "Wird eine Bewertung zurückgenommen, steigt die offene Anzahl wieder."
@@ -305,19 +372,19 @@ export const helpSections: HelpSection[] = [
{ {
id: "statistik-verlauf", id: "statistik-verlauf",
title: "Statistik: Monatsverlauf und Qualität", title: "Statistik: Monatsverlauf und Qualität",
description: "Diese Card verbindet Tagesverlauf, Teamspace-Linie, Bewertungsstand und Qualitätswerte.", description: "Diese Card verbindet Tagesverlauf, Teamspace-Linie und reine Statuswerte.",
quickItems: [ quickItems: [
"Balken zeigen getrackte Sessions.", "Balken zeigen abgerechnete Sessions.",
"Die Linie zeigt Teamspace-Werte.", "Die Linie zeigt Teamspace-Werte.",
"Der rechte Bereich zeigt Bewertung und Teamspace-Abgleich." "Der rechte Bereich zeigt Bewertung und Teamspace-Abgleich."
], ],
details: [ details: [
"Der Monatsverlauf zeigt jeden Tag des Monats. Tage ohne Sessions bleiben leer, damit die Monatsstruktur sichtbar bleibt.", "Der Monatsverlauf zeigt jeden Tag des Monats. Tage ohne Sessions bleiben leer, damit die Monatsstruktur sichtbar bleibt.",
"Die blauen Balken stehen für getrackte Session-Zeit.", "Die blauen Balken stehen für abgerechnete Session-Zeit.",
"Die grüne Linie steht für Teamspace-Zeit, sofern für den Tag Werte gepflegt wurden.", "Die fuchsiafarbene Linie steht für Teamspace-Zeit, sofern für den Tag Werte gepflegt wurden.",
"Der Bewertungsstand vergleicht bewertete und offene Sessions.", "Der Bewertungsstand weist Abgerechnet und Offen getrennt aus.",
"Der Teamspace-Abgleich zeigt die Gesamtdifferenz zwischen CRM-Werten und getrackter Session-Zeit.", "Der Teamspace-Abgleich zeigt die Gesamtdifferenz zwischen CRM-Werten und abgerechneter Session-Zeit.",
"Manuell und Fix trennen selbst erfasste Sessions von automatisch erzeugten Sessions aus fixen Abrechnungen." "Manuell und Fix trennen selbst erfasste abrechenbare Sessions von automatisch erzeugten abrechenbaren Sessions aus fixen Abrechnungen."
] ]
}, },
{ {
@@ -332,9 +399,9 @@ export const helpSections: HelpSection[] = [
details: [ details: [
"Die Organisationsstatistik fasst alle Tickets und Sessions einer Organisation im gewählten Monat zusammen.", "Die Organisationsstatistik fasst alle Tickets und Sessions einer Organisation im gewählten Monat zusammen.",
"Sessions zeigt die Anzahl der einzelnen Arbeitseinträge.", "Sessions zeigt die Anzahl der einzelnen Arbeitseinträge.",
"Zeiten vergleicht getrackte Session-Zeit mit Teamspace-Zeit.", "Zeiten vergleicht abgerechnete Session-Zeit mit Teamspace-Zeit.",
"Die Differenz wird als Teamspace minus Sessions berechnet.", "Die Differenz wird als Teamspace minus abgerechnete Zeit berechnet.",
"Der Anteil am Monat zeigt, wie groß der getrackte Aufwand dieser Organisation im Verhältnis zur größten Organisation im Monat ist.", "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." "Bei vielen Organisationen wird die Liste paginiert, damit die Seite kompakt bleibt."
] ]
}, },
@@ -350,9 +417,9 @@ export const helpSections: HelpSection[] = [
details: [ details: [
"Die Typenstatistik gruppiert Sessions nach Art.", "Die Typenstatistik gruppiert Sessions nach Art.",
"Support und Consulting werden getrennt summiert.", "Support und Consulting werden getrennt summiert.",
"Die Zeitwerte zeigen getrackte Sessions und Teamspace für den jeweiligen Typ.", "Die Zeitwerte zeigen abgerechnete Sessions und Teamspace für den jeweiligen Typ.",
"Die Differenz folgt derselben Logik wie überall: Teamspace minus getrackte Sessions.", "Die Differenz folgt derselben Logik wie überall: Teamspace minus abgerechnete Session-Zeit.",
"Die Zeile Abr., Nicht und Offen zeigt, wie viele Sessions dieses Typs bereits bewertet oder noch offen sind." "Die Zeile Abr. und Offen zeigt, wie viele Sessions dieses Typs bereits abgerechnet oder noch offen sind."
] ]
}, },
{ {
@@ -366,8 +433,8 @@ export const helpSections: HelpSection[] = [
], ],
details: [ details: [
"Ticket-Hotspots helfen, die größten Aufwandstreiber im Monat schnell zu erkennen.", "Ticket-Hotspots helfen, die größten Aufwandstreiber im Monat schnell zu erkennen.",
"Die Liste zeigt die wichtigsten Tickets nach getrackter Zeit.", "Die Liste zeigt die wichtigsten Tickets nach abgerechneter Zeit.",
"Pro Ticket siehst du Organisation, Sessionanzahl, aktive Tage, getrackte Zeit, Teamspace-Zeit und Differenz.", "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.", "Offene Tickets werden mit einem Warnstatus angezeigt, damit Bewertungen nachgezogen werden können.",
"Über Öffnen gelangst du direkt in die Ticketdetailansicht des Monats." "Über Öffnen gelangst du direkt in die Ticketdetailansicht des Monats."
] ]
@@ -392,20 +459,23 @@ export const helpSections: HelpSection[] = [
{ {
id: "admin", id: "admin",
title: "Adminbereich", title: "Adminbereich",
description: "Der Adminbereich verwaltet Benutzer, Session-Zuordnung, manuelle Admin-Nachträge und Zammad-Sync.", description: "Der feste Adminbereich verwaltet Benutzer, Zammad-Sync und restore-fähige Datenbankexporte.",
quickItems: [ quickItems: [
"Admins legen Benutzer an und bearbeiten Rollen.", "Neue Benutzer erhalten automatisch die Rolle User.",
"Sessions können anderen Benutzern zugewiesen werden.", "Der Admin erfasst keine Sessions und sieht keine Auswertungen.",
"Zammad-Organisationen werden über Data-Sync aktualisiert." "SQL-Backups sind für einen kompletten Restore gedacht."
], ],
details: [ details: [
"Neue Benutzer bekommen Benutzername, vollen Namen, Passwort, Rolle und Aktivstatus.", "Neue Benutzer bekommen Benutzername, vollen Namen, Passwort und Aktivstatus. Eine Rollenauswahl gibt es nicht.",
"Admins können bestehende Benutzer bearbeiten, Passwörter setzen und Benutzer deaktivieren.", "Admins können bestehende Benutzer bearbeiten, Passwörter setzen und Benutzer deaktivieren.",
"Der eigene Admin-Zugang kann nicht versehentlich deaktiviert oder zur normalen User-Rolle herabgestuft werden.", "Der feste Admin-Zugang kann nicht deaktiviert oder zur normalen User-Rolle herabgestuft werden.",
"In der Session-Zuordnung sehen Admins die letzten Sessions inklusive Besitzer und können einzelne Sessions umverteilen.", "Der Data-Sync speichert Zammad-Zugangsdaten und synchronisiert Organisationen in die lokale Datenbank.",
"Beim Umverteilen werden die betroffenen Monatsabschlüsse für alten und neuen Besitzer wieder geöffnet, damit Bewertungen und Auswertungen konsistent bleiben.", "Der Datenbankexport erzeugt eine SQL-Datei mit Benutzern, Einstellungen, Organisationen, Tickets, Sessions, Teamspace-Werten, fixen Abrechnungen und Abschlüssen.",
"Admins können Sessions für andere Benutzer nachtragen, wenn Zeiten vergessen wurden.", "Der Restore im Adminbereich prüft zuerst, ob die Dump-Struktur zur aktuellen Datenbank passt.",
"Der Data-Sync speichert Zammad-Zugangsdaten und synchronisiert Organisationen in die lokale Datenbank." "Der Restore-Button wird erst nach erfolgreicher Prüfung aktiv.",
"Beim Einspielen leert TicketTracker zuerst alle Tabellen und spielt danach den Backup-Stand ein.",
"Nach einem Restore ist eine neue Anmeldung mit den Zugangsdaten aus dem Backup notwendig.",
"Für einen Restore lässt du auf dem Zielsystem zuerst die TicketTracker-Migrationen laufen und spielst danach die SQL-Datei mit psql ein."
] ]
} }
]; ];
+17 -23
View File
@@ -16,29 +16,6 @@ export type AdminUser = AuthUser & {
updated_at: string; 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 = { export type Organization = {
id: string; id: string;
zammad_id: string; zammad_id: string;
@@ -84,6 +61,19 @@ export type TicketMeta = {
work_type: WorkType | null; 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 = { export type TicketSummary = {
id: string; id: string;
ticket_number: string; ticket_number: string;
@@ -93,6 +83,8 @@ export type TicketSummary = {
work_type: WorkType | null; work_type: WorkType | null;
session_count: number; session_count: number;
total_minutes: number; total_minutes: number;
non_billable_minutes: number;
open_minutes: number;
open_count: number; open_count: number;
ticket_open_count: number; ticket_open_count: number;
billed_count: number; billed_count: number;
@@ -134,6 +126,8 @@ export type PeriodOverview = {
sessions: number; sessions: number;
minutes: number; minutes: number;
crmBilledMinutes: number; crmBilledMinutes: number;
nonBillableMinutes: number;
openMinutes: number;
openSessions: number; openSessions: number;
}; };
tickets: TicketSummary[]; tickets: TicketSummary[];
+199 -404
View File
@@ -1,115 +1,84 @@
import { FormEvent, useEffect, useState } from "react"; import { ChangeEvent, FormEvent, useEffect, useState } from "react";
import { CalendarPlus, DatabaseZap, Save, Shield, Shuffle, UserPlus } from "lucide-react"; import { CheckCircle2, DatabaseBackup, DatabaseZap, Save, Shield, Upload, UserPlus } from "lucide-react";
import { toast } from "sonner"; import { toast } from "sonner";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Switch } from "@/components/ui/switch"; import { Switch } from "@/components/ui/switch";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { Textarea } from "@/components/ui/textarea";
import { HelpLink } from "@/components/HelpLink"; import { HelpLink } from "@/components/HelpLink";
import { OrganizationSelect } from "@/components/OrganizationSelect";
import { import {
createAdminSession,
createAdminUser, createAdminUser,
getAdminSessions, downloadDatabaseExport,
getAdminUsers, getAdminUsers,
getZammadSettings, getZammadSettings,
reassignAdminSession, restoreDatabaseBackup,
saveZammadSettings, saveZammadSettings,
syncZammadOrganizations, syncZammadOrganizations,
updateAdminUser updateAdminUser,
validateDatabaseBackup,
type RestoreValidationResult
} from "../api"; } from "../api";
import { currentDay, formatDateTime, formatMinutes } from "../format"; import type { AdminUser, AuthUser } from "../types";
import type { AdminSession, AdminUser, AuthUser, UserRole, WorkType } from "../types";
type UserFormState = { type UserFormState = {
username: string; username: string;
displayName: string; displayName: string;
password: string; password: string;
role: UserRole;
active: boolean; 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 = { const emptyForm: UserFormState = {
username: "", username: "",
displayName: "", displayName: "",
password: "", password: "",
role: "user",
active: true 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 { function formFromUser(user: AdminUser): UserFormState {
return { return {
username: user.username, username: user.username,
displayName: user.display_name, displayName: user.display_name,
password: "", password: "",
role: user.role,
active: user.active active: user.active
}; };
} }
export function AdminUsersPage({ currentUser }: { currentUser: AuthUser }) { export function AdminUsersPage({ currentUser }: { currentUser: AuthUser }) {
const [users, setUsers] = useState<AdminUser[]>([]); const [users, setUsers] = useState<AdminUser[]>([]);
const [sessions, setSessions] = useState<AdminSession[]>([]);
const [forms, setForms] = useState<Record<string, UserFormState>>({}); const [forms, setForms] = useState<Record<string, UserFormState>>({});
const [sessionOwners, setSessionOwners] = useState<Record<string, string>>({});
const [newUser, setNewUser] = useState<UserFormState>(emptyForm); const [newUser, setNewUser] = useState<UserFormState>(emptyForm);
const [manualSession, setManualSession] = useState<ManualSessionFormState>(() => emptyManualSession());
const [zammadBaseUrl, setZammadBaseUrl] = useState(""); const [zammadBaseUrl, setZammadBaseUrl] = useState("");
const [zammadApiKey, setZammadApiKey] = useState(""); const [zammadApiKey, setZammadApiKey] = useState("");
const [hasZammadApiKey, setHasZammadApiKey] = useState(false); const [hasZammadApiKey, setHasZammadApiKey] = useState(false);
const [syncingOrganizations, setSyncingOrganizations] = useState(false); const [syncingOrganizations, setSyncingOrganizations] = useState(false);
const [savingZammadSettings, setSavingZammadSettings] = 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 [loading, setLoading] = useState(false);
const [savingId, setSavingId] = useState<string | null>(null); const [savingId, setSavingId] = useState<string | null>(null);
const [movingId, setMovingId] = useState<string | null>(null);
const [creating, setCreating] = useState(false); const [creating, setCreating] = useState(false);
const [creatingSession, setCreatingSession] = useState(false);
async function load(options: { preserveEdits?: boolean } = {}) { async function load(options: { preserveEdits?: boolean } = {}) {
setLoading(true); setLoading(true);
try { try {
const [usersResult, sessionsResult, zammadSettingsResult] = await Promise.all([getAdminUsers(), getAdminSessions(), getZammadSettings()]); const [usersResult, zammadSettingsResult] = await Promise.all([getAdminUsers(), getZammadSettings()]);
setUsers(usersResult.users); setUsers(usersResult.users);
setSessions(sessionsResult.sessions);
if (!options.preserveEdits) { if (!options.preserveEdits) {
setZammadBaseUrl(zammadSettingsResult.settings.baseUrl); setZammadBaseUrl(zammadSettingsResult.settings.baseUrl);
setHasZammadApiKey(zammadSettingsResult.settings.hasApiKey); setHasZammadApiKey(zammadSettingsResult.settings.hasApiKey);
} }
setForms((current) => setForms((current) =>
Object.fromEntries(usersResult.users.map((user) => [user.id, options.preserveEdits ? current[user.id] ?? formFromUser(user) : formFromUser(user)])) 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) { } catch (error) {
toast.error("Adminbereich konnte nicht geladen werden", { toast.error("Adminbereich konnte nicht geladen werden", {
description: error instanceof Error ? error.message : "Unbekannter Fehler" description: error instanceof Error ? error.message : "Unbekannter Fehler"
@@ -157,7 +126,9 @@ export function AdminUsersPage({ currentUser }: { currentUser: AuthUser }) {
try { try {
await createAdminUser(newUser); await createAdminUser(newUser);
toast.success("Benutzer angelegt"); toast.success("Benutzer angelegt", {
description: "Neue Accounts erhalten automatisch die Rolle User."
});
setNewUser(emptyForm); setNewUser(emptyForm);
await load(); await load();
} catch (error) { } catch (error) {
@@ -181,7 +152,6 @@ export function AdminUsersPage({ currentUser }: { currentUser: AuthUser }) {
await updateAdminUser(user.id, { await updateAdminUser(user.id, {
username: form.username, username: form.username,
displayName: form.displayName, displayName: form.displayName,
role: form.role,
active: form.active, active: form.active,
password: form.password || undefined password: form.password || undefined
}); });
@@ -196,91 +166,6 @@ export function AdminUsersPage({ currentUser }: { currentUser: AuthUser }) {
} }
} }
async function moveSession(session: AdminSession) {
const nextUserId = sessionOwners[session.id] ?? session.user_id;
if (nextUserId === session.user_id) {
toast.info("Keine Änderung", {
description: "Diese Session gehört bereits diesem Benutzer."
});
return;
}
setMovingId(session.id);
try {
await reassignAdminSession(session.id, nextUserId);
toast.success("Session umverteilt", {
description: "Betroffene Tages- und Monatsabschlüsse wurden wieder geöffnet."
});
await load();
} catch (error) {
toast.error("Session konnte nicht umverteilt werden", {
description: error instanceof Error ? error.message : "Unbekannter Fehler"
});
} finally {
setMovingId(null);
}
}
const activeUsers = users.filter((user) => user.active);
const manualSessionUserId = manualSession.userId || activeUsers[0]?.id || "";
async function createManualSession(event: FormEvent) {
event.preventDefault();
if (!manualSessionUserId) {
toast.error("Kein aktiver Benutzer vorhanden");
return;
}
if (!manualSession.organizationId) {
toast.error("Organisation wählen");
return;
}
const startedAt = new Date(`${manualSession.day}T${manualSession.startTime}:00`);
const endedAt = new Date(`${manualSession.day}T${manualSession.endTime}:00`);
if (Number.isNaN(startedAt.getTime()) || Number.isNaN(endedAt.getTime())) {
toast.error("Datum oder Uhrzeit prüfen");
return;
}
if (endedAt <= startedAt) {
toast.error("Ende muss nach Beginn liegen");
return;
}
setCreatingSession(true);
try {
await createAdminSession({
userId: manualSessionUserId,
ticketNumber: manualSession.ticketNumber,
organizationId: manualSession.organizationId,
activity: manualSession.activity,
workType: manualSession.workType,
startedAt: startedAt.toISOString(),
endedAt: endedAt.toISOString()
});
toast.success("Session nachgetragen", {
description: "Der Eintrag ist in der Auswertung des gewählten Benutzers offen."
});
setManualSession({
...emptyManualSession(),
userId: manualSessionUserId,
day: manualSession.day
});
await load();
} catch (error) {
toast.error("Session konnte nicht nachgetragen werden", {
description: error instanceof Error ? error.message : "Unbekannter Fehler"
});
} finally {
setCreatingSession(false);
}
}
async function syncOrganizations(event: FormEvent) { async function syncOrganizations(event: FormEvent) {
event.preventDefault(); event.preventDefault();
setSyncingOrganizations(true); setSyncingOrganizations(true);
@@ -327,14 +212,121 @@ 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 ( return (
<div className="space-y-4"> <div className="space-y-4">
<div> <div>
<h2 className="text-xl font-semibold tracking-normal sm:text-2xl">Benutzer</h2> <h2 className="text-xl font-semibold tracking-normal sm:text-2xl">Administration</h2>
<p className="text-sm text-muted-foreground">Accounts verwalten, Besitzer von Sessions prüfen und Einträge umverteilen.</p> <p className="text-sm text-muted-foreground">Systemverwaltung für Benutzer, Zammad-Organisationen und restore-fähige Backups.</p>
</div> </div>
<Card> <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"> <CardHeader className="p-4">
<div className="flex items-start justify-between gap-3"> <div className="flex items-start justify-between gap-3">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
@@ -346,7 +338,7 @@ export function AdminUsersPage({ currentUser }: { currentUser: AuthUser }) {
<CardDescription>Zammad-Zugang speichern und Organisationen in die lokale TicketTracker-Datenbank übernehmen.</CardDescription> <CardDescription>Zammad-Zugang speichern und Organisationen in die lokale TicketTracker-Datenbank übernehmen.</CardDescription>
</CardHeader> </CardHeader>
<CardContent className="px-4 pb-4"> <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}> <form className="grid gap-3 xl:grid-cols-2" onSubmit={syncOrganizations}>
<div className="space-y-2"> <div className="space-y-2">
<label className="text-sm font-medium" htmlFor="zammad-base-url">Zammad URL</label> <label className="text-sm font-medium" htmlFor="zammad-base-url">Zammad URL</label>
<Input <Input
@@ -362,12 +354,12 @@ export function AdminUsersPage({ currentUser }: { currentUser: AuthUser }) {
<Input <Input
id="zammad-api-key" id="zammad-api-key"
type="password" type="password"
placeholder={hasZammadApiKey ? "Gespeicherter API-Key wird verwendet" : ""} placeholder={hasZammadApiKey ? "••••••••••••••••" : ""}
value={zammadApiKey} value={zammadApiKey}
onChange={(event) => setZammadApiKey(event.currentTarget.value)} 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> </div>
<div className="flex flex-wrap gap-2 xl:col-span-2">
<Button type="button" variant="secondary" disabled={savingZammadSettings} onClick={() => void saveZammadAccess()}> <Button type="button" variant="secondary" disabled={savingZammadSettings} onClick={() => void saveZammadAccess()}>
<Save className="size-4" /> <Save className="size-4" />
{savingZammadSettings ? "Speichert..." : "Zugang speichern"} {savingZammadSettings ? "Speichert..." : "Zugang speichern"}
@@ -376,10 +368,65 @@ export function AdminUsersPage({ currentUser }: { currentUser: AuthUser }) {
<DatabaseZap className="size-4" /> <DatabaseZap className="size-4" />
{syncingOrganizations ? "Synchronisiert..." : "Organisationen synchronisieren"} {syncingOrganizations ? "Synchronisiert..." : "Organisationen synchronisieren"}
</Button> </Button>
</div>
</form> </form>
</CardContent> </CardContent>
</Card> </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> <Card>
<CardHeader className="p-4"> <CardHeader className="p-4">
<div className="flex items-start justify-between gap-3"> <div className="flex items-start justify-between gap-3">
@@ -391,7 +438,7 @@ export function AdminUsersPage({ currentUser }: { currentUser: AuthUser }) {
</div> </div>
</CardHeader> </CardHeader>
<CardContent className="px-4 pb-4"> <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"> <div className="space-y-2">
<label className="text-sm font-medium" htmlFor="new-username">Benutzername</label> <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 /> <Input id="new-username" value={newUser.username} onChange={(event) => setNewUser({ ...newUser, username: event.currentTarget.value })} required />
@@ -405,16 +452,8 @@ export function AdminUsersPage({ currentUser }: { currentUser: AuthUser }) {
<Input id="new-password" type="password" value={newUser.password} onChange={(event) => setNewUser({ ...newUser, password: event.currentTarget.value })} required /> <Input id="new-password" type="password" value={newUser.password} onChange={(event) => setNewUser({ ...newUser, password: event.currentTarget.value })} required />
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<label className="text-sm font-medium" htmlFor="new-role">Rolle</label> <span className="text-sm font-medium">Rolle</span>
<select <Badge variant="secondary" className="flex h-8 w-full items-center justify-center">User</Badge>
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>
</div> </div>
<label className="flex h-8 items-center gap-2 text-sm"> <label className="flex h-8 items-center gap-2 text-sm">
<Switch checked={newUser.active} onCheckedChange={(active) => setNewUser({ ...newUser, active })} /> <Switch checked={newUser.active} onCheckedChange={(active) => setNewUser({ ...newUser, active })} />
@@ -428,130 +467,11 @@ export function AdminUsersPage({ currentUser }: { currentUser: AuthUser }) {
</CardContent> </CardContent>
</Card> </Card>
<Card>
<CardHeader className="p-4">
<div className="flex items-start justify-between gap-3">
<div className="flex items-center gap-2">
<CalendarPlus className="size-5 text-muted-foreground" />
<CardTitle>Session nachtragen</CardTitle>
</div>
<HelpLink anchor="session-nachtragen" label="Hilfe zum Nachtragen für Benutzer" />
</div>
<CardDescription>Vergessene Zeiten manuell erfassen und direkt einem Benutzer zuweisen.</CardDescription>
</CardHeader>
<CardContent className="px-4 pb-4">
<form className="space-y-4" onSubmit={createManualSession}>
<div className="grid gap-3 lg:grid-cols-[160px_minmax(180px,1fr)_150px_150px]">
<div className="space-y-2">
<label className="text-sm font-medium" htmlFor="manual-ticket-number">Ticket</label>
<Input
id="manual-ticket-number"
placeholder="Ticket#123456"
value={manualSession.ticketNumber}
onChange={(event) => setManualSession({ ...manualSession, ticketNumber: event.currentTarget.value })}
required
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium" htmlFor="manual-organization">Organisation</label>
<OrganizationSelect
value={manualSession.organizationId}
selectedName={manualSession.organizationName}
onChange={(organization) =>
setManualSession({
...manualSession,
organizationId: organization.id,
organizationName: organization.name
})
}
required
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium" htmlFor="manual-work-type">Art</label>
<select
id="manual-work-type"
className="h-8 w-full rounded-lg border border-input bg-background px-2 text-sm focus-visible:outline-none focus-visible:ring-3 focus-visible:ring-ring/50"
value={manualSession.workType}
onChange={(event) => setManualSession({ ...manualSession, workType: event.currentTarget.value as WorkType })}
>
<option value="support">Support</option>
<option value="consulting">Consulting</option>
</select>
</div>
<div className="space-y-2">
<label className="text-sm font-medium" htmlFor="manual-user">Benutzer</label>
<select
id="manual-user"
className="h-8 w-full rounded-lg border border-input bg-background px-2 text-sm focus-visible:outline-none focus-visible:ring-3 focus-visible:ring-ring/50"
value={manualSessionUserId}
onChange={(event) => setManualSession({ ...manualSession, userId: event.currentTarget.value })}
required
>
{activeUsers.map((user) => (
<option key={user.id} value={user.id}>
{user.display_name}
</option>
))}
</select>
</div>
</div>
<div className="grid gap-3 lg:grid-cols-[160px_120px_120px_minmax(220px,1fr)_auto] lg:items-end">
<div className="space-y-2">
<label className="text-sm font-medium" htmlFor="manual-day">Datum</label>
<Input
id="manual-day"
type="date"
value={manualSession.day}
onChange={(event) => setManualSession({ ...manualSession, day: event.currentTarget.value })}
required
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium" htmlFor="manual-start">Von</label>
<Input
id="manual-start"
type="time"
value={manualSession.startTime}
onChange={(event) => setManualSession({ ...manualSession, startTime: event.currentTarget.value })}
required
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium" htmlFor="manual-end">Bis</label>
<Input
id="manual-end"
type="time"
value={manualSession.endTime}
onChange={(event) => setManualSession({ ...manualSession, endTime: event.currentTarget.value })}
required
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium" htmlFor="manual-activity">Tätigkeit</label>
<Textarea
id="manual-activity"
className="min-h-20"
value={manualSession.activity}
onChange={(event) => setManualSession({ ...manualSession, activity: event.currentTarget.value })}
required
/>
</div>
<Button type="submit" disabled={creatingSession || activeUsers.length === 0}>
<CalendarPlus className="size-4" />
{creatingSession ? "Speichert..." : "Nachtragen"}
</Button>
</div>
</form>
</CardContent>
</Card>
<Card> <Card>
<CardHeader className="gap-3 p-4 sm:flex-row sm:items-center sm:justify-between sm:space-y-0"> <CardHeader className="gap-3 p-4 sm:flex-row sm:items-center sm:justify-between sm:space-y-0">
<div> <div>
<CardTitle>Vorhandene Benutzer</CardTitle> <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>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Badge variant="outline">{currentUser.display_name}</Badge> <Badge variant="outline">{currentUser.display_name}</Badge>
@@ -574,6 +494,7 @@ export function AdminUsersPage({ currentUser }: { currentUser: AuthUser }) {
<TableBody> <TableBody>
{users.map((user) => { {users.map((user) => {
const form = forms[user.id] ?? formFromUser(user); const form = forms[user.id] ?? formFromUser(user);
const isFixedAdmin = user.role === "admin";
return ( return (
<TableRow key={user.id}> <TableRow key={user.id}>
@@ -584,18 +505,11 @@ export function AdminUsersPage({ currentUser }: { currentUser: AuthUser }) {
<Input className="h-8" value={form.displayName} onChange={(event) => updateForm(user.id, { displayName: event.currentTarget.value })} /> <Input className="h-8" value={form.displayName} onChange={(event) => updateForm(user.id, { displayName: event.currentTarget.value })} />
</TableCell> </TableCell>
<TableCell> <TableCell>
<select <Badge variant={isFixedAdmin ? "default" : "secondary"}>{isFixedAdmin ? "Fester Admin" : "User"}</Badge>
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>
</TableCell> </TableCell>
<TableCell> <TableCell>
<label className="flex items-center gap-2 text-sm"> <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"} {form.active ? "aktiv" : "inaktiv"}
</label> </label>
</TableCell> </TableCell>
@@ -624,6 +538,7 @@ export function AdminUsersPage({ currentUser }: { currentUser: AuthUser }) {
<div className="space-y-2 xl:hidden"> <div className="space-y-2 xl:hidden">
{users.map((user) => { {users.map((user) => {
const form = forms[user.id] ?? formFromUser(user); const form = forms[user.id] ?? formFromUser(user);
const isFixedAdmin = user.role === "admin";
return ( return (
<div key={user.id} className="space-y-3 rounded-md border bg-background p-3"> <div key={user.id} className="space-y-3 rounded-md border bg-background p-3">
@@ -632,18 +547,10 @@ export function AdminUsersPage({ currentUser }: { currentUser: AuthUser }) {
<Shield className="size-4 text-muted-foreground" /> <Shield className="size-4 text-muted-foreground" />
<span className="font-medium">{user.username}</span> <span className="font-medium">{user.username}</span>
</div> </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> </div>
<Input value={form.username} onChange={(event) => updateForm(user.id, { username: event.currentTarget.value })} /> <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 })} /> <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 <Input
type="password" type="password"
placeholder="Passwort unverändert lassen" placeholder="Passwort unverändert lassen"
@@ -652,7 +559,7 @@ export function AdminUsersPage({ currentUser }: { currentUser: AuthUser }) {
/> />
<div className="flex items-center justify-between gap-2"> <div className="flex items-center justify-between gap-2">
<label className="flex items-center gap-2 text-sm"> <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"} {form.active ? "aktiv" : "inaktiv"}
</label> </label>
<Button size="sm" onClick={() => void saveUser(user)} disabled={savingId === user.id}> <Button size="sm" onClick={() => void saveUser(user)} disabled={savingId === user.id}>
@@ -666,118 +573,6 @@ export function AdminUsersPage({ currentUser }: { currentUser: AuthUser }) {
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
<Card>
<CardHeader className="gap-3 p-4 sm:flex-row sm:items-center sm:justify-between sm:space-y-0">
<div>
<CardTitle>Session-Zuordnung</CardTitle>
<CardDescription>{loading ? "Lädt..." : `${sessions.length} letzte Session(s), inklusive Besitzer.`}</CardDescription>
</div>
<HelpLink anchor="admin" label="Hilfe zur Session-Zuordnung" />
</CardHeader>
<CardContent className="px-4 pb-4">
<div className="hidden xl:block">
<Table>
<TableHeader>
<TableRow>
<TableHead>Besitzer</TableHead>
<TableHead>Ticket</TableHead>
<TableHead>Organisation</TableHead>
<TableHead>Tätigkeit</TableHead>
<TableHead>Beginn</TableHead>
<TableHead>Zeit</TableHead>
<TableHead>Zuordnen zu</TableHead>
<TableHead />
</TableRow>
</TableHeader>
<TableBody>
{sessions.map((session) => (
<TableRow key={session.id}>
<TableCell>
<div className="font-medium">{session.owner_display_name}</div>
<div className="text-xs text-muted-foreground">{session.owner_username}</div>
</TableCell>
<TableCell className="font-semibold">{session.ticket_number}</TableCell>
<TableCell>{session.customer_name}</TableCell>
<TableCell className="max-w-xs whitespace-normal">{session.activity}</TableCell>
<TableCell className="whitespace-nowrap">{formatDateTime(session.started_at)}</TableCell>
<TableCell>{formatMinutes(session.rounded_minutes)}</TableCell>
<TableCell>
<select
className="h-8 w-full min-w-40 rounded-lg border border-input bg-background px-2 text-sm focus-visible:outline-none focus-visible:ring-3 focus-visible:ring-ring/50"
value={sessionOwners[session.id] ?? session.user_id}
onChange={(event) =>
setSessionOwners((current) => ({
...current,
[session.id]: event.currentTarget.value
}))
}
>
{activeUsers.map((user) => (
<option key={user.id} value={user.id}>
{user.display_name}
</option>
))}
</select>
</TableCell>
<TableCell className="text-right">
<Button size="sm" onClick={() => void moveSession(session)} disabled={movingId === session.id}>
<Shuffle className="size-4" />
{movingId === session.id ? "Speichert..." : "Umverteilen"}
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
<div className="space-y-2 xl:hidden">
{sessions.map((session) => (
<div key={session.id} className="space-y-3 rounded-md border bg-background p-3">
<div className="flex items-start justify-between gap-2">
<div>
<p className="font-semibold">{session.ticket_number}</p>
<p className="text-sm text-muted-foreground">{session.customer_name}</p>
</div>
<Badge variant="outline">{formatMinutes(session.rounded_minutes)}</Badge>
</div>
<div className="rounded-md bg-muted/40 p-2 text-sm">
<p className="font-medium">{session.owner_display_name}</p>
<p className="text-muted-foreground">{session.owner_username} · {formatDateTime(session.started_at)}</p>
</div>
<p className="text-sm">{session.activity}</p>
<div className="grid gap-2 sm:grid-cols-[1fr_auto]">
<select
className="h-9 w-full rounded-lg border border-input bg-background px-2 text-sm focus-visible:outline-none focus-visible:ring-3 focus-visible:ring-ring/50"
value={sessionOwners[session.id] ?? session.user_id}
onChange={(event) =>
setSessionOwners((current) => ({
...current,
[session.id]: event.currentTarget.value
}))
}
>
{activeUsers.map((user) => (
<option key={user.id} value={user.id}>
{user.display_name}
</option>
))}
</select>
<Button size="sm" onClick={() => void moveSession(session)} disabled={movingId === session.id}>
<Shuffle className="size-4" />
Umverteilen
</Button>
</div>
</div>
))}
</div>
{sessions.length === 0 ? (
<p className="py-8 text-center text-sm text-muted-foreground">Noch keine Sessions vorhanden.</p>
) : null}
</CardContent>
</Card>
</div> </div>
); );
} }
+59 -21
View File
@@ -1,7 +1,8 @@
import { ChevronLeft, ChevronRight, Clock3, ExternalLink, RotateCcw, SlidersHorizontal, Ticket, UserCheck, Users } from "lucide-react"; import { ChevronLeft, ChevronRight, Clock3, Download, ExternalLink, RotateCcw, SlidersHorizontal, Ticket, UserCheck, Users } from "lucide-react";
import type { LucideIcon } from "lucide-react"; import type { LucideIcon } from "lucide-react";
import { Fragment, useEffect, useMemo, useRef, useState } from "react"; import { Fragment, useEffect, useMemo, useRef, useState } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import { Alert } from "@/components/ui/alert";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
@@ -10,8 +11,8 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { CopyTicketButton } from "@/components/CopyTicketButton"; import { CopyTicketButton } from "@/components/CopyTicketButton";
import { HelpLink } from "@/components/HelpLink"; import { HelpLink } from "@/components/HelpLink";
import { getPeriodOverview } from "../api"; import { downloadMonthExport, getPeriodOverview } from "../api";
import { currentDay, currentMonth, formatMinutes } from "../format"; import { currentDay, currentMonth, formatDateTime, formatMinutes } from "../format";
import type { PeriodOverview, PeriodType, TicketSummary } from "../types"; import type { PeriodOverview, PeriodType, TicketSummary } from "../types";
type AnalysisPageProps = { type AnalysisPageProps = {
@@ -304,6 +305,7 @@ export function AnalysisPage({ onNavigate }: AnalysisPageProps) {
const [period, setPeriod] = useState(currentMonth()); const [period, setPeriod] = useState(currentMonth());
const [overview, setOverview] = useState<PeriodOverview | null>(null); const [overview, setOverview] = useState<PeriodOverview | null>(null);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [exportingMonth, setExportingMonth] = useState(false);
const [ticketViewSettings, setTicketViewSettings] = useState<AnalysisTicketViewSettings>(() => readStoredTicketViewSettings()); const [ticketViewSettings, setTicketViewSettings] = useState<AnalysisTicketViewSettings>(() => readStoredTicketViewSettings());
const loadRequestId = useRef(0); const loadRequestId = useRef(0);
@@ -379,13 +381,43 @@ export function AnalysisPage({ onNavigate }: AnalysisPageProps) {
} }
async function exportMonth() {
if (periodType !== "month") {
return;
}
setExportingMonth(true);
try {
const { blob, filename } = await downloadMonthExport(period);
const url = window.URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = filename;
document.body.appendChild(link);
link.click();
link.remove();
window.URL.revokeObjectURL(url);
toast.success("Monatsexport erstellt", {
description: filename
});
} catch (error) {
toast.error("Monatsexport fehlgeschlagen", {
description: error instanceof Error ? error.message : "Unbekannter Fehler"
});
} finally {
setExportingMonth(false);
}
}
const totals = overview?.totals; const totals = overview?.totals;
const periodLabel = periodType === "month" ? "Monat" : "Tag"; const periodLabel = periodType === "month" ? "Monat" : "Tag";
const metricCards: Array<{ label: string; value: string | number; detail?: string; icon: LucideIcon }> = [ const metricCards: Array<{ label: string; value: string | number; detail?: string; icon: LucideIcon }> = [
{ label: "Tickets", value: totals?.tickets ?? 0, icon: Ticket }, { label: "Tickets", value: totals?.tickets ?? 0, icon: Ticket },
{ label: "Sessions", value: totals?.sessions ?? 0, icon: Users }, { label: "Sessions", value: totals?.sessions ?? 0, icon: Users },
{ label: "Zeit", value: formatMinutes(totals?.minutes ?? 0), detail: `Teamspace ${formatMinutes(totals?.crmBilledMinutes ?? 0)}`, icon: Clock3 }, { label: "Abgerechnet", value: formatMinutes(totals?.minutes ?? 0), detail: `Teamspace ${formatMinutes(totals?.crmBilledMinutes ?? 0)}`, icon: Clock3 },
{ label: "Offen", value: totals?.openSessions ?? 0, icon: UserCheck } { 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 tickets = overview?.tickets ?? [];
const organizationOptions = useMemo(() => { const organizationOptions = useMemo(() => {
@@ -492,10 +524,22 @@ export function AnalysisPage({ onNavigate }: AnalysisPageProps) {
</Button> </Button>
</div> </div>
</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> </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 }) => ( {metricCards.map(({ label, value, detail, icon: Icon }) => (
<Card key={label}> <Card key={label}>
<CardContent className="flex items-center gap-3 p-3"> <CardContent className="flex items-center gap-3 p-3">
@@ -515,9 +559,9 @@ export function AnalysisPage({ onNavigate }: AnalysisPageProps) {
<Card> <Card>
<CardHeader className="gap-3 p-4 sm:flex-row sm:items-center sm:justify-between sm:space-y-0"> <CardHeader className="gap-3 p-4 sm:flex-row sm:items-center sm:justify-between sm:space-y-0">
<div> <div>
<CardTitle>Gesamtaufwand</CardTitle> <CardTitle>Abgerechneter Aufwand</CardTitle>
<CardDescription> <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> </CardDescription>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
@@ -591,7 +635,7 @@ export function AnalysisPage({ onNavigate }: AnalysisPageProps) {
<div className="mt-3 flex flex-wrap gap-3 text-xs text-muted-foreground"> <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="inline-flex items-center gap-1.5">
<span className="size-2 rounded-sm bg-neutral-300 dark:bg-neutral-700" /> <span className="size-2 rounded-sm bg-neutral-300 dark:bg-neutral-700" />
Minuten Abgerechnet
</span> </span>
<span className="inline-flex items-center gap-1.5"> <span className="inline-flex items-center gap-1.5">
<span className="h-0.5 w-4 bg-foreground" /> <span className="h-0.5 w-4 bg-foreground" />
@@ -749,9 +793,8 @@ export function AnalysisPage({ onNavigate }: AnalysisPageProps) {
<TableHead>Organisation</TableHead> <TableHead>Organisation</TableHead>
<TableHead>Art</TableHead> <TableHead>Art</TableHead>
<TableHead>Sessions</TableHead> <TableHead>Sessions</TableHead>
<TableHead>Zeit</TableHead> <TableHead>Zeit abgerechnet</TableHead>
<TableHead>Abgerechnet</TableHead> <TableHead>Sessions abgerechnet</TableHead>
<TableHead>Nicht abrechenbar</TableHead>
<TableHead>Status</TableHead> <TableHead>Status</TableHead>
<TableHead /> <TableHead />
</TableRow> </TableRow>
@@ -761,7 +804,7 @@ export function AnalysisPage({ onNavigate }: AnalysisPageProps) {
<Fragment key={section.key}> <Fragment key={section.key}>
{ticketViewSettings.groupBy !== "none" ? ( {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`}> <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 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"> <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> <span className="rounded-sm bg-primary px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-wide text-primary-foreground">Gruppe</span>
@@ -789,7 +832,6 @@ export function AnalysisPage({ onNavigate }: AnalysisPageProps) {
<TableCell>{ticket.session_count}</TableCell> <TableCell>{ticket.session_count}</TableCell>
<TableCell>{formatMinutes(ticket.total_minutes)}</TableCell> <TableCell>{formatMinutes(ticket.total_minutes)}</TableCell>
<TableCell>{ticket.billed_count}</TableCell> <TableCell>{ticket.billed_count}</TableCell>
<TableCell>{ticket.non_billable_count}</TableCell>
<TableCell> <TableCell>
<TicketStatus ticket={ticket} /> <TicketStatus ticket={ticket} />
</TableCell> </TableCell>
@@ -835,19 +877,15 @@ export function AnalysisPage({ onNavigate }: AnalysisPageProps) {
</div> </div>
<TicketStatus ticket={ticket} /> <TicketStatus ticket={ticket} />
</div> </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"> <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> <p className="font-medium">{formatMinutes(ticket.total_minutes)}</p>
</div> </div>
<div className="rounded-md bg-muted/50 p-2"> <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> <p className="font-medium">{ticket.billed_count}</p>
</div> </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> </div>
<Button className="w-full" size="sm" variant="secondary" onClick={() => onNavigate(`/analysis/${periodType}/${period}/tickets/${ticket.id}`)}> <Button className="w-full" size="sm" variant="secondary" onClick={() => onNavigate(`/analysis/${periodType}/${period}/tickets/${ticket.id}`)}>
Öffnen Öffnen
+49 -5
View File
@@ -4,6 +4,41 @@ import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { helpSections } from "../help-content"; 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() { export function FaqPage() {
return ( return (
@@ -34,13 +69,18 @@ export function FaqPage() {
</Card> </Card>
<div className="grid gap-3 xl:grid-cols-2"> <div className="grid gap-3 xl:grid-cols-2">
{helpSections.map((section) => ( {helpSections.map((section) => {
<Card key={section.id} id={section.id} className="scroll-mt-16"> 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"> <CardHeader className="p-4">
<CardTitle>{section.title}</CardTitle> <CardTitle>{section.title}</CardTitle>
<CardDescription>{section.description}</CardDescription> <CardDescription>{section.description}</CardDescription>
</CardHeader> </CardHeader>
<CardContent className="space-y-4 px-4 pb-4"> <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"> <div className="rounded-md border bg-muted/20 p-3">
<p className="mb-2 text-sm font-medium">Kurzfassung</p> <p className="mb-2 text-sm font-medium">Kurzfassung</p>
<ul className="space-y-2 text-sm text-muted-foreground"> <ul className="space-y-2 text-sm text-muted-foreground">
@@ -63,7 +103,10 @@ export function FaqPage() {
))} ))}
</ul> </ul>
</div> </div>
<div className="flex justify-end"> {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"> <Button asChild variant="ghost" size="sm">
<a href="#top"> <a href="#top">
<ArrowUp className="size-4" /> <ArrowUp className="size-4" />
@@ -73,7 +116,8 @@ export function FaqPage() {
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
))} );
})}
</div> </div>
</div> </div>
); );
+25 -9
View File
@@ -136,7 +136,7 @@ function CrmDayItem({
</div> </div>
<p className="truncate text-sm text-muted-foreground">{item.organization_name}</p> <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"> <p className="flex flex-wrap gap-x-3 gap-y-1 text-xs">
<span className={trackedTextClass}>Sessions {formatMinutes(item.tracked_minutes)}</span> <span className={trackedTextClass}>Abgerechnet {formatMinutes(item.tracked_minutes)}</span>
<span className={teamspaceTextClass}>Teamspace {formatMinutes(item.crm_billed_minutes)}</span> <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} {typeof item.delta_minutes === "number" ? <span className="text-muted-foreground">Differenz {formatTeamspaceDelta(item.delta_minutes)}</span> : null}
</p> </p>
@@ -280,10 +280,26 @@ export function MonthlyClosePage({ onNavigate }: MonthlyClosePageProps) {
const canClose = Boolean(stats && !stats.closed && stats.totals.sessions > 0 && openSessions.length === 0); const canClose = Boolean(stats && !stats.closed && stats.totals.sessions > 0 && openSessions.length === 0);
const checklist = useMemo( const checklist = useMemo(
() => [ () => [
{ label: "Offene Bewertungen", count: openSessions.length, detail: `${openTicketGroups.length} Ticket(s)`, blocker: true }, {
{ label: "Tage ohne Teamspace-Wert", count: missingCrmDays.length, detail: `${acknowledgedMissingCrmDays.length} zur Kenntnis genommen`, blocker: false } label: "Offene Bewertungen",
ok: openSessions.length === 0,
value: openSessions.length,
detail: openSessions.length === 0 ? "Alle Sessions sind bewertet." : `${openTicketGroups.length} Ticket(s) bearbeiten`
},
{
label: "Teamspace geprüft",
ok: missingCrmDays.length === 0,
value: missingCrmDays.length,
detail: missingCrmDays.length === 0 ? "Keine offenen Teamspace-Prüfpunkte." : `${acknowledgedMissingCrmDays.length} zur Kenntnis genommen`
},
{
label: "Monat abschließbar",
ok: Boolean(stats?.closed || canClose),
value: stats?.closed ? "geschlossen" : canClose ? "bereit" : "offen",
detail: stats?.closed ? "Monat ist gesperrt." : canClose ? "Kann abgeschlossen werden." : "Bewertungen prüfen."
}
], ],
[acknowledgedMissingCrmDays.length, openSessions.length, openTicketGroups.length, missingCrmDays.length] [acknowledgedMissingCrmDays.length, canClose, openSessions.length, openTicketGroups.length, missingCrmDays.length, stats?.closed]
); );
return ( return (
@@ -331,7 +347,7 @@ export function MonthlyClosePage({ onNavigate }: MonthlyClosePageProps) {
) : null} ) : null}
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-5"> <div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-5">
<StatusCard label="Sessions" value={stats?.totals.sessions ?? 0} detail={`${formatMinutes(stats?.totals.minutes ?? 0)} getrackt`} /> <StatusCard label="Sessions" value={stats?.totals.sessions ?? 0} detail={`${formatMinutes(stats?.totals.minutes ?? 0)} abgerechnet`} />
<StatusCard label="Teamspace" value={formatMinutes(stats?.totals.crmBilledMinutes ?? 0)} detail={`Differenz ${formatTeamspaceDelta(stats?.totals.crmDeltaMinutes ?? 0)}`} /> <StatusCard label="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="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="Ohne Teamspace" value={missingCrmDays.length} detail={`${acknowledgedMissingCrmDays.length} zur Kenntnis genommen`} tone={missingCrmDays.length > 0 ? "warn" : "ok"} />
@@ -348,14 +364,14 @@ export function MonthlyClosePage({ onNavigate }: MonthlyClosePageProps) {
<HelpLink anchor="monatsabschluss" label="Hilfe zum Monatsabschluss" /> <HelpLink anchor="monatsabschluss" label="Hilfe zum Monatsabschluss" />
</div> </div>
</CardHeader> </CardHeader>
<CardContent className="grid gap-2 px-4 pb-4 md:grid-cols-2"> <CardContent className="grid gap-2 px-4 pb-4 md:grid-cols-3">
{checklist.map((item) => ( {checklist.map((item) => (
<div key={item.label} className="rounded-md border bg-background p-3"> <div key={item.label} className="rounded-md border bg-background p-3">
<div className="mb-2 flex items-center justify-between gap-2"> <div className="mb-2 flex items-center justify-between gap-2">
<span className="text-sm font-medium">{item.label}</span> <span className="text-sm font-medium">{item.label}</span>
<Badge variant={item.count > 0 ? "warning" : "success"}>{item.count}</Badge> <Badge variant={item.ok ? "success" : "warning"}>{item.value}</Badge>
</div> </div>
<p className="text-xs text-muted-foreground">{item.blocker ? "Muss erledigt sein." : item.detail}</p> <p className="text-xs text-muted-foreground">{item.detail}</p>
</div> </div>
))} ))}
</CardContent> </CardContent>
@@ -409,7 +425,7 @@ export function MonthlyClosePage({ onNavigate }: MonthlyClosePageProps) {
))} ))}
{visibleMissingCrmDays.length === 0 ? ( {visibleMissingCrmDays.length === 0 ? (
<p className="rounded-md border bg-muted/20 p-3 text-sm text-muted-foreground"> <p className="rounded-md border bg-muted/20 p-3 text-sm text-muted-foreground">
{acknowledgedMissingCrmDays.length > 0 ? "Alle offenen Punkte sind zur Kenntnis genommen." : "Alle getrackten Tage haben einen Teamspace-Wert."} {acknowledgedMissingCrmDays.length > 0 ? "Alle offenen Punkte sind zur Kenntnis genommen." : "Alle abrechenbaren Tage haben einen Teamspace-Wert."}
</p> </p>
) : null} ) : null}
</section> </section>
+244
View File
@@ -0,0 +1,244 @@
import { ChangeEvent, FormEvent, useState } from "react";
import { CheckCircle2, DatabaseBackup, KeyRound, Upload } from "lucide-react";
import { toast } from "sonner";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Textarea } from "@/components/ui/textarea";
import { createSetupAdmin, restoreSetupBackup, validateSetupBackup, type RestoreValidationResult } from "../api";
import type { AuthUser } from "../types";
type SetupPageProps = {
onAdminCreated: (user: AuthUser) => void;
onRestoreComplete: () => void;
};
export function SetupPage({ onAdminCreated, onRestoreComplete }: SetupPageProps) {
const [username, setUsername] = useState("admin");
const [displayName, setDisplayName] = useState("Admin");
const [password, setPassword] = useState("");
const [passwordRepeat, setPasswordRepeat] = useState("");
const [restoreSql, setRestoreSql] = useState("");
const [restoreValidation, setRestoreValidation] = useState<RestoreValidationResult | null>(null);
const [creatingAdmin, setCreatingAdmin] = useState(false);
const [validatingRestore, setValidatingRestore] = useState(false);
const [restoring, setRestoring] = useState(false);
async function createAdmin(event: FormEvent) {
event.preventDefault();
if (password !== passwordRepeat) {
toast.error("Passwörter stimmen nicht überein");
return;
}
setCreatingAdmin(true);
try {
const result = await createSetupAdmin({ username, displayName, password });
toast.success("Admin angelegt");
onAdminCreated(result.user);
} catch (error) {
toast.error("Admin konnte nicht angelegt werden", {
description: error instanceof Error ? error.message : "Unbekannter Fehler"
});
} finally {
setCreatingAdmin(false);
}
}
async function readBackupFile(event: ChangeEvent<HTMLInputElement>) {
const file = event.currentTarget.files?.[0];
if (!file) {
return;
}
const sql = await file.text();
setRestoreSql(sql);
setRestoreValidation(null);
await validateRestore(sql);
}
async function validateRestore(sql = restoreSql) {
if (!sql.trim()) {
setRestoreValidation(null);
return;
}
setValidatingRestore(true);
try {
const result = await validateSetupBackup(sql);
setRestoreValidation(result);
if (result.valid) {
toast.success("Backup-Struktur passt", {
description: `${result.checkedTables} Tabelle(n) geprüft.`
});
} else {
toast.error("Backup-Struktur passt nicht", {
description: result.issues[0] ?? "Unbekannter Strukturfehler"
});
}
} catch (error) {
setRestoreValidation({
valid: false,
checkedTables: 0,
issues: [error instanceof Error ? error.message : "Unbekannter Fehler"]
});
toast.error("Backup-Prüfung fehlgeschlagen", {
description: error instanceof Error ? error.message : "Unbekannter Fehler"
});
} finally {
setValidatingRestore(false);
}
}
async function restoreBackup(event: FormEvent) {
event.preventDefault();
if (!restoreValidation?.valid) {
toast.error("Backup zuerst prüfen", {
description: "Der Restore kann erst nach erfolgreicher Strukturprüfung ausgeführt werden."
});
return;
}
setRestoring(true);
try {
await restoreSetupBackup(restoreSql);
toast.success("Backup eingespielt", {
description: "Melde dich jetzt mit einem Benutzer aus dem Backup an."
});
onRestoreComplete();
} catch (error) {
toast.error("Restore fehlgeschlagen", {
description: error instanceof Error ? error.message : "Unbekannter Fehler"
});
} finally {
setRestoring(false);
}
}
return (
<main className="min-h-screen bg-background p-4">
<div className="mx-auto flex min-h-[calc(100vh-2rem)] w-full max-w-4xl flex-col justify-center gap-4">
<div>
<h1 className="text-2xl font-semibold tracking-normal">TicketTracker Setup</h1>
<p className="text-sm text-muted-foreground">Es wurde noch kein Admin gefunden. Lege einen Admin an oder spiele ein bestehendes Backup ein.</p>
</div>
<Tabs defaultValue="admin" className="w-full">
<TabsList className="grid w-full grid-cols-2">
<TabsTrigger value="admin">Admin anlegen</TabsTrigger>
<TabsTrigger value="restore">Backup einspielen</TabsTrigger>
</TabsList>
<TabsContent value="admin">
<Card>
<CardHeader>
<div className="flex items-center gap-2">
<KeyRound className="size-5 text-muted-foreground" />
<CardTitle>Festen Admin erstellen</CardTitle>
</div>
<CardDescription>Dieser Account ist nur für Systemverwaltung, Benutzer, Zammad-Sync und Backups vorgesehen.</CardDescription>
</CardHeader>
<CardContent>
<form className="grid gap-4 sm:grid-cols-2" onSubmit={createAdmin}>
<div className="space-y-2">
<label className="text-sm font-medium" htmlFor="setup-username">Benutzername</label>
<Input id="setup-username" value={username} onChange={(event) => setUsername(event.currentTarget.value)} autoComplete="username" autoFocus required />
</div>
<div className="space-y-2">
<label className="text-sm font-medium" htmlFor="setup-display-name">Voller Name</label>
<Input id="setup-display-name" value={displayName} onChange={(event) => setDisplayName(event.currentTarget.value)} required />
</div>
<div className="space-y-2">
<label className="text-sm font-medium" htmlFor="setup-password">Passwort</label>
<Input id="setup-password" type="password" value={password} onChange={(event) => setPassword(event.currentTarget.value)} autoComplete="new-password" minLength={6} required />
</div>
<div className="space-y-2">
<label className="text-sm font-medium" htmlFor="setup-password-repeat">Passwort wiederholen</label>
<Input id="setup-password-repeat" type="password" value={passwordRepeat} onChange={(event) => setPasswordRepeat(event.currentTarget.value)} autoComplete="new-password" minLength={6} required />
</div>
<Button className="sm:col-span-2" type="submit" disabled={creatingAdmin}>
<KeyRound className="size-4" />
{creatingAdmin ? "Erstellt..." : "Admin erstellen und anmelden"}
</Button>
</form>
</CardContent>
</Card>
</TabsContent>
<TabsContent value="restore">
<Card>
<CardHeader>
<div className="flex items-center gap-2">
<DatabaseBackup className="size-5 text-muted-foreground" />
<CardTitle>Backup einspielen</CardTitle>
</div>
<CardDescription>Nutze ein SQL-Backup aus dem TicketTracker-Adminbereich.</CardDescription>
</CardHeader>
<CardContent>
<form className="space-y-4" onSubmit={restoreBackup}>
<Alert variant="warning">
<Upload className="size-4" />
<AlertTitle>Restore ersetzt den kompletten Systemstand</AlertTitle>
<AlertDescription>
Vor dem Einspielen werden alle TicketTracker-Tabellen geleert. Danach gilt ausschließlich der Stand des Backups, inklusive aller Benutzer,
Passwörter, Sessions, Teamspace-Werte, Einstellungen und Abschlüsse.
</AlertDescription>
</Alert>
<div className="space-y-2">
<label className="text-sm font-medium" htmlFor="setup-restore-file">SQL-Datei</label>
<Input id="setup-restore-file" type="file" accept=".sql,text/sql,text/plain" onChange={(event) => void readBackupFile(event)} />
</div>
<div className="space-y-2">
<label className="text-sm font-medium" htmlFor="setup-restore-sql">Backup-Inhalt</label>
<Textarea
id="setup-restore-sql"
className="min-h-44 font-mono text-xs"
value={restoreSql}
onChange={(event) => {
setRestoreSql(event.currentTarget.value);
setRestoreValidation(null);
}}
placeholder="SQL-Backup hier einfügen oder Datei auswählen"
required
/>
</div>
{restoreValidation ? (
<Alert variant={restoreValidation.valid ? "success" : "destructive"}>
{restoreValidation.valid ? <CheckCircle2 className="size-4" /> : <Upload className="size-4" />}
<AlertTitle>{restoreValidation.valid ? "Strukturprüfung erfolgreich" : "Strukturprüfung fehlgeschlagen"}</AlertTitle>
<AlertDescription>
{restoreValidation.valid
? `${restoreValidation.checkedTables} Tabelle(n) passen zum aktuellen Datenbankschema.`
: restoreValidation.issues.slice(0, 4).join(" ")}
</AlertDescription>
</Alert>
) : null}
<div className="grid gap-2 sm:grid-cols-2">
<Button type="button" variant="secondary" disabled={validatingRestore || !restoreSql.trim()} onClick={() => void validateRestore()}>
<CheckCircle2 className="size-4" />
{validatingRestore ? "Prüft..." : "Struktur prüfen"}
</Button>
<Button type="submit" variant="destructive" disabled={restoring || !restoreValidation?.valid}>
<DatabaseBackup className="size-4" />
{restoring ? "Spielt ein..." : "Backup einspielen"}
</Button>
</div>
</form>
</CardContent>
</Card>
</TabsContent>
</Tabs>
</div>
</main>
);
}
+42 -95
View File
@@ -4,7 +4,6 @@ import {
CircleAlert, CircleAlert,
Clock3, Clock3,
ExternalLink, ExternalLink,
ListChecks,
RotateCcw, RotateCcw,
Ticket, Ticket,
TrendingUp, TrendingUp,
@@ -31,12 +30,11 @@ type StatisticsPageProps = {
}; };
const trackedTextClass = "text-sky-700 dark:text-sky-300"; const trackedTextClass = "text-sky-700 dark:text-sky-300";
const teamspaceTextClass = "text-emerald-700 dark:text-emerald-300"; const teamspaceTextClass = "text-fuchsia-700 dark:text-fuchsia-300";
const trackedFillClass = "fill-sky-500/70 dark:fill-sky-400/65"; const trackedFillClass = "fill-sky-500/70 dark:fill-sky-400/65";
const teamspaceStrokeClass = "text-emerald-600 dark:text-emerald-300"; const teamspaceStrokeClass = "text-fuchsia-600 dark:text-fuchsia-300";
const trackedDotClass = "bg-sky-500 dark:bg-sky-400"; const trackedDotClass = "bg-sky-500 dark:bg-sky-400";
const teamspaceDotClass = "bg-emerald-500 dark:bg-emerald-400"; const teamspaceDotClass = "bg-fuchsia-500 dark:bg-fuchsia-400";
const evaluatedTextClass = "text-violet-700 dark:text-violet-300";
const openTextClass = "text-amber-700 dark:text-amber-300"; const openTextClass = "text-amber-700 dark:text-amber-300";
const organizationPageSize = 8; const organizationPageSize = 8;
@@ -60,32 +58,6 @@ function percentage(value: number, total: number) {
return Math.max(0, Math.min(100, Math.round((value / total) * 100))); return Math.max(0, Math.min(100, Math.round((value / total) * 100)));
} }
function roundedPercentages(values: number[]) {
const total = values.reduce((sum, value) => sum + value, 0);
if (total <= 0) {
return values.map(() => 0);
}
const raw = values.map((value) => (value / total) * 100);
const floors = raw.map(Math.floor);
let remainder = 100 - floors.reduce((sum, value) => sum + value, 0);
const order = raw
.map((value, index) => ({ index, fraction: value - Math.floor(value) }))
.sort((left, right) => right.fraction - left.fraction);
for (const item of order) {
if (remainder <= 0) {
break;
}
floors[item.index] += 1;
remainder -= 1;
}
return floors;
}
function formatSignedMinutes(minutes: number) { function formatSignedMinutes(minutes: number) {
if (minutes === 0) { if (minutes === 0) {
return "ausgeglichen"; return "ausgeglichen";
@@ -112,20 +84,20 @@ function DeltaBadge({ minutes }: { minutes: number }) {
function deltaDescription(minutes: number) { function deltaDescription(minutes: number) {
if (minutes > 0) { if (minutes > 0) {
return "Teamspace liegt über den Sessions"; return "Teamspace liegt über der abgerechneten Zeit";
} }
if (minutes < 0) { if (minutes < 0) {
return "Teamspace liegt unter den Sessions"; return "Teamspace liegt unter der abgerechneten Zeit";
} }
return "Teamspace und Sessions sind ausgeglichen"; return "Teamspace und abgerechnete Zeit sind ausgeglichen";
} }
function TimeComparison({ trackedMinutes, crmMinutes, sessions }: { trackedMinutes: number; crmMinutes: number; sessions?: number }) { function TimeComparison({ trackedMinutes, crmMinutes, sessions }: { trackedMinutes: number; crmMinutes: number; sessions?: number }) {
return ( return (
<div className="space-y-0.5 text-sm"> <div className="space-y-0.5 text-sm">
<div className={`font-semibold ${trackedTextClass}`}>Sessions {formatMinutes(trackedMinutes)}</div> <div className={`font-semibold ${trackedTextClass}`}>Abgerechnet {formatMinutes(trackedMinutes)}</div>
<div className={`font-semibold ${teamspaceTextClass}`}>Teamspace {formatMinutes(crmMinutes)}</div> <div className={`font-semibold ${teamspaceTextClass}`}>Teamspace {formatMinutes(crmMinutes)}</div>
{typeof sessions === "number" ? <div className="text-xs text-muted-foreground">{sessions} Session(s)</div> : null} {typeof sessions === "number" ? <div className="text-xs text-muted-foreground">{sessions} Session(s)</div> : null}
</div> </div>
@@ -137,7 +109,7 @@ function ColorLegend() {
<div className="flex flex-wrap gap-x-4 gap-y-2 rounded-md border bg-muted/20 p-3 text-xs text-muted-foreground"> <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="inline-flex items-center gap-1.5">
<span className={`size-2 rounded-sm ${trackedDotClass}`} /> <span className={`size-2 rounded-sm ${trackedDotClass}`} />
<span className={trackedTextClass}>Sessions</span> <span className={trackedTextClass}>Abgerechnet</span>
</span> </span>
<span className="inline-flex items-center gap-1.5"> <span className="inline-flex items-center gap-1.5">
<span className={`h-0.5 w-4 ${teamspaceDotClass}`} /> <span className={`h-0.5 w-4 ${teamspaceDotClass}`} />
@@ -151,10 +123,6 @@ function ColorLegend() {
<span className="size-2 rounded-full bg-red-500 dark:bg-red-400" /> <span className="size-2 rounded-full bg-red-500 dark:bg-red-400" />
<span className="text-red-700 dark:text-red-300">Differenz minus</span> <span className="text-red-700 dark:text-red-300">Differenz minus</span>
</span> </span>
<span className="inline-flex items-center gap-1.5">
<span className="size-2 rounded-full bg-violet-500 dark:bg-violet-400" />
<span className={evaluatedTextClass}>bewertet</span>
</span>
<span className="inline-flex items-center gap-1.5"> <span className="inline-flex items-center gap-1.5">
<span className="size-2 rounded-full bg-amber-500 dark:bg-amber-400" /> <span className="size-2 rounded-full bg-amber-500 dark:bg-amber-400" />
<span className={openTextClass}>offen</span> <span className={openTextClass}>offen</span>
@@ -221,10 +189,8 @@ function buildMonthSeries(month: string, buckets: StatisticsDailyBucket[]) {
sessions: bucket?.sessions ?? 0, sessions: bucket?.sessions ?? 0,
totalMinutes: bucket?.total_minutes ?? 0, totalMinutes: bucket?.total_minutes ?? 0,
billedMinutes: bucket?.billed_minutes ?? 0, billedMinutes: bucket?.billed_minutes ?? 0,
nonBillableMinutes: bucket?.non_billable_minutes ?? 0,
openMinutes: bucket?.open_minutes ?? 0, openMinutes: bucket?.open_minutes ?? 0,
billedSessions: bucket?.billed_sessions ?? 0, billedSessions: bucket?.billed_sessions ?? 0,
nonBillableSessions: bucket?.non_billable_sessions ?? 0,
openSessions: bucket?.open_sessions ?? 0, openSessions: bucket?.open_sessions ?? 0,
crmBilledMinutes: bucket?.crm_billed_minutes ?? 0 crmBilledMinutes: bucket?.crm_billed_minutes ?? 0
}; };
@@ -341,10 +307,10 @@ function OrganizationRow({ organization, maxMinutes, totalMinutes }: { organizat
<TableCell className="max-w-[320px] whitespace-normal"> <TableCell className="max-w-[320px] whitespace-normal">
<div className="font-medium">{organization.organization_name ?? "Keine Organisation"}</div> <div className="font-medium">{organization.organization_name ?? "Keine Organisation"}</div>
<div className="text-xs text-muted-foreground"> <div className="text-xs text-muted-foreground">
{organization.tickets} Ticket(s) · {organization.sessions} Session(s) {organization.tickets} Ticket(s) · {organization.billed_sessions} abgerechnet
</div> </div>
</TableCell> </TableCell>
<TableCell>{organization.sessions}</TableCell> <TableCell>{organization.billed_sessions}</TableCell>
<TableCell> <TableCell>
<TimeComparison trackedMinutes={organization.total_minutes} crmMinutes={organization.crm_billed_minutes} /> <TimeComparison trackedMinutes={organization.total_minutes} crmMinutes={organization.crm_billed_minutes} />
</TableCell> </TableCell>
@@ -369,9 +335,9 @@ function TicketHotspot({ ticket, month, onNavigate }: { ticket: StatisticsTicket
</div> </div>
<p className="truncate text-sm text-muted-foreground">{ticket.organization_name ?? "Keine Organisation"}</p> <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"> <div className="mt-1 flex flex-wrap gap-x-3 gap-y-1 text-xs">
<span className={trackedTextClass}>Sessions {formatMinutes(ticket.total_minutes)}</span> <span className={trackedTextClass}>Abgerechnet {formatMinutes(ticket.total_minutes)}</span>
<span className={teamspaceTextClass}>Teamspace {formatMinutes(ticket.crm_billed_minutes)}</span> <span className={teamspaceTextClass}>Teamspace {formatMinutes(ticket.crm_billed_minutes)}</span>
<span className="text-muted-foreground">{ticket.sessions} Session(s)</span> <span className="text-muted-foreground">{ticket.billed_sessions} abgerechnet</span>
<span>{ticket.active_days} Tag(e)</span> <span>{ticket.active_days} Tag(e)</span>
<span className={deltaTextClass(ticket.crm_delta_minutes)}>{formatTeamspaceDelta(ticket.crm_delta_minutes)}</span> <span className={deltaTextClass(ticket.crm_delta_minutes)}>{formatTeamspaceDelta(ticket.crm_delta_minutes)}</span>
</div> </div>
@@ -445,10 +411,10 @@ export function StatisticsPage({ onNavigate }: StatisticsPageProps) {
} }
const totals = stats?.totals; const totals = stats?.totals;
const evaluatedSessions = (totals?.billedSessions ?? 0) + (totals?.nonBillableSessions ?? 0); const billedSessions = totals?.billedSessions ?? 0;
const evaluatedMinutes = (totals?.billedMinutes ?? 0) + (totals?.nonBillableMinutes ?? 0); const billedMinutes = totals?.billedMinutes ?? 0;
const [evaluatedSessionShare, openSessionShare] = roundedPercentages([evaluatedSessions, totals?.openSessions ?? 0]); const openSessions = totals?.openSessions ?? 0;
const [evaluatedTimeShare, openTimeShare] = roundedPercentages([evaluatedMinutes, totals?.openMinutes ?? 0]); const openMinutes = totals?.openMinutes ?? 0;
const crmCoverage = percentage(totals?.crmBilledMinutes ?? 0, totals?.minutes ?? 0); const crmCoverage = percentage(totals?.crmBilledMinutes ?? 0, totals?.minutes ?? 0);
const maxOrganizationMinutes = Math.max(...(stats?.organizations ?? []).map((organization) => organization.total_minutes), 1); const maxOrganizationMinutes = Math.max(...(stats?.organizations ?? []).map((organization) => organization.total_minutes), 1);
const topOrganization = stats?.organizations[0] ?? null; const topOrganization = stats?.organizations[0] ?? null;
@@ -494,15 +460,15 @@ export function StatisticsPage({ onNavigate }: StatisticsPageProps) {
</div> </div>
</div> </div>
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-4"> <div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-3">
<MetricCard <MetricCard
label="Getrackt" label="Abgerechnet"
value={formatMinutes(totals?.minutes ?? 0)} value={formatMinutes(totals?.minutes ?? 0)}
detail={`${totals?.sessions ?? 0} Session(s), ${totals?.tickets ?? 0} Ticket(s)`} detail={`${billedSessions} Session(s), ${totals?.tickets ?? 0} Ticket(s)`}
icon={Clock3} icon={Clock3}
valueClassName={trackedTextClass} valueClassName={trackedTextClass}
helpAnchor="statistik-getrackt" helpAnchor="statistik-getrackt"
helpLabel="Hilfe zur Kennzahl Getrackt" helpLabel="Hilfe zur Kennzahl Abgerechnet"
/> />
<MetricCard <MetricCard
label="Teamspace" label="Teamspace"
@@ -513,27 +479,13 @@ export function StatisticsPage({ onNavigate }: StatisticsPageProps) {
helpAnchor="statistik-teamspace" helpAnchor="statistik-teamspace"
helpLabel="Hilfe zur Kennzahl Teamspace" helpLabel="Hilfe zur Kennzahl Teamspace"
/> />
<MetricCard
label="Bewertet"
value={formatMinutes(evaluatedMinutes)}
detail={
<div className="space-y-0.5">
<div>{evaluatedSessions} von {totals?.sessions ?? 0} Session(s), {evaluatedSessionShare}%</div>
<div>{evaluatedTimeShare}% der Zeit · Abr. {totals?.billedSessions ?? 0} · Nicht {totals?.nonBillableSessions ?? 0}</div>
</div>
}
icon={ListChecks}
valueClassName={evaluatedTextClass}
helpAnchor="statistik-bewertet"
helpLabel="Hilfe zur Kennzahl Bewertet"
/>
<MetricCard <MetricCard
label="Offen" label="Offen"
value={formatMinutes(totals?.openMinutes ?? 0)} value={formatMinutes(openMinutes)}
detail={ detail={
<div className="space-y-0.5"> <div className="space-y-0.5">
<div>{totals?.openSessions ?? 0} von {totals?.sessions ?? 0} Session(s), {openSessionShare}%</div> <div>{openSessions} Session(s)</div>
<div>{openTimeShare}% der Zeit</div> <div>wird nicht kalkuliert</div>
</div> </div>
} }
icon={CircleAlert} icon={CircleAlert}
@@ -547,7 +499,7 @@ export function StatisticsPage({ onNavigate }: StatisticsPageProps) {
<CardHeader className="gap-3 p-4 sm:flex-row sm:items-start sm:justify-between sm:space-y-0"> <CardHeader className="gap-3 p-4 sm:flex-row sm:items-start sm:justify-between sm:space-y-0">
<div> <div>
<CardTitle>Monatsverlauf und Abrechnungsqualität</CardTitle> <CardTitle>Monatsverlauf und Abrechnungsqualität</CardTitle>
<CardDescription>Getrackter Aufwand pro Tag, Teamspace-Linie und Bewertungsstand in einem Überblick.</CardDescription> <CardDescription>Abgerechnete Zeit pro Tag, Teamspace-Linie und Bewertungsstand in einem Überblick.</CardDescription>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Badge variant="outline">{crmCoverage}% Teamspace-Abdeckung</Badge> <Badge variant="outline">{crmCoverage}% Teamspace-Abdeckung</Badge>
@@ -558,22 +510,18 @@ export function StatisticsPage({ onNavigate }: StatisticsPageProps) {
<div className="space-y-3"> <div className="space-y-3">
<DailyEffortChart month={month} buckets={stats?.dailySeries ?? []} /> <DailyEffortChart month={month} buckets={stats?.dailySeries ?? []} />
<ColorLegend /> <ColorLegend />
{strongestDay ? <p className="text-xs text-muted-foreground">Stärkster Tag: {formatDate(`${strongestDay.day}T00:00:00`)} mit {formatMinutes(strongestDay.total_minutes)}</p> : null} {strongestDay ? <p className="text-xs text-muted-foreground">Stärkster abrechenbarer Tag: {formatDate(`${strongestDay.day}T00:00:00`)} mit {formatMinutes(strongestDay.total_minutes)}</p> : null}
</div> </div>
<div className="space-y-4"> <div className="space-y-4">
<div className="space-y-2 rounded-md border bg-muted/20 p-3"> <div className="space-y-2 rounded-md border bg-muted/20 p-3">
<div className="flex items-center justify-between gap-3 text-sm"> <div className="flex items-center justify-between gap-3 text-sm">
<span className="text-muted-foreground">Bewertungsstand</span> <span className="text-muted-foreground">Bewertungsstand</span>
<span className="font-medium">{evaluatedSessionShare}% / {openSessionShare}%</span> <span className="font-medium">{billedSessions} abrechenbar</span>
</div>
<div className="flex h-2 overflow-hidden rounded-full bg-muted">
<div className="bg-violet-500 dark:bg-violet-400" style={{ width: `${evaluatedSessionShare}%` }} />
<div className="bg-amber-500 dark:bg-amber-400" style={{ width: `${openSessionShare}%` }} />
</div> </div>
<div className="grid gap-2 text-xs sm:grid-cols-2 xl:grid-cols-1 2xl:grid-cols-2"> <div className="grid gap-2 text-xs sm:grid-cols-2 xl:grid-cols-1 2xl:grid-cols-2">
<div className={evaluatedTextClass}>Bewertet: {evaluatedSessions} Session(s), {formatMinutes(evaluatedMinutes)}</div> <div className={trackedTextClass}>Abgerechnet: {billedSessions} Session(s), {formatMinutes(billedMinutes)}</div>
<div className={openTextClass}>Offen: {totals?.openSessions ?? 0} Session(s), {formatMinutes(totals?.openMinutes ?? 0)}</div> <div className={openTextClass}>Offen: {openSessions} Session(s), {formatMinutes(openMinutes)}</div>
</div> </div>
</div> </div>
@@ -585,8 +533,8 @@ export function StatisticsPage({ onNavigate }: StatisticsPageProps) {
</div> </div>
<DeltaBadge minutes={totals?.crmDeltaMinutes ?? 0} /> <DeltaBadge minutes={totals?.crmDeltaMinutes ?? 0} />
</div> </div>
<TimeComparison trackedMinutes={totals?.minutes ?? 0} crmMinutes={totals?.crmBilledMinutes ?? 0} sessions={totals?.sessions ?? 0} /> <TimeComparison trackedMinutes={totals?.minutes ?? 0} crmMinutes={totals?.crmBilledMinutes ?? 0} sessions={totals?.billedSessions ?? 0} />
<div className="text-xs text-muted-foreground">Teamspace-Abdeckung: {crmCoverage}% der getrackten Session-Zeit</div> <div className="text-xs text-muted-foreground">Teamspace-Abdeckung: {crmCoverage}% der abgerechneten Zeit</div>
</div> </div>
<div className="grid grid-cols-2 gap-2 text-sm"> <div className="grid grid-cols-2 gap-2 text-sm">
@@ -614,7 +562,7 @@ export function StatisticsPage({ onNavigate }: StatisticsPageProps) {
<p className="text-muted-foreground">Top Organisation</p> <p className="text-muted-foreground">Top Organisation</p>
<p className="truncate font-semibold">{topOrganization.organization_name}</p> <p className="truncate font-semibold">{topOrganization.organization_name}</p>
<p className="flex flex-wrap gap-x-3 gap-y-1 text-xs"> <p className="flex flex-wrap gap-x-3 gap-y-1 text-xs">
<span className={trackedTextClass}>Sessions {formatMinutes(topOrganization.total_minutes)}</span> <span className={trackedTextClass}>Abgerechnet {formatMinutes(topOrganization.total_minutes)}</span>
<span className={teamspaceTextClass}>Teamspace {formatMinutes(topOrganization.crm_billed_minutes)}</span> <span className={teamspaceTextClass}>Teamspace {formatMinutes(topOrganization.crm_billed_minutes)}</span>
</p> </p>
</div> </div>
@@ -628,7 +576,7 @@ export function StatisticsPage({ onNavigate }: StatisticsPageProps) {
<CardHeader className="gap-3 p-4 sm:flex-row sm:items-start sm:justify-between sm:space-y-0"> <CardHeader className="gap-3 p-4 sm:flex-row sm:items-start sm:justify-between sm:space-y-0">
<div> <div>
<CardTitle>Statistik pro Organisation</CardTitle> <CardTitle>Statistik pro Organisation</CardTitle>
<CardDescription>Kundenaufwand inklusive Teamspace-Differenz und Anteil am getrackten Monatsaufwand.</CardDescription> <CardDescription>Kundenaufwand inklusive Teamspace-Differenz und Anteil an der abgerechneten Monatszeit.</CardDescription>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Badge variant="outline"> <Badge variant="outline">
@@ -643,10 +591,10 @@ export function StatisticsPage({ onNavigate }: StatisticsPageProps) {
<TableHeader> <TableHeader>
<TableRow> <TableRow>
<TableHead>Organisation</TableHead> <TableHead>Organisation</TableHead>
<TableHead>Sessions</TableHead> <TableHead>Abgerechnet</TableHead>
<TableHead>Zeiten</TableHead> <TableHead>Zeiten</TableHead>
<TableHead>Differenz</TableHead> <TableHead>Differenz</TableHead>
<TableHead>Anteil am Monat</TableHead> <TableHead>Anteil abgerechnet</TableHead>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
@@ -667,13 +615,13 @@ export function StatisticsPage({ onNavigate }: StatisticsPageProps) {
<div className="flex items-start justify-between gap-2"> <div className="flex items-start justify-between gap-2">
<div className="min-w-0"> <div className="min-w-0">
<p className="truncate font-medium">{organization.organization_name ?? "Keine Organisation"}</p> <p className="truncate font-medium">{organization.organization_name ?? "Keine Organisation"}</p>
<p className="text-xs text-muted-foreground">{organization.tickets} Ticket(s) · {organization.sessions} Session(s)</p> <p className="text-xs text-muted-foreground">{organization.tickets} Ticket(s) · {organization.billed_sessions} abgerechnet</p>
</div> </div>
<DeltaBadge minutes={organization.crm_delta_minutes} /> <DeltaBadge minutes={organization.crm_delta_minutes} />
</div> </div>
<Progress value={percentage(organization.total_minutes, maxOrganizationMinutes)} /> <Progress value={percentage(organization.total_minutes, maxOrganizationMinutes)} />
<div className="flex flex-wrap gap-x-3 gap-y-1 text-xs"> <div className="flex flex-wrap gap-x-3 gap-y-1 text-xs">
<span className={trackedTextClass}>Sessions {formatMinutes(organization.total_minutes)}</span> <span className={trackedTextClass}>Abgerechnet {formatMinutes(organization.total_minutes)}</span>
<span className={teamspaceTextClass}>Teamspace {formatMinutes(organization.crm_billed_minutes)}</span> <span className={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="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> <span className={deltaTextClass(organization.crm_delta_minutes)}>{formatTeamspaceDelta(organization.crm_delta_minutes)}</span>
@@ -736,18 +684,17 @@ export function StatisticsPage({ onNavigate }: StatisticsPageProps) {
<div className="flex items-center justify-between gap-3"> <div className="flex items-center justify-between gap-3">
<div> <div>
<p className="font-semibold">{workTypeLabel(group.work_type)}</p> <p className="font-semibold">{workTypeLabel(group.work_type)}</p>
<p className="text-xs text-muted-foreground">{group.tickets} Ticket(s), {group.sessions} Session(s)</p> <p className="text-xs text-muted-foreground">{group.tickets} Ticket(s), {group.billed_sessions} abgerechnet</p>
</div> </div>
<DeltaBadge minutes={group.crm_delta_minutes} /> <DeltaBadge minutes={group.crm_delta_minutes} />
</div> </div>
<Progress value={percentage(group.total_minutes, totals?.minutes ?? 0)} /> <Progress value={percentage(group.total_minutes, totals?.minutes ?? 0)} />
<div className="flex flex-wrap gap-x-3 gap-y-1 text-xs"> <div className="flex flex-wrap gap-x-3 gap-y-1 text-xs">
<span className={trackedTextClass}>Sessions {formatMinutes(group.total_minutes)}</span> <span className={trackedTextClass}>Abgerechnet {formatMinutes(group.total_minutes)}</span>
<span className={teamspaceTextClass}>Teamspace {formatMinutes(group.crm_billed_minutes)}</span> <span className={teamspaceTextClass}>Teamspace {formatMinutes(group.crm_billed_minutes)}</span>
</div> </div>
<div className="grid grid-cols-3 gap-2 text-xs text-muted-foreground"> <div className="grid grid-cols-2 gap-2 text-xs text-muted-foreground">
<span>Abr. {group.billed_sessions} / {formatMinutes(group.billed_minutes)}</span> <span>Abr. {group.billed_sessions} / {formatMinutes(group.billed_minutes)}</span>
<span>Nicht {group.non_billable_sessions} / {formatMinutes(group.non_billable_minutes)}</span>
<span>Offen {group.open_sessions} / {formatMinutes(group.open_minutes)}</span> <span>Offen {group.open_sessions} / {formatMinutes(group.open_minutes)}</span>
</div> </div>
</div> </div>
@@ -779,13 +726,13 @@ export function StatisticsPage({ onNavigate }: StatisticsPageProps) {
</CardContent> </CardContent>
</Card> </Card>
{stats && stats.totals.sessions === 0 ? ( {stats && stats.totals.billedSessions === 0 ? (
<Card> <Card>
<CardContent className="grid place-items-center gap-2 p-8 text-center"> <CardContent className="grid place-items-center gap-2 p-8 text-center">
<Ticket className="size-8 text-muted-foreground" /> <Ticket className="size-8 text-muted-foreground" />
<div> <div>
<p className="font-medium">Keine Sessions in diesem Monat</p> <p className="font-medium">Keine abgerechneten Sessions in diesem Monat</p>
<p className="text-sm text-muted-foreground">Sobald Sessions vorhanden sind, füllt sich die Statistik automatisch.</p> <p className="text-sm text-muted-foreground">Offene Sessions werden separat gezeigt und fließen nicht in Statistiken oder Vergleiche ein.</p>
</div> </div>
<Button variant="secondary" size="sm" onClick={() => setMonth(currentMonth())}> <Button variant="secondary" size="sm" onClick={() => setMonth(currentMonth())}>
<RotateCcw className="size-4" /> <RotateCcw className="size-4" />
+57 -6
View File
@@ -1,6 +1,7 @@
import { ArrowLeft, Pencil, Save, Trash2 } from "lucide-react"; import { ArrowLeft, Pencil, Save, Trash2 } from "lucide-react";
import { FormEvent, useEffect, useMemo, useState } from "react"; import { FormEvent, useEffect, useMemo, useState } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import { Alert } from "@/components/ui/alert";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
@@ -28,6 +29,15 @@ type TicketDetailPageProps = {
period: string; period: string;
ticketId: string; ticketId: string;
onNavigate: (to: string) => void; onNavigate: (to: string) => void;
onOpenTicketChange?: (ticket: {
key: string;
ticketId: string;
ticketNumber: string;
periodType: PeriodType;
period: string;
path: string;
dirty: boolean;
}) => void;
}; };
type TicketSessionFormState = { type TicketSessionFormState = {
@@ -140,7 +150,7 @@ function isPartialHoursInput(value: string) {
return /^\d*(?:[,.]\d*)?$/.test(value.trim()); 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 [data, setData] = useState<TicketPeriod | null>(null);
const [loadingId, setLoadingId] = useState<string | null>(null); const [loadingId, setLoadingId] = useState<string | null>(null);
const [deletingId, setDeletingId] = useState<string | null>(null); const [deletingId, setDeletingId] = useState<string | null>(null);
@@ -206,22 +216,32 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate }: T
const savedDayBillingInput = (day: string) => formatHoursInput(dayBillingMap.get(day)); const savedDayBillingInput = (day: string) => formatHoursInput(dayBillingMap.get(day));
const isDayBillingDirty = (day: string) => (dayBillingInputs[day] ?? "") !== savedDayBillingInput(day); const isDayBillingDirty = (day: string) => (dayBillingInputs[day] ?? "") !== savedDayBillingInput(day);
const sessionGroups = useMemo(() => { 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 ?? []) { for (const session of data?.sessions ?? []) {
const key = sessionDayKey(session.started_at); const key = sessionDayKey(session.started_at);
const group = groups.get(key); 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) { if (group) {
group.sessions.push(session); group.sessions.push(session);
group.totalMinutes += session.rounded_minutes; group.totalMinutes += billedMinutes;
group.nonBillableMinutes += nonBillableMinutes;
group.openMinutes += openMinutes;
continue; continue;
} }
groups.set(key, { groups.set(key, {
key, key,
label: formatDate(session.started_at), label: formatDate(session.started_at),
totalMinutes: session.rounded_minutes, totalMinutes: billedMinutes,
nonBillableMinutes,
openMinutes,
sessions: [session] sessions: [session]
}); });
} }
@@ -230,6 +250,29 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate }: T
}, [data?.sessions]); }, [data?.sessions]);
const periodTotalMinutes = sessionGroups.reduce((sum, group) => sum + group.totalMinutes, 0); 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 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) { async function setBilling(sessionId: string, billingStatus: BillingStatus) {
if (isPeriodClosed && billingStatus !== null) { if (isPeriodClosed && billingStatus !== null) {
@@ -492,6 +535,12 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate }: T
</Badge> </Badge>
</div> </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> <Card>
<CardHeader className="p-4"> <CardHeader className="p-4">
<div className="flex items-start justify-between gap-3"> <div className="flex items-start justify-between gap-3">
@@ -551,7 +600,7 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate }: T
</div> </div>
<div className="flex flex-wrap items-center gap-2"> <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"> <div className="flex items-center gap-2 rounded-md border bg-muted/30 px-3 py-2 text-sm">
<span className="text-muted-foreground">{periodLabel} gesamt</span> <span className="text-muted-foreground">{periodLabel} abgerechnet</span>
<span className="font-semibold">{formatMinutes(periodTotalMinutes)}</span> <span className="font-semibold">{formatMinutes(periodTotalMinutes)}</span>
</div> </div>
<div className="flex items-center gap-2 rounded-md border bg-muted/30 px-3 py-2 text-sm"> <div className="flex items-center gap-2 rounded-md border bg-muted/30 px-3 py-2 text-sm">
@@ -568,7 +617,9 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate }: T
<div className="flex flex-col gap-2 border-b bg-muted/40 px-3 py-2 sm:flex-row sm:items-center sm:justify-between"> <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> <p className="font-medium">{group.label}</p>
<div className="flex flex-wrap items-center gap-2"> <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"> <div className="flex items-center gap-2 text-xs text-muted-foreground">
<label htmlFor={`crm-billing-${group.key}`}>Teamspace</label> <label htmlFor={`crm-billing-${group.key}`}>Teamspace</label>
<Input <Input
+2 -1
View File
@@ -318,6 +318,7 @@ export function TimerPage({ timers, setTimers, selectedTimerId, setSelectedTimer
id="ticket-number" id="ticket-number"
placeholder="Ticket#123456" placeholder="Ticket#123456"
value={ticketNumber} value={ticketNumber}
autoFocus
onChange={(event) => setTicketNumber(event.currentTarget.value)} onChange={(event) => setTicketNumber(event.currentTarget.value)}
onKeyDown={(event) => { onKeyDown={(event) => {
if (event.key === "Enter") { if (event.key === "Enter") {
@@ -525,7 +526,7 @@ export function TimerPage({ timers, setTimers, selectedTimerId, setSelectedTimer
<label className="text-sm font-medium" htmlFor="activity"> <label className="text-sm font-medium" htmlFor="activity">
Tätigkeit Tätigkeit
</label> </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> </div>
{finished?.ticket?.organization_id && finished.ticket.work_type ? null : ( {finished?.ticket?.organization_id && finished.ticket.work_type ? null : (
<div className="space-y-2"> <div className="space-y-2">