This commit is contained in:
michael committed 2026-09-23 11:09:10 +00:00
1 parent 9be6c6e36a
commit fa6db04370
16 files changed
+1123 -118

No files matched your search

+4
View File
@@ -1073,6 +1073,8 @@ export function App() {
startedAt: now,
pausedTotalMs: 0,
pausedAt: null,
activity: "",
billingStatus: null,
phase: "running"
};
@@ -1472,6 +1474,7 @@ export function App() {
selectedTimerId={selectedTimerId}
setSelectedTimerId={setSelectedTimerId}
tick={tick}
currentUser={currentUser}
/>
) : null}
{!isAdmin && route.page === "dashboard" ? <DashboardPage onNavigate={navigate} /> : null}
@@ -1492,6 +1495,7 @@ export function App() {
selectedTimerId={selectedTimerId}
setSelectedTimerId={setSelectedTimerId}
tick={tick}
currentUser={currentUser}
/>
) : null}
{!isAdmin && route.page === "ticket" ? (
+24 -2
View File
@@ -16,7 +16,8 @@ import type {
TicketSearchResult,
TodoPriority,
TodoStatus,
WorkType
WorkType,
ZammadSessionArticleQueueItem
} from "./types";
type CreateSessionPayload = {
@@ -29,6 +30,8 @@ type CreateSessionPayload = {
durationSeconds: number;
zammadLookup?: boolean;
projectTeamspaceEntryId?: string | null;
billingStatus?: BillingStatus;
createZammadArticle?: boolean;
};
type DeleteSessionResult = {
@@ -198,6 +201,10 @@ export function updateCurrentUser(payload: {
newPassword?: string;
zammadApiToken?: string;
zammadEmailSessionWebhookEnabled?: boolean;
sessionBillingPromptEnabled?: boolean;
zammadSessionNoteDefaultEnabled?: boolean;
zammadTimeUnitRoundingEnabled?: boolean;
zammadTimeUnitRoundingMinutes?: number;
}) {
return request<{ user: AuthUser }>("/api/auth/me", {
method: "PATCH",
@@ -681,6 +688,21 @@ export function createSession(payload: CreateSessionPayload) {
});
}
export function getZammadSessionArticleQueue() {
return request<{ queue: ZammadSessionArticleQueueItem[] }>("/api/zammad/session-article-queue");
}
export function retryZammadSessionArticleQueue() {
return request<{
sent: number;
failed: number;
queue: ZammadSessionArticleQueueItem[];
results: Array<{ id: string; status: "sent" | "failed"; error?: string; zammadArticleId?: number | null }>;
}>("/api/zammad/session-article-queue/retry", {
method: "POST"
});
}
export function lookupTicket(ticketNumber: string, source: "local" | "zammad" = "local") {
const params = new URLSearchParams({ ticketNumber });
@@ -771,7 +793,7 @@ export function getTicketDetails(
ticketId: string,
filters: {
month?: string;
status?: "all" | "open" | "billed" | "non_billable";
status?: "all" | "open" | "billable" | "billed" | "non_billable";
source?: "all" | "manual" | "recurring";
} = {}
) {
+35 -25
View File
@@ -34,19 +34,20 @@ export const helpSections: HelpSection[] = [
title: "Monatsworkflow",
description: "Der empfohlene Ablauf vom Start einer Arbeitssession bis zum sauberen Monatsabschluss.",
quickItems: [
"Während des Monats: Ticketnummer eingeben, Timer starten, Session beenden und Tätigkeit dokumentieren.",
"Während des Monats: Ticketnummer eingeben, Timer starten, Tätigkeit dokumentieren und Session beenden.",
"Vergessene Zeiten: Session manuell auf dem Ticket oder als neues Ticket nachtragen.",
"Am Monatsende: offene Bewertungen bearbeiten, Teamspace-Tageswerte pflegen, Kontrolle durchführen und Monat schließen."
],
details: [
"Der normale Einstieg ist das Ticketfeld im Kopfbereich oder die Seite Session starten. Dort gibst du die Ticketnummer ein und startest den Timer mit Timer starten.",
"Während der Arbeit läuft genau ein Timer aktiv. Wechselst du zu einem anderen Timer, pausiert TicketTracker die übrigen Timer automatisch.",
"Wenn die Arbeit erledigt ist, beendest du den Timer. Die Zeit stoppt sofort beim Klick auf Beenden; das anschließende Ausfüllen der Tätigkeit zählt nicht mehr zur Sessionzeit.",
"Im Beenden-Dialog trägst du die Tätigkeit ein. Wenn das Ticket bereits bekannt ist, übernimmt TicketTracker Organisation und Art automatisch. Bei neuen Tickets wählst du Organisation und Art zusätzlich aus.",
"Während der Timer läuft oder pausiert ist, trägst du die Tätigkeit direkt in den Timer-Details ein. Pausierte Timer behalten diese Notizen.",
"Wenn die Arbeit erledigt ist, beendest du den Timer. Die Zeit stoppt sofort beim Klick auf Beenden; die bereits erfasste Tätigkeit wird mit der Session gespeichert.",
"Wenn das Ticket bereits bekannt ist, übernimmt TicketTracker Organisation und Art automatisch. Bei neuen Tickets wählst du Organisation und Art beim Speichern zusätzlich aus.",
"Hat ein Consulting-Projekt mehrere Teamspace-Einträge, wählst du beim Speichern der Session den passenden Eintrag aus. Bei genau einem Teamspace-Eintrag ordnet TicketTracker automatisch zu.",
"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 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 Abrechenbar, Abgerechnet oder Nicht abrechenbar. Ein erneuter Klick auf den aktiven Status nimmt die Bewertung zurück.",
"Im Ticket steht pro Tag das Feld Teamspace. Dort trägst du die Stunden ein, die du für genau dieses Ticket an genau diesem Tag wirklich im CRM beziehungsweise Teamspace erfasst hast. Nach einer Änderung erscheint der Speichern-Button.",
"Die Teamspace-Zeit ist ein Tageswert pro Ticket und Benutzer. Monats- und Tagesansicht zeigen denselben Wert; er muss also nur einmal gepflegt werden.",
"Am Monatsende öffnest du Monatsabschluss. Dort findest du offene Bewertungen gruppiert nach Ticket, Tage ohne Teamspace-Wert und die Schaltfläche Monat abschließen.",
@@ -59,7 +60,7 @@ export const helpSections: HelpSection[] = [
"Ticketfeld im Kopfbereich oder Session starten öffnen.",
"Ticketnummer eingeben und Timer starten.",
"Arbeit erledigen; bei Bedarf zwischen vorbereiteten Timern wechseln.",
"Timer beenden und Tätigkeit speichern.",
"Tätigkeit im Timer erfassen und Timer beenden.",
"Vergessene Zeiten über Session nachtragen ergänzen.",
"Tickets öffnen, Monat wählen und Einträge prüfen.",
"Ticket öffnen, Sessions pro Tag bewerten und Teamspace-Tageswerte speichern.",
@@ -71,8 +72,8 @@ export const helpSections: HelpSection[] = [
"Ticketnummer eingeben",
"Timer starten",
"Arbeit am Ticket erledigen",
"Tätigkeit im Timer eintragen",
"Timer beenden",
"Tätigkeit eintragen",
"Session wird dem Ticket und Benutzer gespeichert",
"Optional: vergessene Session nachtragen",
"Tickets öffnen und Ticket prüfen",
@@ -111,17 +112,21 @@ export const helpSections: HelpSection[] = [
quickItems: [
"Ticketnummer eingeben und Timer starten.",
"Aktivieren eines Timers pausiert alle anderen Timer automatisch.",
"Beim Beenden stoppt die Zeit sofort; Details werden danach gespeichert."
"Die Tätigkeit wird im ausgewählten Timer erfasst und beim Beenden gespeichert."
],
details: [
"Neue Timer werden mit einer Ticketnummer im Format Ticket#XXXXXX gestartet.",
"Alle laufenden und pausierten Timer bleiben in der Seitenleiste sichtbar, damit du auf jeder Unterseite sehen kannst, was vorbereitet oder aktiv ist.",
"Wenn ein anderer Timer aktiviert wird, pausiert TicketTracker die übrigen Timer automatisch. Dadurch kann nie versehentlich auf zwei Tickets gleichzeitig Zeit laufen.",
"Beim Klick auf Beenden wird die Stoppuhr sofort angehalten. Das Speichern der Tätigkeit findet danach statt und verlängert die gemessene Zeit nicht.",
"Existiert das Ticket bereits, übernimmt TicketTracker Organisation und Art. Dann muss beim Beenden nur noch die Tätigkeit eingetragen werden.",
"Wenn du einen Timer auswählst, zeigt die Timerseite dessen Details inklusive gemessener Zeit und Tätigkeitsfeld.",
"Pausierte Timer behalten ihre Tätigkeit. Entfernst du einen Timer, wird auch die noch nicht gespeicherte Tätigkeit verworfen.",
"Beim Klick auf Beenden wird die Stoppuhr sofort angehalten. Die bereits im Timer erfasste Tätigkeit wird beim Speichern an die Session gehängt.",
"Im Abschlussdialog kann die Tätigkeit als interne Zammad-Notiz übertragen werden. Der Article wird intern erstellt und bekommt die getrackte Zeit als time_unit.",
"Wenn Zammad beim Speichern nicht erreichbar ist, bleibt die Session trotzdem gespeichert. Die interne Notiz wird geparkt und kann später im Profil erneut gesendet werden.",
"Existiert das Ticket bereits, übernimmt TicketTracker Organisation und Art.",
"Bei Projekt-Tickets mit mehreren Teamspace-Einträgen zeigt der Beenden-Dialog eine Auswahl. Der Standard-Eintrag ist dort mit Standard gekennzeichnet.",
"Bei Projekt-Tickets mit genau einem Teamspace-Eintrag wird die Session automatisch diesem Eintrag zugeordnet.",
"Existiert das Ticket noch nicht, müssen Organisation, Tätigkeit und Art angegeben werden.",
"Existiert das Ticket noch nicht, müssen Organisation und Art angegeben werden.",
"Pausen werden von der Gesamtdauer abgezogen. Die gespeicherte Zeit wird auf volle Minuten gerundet.",
"Der Schnellstart im Kopfbereich funktioniert auf allen Unterseiten und nutzt dieselbe Timerlogik."
]
@@ -131,7 +136,7 @@ export const helpSections: HelpSection[] = [
title: "Session Nachtragen",
description: "Nachtragen ist für vergessene Zeiten gedacht, wenn kein Timer gelaufen ist.",
quickItems: [
"Datum, Von, Bis und Tätigkeit eintragen.",
"Datum, Von, Bis, Bewertung und Tätigkeit eintragen.",
"Benutzer tragen eigene Sessions nach.",
"Tätigkeiten sind mehrzeilig."
],
@@ -140,7 +145,7 @@ export const helpSections: HelpSection[] = [
"Das Ende muss nach dem Beginn liegen. Übernacht-Sessions sind aktuell nicht vorgesehen und sollten als getrennte Einträge gepflegt werden.",
"Die Organisation muss aus der synchronisierten Organisationsliste gewählt werden. Freitext ist absichtlich gesperrt.",
"Die Tätigkeit ist eine Textarea. Zeilenumbrüche bleiben erhalten und werden in der Ticketdetailansicht wieder ausgegeben.",
"Nachgetragene Sessions starten immer offen. Sie müssen später als Abgerechnet oder Nicht abrechenbar bewertet werden.",
"Mit Bewertung --- bleibt die Session offen. Abrechenbar oder Nicht abrechenbar werden direkt an der neuen Session gespeichert.",
"In der Ticketdetailansicht kann eine Session direkt zum geöffneten Ticket nachgetragen werden. Ticketnummer und Stammdaten sind dort schon vorbelegt beziehungsweise aus dem Ticket abgeleitet.",
"Bei Consulting-Projekten mit mehreren Teamspace-Einträgen ist der passende Teamspace-Eintrag Pflicht. Bei nur einem Eintrag wird er automatisch gesetzt."
]
@@ -214,7 +219,7 @@ export const helpSections: HelpSection[] = [
details: [
"Oben steht die Ticketnummer mit Copy-Icon, damit sie schnell in andere Systeme übernommen werden kann.",
"Die Session-Einträge sind nach Kalendertagen gruppiert. Pro Tagesgruppe steht die Summe der abgerechneten Zeit.",
"Nicht abrechenbare und offene Zeiten werden neben der abgerechneten Tageszeit separat angezeigt.",
"Abrechenbare, nicht abrechenbare und offene Zeiten werden neben der abgerechneten Tageszeit separat angezeigt.",
"Bei Projekt-Tickets mit mehreren Teamspace-Einträgen zeigt jede zugeordnete Session den gewählten Teamspace-Eintrag. Nicht zugeordnete Sessions werden hervorgehoben.",
"Das Teamspace-Feld wird als Stundenwert gepflegt, zum Beispiel 1, 1,5 oder 2.25.",
"Während der Eingabe wird der Teamspace-Wert nicht sofort gespeichert. Sobald eine Änderung vorliegt, erscheint ein Speichern-Button.",
@@ -230,17 +235,18 @@ export const helpSections: HelpSection[] = [
{
id: "sessions-bewerten",
title: "Sessions Bewerten",
description: "Bewertungen bestimmen, ob eine Session abgerechnet wurde oder bewusst nicht abrechenbar ist.",
description: "Bewertungen bestimmen, ob eine Session offen, abrechenbar, abgerechnet oder bewusst nicht abrechenbar ist.",
quickItems: [
"Jede Session braucht genau eine Bewertung.",
"Abgerechnet und Nicht abrechenbar können durch erneuten Klick zurückgenommen werden.",
"Abrechenbar, Abgerechnet und Nicht abrechenbar können durch erneuten Klick zurückgenommen werden.",
"Offene Bewertungen blockieren den Monatsabschluss."
],
details: [
"Abrechenbar bedeutet: Die Session ist bewertet, aber noch nicht als abgerechnet/Teamspace-relevant markiert.",
"Abgerechnet bedeutet: Die Session wurde für die Abrechnung beziehungsweise im CRM berücksichtigt.",
"Nicht abrechenbar bedeutet: Die Session gehört zum Arbeitsnachweis, wird aber nicht abgerechnet.",
"Ein erneuter Klick auf den aktiven Bewertungsbutton entfernt die Bewertung und macht die Session wieder offen.",
"Nicht abrechenbare Sessions zählen als bewertet. Sie blockieren den Monatsabschluss nicht.",
"Abrechenbare und nicht abrechenbare Sessions zählen als bewertet. Sie blockieren den Monatsabschluss nicht.",
"Wenn ein Monat abgeschlossen ist, sind Bewertungen gesperrt. Öffne den Monat zuerst wieder, wenn du etwas korrigieren musst.",
"Die offenen Bewertungen findest du gebündelt im Monatsabschluss."
]
@@ -378,12 +384,12 @@ export const helpSections: HelpSection[] = [
],
details: [
"Die Statistikseite betrachtet immer einen Monat.",
"Abgerechnet zeigt nur Sessions mit der Bewertung Abgerechnet. Offene Zeiten sind dort nicht enthalten.",
"Alle Statistik- und Vergleichswerte nutzen nur abgerechnete Zeiten.",
"Teamspace zeigt, wie viel Zeit für dieselben abrechenbaren Ticket-Tage im CRM eingetragen wurde.",
"Abgerechnet zeigt nur Sessions mit der Bewertung Abgerechnet. Abrechenbare, nicht abrechenbare und offene Zeiten sind dort nicht enthalten.",
"Teamspace-Vergleiche nutzen nur abgerechnete Zeiten.",
"Teamspace zeigt, wie viel Zeit für dieselben abgerechneten Ticket-Tage im CRM eingetragen wurde.",
"Eine positive Differenz bedeutet: In Teamspace steht mehr Zeit als abgerechnet wurde.",
"Eine negative Differenz bedeutet: In Teamspace steht weniger Zeit als abgerechnet wurde.",
"Offene Sessions werden nicht in Relationen, Graphen, Teamspace-Differenzen, Hotspots, Manuell/Fix-Werte oder Durchschnittswerte eingerechnet.",
"Abrechenbare, nicht abrechenbare und offene Sessions werden nicht in Teamspace-Differenzen, Hotspots, Manuell/Fix-Werte oder Durchschnittswerte eingerechnet.",
"Der Monatsverlauf zeigt abgerechnete Session-Zeit als Balken und Teamspace als Linie.",
"Organisationen, Typen und Ticket-Hotspots helfen dabei, Auffälligkeiten im Monat schnell zu finden."
]
@@ -391,7 +397,7 @@ export const helpSections: HelpSection[] = [
{
id: "statistik-getrackt",
title: "Statistik: Abgerechnet",
description: "Diese Kennzahl zeigt die abrechenbare Session-Zeit im gewählten Monat.",
description: "Diese Kennzahl zeigt die abgerechnete Session-Zeit im gewählten Monat.",
quickItems: [
"Summe aller Sessions mit Bewertung Abgerechnet.",
"Zeigt zusätzlich die abgerechnete Sessionanzahl.",
@@ -399,7 +405,7 @@ export const helpSections: HelpSection[] = [
],
details: [
"Abgerechnet umfasst nur Sessions des angemeldeten Benutzers, die aktiv als Abgerechnet markiert wurden.",
"Offene Sessions werden aus diesem Wert herausgehalten.",
"Abrechenbare, nicht abrechenbare und offene Sessions werden aus diesem Wert herausgehalten.",
"Auch automatisch erzeugte Sessions aus fixen Abrechnungen zählen hier mit, sobald sie als Abgerechnet markiert wurden.",
"Es werden keine offenen Zeiten in diesen Wert eingerechnet.",
"Dieser Wert ist die Ausgangsbasis für Teamspace-Differenzen: Teamspace minus abgerechnete Session-Zeit ergibt die Differenz."
@@ -419,7 +425,7 @@ export const helpSections: HelpSection[] = [
"Die Statistik summiert alle Teamspace-Werte des Monats für den angemeldeten Benutzer.",
"Eine positive Differenz bedeutet, dass in Teamspace mehr Zeit steht als abgerechnet wurde.",
"Eine negative Differenz bedeutet, dass in Teamspace weniger Zeit steht als abgerechnet wurde.",
"Offene Sessions fließen nicht in diese Differenz ein.",
"Abrechenbare, nicht abrechenbare und offene Sessions fließen nicht in diese Differenz ein.",
"Fehlende Teamspace-Werte können im Monatsabschluss geprüft und bei Bedarf zur Kenntnis genommen werden."
]
},
@@ -453,7 +459,7 @@ export const helpSections: HelpSection[] = [
"Der Monatsverlauf zeigt jeden Tag des Monats. Tage ohne Sessions bleiben leer, damit die Monatsstruktur sichtbar bleibt.",
"Die blauen Balken stehen für abgerechnete Session-Zeit.",
"Die fuchsiafarbene Linie steht für Teamspace-Zeit, sofern für den Tag Werte gepflegt wurden.",
"Der Bewertungsstand weist Abgerechnet und Offen getrennt aus.",
"Der Bewertungsstand weist Abgerechnet, Abrechenbar, Nicht abrechenbar und Offen getrennt aus.",
"Der Teamspace-Abgleich zeigt die Gesamtdifferenz zwischen CRM-Werten und abgerechneter Session-Zeit.",
"Manuell und Fix trennen selbst erfasste abrechenbare Sessions von automatisch erzeugten abrechenbaren Sessions aus fixen Abrechnungen."
]
@@ -490,7 +496,7 @@ export const helpSections: HelpSection[] = [
"Support und Consulting werden getrennt summiert.",
"Die Zeitwerte zeigen abgerechnete Sessions und Teamspace für den jeweiligen Typ.",
"Die Differenz folgt derselben Logik wie überall: Teamspace minus abgerechnete Session-Zeit.",
"Die Zeile Abr. und Offen zeigt, wie viele Sessions dieses Typs bereits abgerechnet oder noch offen sind."
"Die Zeile Abr., Abrechenbar, Nicht abrechenbar und Offen zeigt, wie viele Sessions dieses Typs bereits abgerechnet, als abrechenbar markiert, bewusst nicht abrechenbar oder noch offen sind."
]
},
{
@@ -530,6 +536,10 @@ export const helpSections: HelpSection[] = [
"Die gespeicherte Zammad User-ID wird danach read-only im Profil angezeigt.",
"Die Token-Prüfung läuft nur, wenn der Token im Profil geändert oder entfernt wird. Normale Profiländerungen prüfen Zammad nicht erneut.",
"Der persönliche Zammad Token ist vom Admin-Token für den Organisations-Sync getrennt. Er wird für ticketbezogene Zammad-Abgleiche, Consulting-Importe und die Zuordnung zum Zammad-Owner verwendet.",
"Die Bewertungsabfrage beim Timerabschluss kann im Profil ein- oder ausgeschaltet werden. Sie erscheint nur, wenn der Timer noch auf --- steht.",
"Die interne Zammad-Notiz beim Timerabschluss steuert, ob die Checkbox im Abschlussdialog standardmäßig aktiv ist.",
"Für die an Zammad gesendete time_unit kann optional eine Aufrundung auf 5, 10, 15 oder 30 Minuten aktiviert werden. Ohne Rundung wird die konkrete Zeit mit zwei Nachkommastellen gesendet.",
"Geparkte Zammad-Notizen listet interne Notizen, die wegen eines Zammad-Fehlers noch nicht übertragen wurden. Über Senden werden sie gesammelt nach Zammad gepusht.",
"Im Admin-Profil wird kein persönlicher Zammad-Token angezeigt.",
"Admins können Benutzer ebenfalls verwalten, aber jeder Benutzer kann die eigenen Daten jederzeit selbst pflegen."
]
+17 -5
View File
@@ -1,3 +1,5 @@
import type { BillingStatus } from "./types";
export type TimerPhase = "running" | "paused";
export type TimerEntry = {
@@ -8,6 +10,8 @@ export type TimerEntry = {
isProject?: boolean;
projectName?: string | null;
ticketLookupDone?: boolean;
activity: string;
billingStatus: BillingStatus;
startedAt: number;
pausedTotalMs: number;
pausedAt: number | null;
@@ -59,23 +63,29 @@ export function normalizeTimers(timers: TimerEntry[]) {
return timers
.filter((timer) => ticketPattern.test(timer.ticketNumber))
.map((timer) => {
const normalizedTimer = {
...timer,
activity: typeof timer.activity === "string" ? timer.activity : "",
billingStatus: timer.billingStatus === "billed" ? "billable" : timer.billingStatus === "billable" || timer.billingStatus === "non_billable" ? timer.billingStatus : null,
};
if (timer.phase !== "running") {
return {
...timer,
...normalizedTimer,
phase: "paused" as const,
pausedAt: timer.pausedAt ?? now,
pausedAt: normalizedTimer.pausedAt ?? now,
};
}
if (!runningSeen) {
runningSeen = true;
return {
...timer,
...normalizedTimer,
pausedAt: null,
};
}
return pauseEntry(timer, now);
return pauseEntry(normalizedTimer, now);
});
}
@@ -106,13 +116,15 @@ export function readStoredTimers(userId: string): TimerEntry[] {
}
try {
const parsed = JSON.parse(legacy) as Omit<TimerEntry, "id">;
const parsed = JSON.parse(legacy) as Omit<TimerEntry, "id" | "activity" | "billingStatus"> & { activity?: string; billingStatus?: BillingStatus };
localStorage.removeItem(legacyStorageKey);
return normalizeTimers([
{
...parsed,
id: createClientId("timer"),
activity: typeof parsed.activity === "string" ? parsed.activity : "",
billingStatus: parsed.billingStatus === "billed" ? "billable" : parsed.billingStatus === "billable" || parsed.billingStatus === "non_billable" ? parsed.billingStatus : null,
},
]);
} catch {
+33 -2
View File
@@ -1,5 +1,5 @@
export type WorkType = "support" | "consulting";
export type BillingStatus = "billed" | "non_billable" | null;
export type BillingStatus = "billable" | "billed" | "non_billable" | null;
export type PeriodType = "month" | "day";
export type TicketDetailPeriodType = PeriodType | "all";
export type UserRole = "admin" | "user";
@@ -13,6 +13,10 @@ export type AuthUser = {
zammad_user_id: string | null;
has_zammad_api_token: boolean;
zammad_email_session_webhook_enabled: boolean;
session_billing_prompt_enabled: boolean;
zammad_session_note_default_enabled: boolean;
zammad_time_unit_rounding_enabled: boolean;
zammad_time_unit_rounding_minutes: number;
};
export type DashboardWidgetType = "dayControl" | "today" | "month" | "openRatings" | "projectBudgets" | "todos" | "teamspace" | "favorites" | "divider" | "statistic" | "notes";
@@ -63,6 +67,22 @@ export type AdminUser = AuthUser & {
updated_at: string;
};
export type ZammadSessionArticleQueueItem = {
id: string;
session_id: string;
ticket_id: string;
ticket_number: string;
zammad_ticket_id: string | null;
activity: string;
time_unit: number;
attempt_count: number;
last_error: string | null;
last_attempt_at: string | null;
created_at: string;
started_at: string;
rounded_minutes: number;
};
export type Organization = {
id: string;
zammad_id: string;
@@ -231,10 +251,12 @@ export type TicketSummary = {
session_count: number;
total_minutes: number;
crm_billed_minutes: number;
billable_minutes: number;
non_billable_minutes: number;
open_minutes: number;
open_count: number;
ticket_open_count: number;
billable_count: number;
billed_count: number;
non_billable_count: number;
recurring_session_count: number;
@@ -277,6 +299,7 @@ export type PeriodOverview = {
sessions: number;
minutes: number;
crmBilledMinutes: number;
billableMinutes: number;
nonBillableMinutes: number;
openMinutes: number;
openSessions: number;
@@ -324,7 +347,7 @@ export type TicketPeriod = {
closedAt: string | null;
filters?: {
month: string;
status: "all" | "open" | "billed" | "non_billable";
status: "all" | "open" | "billable" | "billed" | "non_billable";
source: "all" | "manual" | "recurring";
};
availableMonths?: TicketAvailableMonth[];
@@ -361,9 +384,11 @@ export type StatisticsDailyBucket = {
sessions: number;
total_minutes: number;
billed_minutes: number;
billable_minutes: number;
non_billable_minutes: number;
open_minutes: number;
billed_sessions: number;
billable_sessions: number;
non_billable_sessions: number;
open_sessions: number;
crm_billed_minutes: number;
@@ -377,9 +402,11 @@ export type StatisticsGroup = {
sessions: number;
total_minutes: number;
billed_minutes: number;
billable_minutes: number;
non_billable_minutes: number;
open_minutes: number;
billed_sessions: number;
billable_sessions: number;
non_billable_sessions: number;
open_sessions: number;
crm_billed_minutes: number;
@@ -395,9 +422,11 @@ export type StatisticsTicket = {
sessions: number;
total_minutes: number;
billed_minutes: number;
billable_minutes: number;
non_billable_minutes: number;
open_minutes: number;
billed_sessions: number;
billable_sessions: number;
non_billable_sessions: number;
open_sessions: number;
crm_billed_minutes: number;
@@ -448,9 +477,11 @@ export type StatisticsOverview = {
sessions: number;
minutes: number;
billedMinutes: number;
billableMinutes: number;
nonBillableMinutes: number;
openMinutes: number;
billedSessions: number;
billableSessions: number;
nonBillableSessions: number;
openSessions: number;
recurringMinutes: number;
+3 -2
View File
@@ -443,6 +443,7 @@ export function AnalysisPage({ onNavigate }: AnalysisPageProps) {
{ label: "Tickets", value: totals?.tickets ?? 0, icon: Ticket },
{ label: "Sessions", value: totals?.sessions ?? 0, icon: Users },
{ label: "Abgerechnet", value: formatMinutes(totals?.minutes ?? 0), detail: `Teamspace ${formatMinutes(totals?.crmBilledMinutes ?? 0)}`, icon: Clock3 },
{ label: "Abrechenbar", value: formatMinutes(totals?.billableMinutes ?? 0), icon: UserCheck },
{ label: "Nicht abrechenbar", value: formatMinutes(totals?.nonBillableMinutes ?? 0), icon: UserCheck },
{ label: "Offen", value: totals?.openSessions ?? 0, detail: formatMinutes(totals?.openMinutes ?? 0), icon: UserCheck }
];
@@ -786,8 +787,8 @@ export function AnalysisPage({ onNavigate }: AnalysisPageProps) {
<p className="font-medium">{formatMinutes(ticket.total_minutes)}</p>
</div>
<div className="rounded-md bg-muted/50 p-2">
<p className="text-muted-foreground">Sessions</p>
<p className="font-medium">{ticket.billed_count}</p>
<p className="text-muted-foreground">Abrechenbar</p>
<p className="font-medium">{ticket.billable_count}</p>
</div>
</div>
<Button
+14 -9
View File
@@ -1536,7 +1536,7 @@ function WidgetContent(props: WidgetContentProps) {
return (
<MetricGrid
items={[
{ label: "Bewertet heute", value: formatMinutes(stats.dayTotals?.minutes ?? 0), icon: Timer },
{ label: "Abgerechnet heute", value: formatMinutes(stats.dayTotals?.minutes ?? 0), icon: Timer },
{ label: "Offene Bewertungen", value: stats.dayTotals?.openSessions ?? 0, icon: ClipboardCheck }
]}
/>
@@ -1547,7 +1547,7 @@ function WidgetContent(props: WidgetContentProps) {
return (
<MetricGrid
items={[
{ label: "Bewertet Monat", value: formatMinutes(stats.monthTotals?.minutes ?? 0), icon: CheckCircle2 },
{ label: "Abgerechnet Monat", value: formatMinutes(stats.monthTotals?.minutes ?? 0), icon: CheckCircle2 },
{ label: "Offene Bewertungen", value: stats.monthTotals?.openSessions ?? 0, icon: ClipboardCheck }
]}
/>
@@ -1560,7 +1560,7 @@ function WidgetContent(props: WidgetContentProps) {
<MetricGrid
items={[
{ label: "TS-Zeit Monat", value: formatMinutes(stats.monthTotals?.crmBilledMinutes ?? 0), icon: LayoutDashboard },
{ label: "Bewertet - TS", value: `${delta >= 0 ? "+" : "-"}${formatMinutes(Math.abs(delta))}`, icon: ArrowUp, tone: delta === 0 ? "normal" : delta > 0 ? "warning" : "good" }
{ label: "Abgerechnet - TS", value: `${delta >= 0 ? "+" : "-"}${formatMinutes(Math.abs(delta))}`, icon: ArrowUp, tone: delta === 0 ? "normal" : delta > 0 ? "warning" : "good" }
]}
/>
);
@@ -1697,7 +1697,7 @@ function DayControlWidget({ stats, projects, todos, onNavigate }: WidgetContentP
const todayTickets = stableSort(stats.todayTickets, (left, right) => compareText(left.ticket_number, right.ticket_number));
const todayOpenRatings = stableSort(stats.todayOpenRatings, (left, right) => compareOptionalDate(left.started_at, right.started_at, "asc") || compareText(left.ticket_number, right.ticket_number));
const teamspaceReviewTickets = todayTickets.filter((ticket) => ticket.open_count === 0 && teamspaceDeltaMinutes(ticket) > 0);
const afterworkTickets = todayTickets.filter((ticket) => ticket.open_count > 0 || teamspaceDeltaMinutes(ticket) > 0 || (ticket.total_minutes === 0 && ticket.non_billable_count > 0));
const afterworkTickets = todayTickets.filter((ticket) => ticket.open_count > 0 || teamspaceDeltaMinutes(ticket) > 0 || (ticket.total_minutes === 0 && (ticket.billable_count > 0 || ticket.non_billable_count > 0)));
const budgetAttention = stableSort(
projects.filter((project) => {
const percent = projectConsumptionPercent(project);
@@ -1779,7 +1779,7 @@ function DayControlWidget({ stats, projects, todos, onNavigate }: WidgetContentP
tone: "warning" as const,
title: session.ticket_number,
detail: `${formatMinutes(session.rounded_minutes)} offen · ${session.organization_name ?? "Keine Organisation"}`,
note: "Bewertung setzen: Abgerechnet oder Nicht abrechenbar.",
note: "Bewertung setzen: Abrechenbar oder Nicht abrechenbar.",
onClick: () => onNavigate(`/tickets/${session.ticket_id}?month=${currentMonth()}&status=open`)
})),
...teamspaceReviewTickets.slice(0, 4).map((ticket) => ({
@@ -1831,7 +1831,7 @@ function DayControlWidget({ stats, projects, todos, onNavigate }: WidgetContentP
items={afterworkTickets.slice(0, 4).map((ticket) => ({
key: `today-ticket-${ticket.id}`,
title: ticket.title || ticket.ticket_number,
detail: `${ticket.ticket_number} · ${formatMinutes(ticket.total_minutes + ticket.non_billable_minutes + ticket.open_minutes)} · ${ticket.session_count} Session(s)`,
detail: `${ticket.ticket_number} · ${formatMinutes(ticket.total_minutes + ticket.billable_minutes + ticket.non_billable_minutes + ticket.open_minutes)} · ${ticket.session_count} Session(s)`,
note: dayControlTodaySessionReason(ticket),
onClick: () => onNavigate(`/tickets/${ticket.id}?month=${currentMonth()}`)
}))}
@@ -1896,6 +1896,10 @@ function dayControlTodaySessionReason(ticket: PeriodOverview["tickets"][number])
return `Teamspace ok: ${formatMinutes(crmMinutes)} erfasst.`;
}
if (ticket.billable_count > 0) {
return `Abrechenbar gesetzt: später abrechnen oder Status finalisieren.`;
}
if (ticket.non_billable_count > 0) {
return `Nicht abrechenbar gesetzt: Zammad-Dokumentation kontrollieren.`;
}
@@ -2504,7 +2508,7 @@ function StatisticWidget({ widget, editing, stats, onNavigate, onUpdateConfig }:
{settings}
<MetricGrid
items={[
{ label: "Bewertet", value: formatMinutes(statistics.totals.minutes), icon: CheckCircle2 },
{ label: "Abgerechnet", value: formatMinutes(statistics.totals.minutes), icon: CheckCircle2 },
{ label: "Offen", value: statistics.totals.openSessions, icon: ClipboardCheck },
{ label: "Ø Session", value: formatMinutes(statistics.totals.averageSessionMinutes), icon: Timer },
{ label: "Aktive Tage", value: statistics.totals.activeDays, icon: LayoutDashboard }
@@ -2520,7 +2524,8 @@ function StatisticWidget({ widget, editing, stats, onNavigate, onUpdateConfig }:
{settings}
<BarList
items={[
{ key: "billed", title: "Bewertet", value: statistics.totals.billedMinutes, detail: `${statistics.totals.billedSessions} Session(s)` },
{ key: "billed", title: "Abgerechnet", value: statistics.totals.billedMinutes, detail: `${statistics.totals.billedSessions} Session(s)` },
{ key: "billable", title: "Abrechenbar", value: statistics.totals.billableMinutes, detail: `${statistics.totals.billableSessions} Session(s)` },
{ key: "non", title: "Nicht abrechenbar", value: statistics.totals.nonBillableMinutes, detail: `${statistics.totals.nonBillableSessions} Session(s)` },
{ key: "open", title: "Offen", value: statistics.totals.openMinutes, detail: `${statistics.totals.openSessions} Session(s)` }
]}
@@ -2616,7 +2621,7 @@ function StatisticWidget({ widget, editing, stats, onNavigate, onUpdateConfig }:
key: day.day,
title: formatDate(`${day.day}T00:00:00`),
value: view === "teamspaceDaily" ? day.crm_billed_minutes : day.total_minutes,
detail: view === "teamspaceDaily" ? `Bewertet ${formatMinutes(day.total_minutes)}` : `${day.sessions} Session(s)`
detail: view === "teamspaceDaily" ? `Abgerechnet ${formatMinutes(day.total_minutes)}` : `${day.sessions} Session(s)`
}));
return (
+186 -4
View File
@@ -1,13 +1,14 @@
import { FormEvent, useEffect, useState } from "react";
import { KeyRound, MailPlus, Save, Unplug, UserCircle, X } from "lucide-react";
import { Clock3, ClipboardCheck, FileText, KeyRound, MailPlus, RefreshCw, Save, Send, Unplug, UserCircle, X } from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Switch } from "@/components/ui/switch";
import { HelpLink } from "@/components/HelpLink";
import { updateCurrentUser } from "../api";
import type { AuthUser } from "../types";
import { getZammadSessionArticleQueue, retryZammadSessionArticleQueue, updateCurrentUser } from "../api";
import { formatDateTime, formatMinutes } from "../format";
import type { AuthUser, ZammadSessionArticleQueueItem } from "../types";
type ProfilePageProps = {
currentUser: AuthUser;
@@ -23,6 +24,13 @@ export function ProfilePage({ currentUser, onUserUpdated }: ProfilePageProps) {
const [zammadApiToken, setZammadApiToken] = useState("");
const [zammadTokenDirty, setZammadTokenDirty] = useState(false);
const [zammadEmailSessionWebhookEnabled, setZammadEmailSessionWebhookEnabled] = useState(currentUser.zammad_email_session_webhook_enabled);
const [sessionBillingPromptEnabled, setSessionBillingPromptEnabled] = useState(currentUser.session_billing_prompt_enabled);
const [zammadSessionNoteDefaultEnabled, setZammadSessionNoteDefaultEnabled] = useState(currentUser.zammad_session_note_default_enabled);
const [zammadTimeUnitRoundingEnabled, setZammadTimeUnitRoundingEnabled] = useState(currentUser.zammad_time_unit_rounding_enabled);
const [zammadTimeUnitRoundingMinutes, setZammadTimeUnitRoundingMinutes] = useState(currentUser.zammad_time_unit_rounding_minutes);
const [zammadQueue, setZammadQueue] = useState<ZammadSessionArticleQueueItem[]>([]);
const [loadingZammadQueue, setLoadingZammadQueue] = useState(false);
const [retryingZammadQueue, setRetryingZammadQueue] = useState(false);
const [saving, setSaving] = useState(false);
useEffect(() => {
@@ -31,8 +39,61 @@ export function ProfilePage({ currentUser, onUserUpdated }: ProfilePageProps) {
setZammadApiToken("");
setZammadTokenDirty(false);
setZammadEmailSessionWebhookEnabled(currentUser.zammad_email_session_webhook_enabled);
setSessionBillingPromptEnabled(currentUser.session_billing_prompt_enabled);
setZammadSessionNoteDefaultEnabled(currentUser.zammad_session_note_default_enabled);
setZammadTimeUnitRoundingEnabled(currentUser.zammad_time_unit_rounding_enabled);
setZammadTimeUnitRoundingMinutes(currentUser.zammad_time_unit_rounding_minutes);
}, [currentUser]);
async function loadZammadQueue() {
if (currentUser.role !== "user") {
setZammadQueue([]);
return;
}
setLoadingZammadQueue(true);
try {
const result = await getZammadSessionArticleQueue();
setZammadQueue(result.queue);
} catch (error) {
toast.error("Zammad-Parktabelle konnte nicht geladen werden", {
description: error instanceof Error ? error.message : "Unbekannter Fehler"
});
} finally {
setLoadingZammadQueue(false);
}
}
useEffect(() => {
void loadZammadQueue();
}, [currentUser.role]);
async function retryZammadQueue() {
setRetryingZammadQueue(true);
try {
const result = await retryZammadSessionArticleQueue();
setZammadQueue(result.queue);
if (result.failed > 0) {
toast.warning("Zammad-Notizen teilweise gesendet", {
description: `${result.sent} gesendet, ${result.failed} weiter geparkt.`
});
} else {
toast.success("Zammad-Notizen gesendet", {
description: `${result.sent} Eintrag/Einträge erfolgreich übertragen.`
});
}
} catch (error) {
toast.error("Zammad-Notizen konnten nicht gesendet werden", {
description: error instanceof Error ? error.message : "Unbekannter Fehler"
});
} finally {
setRetryingZammadQueue(false);
}
}
async function submit(event: FormEvent) {
event.preventDefault();
@@ -58,7 +119,15 @@ export function ProfilePage({ currentUser, onUserUpdated }: ProfilePageProps) {
currentPassword: wantsPasswordChange ? currentPassword : undefined,
newPassword: wantsPasswordChange ? newPassword : undefined,
...(currentUser.role === "user" && zammadTokenDirty ? { zammadApiToken } : {}),
...(currentUser.role === "user" ? { zammadEmailSessionWebhookEnabled } : {})
...(currentUser.role === "user"
? {
zammadEmailSessionWebhookEnabled,
sessionBillingPromptEnabled,
zammadSessionNoteDefaultEnabled,
zammadTimeUnitRoundingEnabled,
zammadTimeUnitRoundingMinutes
}
: {})
};
try {
@@ -226,6 +295,119 @@ export function ProfilePage({ currentUser, onUserUpdated }: ProfilePageProps) {
onCheckedChange={setZammadEmailSessionWebhookEnabled}
/>
</div>
<div className="mt-3 flex items-center justify-between gap-3 rounded-md border bg-background p-3">
<div className="flex items-center gap-2">
<ClipboardCheck className="size-4 text-muted-foreground" />
<div>
<label className="text-sm font-medium" htmlFor="profile-session-billing-prompt">
Bewertungsabfrage beim Timerabschluss
</label>
<p className="text-xs text-muted-foreground">Bei Bewertung --- vor dem Speichern noch einmal nachfragen.</p>
</div>
</div>
<Switch
id="profile-session-billing-prompt"
checked={sessionBillingPromptEnabled}
onCheckedChange={setSessionBillingPromptEnabled}
/>
</div>
<div className="mt-3 flex items-center justify-between gap-3 rounded-md border bg-background p-3">
<div className="flex items-center gap-2">
<FileText className="size-4 text-muted-foreground" />
<div>
<label className="text-sm font-medium" htmlFor="profile-zammad-session-note">
Interne Zammad-Notiz beim Timerabschluss
</label>
<p className="text-xs text-muted-foreground">Checkbox im Speichern-Dialog standardmäßig aktivieren.</p>
</div>
</div>
<Switch
id="profile-zammad-session-note"
checked={zammadSessionNoteDefaultEnabled}
onCheckedChange={(checked) => {
setZammadSessionNoteDefaultEnabled(checked);
if (!checked) {
setZammadTimeUnitRoundingEnabled(false);
}
}}
/>
</div>
{zammadSessionNoteDefaultEnabled ? (
<div className="mt-3 rounded-md border bg-background p-3">
<div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-2">
<Clock3 className="size-4 text-muted-foreground" />
<div>
<label className="text-sm font-medium" htmlFor="profile-zammad-time-rounding">
Zammad-Zeit aufrunden
</label>
<p className="text-xs text-muted-foreground">Ohne Rundung wird die konkrete Zeit als Stundenwert mit 2 Nachkommastellen gesendet.</p>
</div>
</div>
<Switch
id="profile-zammad-time-rounding"
checked={zammadTimeUnitRoundingEnabled}
onCheckedChange={setZammadTimeUnitRoundingEnabled}
/>
</div>
{zammadTimeUnitRoundingEnabled ? (
<div className="mt-3 grid gap-2 sm:w-56">
<label className="text-xs font-medium text-muted-foreground" htmlFor="profile-zammad-rounding-minutes">
Rundungsintervall
</label>
<select
id="profile-zammad-rounding-minutes"
className="h-9 rounded-md border border-input bg-background px-3 text-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
value={zammadTimeUnitRoundingMinutes}
onChange={(event) => setZammadTimeUnitRoundingMinutes(Number(event.currentTarget.value))}
>
<option value={5}>5 Minuten</option>
<option value={10}>10 Minuten</option>
<option value={15}>15 Minuten</option>
<option value={30}>30 Minuten</option>
</select>
</div>
) : null}
</div>
) : null}
<div className="mt-3 rounded-md border bg-background p-3">
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div className="flex items-start gap-2">
<Send className="mt-0.5 size-4 text-muted-foreground" />
<div>
<p className="text-sm font-medium">Geparkte Zammad-Notizen</p>
<p className="text-xs text-muted-foreground">Nicht übertragene interne Notizen nachträglich senden.</p>
</div>
</div>
<div className="flex gap-2">
<Button type="button" size="sm" variant="secondary" disabled={loadingZammadQueue} onClick={() => void loadZammadQueue()}>
<RefreshCw className="size-3.5" />
Prüfen
</Button>
<Button type="button" size="sm" disabled={zammadQueue.length === 0 || retryingZammadQueue} onClick={() => void retryZammadQueue()}>
<Send className="size-3.5" />
{retryingZammadQueue ? "Sendet..." : "Senden"}
</Button>
</div>
</div>
<div className="mt-3 space-y-2">
{loadingZammadQueue ? <p className="text-xs text-muted-foreground">Lädt...</p> : null}
{!loadingZammadQueue && zammadQueue.length === 0 ? <p className="text-xs text-muted-foreground">Keine geparkten Zammad-Notizen.</p> : null}
{zammadQueue.slice(0, 5).map((item) => (
<div key={item.id} className="rounded-md border bg-muted/30 p-2 text-xs">
<div className="flex flex-wrap items-center gap-x-2 gap-y-1">
<span className="font-medium">{item.ticket_number}</span>
<span className="text-muted-foreground">{formatDateTime(item.started_at)}</span>
<span className="text-muted-foreground">{formatMinutes(item.rounded_minutes)}</span>
<span className="text-muted-foreground">time_unit {item.time_unit}</span>
</div>
{item.last_error ? <p className="mt-1 line-clamp-2 text-destructive">{item.last_error}</p> : null}
</div>
))}
{zammadQueue.length > 5 ? <p className="text-xs text-muted-foreground">Weitere {zammadQueue.length - 5} Einträge werden beim Senden ebenfalls berücksichtigt.</p> : null}
</div>
</div>
</div>
) : null}
+34 -7
View File
@@ -7,6 +7,7 @@ import {
RotateCcw,
Ticket,
TrendingUp,
UserCheck,
} from "lucide-react";
import type { LucideIcon } from "lucide-react";
import { useEffect, useMemo, useRef, useState } from "react";
@@ -36,6 +37,7 @@ const teamspaceStrokeClass = "text-fuchsia-600 dark:text-fuchsia-300";
const trackedDotClass = "bg-sky-500 dark:bg-sky-400";
const teamspaceDotClass = "bg-fuchsia-500 dark:bg-fuchsia-400";
const openTextClass = "text-amber-700 dark:text-amber-300";
const nonBillableTextClass = "text-indigo-700 dark:text-indigo-300";
const organizationPageSize = 8;
function workTypeLabel(workType: WorkType | undefined) {
@@ -115,6 +117,14 @@ function ColorLegend() {
<span className={`h-0.5 w-4 ${teamspaceDotClass}`} />
<span className={teamspaceTextClass}>Teamspace</span>
</span>
<span className="inline-flex items-center gap-1.5">
<span className="size-2 rounded-sm bg-slate-500 dark:bg-slate-400" />
<span>Abrechenbar</span>
</span>
<span className="inline-flex items-center gap-1.5">
<span className="size-2 rounded-sm bg-indigo-500 dark:bg-indigo-400" />
<span className={nonBillableTextClass}>Nicht abrechenbar</span>
</span>
<span className="inline-flex items-center gap-1.5">
<span className="size-2 rounded-full bg-emerald-500 dark:bg-emerald-400" />
<span className="text-emerald-700 dark:text-emerald-300">Differenz plus oder ausgeglichen</span>
@@ -307,7 +317,7 @@ function OrganizationRow({ organization, maxMinutes, totalMinutes }: { organizat
<TableCell className="max-w-[320px] whitespace-normal">
<div className="font-medium">{organization.organization_name ?? "Keine Organisation"}</div>
<div className="text-xs text-muted-foreground">
{organization.tickets} Ticket(s) · {organization.billed_sessions} abgerechnet
{organization.tickets} Ticket(s) · {organization.billed_sessions} abgerechnet · {organization.billable_sessions} abrechenbar
</div>
</TableCell>
<TableCell>{organization.billed_sessions}</TableCell>
@@ -338,6 +348,7 @@ function TicketHotspot({ ticket, month, onNavigate }: { ticket: StatisticsTicket
<span className={trackedTextClass}>Abgerechnet {formatMinutes(ticket.total_minutes)}</span>
<span className={teamspaceTextClass}>Teamspace {formatMinutes(ticket.crm_billed_minutes)}</span>
<span className="text-muted-foreground">{ticket.billed_sessions} abgerechnet</span>
<span className="text-muted-foreground">{ticket.billable_sessions} abrechenbar</span>
<span>{ticket.active_days} Tag(e)</span>
<span className={deltaTextClass(ticket.crm_delta_minutes)}>{formatTeamspaceDelta(ticket.crm_delta_minutes)}</span>
</div>
@@ -413,8 +424,13 @@ export function StatisticsPage({ onNavigate }: StatisticsPageProps) {
const totals = stats?.totals;
const billedSessions = totals?.billedSessions ?? 0;
const billedMinutes = totals?.billedMinutes ?? 0;
const billableSessions = totals?.billableSessions ?? 0;
const billableMinutes = totals?.billableMinutes ?? 0;
const nonBillableSessions = totals?.nonBillableSessions ?? 0;
const nonBillableMinutes = totals?.nonBillableMinutes ?? 0;
const openSessions = totals?.openSessions ?? 0;
const openMinutes = totals?.openMinutes ?? 0;
const evaluatedSessions = billedSessions + billableSessions + nonBillableSessions;
const crmCoverage = percentage(totals?.crmBilledMinutes ?? 0, totals?.minutes ?? 0);
const maxOrganizationMinutes = Math.max(...(stats?.organizations ?? []).map((organization) => organization.total_minutes), 1);
const topOrganization = stats?.organizations[0] ?? null;
@@ -460,7 +476,7 @@ export function StatisticsPage({ onNavigate }: StatisticsPageProps) {
</div>
</div>
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-3">
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
<MetricCard
label="Abgerechnet"
value={formatMinutes(totals?.minutes ?? 0)}
@@ -470,6 +486,14 @@ export function StatisticsPage({ onNavigate }: StatisticsPageProps) {
helpAnchor="statistik-getrackt"
helpLabel="Hilfe zur Kennzahl Abgerechnet"
/>
<MetricCard
label="Abrechenbar"
value={formatMinutes(billableMinutes)}
detail={`${billableSessions} Session(s)`}
icon={UserCheck}
helpAnchor="bewertungen"
helpLabel="Hilfe zu Bewertungen"
/>
<MetricCard
label="Teamspace"
value={formatMinutes(totals?.crmBilledMinutes ?? 0)}
@@ -510,17 +534,19 @@ export function StatisticsPage({ onNavigate }: StatisticsPageProps) {
<div className="space-y-3">
<DailyEffortChart month={month} buckets={stats?.dailySeries ?? []} />
<ColorLegend />
{strongestDay ? <p className="text-xs text-muted-foreground">Stärkster abrechenbarer Tag: {formatDate(`${strongestDay.day}T00:00:00`)} mit {formatMinutes(strongestDay.total_minutes)}</p> : null}
{strongestDay ? <p className="text-xs text-muted-foreground">Stärkster abgerechneter Tag: {formatDate(`${strongestDay.day}T00:00:00`)} mit {formatMinutes(strongestDay.total_minutes)}</p> : null}
</div>
<div className="space-y-4">
<div className="space-y-2 rounded-md border bg-muted/20 p-3">
<div className="flex items-center justify-between gap-3 text-sm">
<span className="text-muted-foreground">Bewertungsstand</span>
<span className="font-medium">{billedSessions} abrechenbar</span>
<span className="font-medium">{evaluatedSessions} bewertet</span>
</div>
<div className="grid gap-2 text-xs sm:grid-cols-2 xl:grid-cols-1 2xl:grid-cols-2">
<div className={trackedTextClass}>Abgerechnet: {billedSessions} Session(s), {formatMinutes(billedMinutes)}</div>
<div className="text-muted-foreground">Abrechenbar: {billableSessions} Session(s), {formatMinutes(billableMinutes)}</div>
<div className={nonBillableTextClass}>Nicht abrechenbar: {nonBillableSessions} Session(s), {formatMinutes(nonBillableMinutes)}</div>
<div className={openTextClass}>Offen: {openSessions} Session(s), {formatMinutes(openMinutes)}</div>
</div>
</div>
@@ -615,7 +641,7 @@ export function StatisticsPage({ onNavigate }: StatisticsPageProps) {
<div className="flex items-start justify-between gap-2">
<div className="min-w-0">
<p className="truncate font-medium">{organization.organization_name ?? "Keine Organisation"}</p>
<p className="text-xs text-muted-foreground">{organization.tickets} Ticket(s) · {organization.billed_sessions} abgerechnet</p>
<p className="text-xs text-muted-foreground">{organization.tickets} Ticket(s) · {organization.billed_sessions} abgerechnet · {organization.billable_sessions} abrechenbar</p>
</div>
<DeltaBadge minutes={organization.crm_delta_minutes} />
</div>
@@ -684,7 +710,7 @@ export function StatisticsPage({ onNavigate }: StatisticsPageProps) {
<div className="flex items-center justify-between gap-3">
<div>
<p className="font-semibold">{workTypeLabel(group.work_type)}</p>
<p className="text-xs text-muted-foreground">{group.tickets} Ticket(s), {group.billed_sessions} abgerechnet</p>
<p className="text-xs text-muted-foreground">{group.tickets} Ticket(s), {group.billed_sessions} abgerechnet, {group.billable_sessions} abrechenbar</p>
</div>
<DeltaBadge minutes={group.crm_delta_minutes} />
</div>
@@ -693,8 +719,9 @@ export function StatisticsPage({ onNavigate }: StatisticsPageProps) {
<span className={trackedTextClass}>Abgerechnet {formatMinutes(group.total_minutes)}</span>
<span className={teamspaceTextClass}>Teamspace {formatMinutes(group.crm_billed_minutes)}</span>
</div>
<div className="grid grid-cols-2 gap-2 text-xs text-muted-foreground">
<div className="grid gap-2 text-xs text-muted-foreground sm:grid-cols-3">
<span>Abr. {group.billed_sessions} / {formatMinutes(group.billed_minutes)}</span>
<span>Abrechb. {group.billable_sessions} / {formatMinutes(group.billable_minutes)}</span>
<span>Offen {group.open_sessions} / {formatMinutes(group.open_minutes)}</span>
</div>
</div>
+62 -9
View File
@@ -59,6 +59,7 @@ type TicketSessionFormState = {
organizationName: string | null;
projectTeamspaceEntryId: string;
activity: string;
billingStatus: BillingStatus;
workType: WorkType;
day: string;
startTime: string;
@@ -90,6 +91,7 @@ function emptyTicketSessionForm(): TicketSessionFormState {
organizationName: null,
projectTeamspaceEntryId: "",
activity: "",
billingStatus: null,
workType: "support",
day: currentDay(),
startTime: "09:00",
@@ -174,7 +176,17 @@ function BillingButtons({
const status = session.billing_status;
return (
<div className="grid grid-cols-2 gap-1.5 sm:inline-grid">
<div className="grid grid-cols-3 gap-1.5 sm:inline-grid">
<Button
type="button"
size="sm"
variant={status === "billable" ? "default" : "secondary"}
disabled={loading || (disabled && status !== "billable")}
onClick={() => onChange(status === "billable" ? null : "billable")}
className={cn("h-8", status === "billable" ? "" : "bg-secondary/70")}
>
Abrechenbar
</Button>
<Button
type="button"
size="sm"
@@ -199,6 +211,29 @@ function BillingButtons({
);
}
function BillingCaptureSelect({
id,
value,
onChange
}: {
id: string;
value: BillingStatus;
onChange: (status: BillingStatus) => void;
}) {
return (
<select
id={id}
className="h-9 w-full rounded-lg border border-input bg-background px-2 text-sm focus-visible:outline-none focus-visible:ring-3 focus-visible:ring-ring/50"
value={value ?? ""}
onChange={(event) => onChange(event.currentTarget.value === "" ? null : event.currentTarget.value as Exclude<BillingStatus, null>)}
>
<option value="">---</option>
<option value="billable">Abrechenbar</option>
<option value="non_billable">Nicht abrechenbar</option>
</select>
);
}
function sessionDayKey(value: string) {
const date = new Date(value);
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}-${String(date.getDate()).padStart(2, "0")}`;
@@ -474,7 +509,7 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onO
const [ticketSession, setTicketSession] = useState<TicketSessionFormState>(() => emptyTicketSessionForm());
const [savingTicketSession, setSavingTicketSession] = useState(false);
const [globalMonthFilter, setGlobalMonthFilter] = useState(initialGlobalMonthFilter);
const [globalStatusFilter, setGlobalStatusFilter] = useState<"all" | "open" | "billed" | "non_billable">("all");
const [globalStatusFilter, setGlobalStatusFilter] = useState<"all" | "open" | "billable" | "billed" | "non_billable">("all");
const [globalSourceFilter, setGlobalSourceFilter] = useState<"all" | "manual" | "recurring">("all");
const [editingProject, setEditingProject] = useState(false);
const [savingProject, setSavingProject] = useState(false);
@@ -595,19 +630,21 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onO
const sessionGroups = useMemo(() => {
const groups = new Map<
string,
{ key: string; label: string; totalMinutes: number; nonBillableMinutes: number; openMinutes: number; sessions: SessionEntry[] }
{ key: string; label: string; totalMinutes: number; billableMinutes: number; nonBillableMinutes: number; openMinutes: number; sessions: SessionEntry[] }
>();
for (const session of data?.sessions ?? []) {
const key = sessionDayKey(session.started_at);
const group = groups.get(key);
const billedMinutes = session.billing_status === "billed" ? session.rounded_minutes : 0;
const billableMinutes = session.billing_status === "billable" ? session.rounded_minutes : 0;
const nonBillableMinutes = session.billing_status === "non_billable" ? session.rounded_minutes : 0;
const openMinutes = session.billing_status === null ? session.rounded_minutes : 0;
if (group) {
group.sessions.push(session);
group.totalMinutes += billedMinutes;
group.billableMinutes += billableMinutes;
group.nonBillableMinutes += nonBillableMinutes;
group.openMinutes += openMinutes;
continue;
@@ -617,6 +654,7 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onO
key,
label: formatDate(session.started_at),
totalMinutes: billedMinutes,
billableMinutes,
nonBillableMinutes,
openMinutes,
sessions: [session]
@@ -984,7 +1022,8 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onO
startedAt: startedAt.toISOString(),
endedAt: endedAt.toISOString(),
durationSeconds,
projectTeamspaceEntryId: effectiveWorkType === "consulting" && requiresProjectTeamspaceEntry ? ticketSession.projectTeamspaceEntryId : null
projectTeamspaceEntryId: effectiveWorkType === "consulting" && requiresProjectTeamspaceEntry ? ticketSession.projectTeamspaceEntryId : null,
billingStatus: ticketSession.billingStatus
});
toast.success("Session nachgetragen", {
description: "Der Eintrag wurde diesem Ticket zugeordnet. Betroffene Abschlüsse wurden wieder geöffnet."
@@ -995,7 +1034,8 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onO
organizationName: ticket.organization_name ?? null,
projectTeamspaceEntryId: "",
workType: ticket.work_type ?? "support",
day: ticketSession.day
day: ticketSession.day,
billingStatus: null
});
await load();
} catch (error) {
@@ -1277,7 +1317,7 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onO
>
<option value="all">Alle Bewertungen</option>
<option value="open">Offen</option>
<option value="billed">Abgerechnet</option>
<option value="billable">Abrechenbar</option>
<option value="non_billable">Nicht abrechenbar</option>
</select>
</div>
@@ -1657,7 +1697,7 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onO
<p className="truncate font-medium">{group.label}</p>
<p className="text-xs text-muted-foreground">{group.sessions.length} Session(s)</p>
</div>
<div className="grid w-full gap-2 sm:grid-cols-[13rem_10rem_8rem] xl:w-auto xl:grid-cols-[13rem_10rem_8rem_auto] xl:items-center xl:justify-end">
<div className="grid w-full gap-2 sm:grid-cols-[13rem_10rem_10rem_8rem] xl:w-auto xl:grid-cols-[13rem_10rem_10rem_8rem_auto] xl:items-center xl:justify-end">
<Button type="button" size="sm" variant="secondary" className="h-7 w-full justify-center" onClick={() => openTeamspacePreparation(group)}>
<ClipboardList className="size-3.5" />
TS Aufbereitung
@@ -1665,10 +1705,13 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onO
<Badge variant="outline" className="flex h-7 w-full justify-center whitespace-nowrap">
Abgerechnet {formatMinutes(group.totalMinutes)}
</Badge>
<Badge variant="outline" className="flex h-7 w-full justify-center whitespace-nowrap">
Abrechenbar {formatMinutes(group.billableMinutes)}
</Badge>
<Badge variant={group.openMinutes > 0 ? "warning" : "success"} className="flex h-7 w-full justify-center whitespace-nowrap">
Offen {formatMinutes(group.openMinutes)}
</Badge>
<div className="flex items-center gap-2 text-xs text-muted-foreground sm:col-span-3 xl:col-span-1">
<div className="flex items-center gap-2 text-xs text-muted-foreground sm:col-span-4 xl:col-span-1">
<label htmlFor={`crm-billing-${group.key}`}>Teamspace</label>
<Input
id={`crm-billing-${group.key}`}
@@ -1872,7 +1915,7 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onO
</div>
) : null}
<div className="grid gap-3 lg:grid-cols-[150px_120px_120px_minmax(220px,1fr)_auto] lg:items-end">
<div className="grid gap-3 lg:grid-cols-[150px_120px_120px_160px_minmax(220px,1fr)_auto] lg:items-end">
<div className="space-y-2">
<label className="text-sm font-medium" htmlFor="ticket-session-day">
Datum
@@ -1909,6 +1952,16 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onO
required
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium" htmlFor="ticket-session-billing-status">
Bewertung
</label>
<BillingCaptureSelect
id="ticket-session-billing-status"
value={ticketSession.billingStatus}
onChange={(billingStatus) => setTicketSession({ ...ticketSession, billingStatus })}
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium" htmlFor="ticket-session-activity">
Tätigkeit
+164 -16
View File
@@ -5,6 +5,7 @@ import { toast } from "sonner";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Checkbox } from "@/components/ui/checkbox";
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
@@ -14,7 +15,7 @@ import { createSession, getRecurringBillings, lookupTicket } from "../api";
import { formatTimer } from "../format";
import { isRecurringBillingTicketNumber, ticketHasRecurringBilling } from "../recurring-billings";
import { activeElapsedMs, createClientId, pauseEntry, resumeEntry, ticketPattern, type TimerEntry } from "../timers";
import type { RecurringBilling, TicketMeta, WorkType } from "../types";
import type { AuthUser, BillingStatus, RecurringBilling, TicketMeta, WorkType } from "../types";
type ManualSessionFormState = {
ticketNumber: string;
@@ -22,6 +23,7 @@ type ManualSessionFormState = {
organizationName: string | null;
projectTeamspaceEntryId: string;
activity: string;
billingStatus: BillingStatus;
workType: WorkType;
day: string;
startTime: string;
@@ -40,6 +42,7 @@ function emptyManualSession(): ManualSessionFormState {
organizationName: null,
projectTeamspaceEntryId: "",
activity: "",
billingStatus: null,
workType: "support",
day: currentDay(),
startTime: "09:00",
@@ -51,6 +54,10 @@ type FinishedSession = {
timerId: string;
ticketNumber: string;
ticket: TicketMeta | null;
activity: string;
billingStatus: BillingStatus;
promptBillingStatus: boolean;
createZammadArticle: boolean;
startedAt: string;
endedAt: string;
durationSeconds: number;
@@ -77,16 +84,42 @@ type TimerPageProps = {
selectedTimerId: string | null;
setSelectedTimerId: Dispatch<SetStateAction<string | null>>;
tick: number;
currentUser: AuthUser;
};
export function TimerPage({ timers, setTimers, selectedTimerId, setSelectedTimerId, tick }: TimerPageProps) {
function BillingStatusSelect({
id,
value,
onChange,
disabled = false
}: {
id: string;
value: BillingStatus;
onChange: (status: BillingStatus) => void;
disabled?: boolean;
}) {
return (
<select
id={id}
className="h-10 w-full rounded-md border border-input bg-background px-3 text-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
value={value ?? ""}
disabled={disabled}
onChange={(event) => onChange(event.currentTarget.value === "" ? null : event.currentTarget.value as Exclude<BillingStatus, null>)}
>
<option value="">---</option>
<option value="billable">Abrechenbar</option>
<option value="non_billable">Nicht abrechenbar</option>
</select>
);
}
export function TimerPage({ timers, setTimers, selectedTimerId, setSelectedTimerId, tick, currentUser }: TimerPageProps) {
const [ticketNumber, setTicketNumber] = useState("");
const [finished, setFinished] = useState<FinishedSession | null>(null);
const [finishingId, setFinishingId] = useState<string | null>(null);
const [organizationId, setOrganizationId] = useState("");
const [organizationName, setOrganizationName] = useState<string | null>(null);
const [projectTeamspaceEntryId, setProjectTeamspaceEntryId] = useState("");
const [activity, setActivity] = useState("");
const [workType, setWorkType] = useState<WorkType>("support");
const [saving, setSaving] = useState(false);
const [manualSession, setManualSession] = useState<ManualSessionFormState>(() => emptyManualSession());
@@ -250,6 +283,8 @@ export function TimerPage({ timers, setTimers, selectedTimerId, setSelectedTimer
startedAt: now,
pausedTotalMs: 0,
pausedAt: null,
activity: "",
billingStatus: null,
phase: "running"
};
@@ -356,6 +391,14 @@ export function TimerPage({ timers, setTimers, selectedTimerId, setSelectedTimer
setTimers((current) => current.filter((timer) => timer.id !== timerId));
}
function updateTimerActivity(timerId: string, nextActivity: string) {
setTimers((current) => current.map((timer) => (timer.id === timerId ? { ...timer, activity: nextActivity } : timer)));
}
function updateTimerBillingStatus(timerId: string, billingStatus: BillingStatus) {
setTimers((current) => current.map((timer) => (timer.id === timerId ? { ...timer, billingStatus } : timer)));
}
async function finishTimer(timerId: string) {
const timer = timers.find((entry) => entry.id === timerId);
@@ -363,6 +406,16 @@ export function TimerPage({ timers, setTimers, selectedTimerId, setSelectedTimer
return;
}
const timerActivity = timer.activity;
if (!timerActivity.trim()) {
setSelectedTimerId(timer.id);
toast.error("Tätigkeit erfassen", {
description: "Bitte dokumentiere die Tätigkeit vor dem Beenden der Session."
});
return;
}
const now = Date.now();
const stoppedTimer = pauseEntry(timer, now);
const durationSeconds = Math.floor(activeElapsedMs(stoppedTimer, now) / 1000);
@@ -388,11 +441,14 @@ export function TimerPage({ timers, setTimers, selectedTimerId, setSelectedTimer
setOrganizationName(ticket?.organization_name ?? null);
setProjectTeamspaceEntryId("");
setWorkType(ticket?.work_type ?? "support");
setActivity("");
setFinished({
timerId: timer.id,
ticketNumber: timer.ticketNumber,
ticket,
activity: timerActivity,
billingStatus: timer.billingStatus,
promptBillingStatus: currentUser.session_billing_prompt_enabled && timer.billingStatus === null,
createZammadArticle: currentUser.zammad_session_note_default_enabled,
startedAt: new Date(stoppedTimer.startedAt).toISOString(),
endedAt: new Date(now).toISOString(),
durationSeconds,
@@ -414,6 +470,13 @@ export function TimerPage({ timers, setTimers, selectedTimerId, setSelectedTimer
return;
}
if (!finished.activity.trim()) {
toast.error("Tätigkeit erfassen", {
description: "Bitte dokumentiere die Tätigkeit vor dem Speichern der Session."
});
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."
@@ -450,10 +513,12 @@ export function TimerPage({ timers, setTimers, selectedTimerId, setSelectedTimer
endedAt: finished.endedAt,
durationSeconds: finished.durationSeconds,
organizationId: effectiveOrganizationId,
activity,
activity: finished.activity,
workType: effectiveWorkType,
zammadLookup: Boolean(finished.ticket?.zammad_ticket_id),
projectTeamspaceEntryId: finishedRequiresTeamspaceEntry ? projectTeamspaceEntryId : null
projectTeamspaceEntryId: finishedRequiresTeamspaceEntry ? projectTeamspaceEntryId : null,
billingStatus: finished.billingStatus,
createZammadArticle: finished.createZammadArticle
});
toast.success("Session gespeichert", {
@@ -464,7 +529,6 @@ export function TimerPage({ timers, setTimers, selectedTimerId, setSelectedTimer
setOrganizationId("");
setOrganizationName(null);
setProjectTeamspaceEntryId("");
setActivity("");
setWorkType("support");
} catch (error) {
toast.error("Speichern fehlgeschlagen", {
@@ -531,7 +595,8 @@ export function TimerPage({ timers, setTimers, selectedTimerId, setSelectedTimer
endedAt: endedAt.toISOString(),
durationSeconds,
zammadLookup: Boolean(manualLookupTicket?.zammad_ticket_id),
projectTeamspaceEntryId: requiresTeamspaceEntry(manualLookupTicket) ? manualSession.projectTeamspaceEntryId : null
projectTeamspaceEntryId: requiresTeamspaceEntry(manualLookupTicket) ? manualSession.projectTeamspaceEntryId : null,
billingStatus: manualSession.billingStatus
});
toast.success("Session nachgetragen", {
description: manualLookupTicket?.is_project ? "Der Eintrag wurde dem Projekt zugeordnet." : "Der Eintrag ist in deiner Ticketliste offen."
@@ -625,11 +690,44 @@ export function TimerPage({ timers, setTimers, selectedTimerId, setSelectedTimer
</div>
</CardHeader>
<CardContent className="space-y-4 p-4">
<div className="rounded-lg border bg-background p-4 sm:p-5">
<p className="mb-2 text-sm font-medium text-muted-foreground">Gemessene Arbeitszeit</p>
<p className="font-mono text-4xl font-bold leading-none tracking-normal sm:text-6xl">
{formatTimer(elapsedSeconds)}
</p>
<div className="grid gap-4 lg:grid-cols-[minmax(220px,320px)_1fr]">
<div className="rounded-lg border bg-background p-4 sm:p-5">
<p className="mb-2 text-sm font-medium text-muted-foreground">Gemessene Arbeitszeit</p>
<p className="font-mono text-4xl font-bold leading-none tracking-normal sm:text-6xl">
{formatTimer(elapsedSeconds)}
</p>
</div>
<div className="grid gap-3 md:grid-cols-[1fr_180px]">
<div className="space-y-2">
<label className="text-sm font-medium" htmlFor="timer-activity">
Tätigkeit
</label>
<Textarea
id="timer-activity"
className="min-h-32"
value={selectedTimer?.activity ?? ""}
disabled={!selectedTimer}
placeholder={selectedTimer ? "Beschreibe, woran du in dieser Session arbeitest." : "Timer auswählen oder starten."}
onChange={(event) => selectedTimer && updateTimerActivity(selectedTimer.id, event.currentTarget.value)}
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium" htmlFor="timer-billing-status">
Bewertung
</label>
<BillingStatusSelect
id="timer-billing-status"
value={selectedTimer?.billingStatus ?? null}
disabled={!selectedTimer}
onChange={(billingStatus) => selectedTimer && updateTimerBillingStatus(selectedTimer.id, billingStatus)}
/>
</div>
{selectedTimer ? (
<p className="text-xs text-muted-foreground md:col-span-2">
Tätigkeit und Bewertung bleiben bei pausierten Timern erhalten und werden beim Beenden mit der Session gespeichert.
</p>
) : null}
</div>
</div>
<div className="grid grid-cols-2 gap-2 sm:flex sm:flex-wrap">
@@ -761,7 +859,7 @@ export function TimerPage({ timers, setTimers, selectedTimerId, setSelectedTimer
) : null}
</div>
<div className="grid gap-3 lg:grid-cols-[160px_120px_120px_minmax(220px,1fr)_auto] lg:items-end">
<div className="grid gap-3 lg:grid-cols-[160px_120px_120px_160px_minmax(220px,1fr)_auto] lg:items-end">
<div className="space-y-2">
<label className="text-sm font-medium" htmlFor="manual-day">Datum</label>
<Input
@@ -792,6 +890,14 @@ export function TimerPage({ timers, setTimers, selectedTimerId, setSelectedTimer
required
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium" htmlFor="manual-billing-status">Bewertung</label>
<BillingStatusSelect
id="manual-billing-status"
value={manualSession.billingStatus}
onChange={(billingStatus) => setManualSession({ ...manualSession, billingStatus })}
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium" htmlFor="manual-activity">Tätigkeit</label>
<Textarea
@@ -858,11 +964,53 @@ export function TimerPage({ timers, setTimers, selectedTimerId, setSelectedTimer
</div>
)}
<div className="space-y-2">
<label className="text-sm font-medium" htmlFor="activity">
<label className="text-sm font-medium" htmlFor="finished-activity">
Tätigkeit
</label>
<Textarea id="activity" value={activity} onChange={(event) => setActivity(event.currentTarget.value)} autoFocus required />
<Textarea
id="finished-activity"
className="min-h-24"
value={finished?.activity ?? ""}
onChange={(event) => {
const nextActivity = event.currentTarget.value;
setFinished((current) => (current ? { ...current, activity: nextActivity } : current));
}}
required
/>
</div>
{finished ? (
<div className="flex items-start gap-3 rounded-md border bg-muted/30 p-3">
<Checkbox
id="finished-zammad-article"
checked={finished.createZammadArticle}
onCheckedChange={(checked) => setFinished((current) => (current ? { ...current, createZammadArticle: checked === true } : current))}
/>
<div className="space-y-1">
<label className="text-sm font-medium" htmlFor="finished-zammad-article">
Tätigkeit als interne Zammad-Notiz übertragen
</label>
<p className="text-xs text-muted-foreground">
Erstellt einen internen Zammad-Article mit Tätigkeit und getrackter Zeit.
</p>
{!finished.ticket?.zammad_ticket_id ? (
<p className="text-xs text-muted-foreground">Falls die Zammad-ID noch fehlt, wird sie beim Speichern oder späteren Nachsenden erneut gesucht.</p>
) : null}
</div>
</div>
) : null}
{finished?.promptBillingStatus ? (
<div className="space-y-2">
<label className="text-sm font-medium" htmlFor="finished-billing-status">Bewertung</label>
<BillingStatusSelect
id="finished-billing-status"
value={finished.billingStatus}
onChange={(billingStatus) => setFinished((current) => (current ? { ...current, billingStatus } : current))}
/>
<p className="text-xs text-muted-foreground">
<span className="font-mono">---</span> lässt die Session offen und kann später in der Ticketdetailansicht bewertet werden.
</p>
</div>
) : null}
{finished?.ticket?.organization_id && finished.ticket.work_type ? null : (
<div className="space-y-2">
<label className="text-sm font-medium" htmlFor="work-type">