Update
This commit is contained in:
1 parent
3d9674bd5a
commit
d1da86beab
20 files changed
+5789
-603
No files matched your search
+585
-45
File diff suppressed because it is too large.
Load diff
@@ -89,6 +89,16 @@ export async function migrate() {
|
|||||||
);
|
);
|
||||||
`);
|
`);
|
||||||
|
|
||||||
|
await query(`
|
||||||
|
CREATE TABLE IF NOT EXISTS user_preferences (
|
||||||
|
user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
key TEXT NOT NULL,
|
||||||
|
value JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
PRIMARY KEY (user_id, key)
|
||||||
|
);
|
||||||
|
`);
|
||||||
|
|
||||||
await query(`
|
await query(`
|
||||||
CREATE TABLE IF NOT EXISTS organizations (
|
CREATE TABLE IF NOT EXISTS organizations (
|
||||||
id BIGSERIAL PRIMARY KEY,
|
id BIGSERIAL PRIMARY KEY,
|
||||||
@@ -448,14 +458,54 @@ export async function migrate() {
|
|||||||
starts_on DATE,
|
starts_on DATE,
|
||||||
ends_on DATE,
|
ends_on DATE,
|
||||||
hour_budget_minutes INTEGER,
|
hour_budget_minutes INTEGER,
|
||||||
|
manual_consumption_minutes INTEGER NOT NULL DEFAULT 0,
|
||||||
|
closed_at TIMESTAMPTZ,
|
||||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
UNIQUE (user_id, ticket_id),
|
UNIQUE (user_id, ticket_id),
|
||||||
CONSTRAINT projects_budget_non_negative CHECK (hour_budget_minutes IS NULL OR hour_budget_minutes >= 0),
|
CONSTRAINT projects_budget_non_negative CHECK (hour_budget_minutes IS NULL OR hour_budget_minutes >= 0),
|
||||||
|
CONSTRAINT projects_manual_consumption_non_negative CHECK (manual_consumption_minutes >= 0),
|
||||||
CONSTRAINT projects_valid_range CHECK (starts_on IS NULL OR ends_on IS NULL OR ends_on >= starts_on)
|
CONSTRAINT projects_valid_range CHECK (starts_on IS NULL OR ends_on IS NULL OR ends_on >= starts_on)
|
||||||
);
|
);
|
||||||
`);
|
`);
|
||||||
|
|
||||||
|
await query("ALTER TABLE projects ADD COLUMN IF NOT EXISTS closed_at TIMESTAMPTZ;");
|
||||||
|
await query("ALTER TABLE projects ADD COLUMN IF NOT EXISTS manual_consumption_minutes INTEGER NOT NULL DEFAULT 0;");
|
||||||
|
await query(`
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF NOT EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM pg_constraint
|
||||||
|
WHERE conname = 'projects_manual_consumption_non_negative'
|
||||||
|
) THEN
|
||||||
|
ALTER TABLE projects
|
||||||
|
ADD CONSTRAINT projects_manual_consumption_non_negative CHECK (manual_consumption_minutes >= 0);
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
|
`);
|
||||||
|
|
||||||
|
await query(`
|
||||||
|
UPDATE tickets t
|
||||||
|
SET work_type = 'consulting'
|
||||||
|
WHERE EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM sessions s
|
||||||
|
WHERE s.ticket_id = t.id
|
||||||
|
AND s.work_type = 'consulting'
|
||||||
|
);
|
||||||
|
`);
|
||||||
|
|
||||||
|
await query(`
|
||||||
|
INSERT INTO projects (user_id, ticket_id, teamspace_project, description, goals)
|
||||||
|
SELECT DISTINCT s.user_id, s.ticket_id, COALESCE(t.title, ''), '', ''
|
||||||
|
FROM sessions s
|
||||||
|
JOIN tickets t ON t.id = s.ticket_id
|
||||||
|
WHERE s.work_type = 'consulting'
|
||||||
|
AND s.user_id IS NOT NULL
|
||||||
|
ON CONFLICT (user_id, ticket_id) DO NOTHING;
|
||||||
|
`);
|
||||||
|
|
||||||
await query(`
|
await query(`
|
||||||
CREATE TABLE IF NOT EXISTS ticket_todos (
|
CREATE TABLE IF NOT EXISTS ticket_todos (
|
||||||
id BIGSERIAL PRIMARY KEY,
|
id BIGSERIAL PRIMARY KEY,
|
||||||
@@ -465,14 +515,50 @@ export async function migrate() {
|
|||||||
description TEXT NOT NULL DEFAULT '',
|
description TEXT NOT NULL DEFAULT '',
|
||||||
priority TEXT NOT NULL DEFAULT 'medium',
|
priority TEXT NOT NULL DEFAULT 'medium',
|
||||||
status TEXT NOT NULL DEFAULT 'open',
|
status TEXT NOT NULL DEFAULT 'open',
|
||||||
|
sort_order INTEGER NOT NULL DEFAULT 0,
|
||||||
|
starts_on DATE,
|
||||||
|
ends_on DATE,
|
||||||
completed_at TIMESTAMPTZ,
|
completed_at TIMESTAMPTZ,
|
||||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
CONSTRAINT ticket_todos_priority_valid CHECK (priority IN ('low', 'medium', 'high', 'urgent')),
|
CONSTRAINT ticket_todos_priority_valid CHECK (priority IN ('low', 'medium', 'high', 'urgent')),
|
||||||
CONSTRAINT ticket_todos_status_valid CHECK (status IN ('open', 'done'))
|
CONSTRAINT ticket_todos_status_valid CHECK (status IN ('open', 'done')),
|
||||||
|
CONSTRAINT ticket_todos_valid_period CHECK (starts_on IS NULL OR ends_on IS NULL OR ends_on >= starts_on)
|
||||||
);
|
);
|
||||||
`);
|
`);
|
||||||
|
|
||||||
|
await query("ALTER TABLE ticket_todos ADD COLUMN IF NOT EXISTS starts_on DATE;");
|
||||||
|
await query("ALTER TABLE ticket_todos ADD COLUMN IF NOT EXISTS ends_on DATE;");
|
||||||
|
await query("ALTER TABLE ticket_todos ADD COLUMN IF NOT EXISTS sort_order INTEGER;");
|
||||||
|
await query(`
|
||||||
|
WITH ordered_todos AS (
|
||||||
|
SELECT
|
||||||
|
id,
|
||||||
|
ROW_NUMBER() OVER (PARTITION BY user_id ORDER BY created_at ASC, id ASC) AS row_number
|
||||||
|
FROM ticket_todos
|
||||||
|
WHERE sort_order IS NULL
|
||||||
|
)
|
||||||
|
UPDATE ticket_todos tt
|
||||||
|
SET sort_order = ordered_todos.row_number * 1024
|
||||||
|
FROM ordered_todos
|
||||||
|
WHERE tt.id = ordered_todos.id;
|
||||||
|
`);
|
||||||
|
await query("ALTER TABLE ticket_todos ALTER COLUMN sort_order SET DEFAULT 0;");
|
||||||
|
await query("ALTER TABLE ticket_todos ALTER COLUMN sort_order SET NOT NULL;");
|
||||||
|
await query(`
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF NOT EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM pg_constraint
|
||||||
|
WHERE conname = 'ticket_todos_valid_period'
|
||||||
|
) THEN
|
||||||
|
ALTER TABLE ticket_todos
|
||||||
|
ADD CONSTRAINT ticket_todos_valid_period CHECK (starts_on IS NULL OR ends_on IS NULL OR ends_on >= starts_on);
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
|
`);
|
||||||
|
|
||||||
await ensureClosureOwnership("ticket_month_closures", "month", ["ticket_id", "month"], adminId);
|
await ensureClosureOwnership("ticket_month_closures", "month", ["ticket_id", "month"], adminId);
|
||||||
await ensureClosureOwnership("month_closures", "month", ["month"], adminId);
|
await ensureClosureOwnership("month_closures", "month", ["month"], adminId);
|
||||||
await ensureClosureOwnership("ticket_day_closures", "day", ["ticket_id", "day"], adminId);
|
await ensureClosureOwnership("ticket_day_closures", "day", ["ticket_id", "day"], adminId);
|
||||||
@@ -490,8 +576,11 @@ export async function migrate() {
|
|||||||
await query("CREATE INDEX IF NOT EXISTS idx_ticket_day_billing_ack_user_day ON ticket_day_billing_acknowledgements(user_id, day);");
|
await query("CREATE INDEX IF NOT EXISTS idx_ticket_day_billing_ack_user_day ON ticket_day_billing_acknowledgements(user_id, day);");
|
||||||
await query("CREATE INDEX IF NOT EXISTS idx_projects_user_ticket ON projects(user_id, ticket_id);");
|
await query("CREATE INDEX IF NOT EXISTS idx_projects_user_ticket ON projects(user_id, ticket_id);");
|
||||||
await query("CREATE INDEX IF NOT EXISTS idx_projects_user_updated ON projects(user_id, updated_at DESC);");
|
await query("CREATE INDEX IF NOT EXISTS idx_projects_user_updated ON projects(user_id, updated_at DESC);");
|
||||||
|
await query("CREATE INDEX IF NOT EXISTS idx_projects_user_closed_updated ON projects(user_id, closed_at, updated_at DESC);");
|
||||||
await query("CREATE INDEX IF NOT EXISTS idx_ticket_todos_user_status_priority ON ticket_todos(user_id, status, priority);");
|
await query("CREATE INDEX IF NOT EXISTS idx_ticket_todos_user_status_priority ON ticket_todos(user_id, status, priority);");
|
||||||
await query("CREATE INDEX IF NOT EXISTS idx_ticket_todos_ticket_user ON ticket_todos(ticket_id, user_id);");
|
await query("CREATE INDEX IF NOT EXISTS idx_ticket_todos_ticket_user ON ticket_todos(ticket_id, user_id);");
|
||||||
|
await query("CREATE INDEX IF NOT EXISTS idx_ticket_todos_user_period ON ticket_todos(user_id, starts_on, ends_on);");
|
||||||
|
await query("CREATE INDEX IF NOT EXISTS idx_ticket_todos_user_sort_order ON ticket_todos(user_id, sort_order, id);");
|
||||||
await query("CREATE INDEX IF NOT EXISTS idx_recurring_billings_user_active ON recurring_billings(user_id, active);");
|
await query("CREATE INDEX IF NOT EXISTS idx_recurring_billings_user_active ON recurring_billings(user_id, active);");
|
||||||
await query("UPDATE recurring_billings SET active = true WHERE active = false;");
|
await query("UPDATE recurring_billings SET active = true WHERE active = false;");
|
||||||
await query(`
|
await query(`
|
||||||
|
|||||||
+99
-13
@@ -27,7 +27,7 @@ Wichtig: Tickets werden nicht geschlossen. Tickets dienen nur als Gruppierung fu
|
|||||||
|
|
||||||
## Hilfe und FAQ
|
## 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.
|
Die App hat eine eigene Unterseite `FAQ`. Dort steht die ausfuehrliche Bedienungsanleitung mit Abschnitten zu Timer, Tickets, 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.
|
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.
|
||||||
|
|
||||||
@@ -42,7 +42,7 @@ Normale Benutzer sehen ihre eigenen Zeiten, Auswertungen, fixen Abrechnungen und
|
|||||||
Die Navigation ist in Workflows gegliedert:
|
Die Navigation ist in Workflows gegliedert:
|
||||||
|
|
||||||
- Session starten
|
- Session starten
|
||||||
- Auswertung
|
- Tickets
|
||||||
- Monatsabschluss
|
- Monatsabschluss
|
||||||
- Statistiken
|
- Statistiken
|
||||||
- Fixe Abrechnung
|
- Fixe Abrechnung
|
||||||
@@ -50,7 +50,7 @@ Die Navigation ist in Workflows gegliedert:
|
|||||||
- Administration, nur fuer Admin
|
- Administration, nur fuer Admin
|
||||||
- FAQ
|
- FAQ
|
||||||
|
|
||||||
Nach `Auswertung`, nach `Monatsabschluss` und vor `FAQ` trennt eine Linie die Bereiche optisch.
|
Nach `Tickets`, nach `Monatsabschluss` und vor `FAQ` trennt eine Linie die Bereiche optisch.
|
||||||
|
|
||||||
## Globaler Timer-Schnellstart
|
## Globaler Timer-Schnellstart
|
||||||
|
|
||||||
@@ -131,9 +131,9 @@ Der Grund: Nur mit eindeutigen Organisationen sind saubere Auswertungen pro Kund
|
|||||||
|
|
||||||
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.
|
||||||
|
|
||||||
## Auswertung
|
## Tickets
|
||||||
|
|
||||||
Die Seite `Auswertung` hat zwei Ansichten:
|
Die Seite `Tickets` hat zwei Ansichten:
|
||||||
|
|
||||||
- Monat
|
- Monat
|
||||||
- Tag
|
- Tag
|
||||||
@@ -148,10 +148,7 @@ Die oberen Karten zeigen:
|
|||||||
- Nicht abrechenbar: Summe der Sessions mit Bewertung `Nicht abrechenbar`
|
- Nicht abrechenbar: Summe der Sessions mit Bewertung `Nicht abrechenbar`
|
||||||
- Offen: Anzahl unbewerteter Sessions
|
- Offen: Anzahl unbewerteter Sessions
|
||||||
|
|
||||||
Der Graph zeigt abgerechneten Aufwand im Zeitraum:
|
Der Verlauf der abgerechneten Zeit liegt auf der Seite `Statistiken`. Dort wird der Monatsverlauf gemeinsam mit Teamspace und Bewertungsstand betrachtet.
|
||||||
|
|
||||||
- In der Monatsansicht pro Tag
|
|
||||||
- In der Tagesansicht pro Stunde
|
|
||||||
|
|
||||||
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.
|
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.
|
||||||
|
|
||||||
@@ -205,19 +202,106 @@ Die Einstellungen bleiben im Browser gespeichert, auch nach Aktualisierung oder
|
|||||||
|
|
||||||
## Ticketdetailansicht
|
## Ticketdetailansicht
|
||||||
|
|
||||||
Ein Ticket wird aus der Auswertung heraus geoeffnet.
|
Ein Ticket wird aus der Ticketliste heraus geoeffnet.
|
||||||
|
|
||||||
In der Ticketdetailansicht sieht man:
|
In der Ticketdetailansicht sieht man:
|
||||||
|
|
||||||
- Ticketnummer mit Copy-Icon
|
- Ticketnummer mit Copy-Icon
|
||||||
- Status des Monats
|
- Status des Monats
|
||||||
- Ticketdaten
|
- Ticketdaten
|
||||||
|
- Projektinformationen, falls das Ticket ein Projekt ist
|
||||||
|
- ToDos zu diesem Ticket oder Projekt
|
||||||
- Session-Eintraege nach Tagen gruppiert
|
- Session-Eintraege nach Tagen gruppiert
|
||||||
- Teamspace-Wert pro Ticket und Tag
|
- 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.
|
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.
|
||||||
|
|
||||||
|
## Projekte und ToDos
|
||||||
|
|
||||||
|
Projekte erweitern ein normales Ticket um Projektinformationen. Sie werden ueber den Navipunkt `Projekte` oder direkt aus einem Ticket heraus angelegt.
|
||||||
|
|
||||||
|
Ein Projekt enthaelt:
|
||||||
|
|
||||||
|
- Ticketnummer
|
||||||
|
- Optionalen Titel
|
||||||
|
- Organisation
|
||||||
|
- Teamspace-Projekt
|
||||||
|
- Beschreibung/Ziele
|
||||||
|
- Ungefaehren Zeitraum
|
||||||
|
- Optionales Stundenkontingent
|
||||||
|
- Optionalen manuellen Verbrauch in Stunden
|
||||||
|
|
||||||
|
Projekt-Tickets sind immer Consulting. Wird ein Projekt geloescht, bleiben das Ticket, die Sessions und die ToDos erhalten. Geloescht werden nur die Projektinformationen.
|
||||||
|
|
||||||
|
Projekte koennen offen oder geschlossen sein. Auf der Projektseite werden standardmaessig nur offene Projekte angezeigt. Mit `Geschlossene anzeigen` werden geschlossene Projekte zusaetzlich eingeblendet. Offene Projekte stehen immer vor geschlossenen Projekten.
|
||||||
|
|
||||||
|
Der Projektstatus betrifft nur die Projektverwaltung. Die Sessions eines Tickets werden dadurch nicht ausgeblendet oder veraendert.
|
||||||
|
|
||||||
|
Der Verbrauch eines Projekts ist abrechnungstechnisch von den getrackten Sessions getrennt. Fuer den Projektverbrauch zaehlen nur gepflegte Teamspace-Tageswerte dieses Projekt-Tickets und der optionale manuelle Verbrauch am Projekt. Getrackte Sessionzeiten werden im Projekt zwar weiterhin angezeigt und bleiben normal bewertbar, sie erhoehen den Projektverbrauch aber nicht direkt.
|
||||||
|
|
||||||
|
Der manuelle Verbrauch ist fuer Altbestaende oder einmalige Buchungen gedacht, die vor der Nutzung von TicketTracker bereits im Projekt angefallen sind. Dieser Wert wirkt ausschliesslich auf den Projektverbrauch und wird nicht in Statistiken, Monatsabschluss, Auswertung oder Exporte als Sessionzeit eingerechnet.
|
||||||
|
|
||||||
|
Die Projektseite enthaelt Filter und Sortierungen. Projekte koennen nach Freitext, Status, Organisation, Kontingentstatus und ToDos gefiltert werden. Sortiert werden kann unter anderem nach Ticket, Titel, Organisation, Teamspace-Projekt, Verbrauch, Reststunden, Kontingent und offenen ToDos. Die Einstellungen bleiben im Browser gespeichert und koennen ueber `Zuruecksetzen` wieder auf Standard gestellt werden.
|
||||||
|
|
||||||
|
Reststunden werden aus `Stundenkontingent minus Projektverbrauch` berechnet. Wenn kein Stundenkontingent gepflegt ist, kann kein offener Rest berechnet werden. Ist der Verbrauch hoeher als das Kontingent, zeigt die Projektkarte die Ueberziehung an.
|
||||||
|
|
||||||
|
Projektinformationen und ToDos stehen in der Ticketdetailansicht auf breiten Bildschirmen nebeneinander. Auf kleineren Bildschirmen werden sie untereinander angezeigt.
|
||||||
|
|
||||||
|
ToDos werden direkt am Ticket oder Projekt angelegt. Ein ToDo enthaelt:
|
||||||
|
|
||||||
|
- Aufgabe
|
||||||
|
- Prioritaet
|
||||||
|
- Optionalen Umsetzungszeitraum von/bis
|
||||||
|
- Optionale Details
|
||||||
|
|
||||||
|
Wenn `Umsetzung von` und `Umsetzung bis` gleich sind, ist das ToDo ein festes Datum.
|
||||||
|
|
||||||
|
Die zentrale Seite `ToDos` zeigt oben `Heutige ToDos`. Dort erscheinen nur offene ToDos, bei denen `Umsetzung von` und `Umsetzung bis` beide auf den heutigen Tag gesetzt sind. Ein Zeitraum, der heute nur einschliesst, wird nicht als heutiges ToDo gewertet.
|
||||||
|
|
||||||
|
Darunter steht die Aufgabenliste mit Filter fuer:
|
||||||
|
|
||||||
|
- Offen
|
||||||
|
- Alle
|
||||||
|
- Erledigt
|
||||||
|
- Ticketnummer
|
||||||
|
|
||||||
|
Die Aufgabenliste kann zusaetzlich sortiert werden nach:
|
||||||
|
|
||||||
|
- Benutzerdefiniert
|
||||||
|
- Prioritaet
|
||||||
|
- Titel
|
||||||
|
- Ticketnummer
|
||||||
|
- Organisation
|
||||||
|
- Umsetzungsdatum
|
||||||
|
- Zuletzt geaendert
|
||||||
|
|
||||||
|
Die Ticket-Suche filtert nach Ticketnummer. Dabei reicht auch die Nummer ohne `Ticket#`, zum Beispiel `944079`.
|
||||||
|
|
||||||
|
`Benutzerdefiniert` ist die frei gespeicherte Reihenfolge. Diese Reihenfolge wird per Drag and Drop geaendert: ToDo am Griff ziehen, an der neuen Stelle fallen lassen. Sobald frei sortiert wird, stellt das Dropdown automatisch auf `Benutzerdefiniert`. Die Reihenfolge wird serverseitig pro Benutzer gespeichert. Du kannst also zwischendurch nach Titel oder Prioritaet sortieren und spaeter wieder auf deine eigene Reihenfolge zurueckwechseln.
|
||||||
|
|
||||||
|
Mit den drei Gruppierungsfeldern kann die Aufgabenliste mehrstufig gruppiert werden. Die Reihenfolge der Felder ist die Reihenfolge der Gruppen, zum Beispiel `Ticket` -> `Prioritaet` -> `Kunde`. Moegliche Gruppierungen sind `Ticket`, `Prioritaet`, `Kunde` und `Status`. Statusfilter, Ticket-Suche, Sortierung und Gruppierung bleiben nach Aktualisierung und Browserneustart erhalten. Mit `Zuruecksetzen` werden die Standardeinstellungen wiederhergestellt.
|
||||||
|
|
||||||
|
Unterhalb von `FAQ` zeigt die Seitenleiste die Kachel `Termine`. Dort stehen die festen ToDos des heutigen Tages, eine Summe laufender ToDos fuer heute und ueberfaellige ToDos. Ein Klick auf einen Eintrag oeffnet das zugehoerige Ticket.
|
||||||
|
|
||||||
|
Unter der Aufgabenliste steht der `ToDo-Kalender`. Er zeigt offene ToDos pro Monat; erledigte ToDos werden im Kalender ausgeblendet. Wochenenden und bayerische Feiertage bleiben im Kalender ohne ToDo-Einträge. Der Monat kann mit den Pfeilen gewechselt werden.
|
||||||
|
|
||||||
|
Ein-Tages-ToDos werden einzeln angezeigt. Das sind Eintraege, bei denen `Umsetzung von` und `Umsetzung bis` gleich sind. Die Farbe richtet sich nach der Prioritaet.
|
||||||
|
|
||||||
|
ToDos mit Zeitraum oder nur einem Datum werden nicht einzeln ausgeschrieben, sondern pro Tag als Summe angezeigt:
|
||||||
|
|
||||||
|
- `Umsetzung von` und `Umsetzung bis` unterschiedlich: Das ToDo zaehlt an jedem Tag im Zeitraum.
|
||||||
|
- Nur `Umsetzung von`: Das ToDo zaehlt ab diesem Datum weiter, bis es erledigt, geloescht oder mit einem Bis-Datum begrenzt wird.
|
||||||
|
- Nur `Umsetzung bis`: Das ToDo zaehlt ab heute bis zum Bis-Datum.
|
||||||
|
|
||||||
|
Ein offenes ToDo ist ueberfaellig, sobald `Umsetzung bis` vor dem heutigen Tag liegt. Ueberfaellige ToDos werden in Listen, im Kalender und in der Termine-Kachel rot hervorgehoben. Im Kalender bleiben sie ab dem Tag nach der Deadline an jedem Arbeitstag als roter Badge `+X uef` sichtbar, bis sie erledigt oder geloescht werden.
|
||||||
|
|
||||||
|
Ein Klick auf ein einzelnes ToDo oeffnet direkt das Bearbeiten-Modal. Ein Klick auf `+X ToDos` oeffnet ein kompaktes Tagesmodal mit den einzelnen ToDos dieses Tages. Dort koennen ToDos erledigt, geloescht, als einzelnes ToDo geoeffnet oder das zugehoerige Ticket geoeffnet werden.
|
||||||
|
|
||||||
|
Bei den Datumsfeldern gibt es jeweils `Datum entfernen`, um `Umsetzung von` oder `Umsetzung bis` gezielt zu leeren.
|
||||||
|
|
||||||
|
ToDos koennen erledigt, wieder geoeffnet, bearbeitet oder geloescht werden.
|
||||||
|
|
||||||
Die Sessions sind pro Tag gruppiert. In jeder Tagesgruppe stehen:
|
Die Sessions sind pro Tag gruppiert. In jeder Tagesgruppe stehen:
|
||||||
|
|
||||||
- Tagesdatum
|
- Tagesdatum
|
||||||
@@ -239,6 +323,8 @@ Ein leeres Teamspace-Feld entfernt den gespeicherten Teamspace-Wert fuer diesen
|
|||||||
|
|
||||||
Der Wert ist derselbe in Monats- und Tagesansicht. Wenn er in einer Ansicht gespeichert wird, ist er in der anderen Ansicht identisch gesetzt.
|
Der Wert ist derselbe in Monats- und Tagesansicht. Wenn er in einer Ansicht gespeichert wird, ist er in der anderen Ansicht identisch gesetzt.
|
||||||
|
|
||||||
|
Jede einzelne Session ist innerhalb der Tagesgruppe aufklappbar. In der geschlossenen Zeile stehen nur Uhrzeit, berechnete Zeit und die Buttons fuer Bewertung, Bearbeiten und Loeschen. Der Freitext der Taetigkeit wird erst beim Aufklappen angezeigt. Ueber `Alle aufklappen` koennen alle Sessiondetails gleichzeitig eingeblendet werden.
|
||||||
|
|
||||||
## Sessions Bewerten
|
## Sessions Bewerten
|
||||||
|
|
||||||
Jede Session kann bewertet werden als:
|
Jede Session kann bewertet werden als:
|
||||||
@@ -275,7 +361,7 @@ Von und Bis beziehen sich nur auf die Uhrzeit. Das Datum ist ein eigenes Feld.
|
|||||||
|
|
||||||
Eine Session kann ueber das Papierkorb-Icon geloescht werden.
|
Eine Session kann ueber das Papierkorb-Icon geloescht werden.
|
||||||
|
|
||||||
Wird die letzte Session eines Tickets geloescht, wird das Ticket entfernt. Die Ansicht springt danach zurueck zur Auswertung.
|
Wird die letzte Session eines Tickets geloescht, wird das Ticket entfernt. Die Ansicht springt danach zurueck zur Ticketliste.
|
||||||
|
|
||||||
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.
|
||||||
|
|
||||||
@@ -466,7 +552,7 @@ Neue Organisationen werden hinzugefuegt, geaenderte Namen aktualisiert und nicht
|
|||||||
|
|
||||||
1. Im Alltag Sessions per Timer oder Nachtragen erfassen.
|
1. Im Alltag Sessions per Timer oder Nachtragen erfassen.
|
||||||
2. Optional fixe Abrechnungen pflegen, damit wiederkehrende Sessions automatisch erzeugt werden.
|
2. Optional fixe Abrechnungen pflegen, damit wiederkehrende Sessions automatisch erzeugt werden.
|
||||||
3. Am Monatsende `Auswertung` oeffnen.
|
3. Am Monatsende `Tickets` oeffnen.
|
||||||
4. Monat auswaehlen.
|
4. Monat auswaehlen.
|
||||||
5. Offene Sessions pruefen.
|
5. Offene Sessions pruefen.
|
||||||
6. Pro Ticket jede Session als `Abgerechnet` oder `Nicht abrechenbar` markieren.
|
6. Pro Ticket jede Session als `Abgerechnet` oder `Nicht abrechenbar` markieren.
|
||||||
@@ -479,7 +565,7 @@ Neue Organisationen werden hinzugefuegt, geaenderte Namen aktualisiert und nicht
|
|||||||
## 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 Ticket- und Statistikdaten automatisch beim Wechsel von Monat oder Tag.
|
||||||
- Eine zurueckgenommene Bewertung macht eine Session wieder offen und kann den Monatsabschluss wieder verhindern.
|
- Eine zurueckgenommene Bewertung macht eine Session wieder offen und kann den Monatsabschluss wieder verhindern.
|
||||||
- Teamspace-Zeiten sind Tageswerte pro Ticket und Benutzer. Sie sind nicht an einzelne Sessions gebunden.
|
- 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.
|
- Fragezeichen-Icons oeffnen kurze Hilfen direkt in der aktuellen Ansicht.
|
||||||
+319
-9
@@ -1,13 +1,16 @@
|
|||||||
import {
|
import {
|
||||||
BarChart3,
|
AlertCircle,
|
||||||
|
CalendarDays,
|
||||||
CheckCircle2,
|
CheckCircle2,
|
||||||
ClipboardCheck,
|
ClipboardCheck,
|
||||||
Command,
|
Command,
|
||||||
ChartNoAxesCombined,
|
ChartNoAxesCombined,
|
||||||
CircleHelp,
|
CircleHelp,
|
||||||
|
ExternalLink,
|
||||||
FileText,
|
FileText,
|
||||||
FolderKanban,
|
FolderKanban,
|
||||||
GripVertical,
|
GripVertical,
|
||||||
|
LayoutDashboard,
|
||||||
LayoutGrid,
|
LayoutGrid,
|
||||||
ListTodo,
|
ListTodo,
|
||||||
LogOut,
|
LogOut,
|
||||||
@@ -19,7 +22,9 @@ import {
|
|||||||
Search,
|
Search,
|
||||||
Shield,
|
Shield,
|
||||||
Sun,
|
Sun,
|
||||||
|
Ticket,
|
||||||
Timer,
|
Timer,
|
||||||
|
Trash2,
|
||||||
UserCog,
|
UserCog,
|
||||||
UserCircle,
|
UserCircle,
|
||||||
X,
|
X,
|
||||||
@@ -28,7 +33,9 @@ import { FormEvent, MouseEvent, useCallback, useEffect, useMemo, useState } from
|
|||||||
import { useShallow } from "zustand/react/shallow";
|
import { useShallow } from "zustand/react/shallow";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
@@ -58,11 +65,14 @@ 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, getSetupStatus, logout, lookupTicket, searchTickets } from "./api";
|
import { ApiError, deleteTodo, getCurrentUser, getRecurringBillings, getSetupStatus, getTodos, logout, lookupTicket, searchTickets, updateTodo } from "./api";
|
||||||
import { formatDateTime, formatMinutes, formatTimer } from "./format";
|
import { currentDay, formatDateTime, formatMinutes, formatTimer } from "./format";
|
||||||
|
import { isRecurringBillingTicketNumber, ticketHasRecurringBilling } from "./recurring-billings";
|
||||||
import { activeElapsedMs, pauseEntry, readStoredTimers, resumeEntry, storageKeyForUser, ticketPattern, type TimerEntry } from "./timers";
|
import { activeElapsedMs, pauseEntry, readStoredTimers, resumeEntry, storageKeyForUser, ticketPattern, type TimerEntry } from "./timers";
|
||||||
|
import { formatTodoPeriod, isTodoActiveOnDay, isTodoFixedOnDay, isTodoOverdue } from "./todos";
|
||||||
import { AdminUsersPage } from "./views/AdminUsersPage";
|
import { AdminUsersPage } from "./views/AdminUsersPage";
|
||||||
import { AnalysisPage } from "./views/AnalysisPage";
|
import { AnalysisPage } from "./views/AnalysisPage";
|
||||||
|
import { DashboardPage } from "./views/DashboardPage";
|
||||||
import { FaqPage } from "./views/FaqPage";
|
import { FaqPage } from "./views/FaqPage";
|
||||||
import { LoginPage } from "./views/LoginPage";
|
import { LoginPage } from "./views/LoginPage";
|
||||||
import { MonthlyClosePage } from "./views/MonthlyClosePage";
|
import { MonthlyClosePage } from "./views/MonthlyClosePage";
|
||||||
@@ -74,7 +84,7 @@ 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 { TodosPage } from "./views/TodosPage";
|
import { TodosPage } from "./views/TodosPage";
|
||||||
import type { AuthUser, PeriodType, TicketDetailPeriodType, TicketSearchResult } from "./types";
|
import type { AuthUser, PeriodType, TicketDetailPeriodType, TicketSearchResult, TicketTodo } from "./types";
|
||||||
import type { Dispatch, DragEvent, SetStateAction } from "react";
|
import type { Dispatch, DragEvent, SetStateAction } from "react";
|
||||||
|
|
||||||
type NavMenuEntry =
|
type NavMenuEntry =
|
||||||
@@ -97,6 +107,7 @@ type OpenTicketNavItem = {
|
|||||||
periodType: TicketDetailPeriodType;
|
periodType: TicketDetailPeriodType;
|
||||||
period: string;
|
period: string;
|
||||||
path: string;
|
path: string;
|
||||||
|
returnPath?: string;
|
||||||
dirty: boolean;
|
dirty: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -127,6 +138,7 @@ function readOpenTickets(userId: string): OpenTicketNavItem[] {
|
|||||||
periodType: item.periodType === "day" ? "day" : item.periodType === "month" ? "month" : "all",
|
periodType: item.periodType === "day" ? "day" : item.periodType === "month" ? "month" : "all",
|
||||||
period: item.period ? String(item.period) : "all",
|
period: item.period ? String(item.period) : "all",
|
||||||
path: String(item.path),
|
path: String(item.path),
|
||||||
|
returnPath: typeof item.returnPath === "string" ? item.returnPath : undefined,
|
||||||
dirty: false
|
dirty: false
|
||||||
}));
|
}));
|
||||||
} catch {
|
} catch {
|
||||||
@@ -177,6 +189,10 @@ function routeFromPath(pathWithSearch: string) {
|
|||||||
return { page: "analysis" as const };
|
return { page: "analysis" as const };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (pathname.startsWith("/dashboard")) {
|
||||||
|
return { page: "dashboard" as const };
|
||||||
|
}
|
||||||
|
|
||||||
if (pathname.startsWith("/statistics")) {
|
if (pathname.startsWith("/statistics")) {
|
||||||
return { page: "statistics" as const };
|
return { page: "statistics" as const };
|
||||||
}
|
}
|
||||||
@@ -535,8 +551,218 @@ function SidebarTimers({ timers, selectedTimerId, setSelectedTimerId, setTimers,
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type SidebarTodayTodosProps = {
|
||||||
|
todos: TicketTodo[];
|
||||||
|
onNavigate: (to: string) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
const sidebarTodoPriorityLabels: Record<TicketTodo["priority"], string> = {
|
||||||
|
urgent: "Dringend",
|
||||||
|
high: "Hoch",
|
||||||
|
medium: "Normal",
|
||||||
|
low: "Niedrig"
|
||||||
|
};
|
||||||
|
|
||||||
|
const sidebarTodoPriorityClasses: Record<TicketTodo["priority"], string> = {
|
||||||
|
low: "border-sky-500/30 bg-sky-500/10 text-sky-300",
|
||||||
|
medium: "border-muted-foreground/25 bg-muted/50 text-muted-foreground",
|
||||||
|
high: "border-amber-500/30 bg-amber-500/10 text-amber-300",
|
||||||
|
urgent: "border-red-500/30 bg-red-500/10 text-red-300"
|
||||||
|
};
|
||||||
|
|
||||||
|
const sidebarTodoCalendarClasses: Record<TicketTodo["priority"], string> = {
|
||||||
|
low: "border-sky-500/40 bg-sky-500/15 text-sky-200",
|
||||||
|
medium: "border-zinc-500/40 bg-zinc-500/15 text-zinc-200",
|
||||||
|
high: "border-amber-500/45 bg-amber-500/15 text-amber-200",
|
||||||
|
urgent: "border-red-500/45 bg-red-500/15 text-red-200"
|
||||||
|
};
|
||||||
|
|
||||||
|
const sidebarOverdueTodoClass = "border-red-500/60 bg-red-500/15 text-red-100";
|
||||||
|
|
||||||
|
function notifyTodosChanged() {
|
||||||
|
window.dispatchEvent(new Event("tickettracker:todos-changed"));
|
||||||
|
}
|
||||||
|
|
||||||
|
function SidebarTodayTodos({ todos, onNavigate }: SidebarTodayTodosProps) {
|
||||||
|
const [runningTodosOpen, setRunningTodosOpen] = useState(false);
|
||||||
|
const [modalBusy, setModalBusy] = useState(false);
|
||||||
|
const today = currentDay();
|
||||||
|
const fixedTodos = todos.filter((todo) => isTodoFixedOnDay(todo, today));
|
||||||
|
const summarizedTodos = todos.filter((todo) => todo.status === "open" && isTodoActiveOnDay(todo, today) && !isTodoFixedOnDay(todo, today));
|
||||||
|
const overdueTodos = todos.filter((todo) => isTodoOverdue(todo, today));
|
||||||
|
const hasTodos = fixedTodos.length > 0 || summarizedTodos.length > 0 || overdueTodos.length > 0;
|
||||||
|
|
||||||
|
async function toggleTodo(todo: TicketTodo) {
|
||||||
|
setModalBusy(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await updateTodo(todo.id, {
|
||||||
|
title: todo.title,
|
||||||
|
description: todo.description,
|
||||||
|
priority: todo.priority,
|
||||||
|
status: todo.status === "done" ? "open" : "done",
|
||||||
|
startsOn: todo.starts_on,
|
||||||
|
endsOn: todo.ends_on
|
||||||
|
});
|
||||||
|
toast.success(todo.status === "done" ? "ToDo wieder geöffnet" : "ToDo erledigt");
|
||||||
|
notifyTodosChanged();
|
||||||
|
} catch (error) {
|
||||||
|
toast.error("ToDo konnte nicht aktualisiert werden", {
|
||||||
|
description: error instanceof Error ? error.message : "Unbekannter Fehler"
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setModalBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removeTodo(todo: TicketTodo) {
|
||||||
|
const confirmed = window.confirm(`ToDo "${todo.title}" wirklich löschen?`);
|
||||||
|
|
||||||
|
if (!confirmed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setModalBusy(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await deleteTodo(todo.id);
|
||||||
|
toast.success("ToDo gelöscht");
|
||||||
|
notifyTodosChanged();
|
||||||
|
} catch (error) {
|
||||||
|
toast.error("ToDo konnte nicht gelöscht werden", {
|
||||||
|
description: error instanceof Error ? error.message : "Unbekannter Fehler"
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setModalBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<SidebarGroup className="pt-0 group-data-[collapsible=icon]:hidden">
|
||||||
|
<SidebarGroupLabel className="h-6 px-2 text-xs">Termine</SidebarGroupLabel>
|
||||||
|
<SidebarGroupContent>
|
||||||
|
<div className="mx-2 space-y-2 rounded-md border bg-card/70 p-2 shadow-xs">
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<span className="flex min-w-0 items-center gap-1.5 text-xs font-medium text-muted-foreground">
|
||||||
|
<CalendarDays className="size-3.5" />
|
||||||
|
Heute
|
||||||
|
</span>
|
||||||
|
{overdueTodos.length > 0 ? <Badge variant="destructive">{overdueTodos.length} überfällig</Badge> : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{hasTodos ? (
|
||||||
|
<div className="space-y-1">
|
||||||
|
{overdueTodos.slice(0, 3).map((todo) => (
|
||||||
|
<button
|
||||||
|
key={`overdue-${todo.id}`}
|
||||||
|
type="button"
|
||||||
|
className="block w-full rounded-md border border-red-500/45 bg-red-500/10 px-2 py-1.5 text-left hover:bg-red-500/15"
|
||||||
|
onClick={() => onNavigate(`/tickets/${todo.ticket_id}`)}
|
||||||
|
>
|
||||||
|
<span className="flex min-w-0 items-center gap-1.5 text-[11px] font-semibold text-red-200">
|
||||||
|
<AlertCircle className="size-3.5 shrink-0" />
|
||||||
|
<span className="truncate">{todo.title}</span>
|
||||||
|
</span>
|
||||||
|
<span className="block truncate text-[11px] text-muted-foreground">{todo.ticket_number ?? "Ticket"} · {formatTodoPeriod(todo)}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
{overdueTodos.length > 3 ? <p className="px-1 text-[11px] text-red-200">+{overdueTodos.length - 3} weitere überfällig</p> : null}
|
||||||
|
|
||||||
|
{fixedTodos.slice(0, 3).map((todo) => (
|
||||||
|
<button
|
||||||
|
key={`fixed-${todo.id}`}
|
||||||
|
type="button"
|
||||||
|
className="block w-full rounded-md border bg-background/70 px-2 py-1.5 text-left hover:bg-muted"
|
||||||
|
onClick={() => onNavigate(`/tickets/${todo.ticket_id}`)}
|
||||||
|
>
|
||||||
|
<span className="block truncate text-[11px] font-semibold">{todo.title}</span>
|
||||||
|
<span className="block truncate text-[11px] text-muted-foreground">{todo.ticket_number ?? "Ticket"} · {todo.organization_name ?? "Keine Organisation"}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
{fixedTodos.length > 3 ? <p className="px-1 text-[11px] text-muted-foreground">+{fixedTodos.length - 3} weitere feste Termine</p> : null}
|
||||||
|
|
||||||
|
{summarizedTodos.length > 0 ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="block w-full rounded-md border border-muted-foreground/20 bg-background/60 px-2 py-1.5 text-left text-[11px] font-medium text-muted-foreground hover:bg-muted"
|
||||||
|
onClick={() => setRunningTodosOpen(true)}
|
||||||
|
>
|
||||||
|
+{summarizedTodos.length} laufende ToDo{summarizedTodos.length === 1 ? "" : "s"}
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="px-1 py-1 text-xs text-muted-foreground">Heute keine Termine.</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</SidebarGroupContent>
|
||||||
|
</SidebarGroup>
|
||||||
|
|
||||||
|
<Dialog open={runningTodosOpen} onOpenChange={setRunningTodosOpen}>
|
||||||
|
<DialogContent className="max-h-[calc(100dvh-2rem)] overflow-y-auto sm:max-w-xl">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>ToDos am Tag</DialogTitle>
|
||||||
|
<DialogDescription>Heute · {summarizedTodos.length} laufende ToDo(s)</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{summarizedTodos.map((todo) => (
|
||||||
|
<div key={todo.id} className={`rounded-md border p-2.5 ${isTodoOverdue(todo, today) ? sidebarOverdueTodoClass : sidebarTodoCalendarClasses[todo.priority]}`}>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<div className="flex items-start justify-between gap-3">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="flex min-w-0 flex-wrap items-center gap-2">
|
||||||
|
<span className="min-w-0 truncate text-sm font-semibold">{todo.title}</span>
|
||||||
|
<span className={`shrink-0 rounded-full border px-2 py-0.5 text-xs font-medium ${sidebarTodoPriorityClasses[todo.priority]}`}>{sidebarTodoPriorityLabels[todo.priority]}</span>
|
||||||
|
{isTodoOverdue(todo, today) ? <Badge variant="destructive">überfällig</Badge> : null}
|
||||||
|
</div>
|
||||||
|
<p className="mt-1 truncate text-xs text-muted-foreground" title={todo.organization_name ?? "Keine Organisation"}>
|
||||||
|
{todo.organization_name ?? "Keine Organisation"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<span className="shrink-0 pt-0.5 text-xs font-medium text-muted-foreground">{todo.ticket_number ?? "Ticket"}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-2 sm:flex-row sm:items-end sm:justify-between">
|
||||||
|
<div className="min-w-0">
|
||||||
|
{formatTodoPeriod(todo) ? <p className="text-xs text-muted-foreground">{formatTodoPeriod(todo)}</p> : null}
|
||||||
|
</div>
|
||||||
|
<div className="flex shrink-0 flex-wrap gap-1">
|
||||||
|
<Button type="button" size="sm" variant="secondary" disabled={modalBusy} onClick={() => void toggleTodo(todo)}>
|
||||||
|
<CheckCircle2 className="size-4" />
|
||||||
|
Erledigt
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => {
|
||||||
|
setRunningTodosOpen(false);
|
||||||
|
onNavigate(`/tickets/${todo.ticket_id}`);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ExternalLink className="size-4" />
|
||||||
|
Ticket
|
||||||
|
</Button>
|
||||||
|
<Button type="button" size="icon" variant="ghost" className="size-8 text-destructive hover:text-destructive" disabled={modalBusy} onClick={() => void removeTodo(todo)}>
|
||||||
|
<Trash2 className="size-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{summarizedTodos.length === 0 ? (
|
||||||
|
<p className="rounded-md border bg-muted/20 p-4 text-center text-sm text-muted-foreground">Keine laufenden ToDos an diesem Tag.</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function App() {
|
export function App() {
|
||||||
const { path, route, navHandler, navigate } = useAppRouter();
|
const { path, route, navigate: routerNavigate } = 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 [setupRequired, setSetupRequired] = useState(false);
|
||||||
@@ -546,7 +772,37 @@ export function App() {
|
|||||||
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 [openTickets, setOpenTickets] = useState<OpenTicketNavItem[]>([]);
|
||||||
|
const [ticketReturnPaths, setTicketReturnPaths] = useState<Record<string, string>>({});
|
||||||
const [draggedOpenTicketKey, setDraggedOpenTicketKey] = useState<string | null>(null);
|
const [draggedOpenTicketKey, setDraggedOpenTicketKey] = useState<string | null>(null);
|
||||||
|
const [sidebarTodos, setSidebarTodos] = useState<TicketTodo[]>([]);
|
||||||
|
|
||||||
|
const navigate = useCallback(
|
||||||
|
(to: string) => {
|
||||||
|
const targetRoute = routeFromPath(to);
|
||||||
|
|
||||||
|
if (targetRoute.page === "ticket") {
|
||||||
|
const currentRoute = routeFromPath(path);
|
||||||
|
|
||||||
|
if (!(currentRoute.page === "ticket" && currentRoute.ticketId === targetRoute.ticketId)) {
|
||||||
|
setTicketReturnPaths((current) => ({
|
||||||
|
...current,
|
||||||
|
[targetRoute.ticketId]: path
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
routerNavigate(to);
|
||||||
|
},
|
||||||
|
[path, routerNavigate]
|
||||||
|
);
|
||||||
|
|
||||||
|
const navHandler = useCallback(
|
||||||
|
(to: string) => (event: MouseEvent<HTMLAnchorElement>) => {
|
||||||
|
event.preventDefault();
|
||||||
|
navigate(to);
|
||||||
|
},
|
||||||
|
[navigate]
|
||||||
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
async function loadUser() {
|
async function loadUser() {
|
||||||
@@ -582,6 +838,7 @@ export function App() {
|
|||||||
setTimerOwnerId(null);
|
setTimerOwnerId(null);
|
||||||
setTimersLoaded(false);
|
setTimersLoaded(false);
|
||||||
setOpenTickets([]);
|
setOpenTickets([]);
|
||||||
|
setSidebarTodos([]);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -591,6 +848,7 @@ export function App() {
|
|||||||
setTimerOwnerId(currentUser.id);
|
setTimerOwnerId(currentUser.id);
|
||||||
setTimersLoaded(true);
|
setTimersLoaded(true);
|
||||||
setOpenTickets([]);
|
setOpenTickets([]);
|
||||||
|
setSidebarTodos([]);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -628,6 +886,40 @@ export function App() {
|
|||||||
}
|
}
|
||||||
}, [currentUser, timers, selectedTimerId, timersLoaded, timerOwnerId]);
|
}, [currentUser, timers, selectedTimerId, timersLoaded, timerOwnerId]);
|
||||||
|
|
||||||
|
const loadSidebarTodos = useCallback(async () => {
|
||||||
|
if (!currentUser || currentUser.role === "admin") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await getTodos("open");
|
||||||
|
setSidebarTodos(result.todos);
|
||||||
|
} catch {
|
||||||
|
setSidebarTodos([]);
|
||||||
|
}
|
||||||
|
}, [currentUser]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!currentUser || currentUser.role === "admin") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
void loadSidebarTodos();
|
||||||
|
|
||||||
|
const interval = window.setInterval(() => void loadSidebarTodos(), 60_000);
|
||||||
|
const onFocus = () => void loadSidebarTodos();
|
||||||
|
const onTodosChanged = () => void loadSidebarTodos();
|
||||||
|
|
||||||
|
window.addEventListener("focus", onFocus);
|
||||||
|
window.addEventListener("tickettracker:todos-changed", onTodosChanged);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.clearInterval(interval);
|
||||||
|
window.removeEventListener("focus", onFocus);
|
||||||
|
window.removeEventListener("tickettracker:todos-changed", onTodosChanged);
|
||||||
|
};
|
||||||
|
}, [currentUser, loadSidebarTodos]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!currentUser || currentUser.role === "admin" || !timersLoaded || timerOwnerId !== currentUser.id) {
|
if (!currentUser || currentUser.role === "admin" || !timersLoaded || timerOwnerId !== currentUser.id) {
|
||||||
return;
|
return;
|
||||||
@@ -730,6 +1022,19 @@ export function App() {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const [ticketResult, recurringResult] = await Promise.all([
|
||||||
|
lookupTicket(ticketNumber).catch(() => ({ ticket: null })),
|
||||||
|
getRecurringBillings().catch(() => ({ recurringBillings: [] }))
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (ticketHasRecurringBilling(ticketResult.ticket, recurringResult.recurringBillings) || isRecurringBillingTicketNumber(ticketNumber, recurringResult.recurringBillings)) {
|
||||||
|
setTimers((current) => current.filter((timer) => timer.ticketNumber !== ticketNumber));
|
||||||
|
toast.error("Fixe Abrechnung", {
|
||||||
|
description: "Für fixe Abrechnungen dürfen keine manuellen Sessions gestartet werden. Bitte die Definition bearbeiten."
|
||||||
|
});
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
const existingTimer = timers.find((timer) => timer.ticketNumber === ticketNumber);
|
const existingTimer = timers.find((timer) => timer.ticketNumber === ticketNumber);
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
|
|
||||||
@@ -744,8 +1049,6 @@ export function App() {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
const ticketResult = await lookupTicket(ticketNumber).catch(() => ({ ticket: null }));
|
|
||||||
|
|
||||||
const newTimer: TimerEntry = {
|
const newTimer: TimerEntry = {
|
||||||
id: crypto.randomUUID(),
|
id: crypto.randomUUID(),
|
||||||
ticketNumber,
|
ticketNumber,
|
||||||
@@ -847,7 +1150,8 @@ export function App() {
|
|||||||
setOpenTickets((items) => items.filter((item) => item.key !== ticket.key));
|
setOpenTickets((items) => items.filter((item) => item.key !== ticket.key));
|
||||||
|
|
||||||
if (pathWithoutSearch(path) === pathWithoutSearch(ticket.path)) {
|
if (pathWithoutSearch(path) === pathWithoutSearch(ticket.path)) {
|
||||||
navigate("/analysis");
|
const returnPath = current.returnPath && pathWithoutSearch(current.returnPath) !== pathWithoutSearch(ticket.path) ? current.returnPath : "/analysis";
|
||||||
|
routerNavigate(returnPath);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -890,8 +1194,10 @@ export function App() {
|
|||||||
{ type: "item" as const, href: "/faq", label: "FAQ", icon: CircleHelp, active: path.startsWith("/faq") }
|
{ type: "item" as const, href: "/faq", label: "FAQ", icon: CircleHelp, active: path.startsWith("/faq") }
|
||||||
]
|
]
|
||||||
: [
|
: [
|
||||||
|
{ type: "item" as const, href: "/dashboard", label: "Dashboard", icon: LayoutDashboard, active: path.startsWith("/dashboard") },
|
||||||
|
{ type: "separator" as const, key: "after-dashboard" },
|
||||||
{ type: "item" as const, href: "/timer", label: "Session starten", icon: Timer, active: path.startsWith("/timer") || path === "/" },
|
{ 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: "item" as const, href: "/analysis", label: "Ticket-Sessions", icon: Ticket, active: path.startsWith("/analysis") },
|
||||||
{ type: "separator" as const, key: "after-analysis" },
|
{ type: "separator" as const, key: "after-analysis" },
|
||||||
{ type: "item" as const, href: "/projects", label: "Projekte", icon: FolderKanban, active: path.startsWith("/projects") },
|
{ type: "item" as const, href: "/projects", label: "Projekte", icon: FolderKanban, active: path.startsWith("/projects") },
|
||||||
{ type: "item" as const, href: "/todos", label: "ToDos", icon: ListTodo, active: path.startsWith("/todos") },
|
{ type: "item" as const, href: "/todos", label: "ToDos", icon: ListTodo, active: path.startsWith("/todos") },
|
||||||
@@ -990,6 +1296,8 @@ export function App() {
|
|||||||
</SidebarGroupContent>
|
</SidebarGroupContent>
|
||||||
</SidebarGroup>
|
</SidebarGroup>
|
||||||
|
|
||||||
|
{!isAdmin ? <SidebarTodayTodos todos={sidebarTodos} onNavigate={navigate} /> : null}
|
||||||
|
|
||||||
{!isAdmin && openTickets.length > 0 ? (
|
{!isAdmin && openTickets.length > 0 ? (
|
||||||
<SidebarGroup className="pt-0 group-data-[collapsible=icon]:hidden">
|
<SidebarGroup className="pt-0 group-data-[collapsible=icon]:hidden">
|
||||||
<SidebarGroupLabel className="h-6 px-2 text-xs">Geöffnete Tickets</SidebarGroupLabel>
|
<SidebarGroupLabel className="h-6 px-2 text-xs">Geöffnete Tickets</SidebarGroupLabel>
|
||||||
@@ -1156,6 +1464,7 @@ export function App() {
|
|||||||
tick={tick}
|
tick={tick}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
|
{!isAdmin && route.page === "dashboard" ? <DashboardPage onNavigate={navigate} /> : null}
|
||||||
{!isAdmin && route.page === "analysis" ? <AnalysisPage onNavigate={navigate} /> : null}
|
{!isAdmin && route.page === "analysis" ? <AnalysisPage onNavigate={navigate} /> : null}
|
||||||
{!isAdmin && route.page === "monthly-close" ? <MonthlyClosePage onNavigate={navigate} /> : null}
|
{!isAdmin && route.page === "monthly-close" ? <MonthlyClosePage onNavigate={navigate} /> : null}
|
||||||
{!isAdmin && route.page === "statistics" ? <StatisticsPage onNavigate={navigate} /> : null}
|
{!isAdmin && route.page === "statistics" ? <StatisticsPage onNavigate={navigate} /> : null}
|
||||||
@@ -1181,6 +1490,7 @@ export function App() {
|
|||||||
ticketId={route.ticketId}
|
ticketId={route.ticketId}
|
||||||
onNavigate={navigate}
|
onNavigate={navigate}
|
||||||
onOpenTicketChange={updateOpenTicket}
|
onOpenTicketChange={updateOpenTicket}
|
||||||
|
returnPath={ticketReturnPaths[route.ticketId] ?? openTickets.find((ticket) => ticket.ticketId === route.ticketId)?.returnPath ?? "/analysis"}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
{isAdmin && route.page !== "admin-users" && route.page !== "profile" && route.page !== "faq" ? <AdminUsersPage currentUser={currentUser} /> : null}
|
{isAdmin && route.page !== "admin-users" && route.page !== "profile" && route.page !== "faq" ? <AdminUsersPage currentUser={currentUser} /> : null}
|
||||||
|
|||||||
+61
-2
@@ -2,6 +2,7 @@ import type {
|
|||||||
AdminUser,
|
AdminUser,
|
||||||
AuthUser,
|
AuthUser,
|
||||||
BillingStatus,
|
BillingStatus,
|
||||||
|
DashboardPreference,
|
||||||
Organization,
|
Organization,
|
||||||
PeriodOverview,
|
PeriodOverview,
|
||||||
PeriodType,
|
PeriodType,
|
||||||
@@ -100,6 +101,30 @@ export function updateCurrentUser(payload: {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getDashboardPreference() {
|
||||||
|
return request<{ preference: Partial<DashboardPreference> }>("/api/dashboard/preferences");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function saveDashboardPreference(preference: DashboardPreference) {
|
||||||
|
return request<{ preference: Partial<DashboardPreference> }>("/api/dashboard/preferences", {
|
||||||
|
method: "PUT",
|
||||||
|
body: JSON.stringify({ preference })
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function addFavoriteTicket(ticketNumber: string) {
|
||||||
|
return request<{ preference: Partial<DashboardPreference> }>("/api/dashboard/favorites", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ ticketNumber })
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function removeFavoriteTicket(ticketNumber: string) {
|
||||||
|
return request<{ preference: Partial<DashboardPreference> }>(`/api/dashboard/favorites/${encodeURIComponent(ticketNumber)}`, {
|
||||||
|
method: "DELETE"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export function getSetupStatus() {
|
export function getSetupStatus() {
|
||||||
return request<{ required: boolean }>("/api/setup/status");
|
return request<{ required: boolean }>("/api/setup/status");
|
||||||
}
|
}
|
||||||
@@ -294,8 +319,14 @@ export function deleteRecurringBilling(billingId: string) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getProjects() {
|
export function getProjects(includeClosed = false) {
|
||||||
return request<{ projects: ProjectSummary[] }>("/api/projects");
|
const params = new URLSearchParams();
|
||||||
|
|
||||||
|
if (includeClosed) {
|
||||||
|
params.set("includeClosed", "true");
|
||||||
|
}
|
||||||
|
|
||||||
|
return request<{ projects: ProjectSummary[] }>(`/api/projects${params.size ? `?${params}` : ""}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getProject(projectId: string) {
|
export function getProject(projectId: string) {
|
||||||
@@ -312,6 +343,7 @@ export function createProject(payload: {
|
|||||||
startsOn?: string | null;
|
startsOn?: string | null;
|
||||||
endsOn?: string | null;
|
endsOn?: string | null;
|
||||||
hourBudgetMinutes?: number | null;
|
hourBudgetMinutes?: number | null;
|
||||||
|
manualConsumptionMinutes?: number | null;
|
||||||
}) {
|
}) {
|
||||||
return request<{ project: ProjectSummary }>("/api/projects", {
|
return request<{ project: ProjectSummary }>("/api/projects", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
@@ -331,6 +363,7 @@ export function updateProject(
|
|||||||
startsOn?: string | null;
|
startsOn?: string | null;
|
||||||
endsOn?: string | null;
|
endsOn?: string | null;
|
||||||
hourBudgetMinutes?: number | null;
|
hourBudgetMinutes?: number | null;
|
||||||
|
manualConsumptionMinutes?: number | null;
|
||||||
}
|
}
|
||||||
) {
|
) {
|
||||||
return request<{ project: ProjectSummary }>(`/api/projects/${projectId}`, {
|
return request<{ project: ProjectSummary }>(`/api/projects/${projectId}`, {
|
||||||
@@ -339,6 +372,19 @@ export function updateProject(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function updateProjectStatus(projectId: string, closed: boolean) {
|
||||||
|
return request<{ project: { id: string; ticket_id: string; closed_at: string | null } }>(`/api/projects/${projectId}/status`, {
|
||||||
|
method: "PATCH",
|
||||||
|
body: JSON.stringify({ closed })
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deleteProject(projectId: string) {
|
||||||
|
return request<{ deleted: { id: string; ticket_id: string } }>(`/api/projects/${projectId}`, {
|
||||||
|
method: "DELETE"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export function getTodos(status?: TodoStatus | "all") {
|
export function getTodos(status?: TodoStatus | "all") {
|
||||||
const params = new URLSearchParams();
|
const params = new URLSearchParams();
|
||||||
|
|
||||||
@@ -355,6 +401,8 @@ export function createTicketTodo(
|
|||||||
title: string;
|
title: string;
|
||||||
description: string;
|
description: string;
|
||||||
priority: TodoPriority;
|
priority: TodoPriority;
|
||||||
|
startsOn?: string | null;
|
||||||
|
endsOn?: string | null;
|
||||||
}
|
}
|
||||||
) {
|
) {
|
||||||
return request<{ todo: TicketTodo }>(`/api/tickets/${ticketId}/todos`, {
|
return request<{ todo: TicketTodo }>(`/api/tickets/${ticketId}/todos`, {
|
||||||
@@ -370,6 +418,8 @@ export function updateTodo(
|
|||||||
description: string;
|
description: string;
|
||||||
priority: TodoPriority;
|
priority: TodoPriority;
|
||||||
status: TodoStatus;
|
status: TodoStatus;
|
||||||
|
startsOn?: string | null;
|
||||||
|
endsOn?: string | null;
|
||||||
}
|
}
|
||||||
) {
|
) {
|
||||||
return request<{ todo: TicketTodo }>(`/api/todos/${todoId}`, {
|
return request<{ todo: TicketTodo }>(`/api/todos/${todoId}`, {
|
||||||
@@ -378,6 +428,13 @@ export function updateTodo(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function updateTodoOrder(todoIds: string[]) {
|
||||||
|
return request<{ ok: boolean }>("/api/todos/order", {
|
||||||
|
method: "PUT",
|
||||||
|
body: JSON.stringify({ todoIds })
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export function deleteTodo(todoId: string) {
|
export function deleteTodo(todoId: string) {
|
||||||
return request<{ deleted: { id: string } }>(`/api/todos/${todoId}`, {
|
return request<{ deleted: { id: string } }>(`/api/todos/${todoId}`, {
|
||||||
method: "DELETE"
|
method: "DELETE"
|
||||||
@@ -406,6 +463,7 @@ export function updateTicket(
|
|||||||
title?: string | null;
|
title?: string | null;
|
||||||
organizationId: string;
|
organizationId: string;
|
||||||
workType: WorkType;
|
workType: WorkType;
|
||||||
|
removeProjectData?: boolean;
|
||||||
}
|
}
|
||||||
) {
|
) {
|
||||||
return request<{ ticket: TicketMeta }>(`/api/tickets/${ticketId}`, {
|
return request<{ ticket: TicketMeta }>(`/api/tickets/${ticketId}`, {
|
||||||
@@ -420,6 +478,7 @@ export function updateSessionDetails(
|
|||||||
organizationId: string;
|
organizationId: string;
|
||||||
activity: string;
|
activity: string;
|
||||||
workType: WorkType;
|
workType: WorkType;
|
||||||
|
removeProjectData?: boolean;
|
||||||
startedAt: string;
|
startedAt: string;
|
||||||
endedAt: string;
|
endedAt: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,8 @@
|
|||||||
@import "../styles/presets/soft-pop.css";
|
@import "../styles/presets/soft-pop.css";
|
||||||
@import "../styles/presets/tangerine.css";
|
@import "../styles/presets/tangerine.css";
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@custom-variant dark (&:is(.dark *));
|
@custom-variant dark (&:is(.dark *));
|
||||||
|
|
||||||
@theme inline {
|
@theme inline {
|
||||||
@@ -50,6 +52,8 @@
|
|||||||
--color-sidebar-ring: var(--sidebar-ring);
|
--color-sidebar-ring: var(--sidebar-ring);
|
||||||
--font-heading: var(--font-sans);
|
--font-heading: var(--font-sans);
|
||||||
--font-sans: var(--font-sans);
|
--font-sans: var(--font-sans);
|
||||||
|
--breakpoint-xxl: 108.125rem;
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Default theme styles (used when no data-theme-preset is set or when 'default' is selected).
|
/* Default theme styles (used when no data-theme-preset is set or when 'default' is selected).
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { CheckCircle2, Circle, Pencil, Plus, Save, Trash2 } from "lucide-react";
|
import { CalendarDays, CheckCircle2, Circle, GripVertical, Pencil, Plus, Save, Trash2, X } from "lucide-react";
|
||||||
import { FormEvent, useState } from "react";
|
import { FormEvent, useState } from "react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
|
||||||
@@ -9,6 +9,8 @@ import { Input } from "@/components/ui/input";
|
|||||||
import { Textarea } from "@/components/ui/textarea";
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { createTicketTodo, deleteTodo, updateTodo } from "../api";
|
import { createTicketTodo, deleteTodo, updateTodo } from "../api";
|
||||||
|
import { currentDay } from "../format";
|
||||||
|
import { formatTodoPeriod, isTodoOverdue } from "../todos";
|
||||||
import type { TicketTodo, TodoPriority, TodoStatus } from "../types";
|
import type { TicketTodo, TodoPriority, TodoStatus } from "../types";
|
||||||
|
|
||||||
const priorityLabels: Record<TodoPriority, string> = {
|
const priorityLabels: Record<TodoPriority, string> = {
|
||||||
@@ -25,6 +27,10 @@ const priorityClasses: Record<TodoPriority, string> = {
|
|||||||
urgent: "border-red-500/30 bg-red-500/10 text-red-300"
|
urgent: "border-red-500/30 bg-red-500/10 text-red-300"
|
||||||
};
|
};
|
||||||
|
|
||||||
|
function notifyTodosChanged() {
|
||||||
|
window.dispatchEvent(new Event("tickettracker:todos-changed"));
|
||||||
|
}
|
||||||
|
|
||||||
type TodoPanelProps = {
|
type TodoPanelProps = {
|
||||||
ticketId?: string;
|
ticketId?: string;
|
||||||
todos: TicketTodo[];
|
todos: TicketTodo[];
|
||||||
@@ -32,19 +38,26 @@ type TodoPanelProps = {
|
|||||||
showCreate?: boolean;
|
showCreate?: boolean;
|
||||||
showTicketMeta?: boolean;
|
showTicketMeta?: boolean;
|
||||||
onNavigate?: (to: string) => void;
|
onNavigate?: (to: string) => void;
|
||||||
|
reorderEnabled?: boolean;
|
||||||
|
onReorder?: (draggedTodoId: string, targetTodoId: string) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function TodoPanel({ ticketId, todos, onChanged, showCreate = true, showTicketMeta = false, onNavigate }: TodoPanelProps) {
|
export function TodoPanel({ ticketId, todos, onChanged, showCreate = true, showTicketMeta = false, onNavigate, reorderEnabled = false, onReorder }: TodoPanelProps) {
|
||||||
const [title, setTitle] = useState("");
|
const [title, setTitle] = useState("");
|
||||||
const [description, setDescription] = useState("");
|
const [description, setDescription] = useState("");
|
||||||
const [priority, setPriority] = useState<TodoPriority>("medium");
|
const [priority, setPriority] = useState<TodoPriority>("medium");
|
||||||
|
const [startsOn, setStartsOn] = useState("");
|
||||||
|
const [endsOn, setEndsOn] = useState("");
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
const [createOpen, setCreateOpen] = useState(false);
|
const [createOpen, setCreateOpen] = useState(false);
|
||||||
const [editingId, setEditingId] = useState<string | null>(null);
|
const [editingId, setEditingId] = useState<string | null>(null);
|
||||||
const [editTitle, setEditTitle] = useState("");
|
const [editTitle, setEditTitle] = useState("");
|
||||||
const [editDescription, setEditDescription] = useState("");
|
const [editDescription, setEditDescription] = useState("");
|
||||||
const [editPriority, setEditPriority] = useState<TodoPriority>("medium");
|
const [editPriority, setEditPriority] = useState<TodoPriority>("medium");
|
||||||
|
const [editStartsOn, setEditStartsOn] = useState("");
|
||||||
|
const [editEndsOn, setEditEndsOn] = useState("");
|
||||||
const [busyId, setBusyId] = useState<string | null>(null);
|
const [busyId, setBusyId] = useState<string | null>(null);
|
||||||
|
const [draggingId, setDraggingId] = useState<string | null>(null);
|
||||||
|
|
||||||
async function submitTodo(event: FormEvent) {
|
async function submitTodo(event: FormEvent) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
@@ -53,15 +66,25 @@ export function TodoPanel({ ticketId, todos, onChanged, showCreate = true, showT
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (startsOn && endsOn && endsOn < startsOn) {
|
||||||
|
toast.error("Zeitraum prüfen", {
|
||||||
|
description: "Das Bis-Datum darf nicht vor dem Von-Datum liegen."
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
try {
|
try {
|
||||||
await createTicketTodo(ticketId, { title, description, priority });
|
await createTicketTodo(ticketId, { title, description, priority, startsOn: startsOn || null, endsOn: endsOn || null });
|
||||||
setTitle("");
|
setTitle("");
|
||||||
setDescription("");
|
setDescription("");
|
||||||
setPriority("medium");
|
setPriority("medium");
|
||||||
|
setStartsOn("");
|
||||||
|
setEndsOn("");
|
||||||
setCreateOpen(false);
|
setCreateOpen(false);
|
||||||
toast.success("ToDo angelegt");
|
toast.success("ToDo angelegt");
|
||||||
await onChanged();
|
await onChanged();
|
||||||
|
notifyTodosChanged();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
toast.error("ToDo konnte nicht angelegt werden", {
|
toast.error("ToDo konnte nicht angelegt werden", {
|
||||||
description: error instanceof Error ? error.message : "Unbekannter Fehler"
|
description: error instanceof Error ? error.message : "Unbekannter Fehler"
|
||||||
@@ -76,19 +99,31 @@ export function TodoPanel({ ticketId, todos, onChanged, showCreate = true, showT
|
|||||||
setEditTitle(todo.title);
|
setEditTitle(todo.title);
|
||||||
setEditDescription(todo.description);
|
setEditDescription(todo.description);
|
||||||
setEditPriority(todo.priority);
|
setEditPriority(todo.priority);
|
||||||
|
setEditStartsOn(todo.starts_on ?? "");
|
||||||
|
setEditEndsOn(todo.ends_on ?? "");
|
||||||
}
|
}
|
||||||
|
|
||||||
async function saveTodo(todo: TicketTodo, status: TodoStatus = todo.status) {
|
async function saveTodo(todo: TicketTodo, status: TodoStatus = todo.status) {
|
||||||
|
if (editingId === todo.id && editStartsOn && editEndsOn && editEndsOn < editStartsOn) {
|
||||||
|
toast.error("Zeitraum prüfen", {
|
||||||
|
description: "Das Bis-Datum darf nicht vor dem Von-Datum liegen."
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
setBusyId(todo.id);
|
setBusyId(todo.id);
|
||||||
try {
|
try {
|
||||||
await updateTodo(todo.id, {
|
await updateTodo(todo.id, {
|
||||||
title: editingId === todo.id ? editTitle : todo.title,
|
title: editingId === todo.id ? editTitle : todo.title,
|
||||||
description: editingId === todo.id ? editDescription : todo.description,
|
description: editingId === todo.id ? editDescription : todo.description,
|
||||||
priority: editingId === todo.id ? editPriority : todo.priority,
|
priority: editingId === todo.id ? editPriority : todo.priority,
|
||||||
status
|
status,
|
||||||
|
startsOn: editingId === todo.id ? editStartsOn || null : todo.starts_on,
|
||||||
|
endsOn: editingId === todo.id ? editEndsOn || null : todo.ends_on
|
||||||
});
|
});
|
||||||
setEditingId(null);
|
setEditingId(null);
|
||||||
await onChanged();
|
await onChanged();
|
||||||
|
notifyTodosChanged();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
toast.error("ToDo konnte nicht gespeichert werden", {
|
toast.error("ToDo konnte nicht gespeichert werden", {
|
||||||
description: error instanceof Error ? error.message : "Unbekannter Fehler"
|
description: error instanceof Error ? error.message : "Unbekannter Fehler"
|
||||||
@@ -110,6 +145,7 @@ export function TodoPanel({ ticketId, todos, onChanged, showCreate = true, showT
|
|||||||
await deleteTodo(todo.id);
|
await deleteTodo(todo.id);
|
||||||
toast.success("ToDo gelöscht");
|
toast.success("ToDo gelöscht");
|
||||||
await onChanged();
|
await onChanged();
|
||||||
|
notifyTodosChanged();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
toast.error("ToDo konnte nicht gelöscht werden", {
|
toast.error("ToDo konnte nicht gelöscht werden", {
|
||||||
description: error instanceof Error ? error.message : "Unbekannter Fehler"
|
description: error instanceof Error ? error.message : "Unbekannter Fehler"
|
||||||
@@ -159,6 +195,34 @@ export function TodoPanel({ ticketId, todos, onChanged, showCreate = true, showT
|
|||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="grid gap-3 sm:grid-cols-2">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-sm font-medium" htmlFor={`todo-starts-on-${ticketId}`}>
|
||||||
|
Umsetzung von
|
||||||
|
</label>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Input id={`todo-starts-on-${ticketId}`} type="date" value={startsOn} onChange={(event) => setStartsOn(event.currentTarget.value)} />
|
||||||
|
{startsOn ? (
|
||||||
|
<Button type="button" size="icon" variant="ghost" className="shrink-0" onClick={() => setStartsOn("")} aria-label="Von-Datum entfernen">
|
||||||
|
<X className="size-4" />
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-sm font-medium" htmlFor={`todo-ends-on-${ticketId}`}>
|
||||||
|
Umsetzung bis
|
||||||
|
</label>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Input id={`todo-ends-on-${ticketId}`} type="date" value={endsOn} onChange={(event) => setEndsOn(event.currentTarget.value)} />
|
||||||
|
{endsOn ? (
|
||||||
|
<Button type="button" size="icon" variant="ghost" className="shrink-0" onClick={() => setEndsOn("")} aria-label="Bis-Datum entfernen">
|
||||||
|
<X className="size-4" />
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label className="text-sm font-medium" htmlFor={`todo-description-${ticketId}`}>
|
<label className="text-sm font-medium" htmlFor={`todo-description-${ticketId}`}>
|
||||||
Details
|
Details
|
||||||
@@ -185,9 +249,40 @@ export function TodoPanel({ ticketId, todos, onChanged, showCreate = true, showT
|
|||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{todos.map((todo) => {
|
{todos.map((todo) => {
|
||||||
const isEditing = editingId === todo.id;
|
const isEditing = editingId === todo.id;
|
||||||
|
const periodLabel = formatTodoPeriod(todo);
|
||||||
|
const overdue = isTodoOverdue(todo, currentDay());
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={todo.id} className={cn("rounded-md border bg-card/50 p-3", todo.status === "done" && "opacity-70")}>
|
<div
|
||||||
|
key={todo.id}
|
||||||
|
className={cn(
|
||||||
|
"rounded-md border bg-card/50 p-3 transition-colors",
|
||||||
|
overdue && "border-red-500/50 bg-red-500/10",
|
||||||
|
todo.status === "done" && "opacity-70",
|
||||||
|
reorderEnabled && draggingId === todo.id && "border-primary/50 bg-primary/5"
|
||||||
|
)}
|
||||||
|
onDragOver={
|
||||||
|
reorderEnabled
|
||||||
|
? (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
}
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
onDrop={
|
||||||
|
reorderEnabled
|
||||||
|
? (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
const draggedTodoId = event.dataTransfer.getData("text/todo-id") || draggingId;
|
||||||
|
|
||||||
|
if (draggedTodoId && draggedTodoId !== todo.id) {
|
||||||
|
onReorder?.(draggedTodoId, todo.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
setDraggingId(null);
|
||||||
|
}
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
>
|
||||||
{showTicketMeta ? (
|
{showTicketMeta ? (
|
||||||
<div className="mb-2 flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
|
<div className="mb-2 flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
|
||||||
<button type="button" className="font-medium text-foreground hover:underline" onClick={() => onNavigate?.(`/tickets/${todo.ticket_id}`)}>
|
<button type="button" className="font-medium text-foreground hover:underline" onClick={() => onNavigate?.(`/tickets/${todo.ticket_id}`)}>
|
||||||
@@ -202,6 +297,24 @@ export function TodoPanel({ ticketId, todos, onChanged, showCreate = true, showT
|
|||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Input value={editTitle} onChange={(event) => setEditTitle(event.currentTarget.value)} />
|
<Input value={editTitle} onChange={(event) => setEditTitle(event.currentTarget.value)} />
|
||||||
<Textarea className="min-h-16" value={editDescription} onChange={(event) => setEditDescription(event.currentTarget.value)} />
|
<Textarea className="min-h-16" value={editDescription} onChange={(event) => setEditDescription(event.currentTarget.value)} />
|
||||||
|
<div className="grid gap-2 sm:grid-cols-2">
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Input type="date" value={editStartsOn} onChange={(event) => setEditStartsOn(event.currentTarget.value)} />
|
||||||
|
{editStartsOn ? (
|
||||||
|
<Button type="button" size="icon" variant="ghost" className="shrink-0" onClick={() => setEditStartsOn("")} aria-label="Von-Datum entfernen">
|
||||||
|
<X className="size-4" />
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Input type="date" value={editEndsOn} onChange={(event) => setEditEndsOn(event.currentTarget.value)} />
|
||||||
|
{editEndsOn ? (
|
||||||
|
<Button type="button" size="icon" variant="ghost" className="shrink-0" onClick={() => setEditEndsOn("")} aria-label="Bis-Datum entfernen">
|
||||||
|
<X className="size-4" />
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||||
<select
|
<select
|
||||||
className="h-8 rounded-lg border border-input bg-background px-2 text-sm focus-visible:outline-none focus-visible:ring-3 focus-visible:ring-ring/50"
|
className="h-8 rounded-lg border border-input bg-background px-2 text-sm focus-visible:outline-none focus-visible:ring-3 focus-visible:ring-ring/50"
|
||||||
@@ -227,6 +340,23 @@ export function TodoPanel({ ticketId, todos, onChanged, showCreate = true, showT
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
|
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
|
||||||
|
<div className="flex min-w-0 flex-1 items-start gap-2">
|
||||||
|
{reorderEnabled ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="mt-0.5 flex size-6 shrink-0 cursor-grab items-center justify-center rounded-md text-muted-foreground hover:bg-muted hover:text-foreground active:cursor-grabbing"
|
||||||
|
aria-label="ToDo verschieben"
|
||||||
|
draggable
|
||||||
|
onDragStart={(event) => {
|
||||||
|
event.dataTransfer.effectAllowed = "move";
|
||||||
|
event.dataTransfer.setData("text/todo-id", todo.id);
|
||||||
|
setDraggingId(todo.id);
|
||||||
|
}}
|
||||||
|
onDragEnd={() => setDraggingId(null)}
|
||||||
|
>
|
||||||
|
<GripVertical className="size-4" />
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="flex min-w-0 flex-1 items-start gap-2 text-left"
|
className="flex min-w-0 flex-1 items-start gap-2 text-left"
|
||||||
@@ -236,10 +366,18 @@ export function TodoPanel({ ticketId, todos, onChanged, showCreate = true, showT
|
|||||||
{todo.status === "done" ? <CheckCircle2 className="mt-0.5 size-4 shrink-0 text-emerald-400" /> : <Circle className="mt-0.5 size-4 shrink-0 text-muted-foreground" />}
|
{todo.status === "done" ? <CheckCircle2 className="mt-0.5 size-4 shrink-0 text-emerald-400" /> : <Circle className="mt-0.5 size-4 shrink-0 text-muted-foreground" />}
|
||||||
<span className="min-w-0">
|
<span className="min-w-0">
|
||||||
<span className={cn("block break-words text-sm font-medium", todo.status === "done" && "line-through")}>{todo.title}</span>
|
<span className={cn("block break-words text-sm font-medium", todo.status === "done" && "line-through")}>{todo.title}</span>
|
||||||
|
{periodLabel ? (
|
||||||
|
<span className={cn("mt-1 flex items-center gap-1 text-xs", overdue ? "text-red-300" : "text-muted-foreground")}>
|
||||||
|
<CalendarDays className="size-3.5" />
|
||||||
|
{periodLabel}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
{todo.description ? <span className="mt-1 block whitespace-pre-wrap break-words text-xs text-muted-foreground">{todo.description}</span> : null}
|
{todo.description ? <span className="mt-1 block whitespace-pre-wrap break-words text-xs text-muted-foreground">{todo.description}</span> : null}
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
|
</div>
|
||||||
<div className="flex shrink-0 items-center gap-1">
|
<div className="flex shrink-0 items-center gap-1">
|
||||||
|
{overdue ? <Badge variant="destructive">überfällig</Badge> : null}
|
||||||
<span className={cn("rounded-full border px-2 py-0.5 text-xs font-medium", priorityClasses[todo.priority])}>{priorityLabels[todo.priority]}</span>
|
<span className={cn("rounded-full border px-2 py-0.5 text-xs font-medium", priorityClasses[todo.priority])}>{priorityLabels[todo.priority]}</span>
|
||||||
<Button type="button" size="icon" variant="ghost" className="size-8" onClick={() => startEdit(todo)}>
|
<Button type="button" size="icon" variant="ghost" className="size-8" onClick={() => startEdit(todo)}>
|
||||||
<Pencil className="size-4" />
|
<Pencil className="size-4" />
|
||||||
|
|||||||
@@ -41,8 +41,8 @@ export const helpSections: HelpSection[] = [
|
|||||||
"Während der Arbeit läuft genau ein Timer aktiv. Wechselst du zu einem anderen Timer, pausiert TicketTracker die übrigen Timer automatisch.",
|
"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.",
|
"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.",
|
"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.",
|
"Vergessene Sessions trägst du auf der Seite Session starten im Bereich Session nachtragen nach. Wenn das Ticket bereits in der Ticketliste 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.",
|
"Unter Tickets 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 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.",
|
"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.",
|
"Die Teamspace-Zeit ist ein Tageswert pro Ticket und Benutzer. Monats- und Tagesansicht zeigen denselben Wert; er muss also nur einmal gepflegt werden.",
|
||||||
@@ -58,7 +58,7 @@ export const helpSections: HelpSection[] = [
|
|||||||
"Arbeit erledigen; bei Bedarf zwischen vorbereiteten Timern wechseln.",
|
"Arbeit erledigen; bei Bedarf zwischen vorbereiteten Timern wechseln.",
|
||||||
"Timer beenden und Tätigkeit speichern.",
|
"Timer beenden und Tätigkeit speichern.",
|
||||||
"Vergessene Zeiten über Session nachtragen ergänzen.",
|
"Vergessene Zeiten über Session nachtragen ergänzen.",
|
||||||
"Auswertung öffnen, Monat wählen und Tickets prüfen.",
|
"Tickets öffnen, Monat wählen und Einträge prüfen.",
|
||||||
"Ticket öffnen, Sessions pro Tag bewerten und Teamspace-Tageswerte speichern.",
|
"Ticket öffnen, Sessions pro Tag bewerten und Teamspace-Tageswerte speichern.",
|
||||||
"Monatsabschluss öffnen, offene Bewertungen und Teamspace-Prüfpunkte abarbeiten.",
|
"Monatsabschluss öffnen, offene Bewertungen und Teamspace-Prüfpunkte abarbeiten.",
|
||||||
"Teamspace-Summe mit dem CRM vergleichen.",
|
"Teamspace-Summe mit dem CRM vergleichen.",
|
||||||
@@ -72,7 +72,7 @@ export const helpSections: HelpSection[] = [
|
|||||||
"Tätigkeit eintragen",
|
"Tätigkeit eintragen",
|
||||||
"Session wird dem Ticket und Benutzer gespeichert",
|
"Session wird dem Ticket und Benutzer gespeichert",
|
||||||
"Optional: vergessene Session nachtragen",
|
"Optional: vergessene Session nachtragen",
|
||||||
"Auswertung öffnen und Ticket prüfen",
|
"Tickets öffnen und Ticket prüfen",
|
||||||
"Sessions bewerten",
|
"Sessions bewerten",
|
||||||
"Teamspace pro Ticket-Tag eintragen",
|
"Teamspace pro Ticket-Tag eintragen",
|
||||||
"Monatsabschluss öffnen",
|
"Monatsabschluss öffnen",
|
||||||
@@ -176,8 +176,8 @@ export const helpSections: HelpSection[] = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "auswertung",
|
id: "auswertung",
|
||||||
title: "Auswertung",
|
title: "Tickets",
|
||||||
description: "Die Auswertung ist die Arbeitsliste für Monat und Tag: Tickets öffnen, Sessions prüfen, sortieren und bewerten.",
|
description: "Tickets ist die Arbeitsliste für Monat und Tag: Tickets öffnen, Sessions prüfen, sortieren und bewerten.",
|
||||||
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.",
|
||||||
@@ -250,8 +250,14 @@ export const helpSections: HelpSection[] = [
|
|||||||
"Der Navipunkt Projekte ist für Consulting-Arbeiten gedacht, die länger laufen oder ein Stundenkontingent haben.",
|
"Der Navipunkt Projekte ist für Consulting-Arbeiten gedacht, die länger laufen oder ein Stundenkontingent haben.",
|
||||||
"Beim Anlegen gibst du die Ticketnummer ein. Existiert das Ticket bereits, übernimmt TicketTracker Organisation und Stammdaten automatisch.",
|
"Beim Anlegen gibst du die Ticketnummer ein. Existiert das Ticket bereits, übernimmt TicketTracker Organisation und Stammdaten automatisch.",
|
||||||
"Existiert das Ticket noch nicht, muss eine Organisation aus der synchronisierten Organisationsliste gewählt werden.",
|
"Existiert das Ticket noch nicht, muss eine Organisation aus der synchronisierten Organisationsliste gewählt werden.",
|
||||||
"Ein Projekt-Ticket bleibt ein normales Ticket. Es kann über Suche, Auswertung, geöffnete Tickets und ToDos geöffnet werden.",
|
"Ein Projekt-Ticket bleibt ein normales Ticket. Es kann über Suche, Tickets, geöffnete Tickets und ToDos geöffnet werden.",
|
||||||
"Zusätzlich speicherst du Teamspace-Projekt, Beschreibung, Ziele, ungefähren Zeitraum und gesamtes Stundenkontingent.",
|
"Zusätzlich speicherst du Teamspace-Projekt, Beschreibung/Ziele, ungefähren Zeitraum und gesamtes Stundenkontingent.",
|
||||||
|
"Der Projektverbrauch wird nur aus gepflegten Teamspace-Tageswerten und einem optionalen manuellen Projektverbrauch berechnet. Getrackte Sessionzeiten zählen nicht in den Projektverbrauch.",
|
||||||
|
"Der manuelle Projektverbrauch ist für Altbestände oder einmalige Vorbuchungen gedacht und wirkt ausschließlich im Projektverbrauch. Er erscheint nicht in Auswertungen, Statistiken, CSV-Export oder Monatsabschluss.",
|
||||||
|
"Projekte können offen oder geschlossen sein. Standardmäßig zeigt die Projektseite offene Projekte; geschlossene können zusätzlich eingeblendet werden.",
|
||||||
|
"Auf der Projektseite kannst du Projekte nach Freitext, Status, Organisation, Kontingentstatus und ToDos filtern sowie nach Ticket, Titel, Organisation, Teamspace-ID, Verbrauch, Reststunden, Kontingent oder ToDos sortieren. Diese Einstellungen bleiben im Browser gespeichert.",
|
||||||
|
"Reststunden werden aus dem Stundenkontingent minus Projektverbrauch berechnet. Gibt es kein Kontingent, kann TicketTracker keinen offenen Rest berechnen.",
|
||||||
|
"Offene Projekte stehen immer vor geschlossenen Projekten. Der Projektstatus ändert nicht die Anzeige der Sessions im Ticket.",
|
||||||
"Projekt-Tickets sind immer Consulting. Auch wenn Sessions später über den normalen Timer oder das Nachtragen erfasst werden, erkennt TicketTracker das Projekt und übernimmt Consulting.",
|
"Projekt-Tickets sind immer Consulting. Auch wenn Sessions später über den normalen Timer oder das Nachtragen erfasst werden, erkennt TicketTracker das Projekt und übernimmt Consulting.",
|
||||||
"Aus einem Projekt heraus kann direkt eine Session gestartet werden. Der Timer verhält sich wie jeder andere Timer und pausiert andere Timer automatisch."
|
"Aus einem Projekt heraus kann direkt eine Session gestartet werden. Der Timer verhält sich wie jeder andere Timer und pausiert andere Timer automatisch."
|
||||||
]
|
]
|
||||||
@@ -263,11 +269,22 @@ export const helpSections: HelpSection[] = [
|
|||||||
quickItems: [
|
quickItems: [
|
||||||
"ToDos werden direkt im Ticket angelegt.",
|
"ToDos werden direkt im Ticket angelegt.",
|
||||||
"Die zentrale ToDo-Seite sammelt alle offenen Aufgaben.",
|
"Die zentrale ToDo-Seite sammelt alle offenen Aufgaben.",
|
||||||
"Prioritäten helfen beim Sortieren der nächsten Schritte."
|
"Prioritäten und optionale Umsetzungszeiträume helfen beim Sortieren der nächsten Schritte."
|
||||||
],
|
],
|
||||||
details: [
|
details: [
|
||||||
"Jedes Ticket kann ToDos enthalten, egal ob es ein Projekt oder ein normales Einzelticket ist.",
|
"Jedes Ticket kann ToDos enthalten, egal ob es ein Projekt oder ein normales Einzelticket ist.",
|
||||||
"In der Ticketdetailansicht legst du neue Aufgaben mit Titel, optionaler Beschreibung und Priorität an.",
|
"In der Ticketdetailansicht legst du neue Aufgaben mit Titel, optionaler Beschreibung, Priorität und optionalem Umsetzungzeitraum an.",
|
||||||
|
"Wenn Umsetzung von und Umsetzung bis gleich sind, ist das ToDo ein fester Termin an diesem Tag.",
|
||||||
|
"Die zentrale Seite ToDos zeigt zusätzlich Heutige ToDos. Dort erscheinen nur offene Aufgaben, bei denen Umsetzung von und Umsetzung bis beide auf heute stehen.",
|
||||||
|
"Die Aufgabenliste kann nach Status und Ticketnummer gefiltert, nach Kriterien sortiert oder per Drag and Drop frei sortiert werden.",
|
||||||
|
"Wenn du eine Aufgabe ziehst, wird die Sortierung automatisch auf Benutzerdefiniert gestellt und dauerhaft gespeichert.",
|
||||||
|
"Optional kannst du bis zu drei Gruppierungen in Reihenfolge setzen, zum Beispiel erst Ticket, dann Priorität, dann Kunde. Filter, Sortierung und Gruppierung bleiben im Browser gespeichert.",
|
||||||
|
"In der linken Navigation zeigt die Kachel Termine die festen heutigen ToDos, laufende ToDos als Summe und überfällige Aufgaben.",
|
||||||
|
"Unter der Aufgabenliste zeigt der ToDo-Kalender offene ToDos im Monatsüberblick. Erledigte ToDos werden dort ausgeblendet; Wochenenden und bayerische Feiertage bleiben ohne ToDo-Einträge.",
|
||||||
|
"Ein-Tages-ToDos werden einzeln und farblich nach Priorität angezeigt. Zeiträume, offene Von-Daten und Bis-Daten werden pro Tag nur als Summe angezeigt.",
|
||||||
|
"Bei Nur-Von läuft die Summe ab diesem Datum weiter, bis das ToDo erledigt, gelöscht oder mit einem Bis-Datum begrenzt wird. Bei Nur-Bis zählt der Kalender ab heute bis zum Bis-Datum.",
|
||||||
|
"Ein offenes ToDo ist überfällig, wenn Umsetzung bis vor heute liegt. Im Kalender bleibt es ab dem Tag nach der Deadline an jedem Arbeitstag als roter +X üf-Badge sichtbar, bis es erledigt oder gelöscht wird.",
|
||||||
|
"Ein Klick auf ein einzelnes ToDo öffnet die Bearbeitung. Eine Tagessumme öffnet eine kompakte Tagesliste; daraus kannst du ein ToDo separat öffnen, erledigen, löschen oder das Ticket öffnen.",
|
||||||
"Die Prioritäten Niedrig, Normal, Hoch und Dringend helfen dabei, wichtige Punkte nicht zwischen Sessions zu verlieren.",
|
"Die Prioritäten Niedrig, Normal, Hoch und Dringend helfen dabei, wichtige Punkte nicht zwischen Sessions zu verlieren.",
|
||||||
"ToDos lassen sich als erledigt markieren, wieder öffnen, bearbeiten oder löschen.",
|
"ToDos lassen sich als erledigt markieren, wieder öffnen, bearbeiten oder löschen.",
|
||||||
"Die zentrale Seite ToDos zeigt Aufgaben aus allen deinen Tickets. Von dort öffnest du direkt das zugehörige Ticket.",
|
"Die zentrale Seite ToDos zeigt Aufgaben aus allen deinen Tickets. Von dort öffnest du direkt das zugehörige Ticket.",
|
||||||
@@ -288,7 +305,7 @@ export const helpSections: HelpSection[] = [
|
|||||||
"Die Art ist bei manuellen Sessions änderbar. Bei wiederkehrenden Sessions ist sie gesperrt und kommt aus der Regel.",
|
"Die Art ist bei manuellen Sessions änderbar. Bei wiederkehrenden Sessions ist sie gesperrt und kommt aus der Regel.",
|
||||||
"Die Dauer wird aus Von und Bis neu berechnet und wieder auf Minuten gerundet.",
|
"Die Dauer wird aus Von und Bis neu berechnet und wieder auf Minuten gerundet.",
|
||||||
"Beim Löschen wird geprüft, ob dadurch ein Ticket für dich oder komplett leer wird. Leere Tickets werden entfernt.",
|
"Beim Löschen wird geprüft, ob dadurch ein Ticket für dich oder komplett leer wird. Leere Tickets werden entfernt.",
|
||||||
"Wenn die letzte Session eines Tickets gelöscht wird, springt die Ansicht zurück zur Auswertung.",
|
"Wenn die letzte Session eines Tickets gelöscht wird, springt die Ansicht zurück zur Ticketliste.",
|
||||||
"Wiederkehrend erzeugte Sessions werden beim Löschen als Ausnahme gespeichert. Dadurch erscheinen sie beim nächsten Öffnen des Monats nicht wieder automatisch.",
|
"Wiederkehrend erzeugte Sessions werden beim Löschen als Ausnahme gespeichert. Dadurch erscheinen sie beim nächsten Öffnen des Monats nicht wieder automatisch.",
|
||||||
"Wenn eine wiederkehrende Leistung nur an einem anderen Tag erledigt wurde, wird die erzeugte Session bearbeitet beziehungsweise verschoben."
|
"Wenn eine wiederkehrende Leistung nur an einem anderen Tag erledigt wurde, wird die erzeugte Session bearbeitet beziehungsweise verschoben."
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import type { ProjectSummary, RecurringBilling, TicketMeta, TicketPeriod } from "./types";
|
||||||
|
|
||||||
|
type RecurringAwareTicket = (TicketMeta | TicketPeriod["ticket"]) & {
|
||||||
|
recurring_session_count?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function isGeneratedRecurringTicketNumber(ticketNumber: string | null | undefined) {
|
||||||
|
return Boolean(ticketNumber?.startsWith("Fix#"));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function recurringBillingTicketNumbers(recurringBillings: RecurringBilling[]) {
|
||||||
|
const numbers = new Set<string>();
|
||||||
|
|
||||||
|
for (const billing of recurringBillings) {
|
||||||
|
numbers.add(billing.ticket_number);
|
||||||
|
|
||||||
|
if (billing.configured_ticket_number) {
|
||||||
|
numbers.add(billing.configured_ticket_number);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return numbers;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isRecurringBillingTicketNumber(ticketNumber: string | null | undefined, recurringBillings: RecurringBilling[] = []) {
|
||||||
|
if (!ticketNumber) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return isGeneratedRecurringTicketNumber(ticketNumber) || recurringBillingTicketNumbers(recurringBillings).has(ticketNumber);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ticketHasRecurringBilling(ticket: RecurringAwareTicket | null | undefined, recurringBillings: RecurringBilling[] = []) {
|
||||||
|
return Boolean(ticket?.has_recurring_billing || Number(ticket?.recurring_session_count ?? 0) > 0 || isRecurringBillingTicketNumber(ticket?.ticket_number, recurringBillings));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function projectHasRecurringBilling(project: ProjectSummary) {
|
||||||
|
return Boolean(project.has_recurring_billing || Number(project.recurring_session_count ?? 0) > 0 || isGeneratedRecurringTicketNumber(project.ticket_number));
|
||||||
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
import type { TicketTodo } from "./types";
|
||||||
|
|
||||||
|
function parseDateOnly(value: string) {
|
||||||
|
const [year, month, day] = value.split("-").map(Number);
|
||||||
|
return new Date(year, month - 1, day);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatDateOnly(date: Date) {
|
||||||
|
const year = date.getFullYear();
|
||||||
|
const month = String(date.getMonth() + 1).padStart(2, "0");
|
||||||
|
const day = String(date.getDate()).padStart(2, "0");
|
||||||
|
|
||||||
|
return `${year}-${month}-${day}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatTodoDate(value: string) {
|
||||||
|
return new Intl.DateTimeFormat("de-DE", {
|
||||||
|
day: "2-digit",
|
||||||
|
month: "2-digit",
|
||||||
|
year: "numeric"
|
||||||
|
}).format(parseDateOnly(value));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatTodoPeriod(todo: Pick<TicketTodo, "starts_on" | "ends_on">) {
|
||||||
|
if (!todo.starts_on && !todo.ends_on) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (todo.starts_on && todo.ends_on && todo.starts_on === todo.ends_on) {
|
||||||
|
return `am ${formatTodoDate(todo.starts_on)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (todo.starts_on && todo.ends_on) {
|
||||||
|
return `${formatTodoDate(todo.starts_on)} - ${formatTodoDate(todo.ends_on)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (todo.starts_on) {
|
||||||
|
return `ab ${formatTodoDate(todo.starts_on)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `bis ${formatTodoDate(todo.ends_on!)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isTodoActiveOnDay(todo: Pick<TicketTodo, "starts_on" | "ends_on" | "status">, day: string) {
|
||||||
|
if (todo.status !== "open") {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!todo.starts_on && !todo.ends_on) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (!todo.starts_on || todo.starts_on <= day) && (!todo.ends_on || todo.ends_on >= day);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isTodoFixedOnDay(todo: Pick<TicketTodo, "starts_on" | "ends_on" | "status">, day: string) {
|
||||||
|
return todo.status === "open" && todo.starts_on === day && todo.ends_on === day;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isTodoOverdue(todo: Pick<TicketTodo, "ends_on" | "status">, today: string) {
|
||||||
|
return todo.status === "open" && Boolean(todo.ends_on) && todo.ends_on! < today;
|
||||||
|
}
|
||||||
@@ -12,6 +12,32 @@ export type AuthUser = {
|
|||||||
active: boolean;
|
active: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type DashboardWidgetType = "today" | "month" | "openRatings" | "projectBudgets" | "todos" | "teamspace" | "favorites" | "divider" | "statistic" | "notes";
|
||||||
|
export type DashboardSortDirection = "asc" | "desc";
|
||||||
|
|
||||||
|
export type DashboardFavorite = {
|
||||||
|
ticketNumber: string;
|
||||||
|
addedAt: string;
|
||||||
|
sortOrder: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type DashboardWidget = {
|
||||||
|
id: string;
|
||||||
|
type: DashboardWidgetType;
|
||||||
|
title?: string;
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
w: number;
|
||||||
|
h: number;
|
||||||
|
config?: Record<string, unknown>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type DashboardPreference = {
|
||||||
|
version: number;
|
||||||
|
widgets: DashboardWidget[];
|
||||||
|
favorites: DashboardFavorite[];
|
||||||
|
};
|
||||||
|
|
||||||
export type AdminUser = AuthUser & {
|
export type AdminUser = AuthUser & {
|
||||||
created_at: string;
|
created_at: string;
|
||||||
updated_at: string;
|
updated_at: string;
|
||||||
@@ -64,6 +90,7 @@ export type TicketMeta = {
|
|||||||
project_id?: string | null;
|
project_id?: string | null;
|
||||||
is_project?: boolean;
|
is_project?: boolean;
|
||||||
project_name?: string | null;
|
project_name?: string | null;
|
||||||
|
has_recurring_billing?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type TicketSearchResult = {
|
export type TicketSearchResult = {
|
||||||
@@ -98,11 +125,19 @@ export type ProjectSummary = {
|
|||||||
starts_on: string | null;
|
starts_on: string | null;
|
||||||
ends_on: string | null;
|
ends_on: string | null;
|
||||||
hour_budget_minutes: number | null;
|
hour_budget_minutes: number | null;
|
||||||
|
manual_consumption_minutes: number;
|
||||||
|
teamspace_consumption_minutes?: number;
|
||||||
|
consumption_minutes?: number;
|
||||||
|
closed_at: string | null;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
updated_at: string;
|
updated_at: string;
|
||||||
session_count?: number;
|
session_count?: number;
|
||||||
|
recurring_session_count?: number;
|
||||||
|
manual_session_count?: number;
|
||||||
|
has_recurring_billing?: boolean;
|
||||||
billed_minutes?: number;
|
billed_minutes?: number;
|
||||||
open_todos?: number;
|
open_todos?: number;
|
||||||
|
overdue_todos?: number;
|
||||||
done_todos?: number;
|
done_todos?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -120,6 +155,9 @@ export type TicketTodo = {
|
|||||||
description: string;
|
description: string;
|
||||||
priority: TodoPriority;
|
priority: TodoPriority;
|
||||||
status: TodoStatus;
|
status: TodoStatus;
|
||||||
|
sort_order: number;
|
||||||
|
starts_on: string | null;
|
||||||
|
ends_on: string | null;
|
||||||
completed_at: string | null;
|
completed_at: string | null;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
updated_at: string;
|
updated_at: string;
|
||||||
@@ -133,6 +171,7 @@ export type TicketSummary = {
|
|||||||
organization_name: string | null;
|
organization_name: string | null;
|
||||||
customer_name: string | null;
|
customer_name: string | null;
|
||||||
work_type: WorkType | null;
|
work_type: WorkType | null;
|
||||||
|
project_id: string | null;
|
||||||
session_count: number;
|
session_count: number;
|
||||||
total_minutes: number;
|
total_minutes: number;
|
||||||
non_billable_minutes: number;
|
non_billable_minutes: number;
|
||||||
@@ -159,6 +198,7 @@ export type OpenSession = {
|
|||||||
work_type: WorkType;
|
work_type: WorkType;
|
||||||
started_at: string;
|
started_at: string;
|
||||||
rounded_minutes: number;
|
rounded_minutes: number;
|
||||||
|
recurring_billing_id?: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type ActivityBucket = {
|
export type ActivityBucket = {
|
||||||
@@ -239,6 +279,7 @@ export type TicketPeriod = {
|
|||||||
requires_closure: boolean;
|
requires_closure: boolean;
|
||||||
recurring_session_count: number;
|
recurring_session_count: number;
|
||||||
manual_session_count: number;
|
manual_session_count: number;
|
||||||
|
has_recurring_billing?: boolean;
|
||||||
};
|
};
|
||||||
sessions: SessionEntry[];
|
sessions: SessionEntry[];
|
||||||
dayBillings: TicketDayBilling[];
|
dayBillings: TicketDayBilling[];
|
||||||
|
|||||||
@@ -19,13 +19,6 @@ type AnalysisPageProps = {
|
|||||||
onNavigate: (to: string) => void;
|
onNavigate: (to: string) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
type TimelineBucket = {
|
|
||||||
key: string;
|
|
||||||
label: string;
|
|
||||||
totalMinutes: number;
|
|
||||||
sessionCount: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
type TicketGroupBy = "none" | "workType" | "organization" | "status";
|
type TicketGroupBy = "none" | "workType" | "organization" | "status";
|
||||||
type TicketSortBy = "ticket" | "workType" | "organization" | "status";
|
type TicketSortBy = "ticket" | "workType" | "organization" | "status";
|
||||||
type TicketSortDirection = "asc" | "desc";
|
type TicketSortDirection = "asc" | "desc";
|
||||||
@@ -224,82 +217,6 @@ function groupTicketSections(tickets: TicketSummary[], groupBy: TicketGroupBy):
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildChartPath(points: Array<{ x: number; y: number }>) {
|
|
||||||
if (points.length === 0) {
|
|
||||||
return "";
|
|
||||||
}
|
|
||||||
|
|
||||||
if (points.length === 1) {
|
|
||||||
return `M ${points[0].x - 18} ${points[0].y} L ${points[0].x + 18} ${points[0].y}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
return points.map((point, index) => `${index === 0 ? "M" : "L"} ${point.x} ${point.y}`).join(" ");
|
|
||||||
}
|
|
||||||
|
|
||||||
function niceCeilMinutes(value: number) {
|
|
||||||
if (value <= 30) {
|
|
||||||
return Math.max(5, Math.ceil(value / 5) * 5);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (value <= 120) {
|
|
||||||
return Math.ceil(value / 15) * 15;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (value <= 480) {
|
|
||||||
return Math.ceil(value / 30) * 30;
|
|
||||||
}
|
|
||||||
|
|
||||||
return Math.ceil(value / 60) * 60;
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatAxisMinutes(value: number) {
|
|
||||||
const minutes = Math.round(value);
|
|
||||||
|
|
||||||
if (minutes === 0) {
|
|
||||||
return "0";
|
|
||||||
}
|
|
||||||
|
|
||||||
if (minutes >= 60) {
|
|
||||||
return `${new Intl.NumberFormat("de-DE", { maximumFractionDigits: minutes < 600 ? 1 : 0 }).format(minutes / 60)} h`;
|
|
||||||
}
|
|
||||||
|
|
||||||
return `${minutes} min`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildTimeline(periodType: PeriodType, period: string, overview: PeriodOverview | null): TimelineBucket[] {
|
|
||||||
const series = new Map((overview?.activitySeries ?? []).map((bucket) => [bucket.bucket_key, bucket]));
|
|
||||||
|
|
||||||
if (periodType === "day") {
|
|
||||||
return Array.from({ length: 24 }).map((_, hour) => {
|
|
||||||
const key = String(hour).padStart(2, "0");
|
|
||||||
const bucket = series.get(key);
|
|
||||||
|
|
||||||
return {
|
|
||||||
key,
|
|
||||||
label: `${key}:00`,
|
|
||||||
totalMinutes: bucket?.total_minutes ?? 0,
|
|
||||||
sessionCount: bucket?.session_count ?? 0
|
|
||||||
};
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const [year, month] = period.split("-").map(Number);
|
|
||||||
const daysInMonth = Number.isFinite(year) && Number.isFinite(month) ? new Date(year, month, 0).getDate() : 31;
|
|
||||||
|
|
||||||
return Array.from({ length: daysInMonth }).map((_, index) => {
|
|
||||||
const day = String(index + 1).padStart(2, "0");
|
|
||||||
const key = `${period}-${day}`;
|
|
||||||
const bucket = series.get(key);
|
|
||||||
|
|
||||||
return {
|
|
||||||
key,
|
|
||||||
label: day,
|
|
||||||
totalMinutes: bucket?.total_minutes ?? 0,
|
|
||||||
sessionCount: bucket?.session_count ?? 0
|
|
||||||
};
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function AnalysisPage({ onNavigate }: AnalysisPageProps) {
|
export function AnalysisPage({ onNavigate }: AnalysisPageProps) {
|
||||||
const [periodType, setPeriodType] = useState<PeriodType>("month");
|
const [periodType, setPeriodType] = useState<PeriodType>("month");
|
||||||
const [period, setPeriod] = useState(currentMonth());
|
const [period, setPeriod] = useState(currentMonth());
|
||||||
@@ -325,7 +242,7 @@ export function AnalysisPage({ onNavigate }: AnalysisPageProps) {
|
|||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (requestId === loadRequestId.current) {
|
if (requestId === loadRequestId.current) {
|
||||||
toast.error("Auswertung konnte nicht geladen werden", {
|
toast.error("Tickets konnten nicht geladen werden", {
|
||||||
description: error instanceof Error ? error.message : "Unbekannter Fehler"
|
description: error instanceof Error ? error.message : "Unbekannter Fehler"
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -458,37 +375,11 @@ export function AnalysisPage({ onNavigate }: AnalysisPageProps) {
|
|||||||
ticketViewSettings.sortBy !== "ticket",
|
ticketViewSettings.sortBy !== "ticket",
|
||||||
ticketViewSettings.sortDirection !== "asc"
|
ticketViewSettings.sortDirection !== "asc"
|
||||||
].filter(Boolean).length;
|
].filter(Boolean).length;
|
||||||
const timeline = buildTimeline(periodType, period, overview);
|
|
||||||
const chartWidth = 900;
|
|
||||||
const chartHeight = 180;
|
|
||||||
const chartPadding = { top: 18, right: 18, bottom: 34, left: 64 };
|
|
||||||
const chartInnerWidth = chartWidth - chartPadding.left - chartPadding.right;
|
|
||||||
const chartInnerHeight = chartHeight - chartPadding.top - chartPadding.bottom;
|
|
||||||
const maxMinutes = Math.max(...timeline.map((bucket) => bucket.totalMinutes), 1);
|
|
||||||
const chartMaxMinutes = niceCeilMinutes(maxMinutes);
|
|
||||||
const yAxisTicks = Array.from({ length: 5 }).map((_, index) => chartMaxMinutes - (index * chartMaxMinutes) / 4);
|
|
||||||
const barWidth = Math.max(4, Math.min(18, chartInnerWidth / Math.max(timeline.length, 1) / 1.8));
|
|
||||||
const chartPoints = timeline.map((bucket, index) => {
|
|
||||||
const x =
|
|
||||||
timeline.length === 1
|
|
||||||
? chartPadding.left + chartInnerWidth / 2
|
|
||||||
: chartPadding.left + (index * chartInnerWidth) / (timeline.length - 1);
|
|
||||||
|
|
||||||
return {
|
|
||||||
bucket,
|
|
||||||
x,
|
|
||||||
barHeight: bucket.totalMinutes > 0 ? Math.max(2, (bucket.totalMinutes / chartMaxMinutes) * chartInnerHeight) : 0,
|
|
||||||
y: chartPadding.top + (1 - bucket.totalMinutes / chartMaxMinutes) * chartInnerHeight
|
|
||||||
};
|
|
||||||
});
|
|
||||||
const effortPath = buildChartPath(chartPoints.filter((point) => point.bucket.totalMinutes > 0).map((point) => ({ x: point.x, y: point.y })));
|
|
||||||
const labelStep = periodType === "month" ? Math.max(1, Math.ceil(timeline.length / 8)) : 3;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-5">
|
<div className="space-y-5">
|
||||||
<div className="flex flex-col gap-3 xl:flex-row xl:items-end xl:justify-between">
|
<div className="flex flex-col gap-3 xl:flex-row xl:items-end xl:justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-2xl font-semibold tracking-normal">Auswertung</h2>
|
<h2 className="text-2xl font-semibold tracking-normal">Tickets</h2>
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
{periodType === "month" ? "Tickets prüfen, Sessions bewerten und Zeiten kontrollieren." : "Sessions des Tages prüfen und bewerten."}
|
{periodType === "month" ? "Tickets prüfen, Sessions bewerten und Zeiten kontrollieren." : "Sessions des Tages prüfen und bewerten."}
|
||||||
</p>
|
</p>
|
||||||
@@ -557,95 +448,6 @@ export function AnalysisPage({ onNavigate }: AnalysisPageProps) {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Card>
|
|
||||||
<CardHeader className="gap-3 p-4 sm:flex-row sm:items-center sm:justify-between sm:space-y-0">
|
|
||||||
<div>
|
|
||||||
<CardTitle>Abgerechneter Aufwand</CardTitle>
|
|
||||||
<CardDescription>
|
|
||||||
{periodType === "month" ? "Abrechenbare Zeit pro Tag im ausgewählten Monat." : "Abrechenbare Zeit pro Stunde am ausgewählten Tag."}
|
|
||||||
</CardDescription>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Badge variant="outline">{formatMinutes(totals?.minutes ?? 0)}</Badge>
|
|
||||||
<HelpLink anchor="auswertung" label="Hilfe zur Auswertung" />
|
|
||||||
</div>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="px-4 pb-4">
|
|
||||||
<div className="h-44 w-full overflow-hidden rounded-md border bg-background">
|
|
||||||
{(totals?.minutes ?? 0) > 0 ? (
|
|
||||||
<svg viewBox={`0 0 ${chartWidth} ${chartHeight}`} className="h-full w-full">
|
|
||||||
{yAxisTicks.map((tick) => {
|
|
||||||
const y = chartPadding.top + (1 - tick / chartMaxMinutes) * chartInnerHeight;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<g key={tick}>
|
|
||||||
<line x1={chartPadding.left} x2={chartWidth - chartPadding.right} y1={y} y2={y} stroke="currentColor" className="text-muted/70" />
|
|
||||||
<line x1={chartPadding.left - 5} x2={chartPadding.left} y1={y} y2={y} stroke="currentColor" className="text-muted-foreground/70" />
|
|
||||||
<text x={chartPadding.left - 9} y={y + 4} textAnchor="end" className="fill-muted-foreground text-[11px]">
|
|
||||||
{formatAxisMinutes(tick)}
|
|
||||||
</text>
|
|
||||||
</g>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
<line
|
|
||||||
x1={chartPadding.left}
|
|
||||||
x2={chartPadding.left}
|
|
||||||
y1={chartPadding.top}
|
|
||||||
y2={chartPadding.top + chartInnerHeight}
|
|
||||||
stroke="currentColor"
|
|
||||||
className="text-muted-foreground/70"
|
|
||||||
/>
|
|
||||||
|
|
||||||
{chartPoints.map((point) => {
|
|
||||||
const barY = chartPadding.top + chartInnerHeight - point.barHeight;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<g key={point.bucket.key}>
|
|
||||||
{point.bucket.totalMinutes > 0 ? (
|
|
||||||
<rect
|
|
||||||
x={point.x - barWidth / 2}
|
|
||||||
y={barY}
|
|
||||||
width={barWidth}
|
|
||||||
height={point.barHeight}
|
|
||||||
rx="4"
|
|
||||||
className="fill-neutral-300 dark:fill-neutral-700"
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
</g>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
|
|
||||||
{effortPath ? <path d={effortPath} fill="none" stroke="currentColor" strokeWidth="2.5" className="text-foreground" /> : null}
|
|
||||||
|
|
||||||
{chartPoints.filter((point) => point.bucket.totalMinutes > 0).map((point) => (
|
|
||||||
<circle key={`${point.bucket.key}-effort`} cx={point.x} cy={point.y} r="3" className="fill-background stroke-foreground" strokeWidth="2" />
|
|
||||||
))}
|
|
||||||
|
|
||||||
{chartPoints.map((point, index) =>
|
|
||||||
index % labelStep === 0 || index === chartPoints.length - 1 ? (
|
|
||||||
<text key={`${point.bucket.key}-label`} x={point.x} y={chartHeight - 10} textAnchor="middle" className="fill-muted-foreground text-[11px]">
|
|
||||||
{point.bucket.label}
|
|
||||||
</text>
|
|
||||||
) : null
|
|
||||||
)}
|
|
||||||
</svg>
|
|
||||||
) : (
|
|
||||||
<div className="grid h-full place-items-center text-sm text-muted-foreground">Keine Daten für den gewählten Zeitraum.</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="mt-3 flex flex-wrap gap-3 text-xs text-muted-foreground">
|
|
||||||
<span className="inline-flex items-center gap-1.5">
|
|
||||||
<span className="size-2 rounded-sm bg-neutral-300 dark:bg-neutral-700" />
|
|
||||||
Abgerechnet
|
|
||||||
</span>
|
|
||||||
<span className="inline-flex items-center gap-1.5">
|
|
||||||
<span className="h-0.5 w-4 bg-foreground" />
|
|
||||||
Verlauf
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</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>
|
||||||
@@ -690,7 +492,10 @@ export function AnalysisPage({ onNavigate }: AnalysisPageProps) {
|
|||||||
className="h-8"
|
className="h-8"
|
||||||
value={ticketViewSettings.query}
|
value={ticketViewSettings.query}
|
||||||
placeholder="Ticket oder Organisation"
|
placeholder="Ticket oder Organisation"
|
||||||
onChange={(event) => setTicketViewSettings((current) => ({ ...current, query: event.currentTarget.value }))}
|
onChange={(event) => {
|
||||||
|
const query = event.currentTarget.value;
|
||||||
|
setTicketViewSettings((current) => ({ ...current, query }));
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -823,11 +628,14 @@ export function AnalysisPage({ onNavigate }: AnalysisPageProps) {
|
|||||||
{section.tickets.map((ticket) => (
|
{section.tickets.map((ticket) => (
|
||||||
<TableRow key={ticket.id}>
|
<TableRow key={ticket.id}>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex min-w-0 items-center justify-between gap-2">
|
||||||
<span className="font-semibold">{ticket.title || ticket.ticket_number}</span>
|
<div className="flex min-w-0 items-center gap-1">
|
||||||
|
<span className="truncate font-semibold">{ticket.ticket_number}</span>
|
||||||
<CopyTicketButton ticketNumber={ticket.ticket_number} />
|
<CopyTicketButton ticketNumber={ticket.ticket_number} />
|
||||||
</div>
|
</div>
|
||||||
{ticket.title ? <p className="text-xs text-muted-foreground">{ticket.ticket_number}</p> : null}
|
{ticket.project_id ? <Badge variant="outline" className="shrink-0">Projekt</Badge> : null}
|
||||||
|
</div>
|
||||||
|
{ticket.title ? <p className="max-w-[220px] truncate text-xs text-muted-foreground">{ticket.title}</p> : null}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>{ticket.customer_name ?? "-"}</TableCell>
|
<TableCell>{ticket.customer_name ?? "-"}</TableCell>
|
||||||
<TableCell>{workTypeLabel(ticket.work_type)}</TableCell>
|
<TableCell>{workTypeLabel(ticket.work_type)}</TableCell>
|
||||||
@@ -875,11 +683,14 @@ export function AnalysisPage({ onNavigate }: AnalysisPageProps) {
|
|||||||
<div key={ticket.id} className="space-y-2 rounded-md border bg-background p-3">
|
<div key={ticket.id} className="space-y-2 rounded-md border bg-background p-3">
|
||||||
<div className="flex items-start justify-between gap-3">
|
<div className="flex items-start justify-between gap-3">
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center justify-between gap-2">
|
||||||
<p className="truncate font-semibold">{ticket.title || ticket.ticket_number}</p>
|
<div className="flex min-w-0 items-center gap-1">
|
||||||
|
<p className="truncate font-semibold">{ticket.ticket_number}</p>
|
||||||
<CopyTicketButton ticketNumber={ticket.ticket_number} />
|
<CopyTicketButton ticketNumber={ticket.ticket_number} />
|
||||||
</div>
|
</div>
|
||||||
{ticket.title ? <p className="text-xs text-muted-foreground">{ticket.ticket_number}</p> : null}
|
{ticket.project_id ? <Badge variant="outline" className="shrink-0">Projekt</Badge> : null}
|
||||||
|
</div>
|
||||||
|
{ticket.title ? <p className="truncate text-xs text-muted-foreground">{ticket.title}</p> : null}
|
||||||
<p className="text-sm text-muted-foreground">{ticket.customer_name ?? "Keine Organisation"} · {workTypeLabel(ticket.work_type)} · {ticket.session_count} Session(s)</p>
|
<p className="text-sm text-muted-foreground">{ticket.customer_name ?? "Keine Organisation"} · {workTypeLabel(ticket.work_type)} · {ticket.session_count} Session(s)</p>
|
||||||
</div>
|
</div>
|
||||||
<TicketStatus ticket={ticket} />
|
<TicketStatus ticket={ticket} />
|
||||||
|
|||||||
File diff suppressed because it is too large.
Load diff
@@ -46,7 +46,7 @@ export function FaqPage() {
|
|||||||
<div className="flex flex-col gap-3 lg:flex-row lg:items-end lg:justify-between">
|
<div className="flex flex-col gap-3 lg:flex-row lg:items-end lg:justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-2xl font-semibold tracking-normal">FAQ und Anleitung</h2>
|
<h2 className="text-2xl font-semibold tracking-normal">FAQ und Anleitung</h2>
|
||||||
<p className="text-sm text-muted-foreground">Ausführliche Anleitung für Timer, Auswertung, Teamspace, fixe Abrechnungen, Monatsabschluss und Adminfunktionen.</p>
|
<p className="text-sm text-muted-foreground">Ausführliche Anleitung für Timer, Tickets, Teamspace, fixe Abrechnungen, Monatsabschluss und Adminfunktionen.</p>
|
||||||
</div>
|
</div>
|
||||||
<Badge variant="outline">{helpSections.length} Abschnitt(e)</Badge>
|
<Badge variant="outline">{helpSections.length} Abschnitt(e)</Badge>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+662
-165
File diff suppressed because it is too large.
Load diff
@@ -204,7 +204,7 @@ export function RecurringBillingsPage() {
|
|||||||
setRecurringForm(emptyRecurringForm());
|
setRecurringForm(emptyRecurringForm());
|
||||||
setEditingBillingId(null);
|
setEditingBillingId(null);
|
||||||
toast.success(editingBillingId ? "Fixe Abrechnung gespeichert" : "Fixe Abrechnung angelegt", {
|
toast.success(editingBillingId ? "Fixe Abrechnung gespeichert" : "Fixe Abrechnung angelegt", {
|
||||||
description: "Die Sessions erscheinen automatisch in der Auswertung, sobald du den Zeitraum öffnest. Nicht mehr gültige erzeugte Sessions werden entfernt."
|
description: "Die Sessions erscheinen automatisch in Tickets, sobald du den Zeitraum öffnest. Nicht mehr gültige erzeugte Sessions werden entfernt."
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
toast.error("Fixe Abrechnung konnte nicht angelegt werden", {
|
toast.error("Fixe Abrechnung konnte nicht angelegt werden", {
|
||||||
@@ -454,7 +454,7 @@ export function RecurringBillingsPage() {
|
|||||||
<div className="flex items-start justify-between gap-3">
|
<div className="flex items-start justify-between gap-3">
|
||||||
<div>
|
<div>
|
||||||
<CardTitle>Regeln</CardTitle>
|
<CardTitle>Regeln</CardTitle>
|
||||||
<CardDescription>Erzeugte Einträge können in der Auswertung verschoben oder gelöscht werden.</CardDescription>
|
<CardDescription>Erzeugte Einträge können in Tickets verschoben oder gelöscht werden.</CardDescription>
|
||||||
</div>
|
</div>
|
||||||
<HelpLink anchor="fixe-abrechnungen" label="Hilfe zu erzeugten fixen Sessions" />
|
<HelpLink anchor="fixe-abrechnungen" label="Hilfe zu erzeugten fixen Sessions" />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -712,7 +712,7 @@ export function StatisticsPage({ onNavigate }: StatisticsPageProps) {
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Button variant="secondary" size="sm" onClick={() => onNavigate("/analysis")}>
|
<Button variant="secondary" size="sm" onClick={() => onNavigate("/analysis")}>
|
||||||
Auswertung öffnen
|
Tickets öffnen
|
||||||
<ExternalLink className="size-4" />
|
<ExternalLink className="size-4" />
|
||||||
</Button>
|
</Button>
|
||||||
<HelpLink anchor="statistik-hotspots" label="Hilfe zu Ticket-Hotspots" />
|
<HelpLink anchor="statistik-hotspots" label="Hilfe zu Ticket-Hotspots" />
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
import { ArrowLeft, ClipboardList, FolderKanban, Pencil, Plus, Save, Target, Trash2 } from "lucide-react";
|
import { ArrowLeft, CheckCircle2, ChevronDown, ChevronUp, ClipboardList, FolderKanban, Pencil, Plus, RotateCcw, Save, Star, 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 { Alert } from "@/components/ui/alert";
|
||||||
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion";
|
|
||||||
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";
|
||||||
@@ -14,18 +13,22 @@ import { HelpLink } from "@/components/HelpLink";
|
|||||||
import { OrganizationSelect } from "@/components/OrganizationSelect";
|
import { OrganizationSelect } from "@/components/OrganizationSelect";
|
||||||
import { TodoPanel } from "@/components/TodoPanel";
|
import { TodoPanel } from "@/components/TodoPanel";
|
||||||
import {
|
import {
|
||||||
createProject,
|
|
||||||
createSession,
|
createSession,
|
||||||
deleteSession,
|
deleteSession,
|
||||||
|
addFavoriteTicket,
|
||||||
|
getDashboardPreference,
|
||||||
getTicketDetails,
|
getTicketDetails,
|
||||||
getTicketPeriod,
|
getTicketPeriod,
|
||||||
|
removeFavoriteTicket,
|
||||||
updateSessionBilling,
|
updateSessionBilling,
|
||||||
updateSessionDetails,
|
updateSessionDetails,
|
||||||
updateTicket,
|
updateTicket,
|
||||||
updateTicketDayBilling,
|
updateTicketDayBilling,
|
||||||
updateProject
|
updateProject,
|
||||||
|
updateProjectStatus
|
||||||
} from "../api";
|
} from "../api";
|
||||||
import { currentDay, currentMonth, formatDate, formatDateTime, formatMinutes, formatTimeRange } from "../format";
|
import { currentDay, currentMonth, formatDate, formatDateTime, formatMinutes, formatTimeRange } from "../format";
|
||||||
|
import { ticketHasRecurringBilling } from "../recurring-billings";
|
||||||
import type { BillingStatus, PeriodType, ProjectSummary, SessionEntry, TicketDetailPeriodType, TicketPeriod, WorkType } from "../types";
|
import type { BillingStatus, PeriodType, ProjectSummary, SessionEntry, TicketDetailPeriodType, TicketPeriod, WorkType } from "../types";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
@@ -41,8 +44,10 @@ type TicketDetailPageProps = {
|
|||||||
periodType: TicketDetailPeriodType;
|
periodType: TicketDetailPeriodType;
|
||||||
period: string;
|
period: string;
|
||||||
path: string;
|
path: string;
|
||||||
|
returnPath?: string;
|
||||||
dirty: boolean;
|
dirty: boolean;
|
||||||
}) => void;
|
}) => void;
|
||||||
|
returnPath: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
type TicketSessionFormState = {
|
type TicketSessionFormState = {
|
||||||
@@ -61,10 +66,10 @@ type ProjectEditFormState = {
|
|||||||
organizationName: string | null;
|
organizationName: string | null;
|
||||||
teamspaceProject: string;
|
teamspaceProject: string;
|
||||||
description: string;
|
description: string;
|
||||||
goals: string;
|
|
||||||
startsOn: string;
|
startsOn: string;
|
||||||
endsOn: string;
|
endsOn: string;
|
||||||
hourBudget: string;
|
hourBudget: string;
|
||||||
|
manualConsumption: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
function emptyTicketSessionForm(): TicketSessionFormState {
|
function emptyTicketSessionForm(): TicketSessionFormState {
|
||||||
@@ -85,14 +90,18 @@ function projectEditForm(project: ProjectSummary): ProjectEditFormState {
|
|||||||
organizationId: project.organization_id ?? "",
|
organizationId: project.organization_id ?? "",
|
||||||
organizationName: project.organization_name ?? project.customer_name ?? null,
|
organizationName: project.organization_name ?? project.customer_name ?? null,
|
||||||
teamspaceProject: project.teamspace_project ?? "",
|
teamspaceProject: project.teamspace_project ?? "",
|
||||||
description: project.description,
|
description: combinedProjectDescription(project),
|
||||||
goals: project.goals,
|
|
||||||
startsOn: project.starts_on ?? "",
|
startsOn: project.starts_on ?? "",
|
||||||
endsOn: project.ends_on ?? "",
|
endsOn: project.ends_on ?? "",
|
||||||
hourBudget: formatHoursInput(project.hour_budget_minutes)
|
hourBudget: formatHoursInput(project.hour_budget_minutes),
|
||||||
|
manualConsumption: formatHoursInput(project.manual_consumption_minutes)
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function combinedProjectDescription(project: ProjectSummary) {
|
||||||
|
return [project.description, project.goals].map((part) => part.trim()).filter(Boolean).join("\n\n");
|
||||||
|
}
|
||||||
|
|
||||||
function BillingButtons({
|
function BillingButtons({
|
||||||
session,
|
session,
|
||||||
disabled,
|
disabled,
|
||||||
@@ -181,12 +190,14 @@ function isPartialHoursInput(value: string) {
|
|||||||
return /^\d*(?:[,.]\d*)?$/.test(value.trim());
|
return /^\d*(?:[,.]\d*)?$/.test(value.trim());
|
||||||
}
|
}
|
||||||
|
|
||||||
export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onOpenTicketChange }: TicketDetailPageProps) {
|
export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onOpenTicketChange, returnPath }: TicketDetailPageProps) {
|
||||||
const initialGlobalMonthFilter = periodType === "all" && period !== "all" ? period : currentMonth();
|
const initialGlobalMonthFilter = periodType === "all" && period !== "all" ? period : currentMonth();
|
||||||
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);
|
||||||
const [savingTicket, setSavingTicket] = useState(false);
|
const [savingTicket, setSavingTicket] = useState(false);
|
||||||
|
const [savingFavorite, setSavingFavorite] = useState(false);
|
||||||
|
const [isFavorite, setIsFavorite] = useState(false);
|
||||||
const [editTicketNumber, setEditTicketNumber] = useState("");
|
const [editTicketNumber, setEditTicketNumber] = useState("");
|
||||||
const [editTicketTitle, setEditTicketTitle] = useState("");
|
const [editTicketTitle, setEditTicketTitle] = useState("");
|
||||||
const [editOrganizationId, setEditOrganizationId] = useState("");
|
const [editOrganizationId, setEditOrganizationId] = useState("");
|
||||||
@@ -208,21 +219,23 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onO
|
|||||||
const [globalMonthFilter, setGlobalMonthFilter] = useState(initialGlobalMonthFilter);
|
const [globalMonthFilter, setGlobalMonthFilter] = useState(initialGlobalMonthFilter);
|
||||||
const [globalStatusFilter, setGlobalStatusFilter] = useState<"all" | "open" | "billed" | "non_billable">("all");
|
const [globalStatusFilter, setGlobalStatusFilter] = useState<"all" | "open" | "billed" | "non_billable">("all");
|
||||||
const [globalSourceFilter, setGlobalSourceFilter] = useState<"all" | "manual" | "recurring">("all");
|
const [globalSourceFilter, setGlobalSourceFilter] = useState<"all" | "manual" | "recurring">("all");
|
||||||
const [creatingProject, setCreatingProject] = useState(false);
|
|
||||||
const [editingProject, setEditingProject] = useState(false);
|
const [editingProject, setEditingProject] = useState(false);
|
||||||
const [savingProject, setSavingProject] = useState(false);
|
const [savingProject, setSavingProject] = useState(false);
|
||||||
|
const [savingProjectStatus, setSavingProjectStatus] = useState(false);
|
||||||
const [projectForm, setProjectForm] = useState<ProjectEditFormState>({
|
const [projectForm, setProjectForm] = useState<ProjectEditFormState>({
|
||||||
title: "",
|
title: "",
|
||||||
organizationId: "",
|
organizationId: "",
|
||||||
organizationName: null,
|
organizationName: null,
|
||||||
teamspaceProject: "",
|
teamspaceProject: "",
|
||||||
description: "",
|
description: "",
|
||||||
goals: "",
|
|
||||||
startsOn: "",
|
startsOn: "",
|
||||||
endsOn: "",
|
endsOn: "",
|
||||||
hourBudget: ""
|
hourBudget: "",
|
||||||
|
manualConsumption: ""
|
||||||
});
|
});
|
||||||
const [todoFilter, setTodoFilter] = useState<"open" | "all" | "done">("open");
|
const [todoFilter, setTodoFilter] = useState<"open" | "all" | "done">("open");
|
||||||
|
const [expandedSessionIds, setExpandedSessionIds] = useState<string[]>([]);
|
||||||
|
const [projectDescriptionExpanded, setProjectDescriptionExpanded] = useState(false);
|
||||||
|
|
||||||
async function load() {
|
async function load() {
|
||||||
try {
|
try {
|
||||||
@@ -277,8 +290,14 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onO
|
|||||||
}));
|
}));
|
||||||
}, [data?.ticket]);
|
}, [data?.ticket]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setProjectDescriptionExpanded(false);
|
||||||
|
}, [data?.project?.id]);
|
||||||
|
|
||||||
const ticket = data?.ticket;
|
const ticket = data?.ticket;
|
||||||
const project = data?.project ?? null;
|
const project = data?.project ?? null;
|
||||||
|
const isRecurringBillingTicket = ticketHasRecurringBilling(ticket);
|
||||||
|
const projectDescription = project ? combinedProjectDescription(project) : "";
|
||||||
const isProject = Boolean(project);
|
const isProject = Boolean(project);
|
||||||
const isGlobalTicket = periodType === "all";
|
const isGlobalTicket = periodType === "all";
|
||||||
const isPeriodClosed = Boolean(data?.closed);
|
const isPeriodClosed = Boolean(data?.closed);
|
||||||
@@ -327,6 +346,8 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onO
|
|||||||
|
|
||||||
return Array.from(groups.values());
|
return Array.from(groups.values());
|
||||||
}, [data?.sessions]);
|
}, [data?.sessions]);
|
||||||
|
const sessionIds = useMemo(() => sessionGroups.flatMap((group) => group.sessions.map((session) => session.id)), [sessionGroups]);
|
||||||
|
const allSessionsExpanded = sessionIds.length > 0 && sessionIds.every((sessionId) => expandedSessionIds.includes(sessionId));
|
||||||
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(
|
const ticketDataDirty = Boolean(
|
||||||
@@ -345,6 +366,10 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onO
|
|||||||
};
|
};
|
||||||
const hasUnsavedChanges = ticketDataDirty || sessionGroups.some((group) => isDayBillingDirty(group.key));
|
const hasUnsavedChanges = ticketDataDirty || sessionGroups.some((group) => isDayBillingDirty(group.key));
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setExpandedSessionIds((current) => current.filter((sessionId) => sessionIds.includes(sessionId)));
|
||||||
|
}, [sessionIds]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!ticket) {
|
if (!ticket) {
|
||||||
return;
|
return;
|
||||||
@@ -359,9 +384,40 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onO
|
|||||||
periodType: "all",
|
periodType: "all",
|
||||||
period: ticketMonth,
|
period: ticketMonth,
|
||||||
path: `/tickets/${ticket.id}?month=${ticketMonth}`,
|
path: `/tickets/${ticket.id}?month=${ticketMonth}`,
|
||||||
|
returnPath,
|
||||||
dirty: hasUnsavedChanges
|
dirty: hasUnsavedChanges
|
||||||
});
|
});
|
||||||
}, [ticket, periodType, period, isGlobalTicket, globalMonthFilter, hasUnsavedChanges, onOpenTicketChange]);
|
}, [ticket, periodType, period, isGlobalTicket, globalMonthFilter, returnPath, hasUnsavedChanges, onOpenTicketChange]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
|
||||||
|
if (!ticket?.ticket_number) {
|
||||||
|
setIsFavorite(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadFavoriteStatus() {
|
||||||
|
try {
|
||||||
|
const result = await getDashboardPreference();
|
||||||
|
const favorites = Array.isArray(result.preference.favorites) ? result.preference.favorites : [];
|
||||||
|
|
||||||
|
if (!cancelled) {
|
||||||
|
setIsFavorite(favorites.some((favorite) => favorite.ticketNumber === ticket?.ticket_number));
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
if (!cancelled) {
|
||||||
|
setIsFavorite(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void loadFavoriteStatus();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [ticket?.ticket_number]);
|
||||||
|
|
||||||
async function setBilling(sessionId: string, billingStatus: BillingStatus) {
|
async function setBilling(sessionId: string, billingStatus: BillingStatus) {
|
||||||
if (isPeriodClosed && billingStatus !== null) {
|
if (isPeriodClosed && billingStatus !== null) {
|
||||||
@@ -384,6 +440,16 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onO
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function toggleSessionDetails(sessionId: string) {
|
||||||
|
setExpandedSessionIds((current) =>
|
||||||
|
current.includes(sessionId) ? current.filter((id) => id !== sessionId) : [...current, sessionId]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleAllSessionDetails() {
|
||||||
|
setExpandedSessionIds(allSessionsExpanded ? [] : sessionIds);
|
||||||
|
}
|
||||||
|
|
||||||
async function saveDayBilling(day: string) {
|
async function saveDayBilling(day: string) {
|
||||||
if (!ticket) {
|
if (!ticket) {
|
||||||
return;
|
return;
|
||||||
@@ -437,16 +503,29 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onO
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const removeProjectData = Boolean(project && editWorkType === "support");
|
||||||
|
|
||||||
|
if (removeProjectData) {
|
||||||
|
const confirmed = window.confirm(
|
||||||
|
`Ticket "${ticket.title || ticket.ticket_number}" auf Support umstellen? Projektdaten, Kontingent und Teamspace-Zeiten werden entfernt. Sessions und ToDos bleiben erhalten.`
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!confirmed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
setSavingTicket(true);
|
setSavingTicket(true);
|
||||||
try {
|
try {
|
||||||
await updateTicket(ticket.id, {
|
await updateTicket(ticket.id, {
|
||||||
ticketNumber: editTicketNumber,
|
ticketNumber: editTicketNumber,
|
||||||
title: editTicketTitle || null,
|
title: editTicketTitle || null,
|
||||||
organizationId: editOrganizationId,
|
organizationId: editOrganizationId,
|
||||||
workType: isProject ? "consulting" : editWorkType
|
workType: editWorkType,
|
||||||
|
removeProjectData
|
||||||
});
|
});
|
||||||
toast.success("Ticketdaten gespeichert", {
|
toast.success("Ticketdaten gespeichert", {
|
||||||
description: "Organisation und Art wurden auf die vorhandenen Sessions übernommen."
|
description: editWorkType === "consulting" ? "Das Ticket ist jetzt als Projekt angelegt." : "Organisation und Art wurden auf die vorhandenen Sessions übernommen."
|
||||||
});
|
});
|
||||||
await load();
|
await load();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -458,44 +537,6 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onO
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function createProjectFromTicket() {
|
|
||||||
if (!ticket) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!ticket.organization_id) {
|
|
||||||
toast.error("Organisation fehlt", {
|
|
||||||
description: "Speichere zuerst eine Organisation am Ticket, bevor daraus ein Projekt wird."
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setCreatingProject(true);
|
|
||||||
try {
|
|
||||||
await createProject({
|
|
||||||
ticketNumber: ticket.ticket_number,
|
|
||||||
title: ticket.title ?? null,
|
|
||||||
organizationId: ticket.organization_id,
|
|
||||||
teamspaceProject: ticket.title ?? "",
|
|
||||||
description: "",
|
|
||||||
goals: "",
|
|
||||||
startsOn: null,
|
|
||||||
endsOn: null,
|
|
||||||
hourBudgetMinutes: null
|
|
||||||
});
|
|
||||||
toast.success("Projekt angelegt", {
|
|
||||||
description: "Das Ticket ist jetzt als Consulting-Projekt markiert."
|
|
||||||
});
|
|
||||||
await load();
|
|
||||||
} catch (error) {
|
|
||||||
toast.error("Projekt konnte nicht angelegt werden", {
|
|
||||||
description: error instanceof Error ? error.message : "Unbekannter Fehler"
|
|
||||||
});
|
|
||||||
} finally {
|
|
||||||
setCreatingProject(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function openProjectEditor() {
|
function openProjectEditor() {
|
||||||
if (!project) {
|
if (!project) {
|
||||||
return;
|
return;
|
||||||
@@ -518,6 +559,7 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onO
|
|||||||
}
|
}
|
||||||
|
|
||||||
const hourBudgetMinutes = parseHoursInput(projectForm.hourBudget);
|
const hourBudgetMinutes = parseHoursInput(projectForm.hourBudget);
|
||||||
|
const parsedManualConsumptionMinutes = parseHoursInput(projectForm.manualConsumption);
|
||||||
|
|
||||||
if (hourBudgetMinutes === undefined) {
|
if (hourBudgetMinutes === undefined) {
|
||||||
toast.error("Stundenkontingent prüfen", {
|
toast.error("Stundenkontingent prüfen", {
|
||||||
@@ -526,6 +568,15 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onO
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (parsedManualConsumptionMinutes === undefined) {
|
||||||
|
toast.error("Manuellen Verbrauch prüfen", {
|
||||||
|
description: "Bitte Stunden als Zahl eintragen, z.B. 12,5."
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const manualConsumptionMinutes = parsedManualConsumptionMinutes ?? 0;
|
||||||
|
|
||||||
setSavingProject(true);
|
setSavingProject(true);
|
||||||
try {
|
try {
|
||||||
await updateProject(project.id, {
|
await updateProject(project.id, {
|
||||||
@@ -534,10 +585,11 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onO
|
|||||||
organizationId: projectForm.organizationId,
|
organizationId: projectForm.organizationId,
|
||||||
teamspaceProject: projectForm.teamspaceProject || null,
|
teamspaceProject: projectForm.teamspaceProject || null,
|
||||||
description: projectForm.description,
|
description: projectForm.description,
|
||||||
goals: projectForm.goals,
|
goals: "",
|
||||||
startsOn: projectForm.startsOn || null,
|
startsOn: projectForm.startsOn || null,
|
||||||
endsOn: projectForm.endsOn || null,
|
endsOn: projectForm.endsOn || null,
|
||||||
hourBudgetMinutes
|
hourBudgetMinutes,
|
||||||
|
manualConsumptionMinutes
|
||||||
});
|
});
|
||||||
toast.success("Projekt gespeichert");
|
toast.success("Projekt gespeichert");
|
||||||
setEditingProject(false);
|
setEditingProject(false);
|
||||||
@@ -551,6 +603,25 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onO
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function setProjectClosed(closed: boolean) {
|
||||||
|
if (!project) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setSavingProjectStatus(true);
|
||||||
|
try {
|
||||||
|
await updateProjectStatus(project.id, closed);
|
||||||
|
toast.success(closed ? "Projekt geschlossen" : "Projekt wieder geöffnet");
|
||||||
|
await load();
|
||||||
|
} catch (error) {
|
||||||
|
toast.error("Projektstatus konnte nicht gespeichert werden", {
|
||||||
|
description: error instanceof Error ? error.message : "Unbekannter Fehler"
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setSavingProjectStatus(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function submitTicketSession(event: FormEvent) {
|
async function submitTicketSession(event: FormEvent) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
|
|
||||||
@@ -558,8 +629,15 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onO
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (isRecurringBillingTicket) {
|
||||||
|
toast.error("Fixe Abrechnung", {
|
||||||
|
description: "Für fixe Abrechnungen dürfen keine Sessions nachgetragen werden. Bitte die Definition bearbeiten."
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const effectiveOrganizationId = ticket.organization_id ?? ticketSession.organizationId;
|
const effectiveOrganizationId = ticket.organization_id ?? ticketSession.organizationId;
|
||||||
const effectiveWorkType = isProject ? "consulting" : ticket.work_type ?? ticketSession.workType;
|
const effectiveWorkType = ticket.work_type ?? ticketSession.workType;
|
||||||
|
|
||||||
if (!effectiveOrganizationId) {
|
if (!effectiveOrganizationId) {
|
||||||
toast.error("Organisation wählen");
|
toast.error("Organisation wählen");
|
||||||
@@ -649,12 +727,25 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onO
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const removeProjectData = Boolean(project && editingSession.work_type === "consulting" && editSessionWorkType === "support");
|
||||||
|
|
||||||
|
if (removeProjectData) {
|
||||||
|
const confirmed = window.confirm(
|
||||||
|
`Session auf Support umstellen? Falls dadurch keine Consulting-Sessions mehr für "${ticket?.title || ticket?.ticket_number || "dieses Ticket"}" bleiben, werden Projektdaten, Kontingent und Teamspace-Zeiten entfernt. Sessions und ToDos bleiben erhalten.`
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!confirmed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
setSavingSessionDetails(true);
|
setSavingSessionDetails(true);
|
||||||
try {
|
try {
|
||||||
await updateSessionDetails(editingSession.id, {
|
await updateSessionDetails(editingSession.id, {
|
||||||
organizationId: editSessionOrganizationId,
|
organizationId: editSessionOrganizationId,
|
||||||
activity: editSessionActivity,
|
activity: editSessionActivity,
|
||||||
workType: isProject ? "consulting" : editSessionWorkType,
|
workType: editSessionWorkType,
|
||||||
|
removeProjectData,
|
||||||
startedAt: startedAt.toISOString(),
|
startedAt: startedAt.toISOString(),
|
||||||
endedAt: endedAt.toISOString()
|
endedAt: endedAt.toISOString()
|
||||||
});
|
});
|
||||||
@@ -685,7 +776,7 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onO
|
|||||||
toast.success("Letzte Session gelöscht", {
|
toast.success("Letzte Session gelöscht", {
|
||||||
description: result.deleted.ticketDeleted ? "Das leere Ticket wurde entfernt." : "Für dich gibt es zu diesem Ticket keine Einträge mehr."
|
description: result.deleted.ticketDeleted ? "Das leere Ticket wurde entfernt." : "Für dich gibt es zu diesem Ticket keine Einträge mehr."
|
||||||
});
|
});
|
||||||
onNavigate("/analysis");
|
onNavigate(returnPath);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -702,13 +793,43 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onO
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function toggleTicketFavorite() {
|
||||||
|
if (!ticket?.ticket_number) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setSavingFavorite(true);
|
||||||
|
try {
|
||||||
|
if (isFavorite) {
|
||||||
|
await removeFavoriteTicket(ticket.ticket_number);
|
||||||
|
setIsFavorite(false);
|
||||||
|
toast.success("Favorit entfernt", {
|
||||||
|
description: ticket.ticket_number
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await addFavoriteTicket(ticket.ticket_number);
|
||||||
|
setIsFavorite(true);
|
||||||
|
toast.success("Favorit gespeichert", {
|
||||||
|
description: ticket.ticket_number
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(isFavorite ? "Favorit konnte nicht entfernt werden" : "Favorit konnte nicht gespeichert werden", {
|
||||||
|
description: error instanceof Error ? error.message : "Unbekannter Fehler"
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setSavingFavorite(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="flex flex-col gap-3 lg:flex-row lg:items-end lg:justify-between">
|
<div className="flex flex-col gap-3 lg:flex-row lg:items-end lg:justify-between">
|
||||||
<div>
|
<div>
|
||||||
<Button variant="ghost" size="sm" className="-ml-2 mb-1 h-8" onClick={() => onNavigate("/analysis")}>
|
<Button variant="ghost" size="sm" className="-ml-2 mb-1 h-8" onClick={() => onNavigate(returnPath)}>
|
||||||
<ArrowLeft className="size-4" />
|
<ArrowLeft className="size-4" />
|
||||||
Zur Auswertung
|
Zurück
|
||||||
</Button>
|
</Button>
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
<h2 className="text-xl font-semibold tracking-normal sm:text-2xl">{ticket?.title || ticket?.ticket_number || "Ticket"}</h2>
|
<h2 className="text-xl font-semibold tracking-normal sm:text-2xl">{ticket?.title || ticket?.ticket_number || "Ticket"}</h2>
|
||||||
@@ -723,10 +844,10 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onO
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
{!isProject && ticket ? (
|
{ticket?.ticket_number ? (
|
||||||
<Button type="button" size="sm" variant="secondary" disabled={creatingProject} onClick={() => void createProjectFromTicket()}>
|
<Button type="button" size="sm" variant={isFavorite ? "default" : "secondary"} disabled={savingFavorite} onClick={() => void toggleTicketFavorite()}>
|
||||||
<Plus className="size-4" />
|
<Star className={cn("size-4", isFavorite && "fill-current")} />
|
||||||
{creatingProject ? "Erstellt..." : "Als Projekt anlegen"}
|
{isFavorite ? "Favorit entfernen" : "Favorit"}
|
||||||
</Button>
|
</Button>
|
||||||
) : null}
|
) : null}
|
||||||
<Badge variant={data?.openCount === 0 ? "success" : "warning"}>
|
<Badge variant={data?.openCount === 0 ? "success" : "warning"}>
|
||||||
@@ -837,13 +958,11 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onO
|
|||||||
id="edit-ticket-work-type"
|
id="edit-ticket-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"
|
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={editWorkType}
|
value={editWorkType}
|
||||||
disabled={isProject}
|
|
||||||
onChange={(event) => setEditWorkType(event.currentTarget.value as WorkType)}
|
onChange={(event) => setEditWorkType(event.currentTarget.value as WorkType)}
|
||||||
>
|
>
|
||||||
<option value="support">Support</option>
|
<option value="support">Support</option>
|
||||||
<option value="consulting">Consulting</option>
|
<option value="consulting">Consulting</option>
|
||||||
</select>
|
</select>
|
||||||
{isProject ? <p className="text-xs text-muted-foreground">Projekte sind immer Consulting.</p> : null}
|
|
||||||
</div>
|
</div>
|
||||||
<Button type="submit" disabled={savingTicket}>
|
<Button type="submit" disabled={savingTicket}>
|
||||||
<Save className="size-4" />
|
<Save className="size-4" />
|
||||||
@@ -853,6 +972,7 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onO
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
<div className={cn("grid gap-4", project && "xl:grid-cols-2")}>
|
||||||
{project ? (
|
{project ? (
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader className="p-4">
|
<CardHeader className="p-4">
|
||||||
@@ -861,10 +981,21 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onO
|
|||||||
<CardTitle className="flex items-center gap-2">
|
<CardTitle className="flex items-center gap-2">
|
||||||
<FolderKanban className="size-5 text-muted-foreground" />
|
<FolderKanban className="size-5 text-muted-foreground" />
|
||||||
Projekt
|
Projekt
|
||||||
|
{project.closed_at ? <Badge variant="secondary">geschlossen</Badge> : <Badge variant="success">offen</Badge>}
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
<CardDescription>{project.teamspace_project || "Kein Teamspace-Projekt hinterlegt"}</CardDescription>
|
<CardDescription>{project.teamspace_project || "Kein Teamspace-Projekt hinterlegt"}</CardDescription>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex shrink-0 items-center gap-1">
|
<div className="flex shrink-0 items-center gap-1">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="sm"
|
||||||
|
variant="secondary"
|
||||||
|
disabled={savingProjectStatus}
|
||||||
|
onClick={() => void setProjectClosed(!project.closed_at)}
|
||||||
|
>
|
||||||
|
{project.closed_at ? <RotateCcw className="size-4" /> : <CheckCircle2 className="size-4" />}
|
||||||
|
{project.closed_at ? "Öffnen" : "Schließen"}
|
||||||
|
</Button>
|
||||||
<Button type="button" size="sm" variant="secondary" onClick={openProjectEditor}>
|
<Button type="button" size="sm" variant="secondary" onClick={openProjectEditor}>
|
||||||
<Pencil className="size-4" />
|
<Pencil className="size-4" />
|
||||||
Bearbeiten
|
Bearbeiten
|
||||||
@@ -877,22 +1008,40 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onO
|
|||||||
<div className="flex flex-wrap gap-2 text-sm">
|
<div className="flex flex-wrap gap-2 text-sm">
|
||||||
<Badge variant="secondary">Zeitraum {project.starts_on || "offen"} - {project.ends_on || "offen"}</Badge>
|
<Badge variant="secondary">Zeitraum {project.starts_on || "offen"} - {project.ends_on || "offen"}</Badge>
|
||||||
<Badge variant="secondary">Kontingent {project.hour_budget_minutes ? formatMinutes(project.hour_budget_minutes) : "offen"}</Badge>
|
<Badge variant="secondary">Kontingent {project.hour_budget_minutes ? formatMinutes(project.hour_budget_minutes) : "offen"}</Badge>
|
||||||
|
<Badge variant="secondary">Verbrauch {formatMinutes(project.consumption_minutes ?? 0)}</Badge>
|
||||||
|
{project.manual_consumption_minutes > 0 ? <Badge variant="outline">Manuell {formatMinutes(project.manual_consumption_minutes)}</Badge> : null}
|
||||||
{project.teamspace_project ? <Badge variant="outline">{project.teamspace_project}</Badge> : null}
|
{project.teamspace_project ? <Badge variant="outline">{project.teamspace_project}</Badge> : null}
|
||||||
</div>
|
</div>
|
||||||
<div className="grid gap-3 text-sm lg:grid-cols-2">
|
<div className="text-sm">
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<div className="mb-1 flex items-center gap-2 font-medium">
|
<div className="mb-1 flex items-center gap-2 font-medium">
|
||||||
<FolderKanban className="size-4 text-muted-foreground" />
|
<FolderKanban className="size-4 text-muted-foreground" />
|
||||||
Beschreibung
|
Beschreibung/Ziele
|
||||||
</div>
|
</div>
|
||||||
<p className="line-clamp-3 whitespace-pre-wrap text-muted-foreground">{project.description || "Noch keine Beschreibung."}</p>
|
<p className={cn("whitespace-pre-wrap text-muted-foreground", !projectDescriptionExpanded && "line-clamp-4")}>
|
||||||
</div>
|
{projectDescription || "Noch keine Beschreibung/Ziele."}
|
||||||
<div className="min-w-0">
|
</p>
|
||||||
<div className="mb-1 flex items-center gap-2 font-medium">
|
{projectDescription ? (
|
||||||
<Target className="size-4 text-muted-foreground" />
|
<Button
|
||||||
Ziele
|
type="button"
|
||||||
</div>
|
size="sm"
|
||||||
<p className="line-clamp-3 whitespace-pre-wrap text-muted-foreground">{project.goals || "Noch keine Ziele."}</p>
|
variant="ghost"
|
||||||
|
className="mt-1 h-7 px-2 text-xs"
|
||||||
|
onClick={() => setProjectDescriptionExpanded((current) => !current)}
|
||||||
|
>
|
||||||
|
{projectDescriptionExpanded ? (
|
||||||
|
<>
|
||||||
|
<ChevronUp className="size-3.5" />
|
||||||
|
Einklappen
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<ChevronDown className="size-3.5" />
|
||||||
|
Vollständig anzeigen
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
@@ -925,7 +1074,7 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onO
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid gap-3 lg:grid-cols-[minmax(220px,1fr)_140px_140px_160px]">
|
<div className="grid gap-3 lg:grid-cols-[minmax(220px,1fr)_140px_140px_150px_170px]">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label className="text-sm font-medium">Organisation</label>
|
<label className="text-sm font-medium">Organisation</label>
|
||||||
<OrganizationSelect
|
<OrganizationSelect
|
||||||
@@ -958,28 +1107,36 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onO
|
|||||||
onChange={(event) => setProjectForm({ ...projectForm, hourBudget: event.currentTarget.value })}
|
onChange={(event) => setProjectForm({ ...projectForm, hourBudget: event.currentTarget.value })}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
<div className="grid gap-3 lg:grid-cols-2">
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label className="text-sm font-medium" htmlFor="project-edit-description">Projektbeschreibung</label>
|
<label className="text-sm font-medium" htmlFor="project-edit-manual-consumption">Manueller Verbrauch h</label>
|
||||||
|
<Input
|
||||||
|
id="project-edit-manual-consumption"
|
||||||
|
inputMode="decimal"
|
||||||
|
value={projectForm.manualConsumption}
|
||||||
|
onChange={(event) => setProjectForm({ ...projectForm, manualConsumption: event.currentTarget.value })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground">Manueller Verbrauch wird nur im Projektverbrauch verrechnet, nicht in Auswertungen, Statistiken oder Monatsabschluss.</p>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-sm font-medium" htmlFor="project-edit-description">Beschreibung/Ziele</label>
|
||||||
<Textarea
|
<Textarea
|
||||||
id="project-edit-description"
|
id="project-edit-description"
|
||||||
className="min-h-28"
|
className="min-h-32"
|
||||||
value={projectForm.description}
|
value={projectForm.description}
|
||||||
onChange={(event) => setProjectForm({ ...projectForm, description: event.currentTarget.value })}
|
onChange={(event) => setProjectForm({ ...projectForm, description: event.currentTarget.value })}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="flex flex-wrap justify-end gap-2">
|
||||||
<label className="text-sm font-medium" htmlFor="project-edit-goals">Ziele</label>
|
<Button
|
||||||
<Textarea
|
type="button"
|
||||||
id="project-edit-goals"
|
variant="secondary"
|
||||||
className="min-h-28"
|
disabled={savingProjectStatus}
|
||||||
value={projectForm.goals}
|
onClick={() => void setProjectClosed(!project?.closed_at)}
|
||||||
onChange={(event) => setProjectForm({ ...projectForm, goals: event.currentTarget.value })}
|
>
|
||||||
/>
|
{project?.closed_at ? <RotateCcw className="size-4" /> : <CheckCircle2 className="size-4" />}
|
||||||
</div>
|
{project?.closed_at ? "Wieder öffnen" : "Projekt schließen"}
|
||||||
</div>
|
</Button>
|
||||||
<div className="flex justify-end">
|
|
||||||
<Button type="submit" disabled={savingProject}>
|
<Button type="submit" disabled={savingProject}>
|
||||||
<Save className="size-4" />
|
<Save className="size-4" />
|
||||||
{savingProject ? "Speichert..." : "Projekt speichern"}
|
{savingProject ? "Speichert..." : "Projekt speichern"}
|
||||||
@@ -1022,6 +1179,7 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onO
|
|||||||
<TodoPanel ticketId={ticket?.id} todos={visibleTodos} onChanged={load} />
|
<TodoPanel ticketId={ticket?.id} todos={visibleTodos} onChanged={load} />
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
<Card>
|
<Card>
|
||||||
<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">
|
||||||
@@ -1042,11 +1200,28 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onO
|
|||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="px-4 pb-4">
|
<CardContent className="px-4 pb-4">
|
||||||
<Accordion type="multiple" className="gap-2">
|
{data?.sessions.length ? (
|
||||||
|
<div className="mb-3 flex justify-end">
|
||||||
|
<Button type="button" size="sm" variant="secondary" onClick={toggleAllSessionDetails}>
|
||||||
|
{allSessionsExpanded ? (
|
||||||
|
<>
|
||||||
|
<ChevronUp className="size-4" />
|
||||||
|
Alle einklappen
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<ChevronDown className="size-4" />
|
||||||
|
Alle aufklappen
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
{sessionGroups.map((group) => (
|
{sessionGroups.map((group) => (
|
||||||
<AccordionItem key={group.key} value={group.key} className="rounded-md border bg-background px-3">
|
<div key={group.key} className="rounded-md border bg-background">
|
||||||
<AccordionTrigger className="gap-3 py-2 hover:no-underline">
|
<div className="flex min-w-0 flex-col gap-2 border-b bg-muted/25 px-3 py-2 lg:flex-row lg:items-center lg:justify-between">
|
||||||
<div className="flex min-w-0 flex-1 flex-col gap-2 lg:flex-row lg:items-center lg:justify-between">
|
|
||||||
<div className="min-w-0 text-left">
|
<div className="min-w-0 text-left">
|
||||||
<p className="truncate font-medium">{group.label}</p>
|
<p className="truncate font-medium">{group.label}</p>
|
||||||
<p className="text-xs text-muted-foreground">{group.sessions.length} Session(s)</p>
|
<p className="text-xs text-muted-foreground">{group.sessions.length} Session(s)</p>
|
||||||
@@ -1055,7 +1230,7 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onO
|
|||||||
<Badge variant="outline">Abgerechnet {formatMinutes(group.totalMinutes)}</Badge>
|
<Badge variant="outline">Abgerechnet {formatMinutes(group.totalMinutes)}</Badge>
|
||||||
{group.nonBillableMinutes > 0 ? <Badge variant="secondary">Nicht {formatMinutes(group.nonBillableMinutes)}</Badge> : null}
|
{group.nonBillableMinutes > 0 ? <Badge variant="secondary">Nicht {formatMinutes(group.nonBillableMinutes)}</Badge> : null}
|
||||||
{group.openMinutes > 0 ? <Badge variant="warning">Offen {formatMinutes(group.openMinutes)}</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" onClick={(event) => event.stopPropagation()}>
|
<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
|
||||||
id={`crm-billing-${group.key}`}
|
id={`crm-billing-${group.key}`}
|
||||||
@@ -1099,18 +1274,24 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onO
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</AccordionTrigger>
|
<div className="space-y-1.5 p-2 sm:p-3">
|
||||||
<AccordionContent className="pb-3">
|
{group.sessions.map((session) => {
|
||||||
<div className="space-y-1.5">
|
const expanded = expandedSessionIds.includes(session.id);
|
||||||
{group.sessions.map((session) => (
|
|
||||||
<div key={session.id} className="ml-3 rounded-md border bg-card/40 p-2 sm:ml-6">
|
return (
|
||||||
<div className="grid gap-2 lg:grid-cols-[140px_minmax(0,1fr)_90px_auto_auto] lg:items-center">
|
<div key={session.id} className="ml-3 rounded-md border bg-card/40 sm:ml-6">
|
||||||
<div className="text-sm text-muted-foreground">
|
<div className="grid gap-2 p-2 lg:grid-cols-[220px_100px_minmax(260px,auto)_auto] lg:items-center">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="flex min-w-0 items-center gap-2 rounded-md text-left text-sm text-muted-foreground hover:cursor-pointer hover:text-foreground focus-visible:outline-none focus-visible:ring-3 focus-visible:ring-ring/50"
|
||||||
|
onClick={() => toggleSessionDetails(session.id)}
|
||||||
|
aria-expanded={expanded}
|
||||||
|
>
|
||||||
|
{expanded ? <ChevronUp className="size-4 shrink-0" /> : <ChevronDown className="size-4 shrink-0" />}
|
||||||
|
<span className="truncate">
|
||||||
{formatTimeRange(session.started_at, session.ended_at)}
|
{formatTimeRange(session.started_at, session.ended_at)}
|
||||||
</div>
|
</span>
|
||||||
<div className="min-w-0">
|
</button>
|
||||||
<p className="whitespace-pre-wrap break-words text-sm font-medium">{session.activity}</p>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-wrap gap-1">
|
<div className="flex flex-wrap gap-1">
|
||||||
<Badge variant="outline" className="w-fit">{formatMinutes(session.rounded_minutes)}</Badge>
|
<Badge variant="outline" className="w-fit">{formatMinutes(session.rounded_minutes)}</Badge>
|
||||||
{session.recurring_billing_id ? <Badge variant="secondary">fix</Badge> : null}
|
{session.recurring_billing_id ? <Badge variant="secondary">fix</Badge> : null}
|
||||||
@@ -1138,19 +1319,35 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onO
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{expanded ? (
|
||||||
|
<div className="border-t px-3 pb-3 pt-2">
|
||||||
|
<p className="whitespace-pre-wrap break-words text-sm font-medium">{session.activity}</p>
|
||||||
|
<div className="mt-2 flex flex-wrap gap-x-3 gap-y-1 text-xs text-muted-foreground">
|
||||||
|
<span>{session.organization_name ?? session.customer_name}</span>
|
||||||
|
<span>{session.work_type === "support" ? "Support" : "Consulting"}</span>
|
||||||
|
<span>Erfasst {formatDateTime(session.created_at)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</AccordionContent>
|
|
||||||
</AccordionItem>
|
|
||||||
))}
|
|
||||||
</Accordion>
|
|
||||||
|
|
||||||
{data?.sessions.length === 0 ? (
|
{data?.sessions.length === 0 ? (
|
||||||
<p className="py-8 text-center text-sm text-muted-foreground">Keine Sessions für dieses Ticket im gewählten Filter.</p>
|
<p className="py-8 text-center text-sm text-muted-foreground">Keine Sessions für dieses Ticket im gewählten Filter.</p>
|
||||||
) : null}
|
) : null}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
{isRecurringBillingTicket ? (
|
||||||
|
<Alert variant="warning">
|
||||||
|
<div className="font-medium">Fixe Abrechnung</div>
|
||||||
|
<div className="text-sm text-muted-foreground">Sessions für dieses Ticket werden ausschließlich aus der Definition erzeugt. Manuelles Nachtragen ist deaktiviert.</div>
|
||||||
|
</Alert>
|
||||||
|
) : (
|
||||||
<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">
|
||||||
@@ -1262,6 +1459,7 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onO
|
|||||||
</form>
|
</form>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
|
||||||
{data && data.openCount > 0 ? (
|
{data && data.openCount > 0 ? (
|
||||||
@@ -1340,13 +1538,12 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onO
|
|||||||
id="session-work-type"
|
id="session-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"
|
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={editSessionWorkType}
|
value={editSessionWorkType}
|
||||||
disabled={Boolean(editingSession?.recurring_billing_id) || isProject}
|
disabled={Boolean(editingSession?.recurring_billing_id)}
|
||||||
onChange={(event) => setEditSessionWorkType(event.currentTarget.value as WorkType)}
|
onChange={(event) => setEditSessionWorkType(event.currentTarget.value as WorkType)}
|
||||||
>
|
>
|
||||||
<option value="support">Support</option>
|
<option value="support">Support</option>
|
||||||
<option value="consulting">Consulting</option>
|
<option value="consulting">Consulting</option>
|
||||||
</select>
|
</select>
|
||||||
{isProject ? <p className="text-xs text-muted-foreground">Projekt-Sessions bleiben Consulting.</p> : null}
|
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-end">
|
<div className="flex justify-end">
|
||||||
<Button type="submit" disabled={savingSessionDetails}>
|
<Button type="submit" disabled={savingSessionDetails}>
|
||||||
|
|||||||
@@ -10,10 +10,11 @@ import { Input } from "@/components/ui/input";
|
|||||||
import { Textarea } from "@/components/ui/textarea";
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
import { HelpLink } from "@/components/HelpLink";
|
import { HelpLink } from "@/components/HelpLink";
|
||||||
import { OrganizationSelect } from "@/components/OrganizationSelect";
|
import { OrganizationSelect } from "@/components/OrganizationSelect";
|
||||||
import { createSession, lookupTicket } from "../api";
|
import { createSession, getRecurringBillings, lookupTicket } from "../api";
|
||||||
import { formatTimer } from "../format";
|
import { formatTimer } from "../format";
|
||||||
|
import { isRecurringBillingTicketNumber, ticketHasRecurringBilling } from "../recurring-billings";
|
||||||
import { activeElapsedMs, pauseEntry, resumeEntry, ticketPattern, type TimerEntry } from "../timers";
|
import { activeElapsedMs, pauseEntry, resumeEntry, ticketPattern, type TimerEntry } from "../timers";
|
||||||
import type { TicketMeta, WorkType } from "../types";
|
import type { RecurringBilling, TicketMeta, WorkType } from "../types";
|
||||||
|
|
||||||
type ManualSessionFormState = {
|
type ManualSessionFormState = {
|
||||||
ticketNumber: string;
|
ticketNumber: string;
|
||||||
@@ -75,14 +76,41 @@ export function TimerPage({ timers, setTimers, selectedTimerId, setSelectedTimer
|
|||||||
const [savingManualSession, setSavingManualSession] = useState(false);
|
const [savingManualSession, setSavingManualSession] = useState(false);
|
||||||
const [manualLookupStatus, setManualLookupStatus] = useState<"idle" | "loading" | "found" | "not-found">("idle");
|
const [manualLookupStatus, setManualLookupStatus] = useState<"idle" | "loading" | "found" | "not-found">("idle");
|
||||||
const [manualLookupTicket, setManualLookupTicket] = useState<TicketMeta | null>(null);
|
const [manualLookupTicket, setManualLookupTicket] = useState<TicketMeta | null>(null);
|
||||||
|
const [recurringBillings, setRecurringBillings] = useState<RecurringBilling[]>([]);
|
||||||
|
|
||||||
const selectedTimer = timers.find((timer) => timer.id === selectedTimerId) ?? timers[0] ?? null;
|
const selectedTimer = timers.find((timer) => timer.id === selectedTimerId) ?? timers[0] ?? null;
|
||||||
const elapsedSeconds = useMemo(() => Math.floor(activeElapsedMs(selectedTimer, tick) / 1000), [selectedTimer, tick]);
|
const elapsedSeconds = useMemo(() => Math.floor(activeElapsedMs(selectedTimer, tick) / 1000), [selectedTimer, tick]);
|
||||||
const isValidTicket = ticketPattern.test(ticketNumber);
|
const isValidTicket = ticketPattern.test(ticketNumber);
|
||||||
const isManualTicketValid = ticketPattern.test(manualSession.ticketNumber.trim());
|
const isManualTicketValid = ticketPattern.test(manualSession.ticketNumber.trim());
|
||||||
const manualTicketDefaultsLocked = Boolean(manualLookupTicket?.organization_id && manualLookupTicket.work_type);
|
const manualTicketDefaultsLocked = Boolean(manualLookupTicket?.organization_id && manualLookupTicket.work_type);
|
||||||
|
const manualTicketIsRecurringBilling =
|
||||||
|
ticketHasRecurringBilling(manualLookupTicket, recurringBillings) || isRecurringBillingTicketNumber(manualSession.ticketNumber.trim(), recurringBillings);
|
||||||
const runningTimer = timers.find((timer) => timer.phase === "running") ?? null;
|
const runningTimer = timers.find((timer) => timer.phase === "running") ?? null;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
|
||||||
|
async function loadRecurringBillings() {
|
||||||
|
try {
|
||||||
|
const result = await getRecurringBillings();
|
||||||
|
|
||||||
|
if (!cancelled) {
|
||||||
|
setRecurringBillings(result.recurringBillings);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
if (!cancelled) {
|
||||||
|
setRecurringBillings([]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void loadRecurringBillings();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const normalizedTicketNumber = manualSession.ticketNumber.trim();
|
const normalizedTicketNumber = manualSession.ticketNumber.trim();
|
||||||
|
|
||||||
@@ -149,6 +177,16 @@ export function TimerPage({ timers, setTimers, selectedTimerId, setSelectedTimer
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const ticketResult = await lookupTicket(ticketNumber).catch(() => ({ ticket: null }));
|
||||||
|
|
||||||
|
if (ticketHasRecurringBilling(ticketResult.ticket, recurringBillings) || isRecurringBillingTicketNumber(ticketNumber, recurringBillings)) {
|
||||||
|
setTimers((current) => current.filter((timer) => timer.ticketNumber !== ticketNumber));
|
||||||
|
toast.error("Fixe Abrechnung", {
|
||||||
|
description: "Für fixe Abrechnungen dürfen keine manuellen Sessions gestartet werden. Bitte die Definition bearbeiten."
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (timers.some((timer) => timer.ticketNumber === ticketNumber)) {
|
if (timers.some((timer) => timer.ticketNumber === ticketNumber)) {
|
||||||
toast.error("Timer existiert bereits", {
|
toast.error("Timer existiert bereits", {
|
||||||
description: "Für dieses Ticket läuft oder pausiert schon ein Timer."
|
description: "Für dieses Ticket läuft oder pausiert schon ein Timer."
|
||||||
@@ -157,7 +195,6 @@ export function TimerPage({ timers, setTimers, selectedTimerId, setSelectedTimer
|
|||||||
}
|
}
|
||||||
|
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
const ticketResult = await lookupTicket(ticketNumber).catch(() => ({ ticket: null }));
|
|
||||||
const newTimer: TimerEntry = {
|
const newTimer: TimerEntry = {
|
||||||
id: crypto.randomUUID(),
|
id: crypto.randomUUID(),
|
||||||
ticketNumber,
|
ticketNumber,
|
||||||
@@ -221,6 +258,14 @@ export function TimerPage({ timers, setTimers, selectedTimerId, setSelectedTimer
|
|||||||
const result = await lookupTicket(timer.ticketNumber);
|
const result = await lookupTicket(timer.ticketNumber);
|
||||||
const ticket = result.ticket;
|
const ticket = result.ticket;
|
||||||
|
|
||||||
|
if (ticketHasRecurringBilling(ticket, recurringBillings) || isRecurringBillingTicketNumber(timer.ticketNumber, recurringBillings)) {
|
||||||
|
setTimers((current) => current.filter((entry) => entry.id !== timer.id));
|
||||||
|
toast.error("Fixe Abrechnung", {
|
||||||
|
description: "Für fixe Abrechnungen dürfen keine manuellen Sessions gespeichert werden. Bitte die Definition bearbeiten."
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
setOrganizationId(ticket?.organization_id ?? "");
|
setOrganizationId(ticket?.organization_id ?? "");
|
||||||
setOrganizationName(ticket?.organization_name ?? null);
|
setOrganizationName(ticket?.organization_name ?? null);
|
||||||
setWorkType(ticket?.work_type ?? "support");
|
setWorkType(ticket?.work_type ?? "support");
|
||||||
@@ -250,6 +295,14 @@ export function TimerPage({ timers, setTimers, selectedTimerId, setSelectedTimer
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (ticketHasRecurringBilling(finished.ticket, recurringBillings) || isRecurringBillingTicketNumber(finished.ticketNumber, recurringBillings)) {
|
||||||
|
toast.error("Fixe Abrechnung", {
|
||||||
|
description: "Für fixe Abrechnungen dürfen keine manuellen Sessions gespeichert werden. Bitte die Definition bearbeiten."
|
||||||
|
});
|
||||||
|
setFinished(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const existingTicketHasDefaults = Boolean(finished.ticket?.organization_id && finished.ticket?.work_type);
|
const existingTicketHasDefaults = Boolean(finished.ticket?.organization_id && finished.ticket?.work_type);
|
||||||
const effectiveOrganizationId = existingTicketHasDefaults ? finished.ticket!.organization_id! : organizationId;
|
const effectiveOrganizationId = existingTicketHasDefaults ? finished.ticket!.organization_id! : organizationId;
|
||||||
const effectiveWorkType = existingTicketHasDefaults ? finished.ticket!.work_type! : workType;
|
const effectiveWorkType = existingTicketHasDefaults ? finished.ticket!.work_type! : workType;
|
||||||
@@ -307,6 +360,13 @@ export function TimerPage({ timers, setTimers, selectedTimerId, setSelectedTimer
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (manualTicketIsRecurringBilling) {
|
||||||
|
toast.error("Fixe Abrechnung", {
|
||||||
|
description: "Für fixe Abrechnungen dürfen keine Sessions nachgetragen werden. Bitte die Definition bearbeiten."
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const startedAt = new Date(`${manualSession.day}T${manualSession.startTime}:00`);
|
const startedAt = new Date(`${manualSession.day}T${manualSession.startTime}:00`);
|
||||||
const endedAt = new Date(`${manualSession.day}T${manualSession.endTime}:00`);
|
const endedAt = new Date(`${manualSession.day}T${manualSession.endTime}:00`);
|
||||||
|
|
||||||
@@ -335,7 +395,7 @@ export function TimerPage({ timers, setTimers, selectedTimerId, setSelectedTimer
|
|||||||
durationSeconds
|
durationSeconds
|
||||||
});
|
});
|
||||||
toast.success("Session nachgetragen", {
|
toast.success("Session nachgetragen", {
|
||||||
description: manualLookupTicket?.is_project ? "Der Eintrag wurde dem Projekt zugeordnet." : "Der Eintrag ist in deiner Auswertung offen."
|
description: manualLookupTicket?.is_project ? "Der Eintrag wurde dem Projekt zugeordnet." : "Der Eintrag ist in deiner Ticketliste offen."
|
||||||
});
|
});
|
||||||
setManualSession({
|
setManualSession({
|
||||||
...emptyManualSession(),
|
...emptyManualSession(),
|
||||||
@@ -488,6 +548,9 @@ export function TimerPage({ timers, setTimers, selectedTimerId, setSelectedTimer
|
|||||||
{manualLookupStatus === "found" && !manualTicketDefaultsLocked ? (
|
{manualLookupStatus === "found" && !manualTicketDefaultsLocked ? (
|
||||||
<p className="text-xs text-muted-foreground">Ticket gefunden, aber Stammdaten sind unvollständig. Bitte ergänzen.</p>
|
<p className="text-xs text-muted-foreground">Ticket gefunden, aber Stammdaten sind unvollständig. Bitte ergänzen.</p>
|
||||||
) : null}
|
) : null}
|
||||||
|
{manualTicketIsRecurringBilling ? (
|
||||||
|
<p className="text-xs text-destructive">Fixe Abrechnung: Sessions kommen ausschließlich aus der Definition.</p>
|
||||||
|
) : null}
|
||||||
{manualLookupStatus === "not-found" ? <p className="text-xs text-muted-foreground">Neues Ticket. Organisation und Art bitte wählen.</p> : null}
|
{manualLookupStatus === "not-found" ? <p className="text-xs text-muted-foreground">Neues Ticket. Organisation und Art bitte wählen.</p> : null}
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
@@ -562,7 +625,7 @@ export function TimerPage({ timers, setTimers, selectedTimerId, setSelectedTimer
|
|||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<Button type="submit" disabled={savingManualSession}>
|
<Button type="submit" disabled={savingManualSession || manualTicketIsRecurringBilling}>
|
||||||
<Upload className="size-4" />
|
<Upload className="size-4" />
|
||||||
{savingManualSession ? "Speichert..." : "Nachtragen"}
|
{savingManualSession ? "Speichert..." : "Nachtragen"}
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
+1074
-22
File diff suppressed because it is too large.
Load diff
Reference in new issue
Block a user