This commit is contained in:
michael committed 2026-09-23 13:34:38 +00:00
1 parent 0c14a3acbd
commit 2105a0c5f7
11 files changed
+442 -86

No files matched your search

+12 -1
View File
@@ -32,6 +32,7 @@ type CreateSessionPayload = {
projectTeamspaceEntryId?: string | null;
billingStatus?: BillingStatus;
createZammadArticle?: boolean;
zammadArticleTypeId?: number;
};
type DeleteSessionResult = {
@@ -46,6 +47,15 @@ type DeleteSessionResult = {
};
};
export type CreateSessionResult = {
zammadArticle?: {
requested: boolean;
status: "not_requested" | "sent" | "queued";
error: string | null;
articleId: number | null;
};
};
export type ZammadSyncExistingFields = {
title: boolean;
organization: boolean;
@@ -205,6 +215,7 @@ export function updateCurrentUser(payload: {
zammadSessionNoteDefaultEnabled?: boolean;
zammadTimeUnitRoundingEnabled?: boolean;
zammadTimeUnitRoundingMinutes?: number;
zammadArticleTypeId?: number;
}) {
return request<{ user: AuthUser }>("/api/auth/me", {
method: "PATCH",
@@ -682,7 +693,7 @@ export function deleteTodo(todoId: string) {
}
export function createSession(payload: CreateSessionPayload) {
return request("/api/sessions", {
return request<CreateSessionResult>("/api/sessions", {
method: "POST",
body: JSON.stringify(payload)
});
+4 -3
View File
@@ -121,7 +121,7 @@ export const helpSections: HelpSection[] = [
"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.",
"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 sowie den gewählten Zammad-Typ.",
"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.",
@@ -146,7 +146,7 @@ export const helpSections: HelpSection[] = [
"Die Organisation muss aus der synchronisierten Organisationsliste gewählt werden. Freitext ist absichtlich gesperrt.",
"Die Tätigkeit ist eine Textarea. Zeilenumbrüche bleiben erhalten und werden in der Ticketdetailansicht wieder ausgegeben.",
"Mit Bewertung --- bleibt die Session offen. Abrechenbar oder Nicht abrechenbar werden direkt an der neuen Session gespeichert.",
"Die interne Zammad-Notiz kann auch beim Nachtragen gesetzt werden. Bei Zammad-Fehlern wird sie geparkt und kann später im Profil gesendet werden.",
"Die interne Zammad-Notiz kann auch beim Nachtragen gesetzt werden. Ist sie aktiv, kann der Zammad-Typ angepasst werden. Bei Zammad-Fehlern wird sie geparkt und kann später im Profil gesendet werden.",
"In der Ticketdetailansicht kann eine Session direkt zum geöffneten Ticket nachgetragen werden. Ticketnummer und Stammdaten sind dort schon vorbelegt beziehungsweise aus dem Ticket abgeleitet.",
"Bei Consulting-Projekten mit mehreren Teamspace-Einträgen ist der passende Teamspace-Eintrag Pflicht. Bei nur einem Eintrag wird er automatisch gesetzt."
]
@@ -539,7 +539,8 @@ export const helpSections: HelpSection[] = [
"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.",
"Die interne Zammad-Notiz beim Timerabschluss steuert, ob die Checkbox im Abschlussdialog standardmäßig aktiv ist. Nur dann werden Standard-Typ und Zeitaufrundung angezeigt.",
"Der Standard-Typ belegt die Zammad-type_id für interne Notizen vor und kann beim Timerabschluss oder Nachtragen noch geändert werden.",
"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.",
+5
View File
@@ -17,6 +17,7 @@ export type AuthUser = {
zammad_session_note_default_enabled: boolean;
zammad_time_unit_rounding_enabled: boolean;
zammad_time_unit_rounding_minutes: number;
zammad_article_type_id: number;
};
export type DashboardWidgetType = "dayControl" | "today" | "month" | "openRatings" | "projectBudgets" | "todos" | "teamspace" | "favorites" | "divider" | "statistic" | "notes";
@@ -75,6 +76,7 @@ export type ZammadSessionArticleQueueItem = {
zammad_ticket_id: string | null;
activity: string;
time_unit: number;
zammad_article_type_id: number | null;
attempt_count: number;
last_error: string | null;
last_attempt_at: string | null;
@@ -338,6 +340,9 @@ export type SessionEntry = {
recurring_billing_id: string | null;
recurring_billing_slot_id: string | null;
recurring_occurrence_date: string | null;
zammad_article_status?: "pending" | "sent" | null;
zammad_article_id?: string | null;
zammad_article_type_id?: number | null;
};
export type TicketPeriod = {
+57 -30
View File
@@ -9,6 +9,7 @@ import { HelpLink } from "@/components/HelpLink";
import { getZammadSessionArticleQueue, retryZammadSessionArticleQueue, updateCurrentUser } from "../api";
import { formatDateTime, formatMinutes } from "../format";
import type { AuthUser, ZammadSessionArticleQueueItem } from "../types";
import { zammadArticleTypeFromValue, zammadArticleTypeLabel, zammadArticleTypeOptions, zammadArticleTypeValue, type ZammadArticleTypeId } from "../zammad-article-types";
type ProfilePageProps = {
currentUser: AuthUser;
@@ -28,6 +29,7 @@ export function ProfilePage({ currentUser, onUserUpdated }: ProfilePageProps) {
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 [zammadArticleTypeId, setZammadArticleTypeId] = useState<ZammadArticleTypeId>(currentUser.zammad_article_type_id ?? 6);
const [zammadQueue, setZammadQueue] = useState<ZammadSessionArticleQueueItem[]>([]);
const [loadingZammadQueue, setLoadingZammadQueue] = useState(false);
const [retryingZammadQueue, setRetryingZammadQueue] = useState(false);
@@ -43,6 +45,7 @@ export function ProfilePage({ currentUser, onUserUpdated }: ProfilePageProps) {
setZammadSessionNoteDefaultEnabled(currentUser.zammad_session_note_default_enabled);
setZammadTimeUnitRoundingEnabled(currentUser.zammad_time_unit_rounding_enabled);
setZammadTimeUnitRoundingMinutes(currentUser.zammad_time_unit_rounding_minutes);
setZammadArticleTypeId(currentUser.zammad_article_type_id ?? 6);
}, [currentUser]);
async function loadZammadQueue() {
@@ -125,7 +128,8 @@ export function ProfilePage({ currentUser, onUserUpdated }: ProfilePageProps) {
sessionBillingPromptEnabled,
zammadSessionNoteDefaultEnabled,
zammadTimeUnitRoundingEnabled,
zammadTimeUnitRoundingMinutes
zammadTimeUnitRoundingMinutes,
zammadArticleTypeId
}
: {})
};
@@ -334,41 +338,63 @@ export function ProfilePage({ currentUser, onUserUpdated }: ProfilePageProps) {
/>
</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
<div className="mt-3 space-y-3">
<div className="rounded-md border bg-background p-3">
<div className="grid gap-2 sm:max-w-lg">
<label className="text-sm font-medium" htmlFor="profile-zammad-article-type">
Standard-Typ
</label>
<select
id="profile-zammad-rounding-minutes"
id="profile-zammad-article-type"
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))}
value={zammadArticleTypeValue(zammadArticleTypeId)}
onChange={(event) => setZammadArticleTypeId(zammadArticleTypeFromValue(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>
{zammadArticleTypeOptions.map((option) => (
<option key={option.value || "empty"} value={option.value}>
{option.label}
</option>
))}
</select>
<p className="text-xs text-muted-foreground">Wird beim Erstellen interner Zammad-Notizen vorbelegt.</p>
</div>
) : null}
</div>
<div className="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>
</div>
) : null}
<div className="mt-3 rounded-md border bg-background p-3">
@@ -401,6 +427,7 @@ export function ProfilePage({ currentUser, onUserUpdated }: ProfilePageProps) {
<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>
<span className="text-muted-foreground">{zammadArticleTypeLabel(item.zammad_article_type_id)}</span>
</div>
{item.last_error ? <p className="mt-1 line-clamp-2 text-destructive">{item.last_error}</p> : null}
</div>
+61 -8
View File
@@ -1,4 +1,4 @@
import { ArrowLeft, CheckCircle2, ChevronDown, ChevronUp, ClipboardList, Copy, EyeOff, FolderKanban, Pencil, Play, Plus, RotateCcw, Save, Star, Trash2 } from "lucide-react";
import { ArrowLeft, CheckCircle2, ChevronDown, ChevronUp, ClipboardList, Copy, EyeOff, FolderKanban, Pencil, Play, Plus, RotateCcw, Save, Send, Star, Trash2 } from "lucide-react";
import { FormEvent, useEffect, useMemo, useState } from "react";
import { toast } from "sonner";
import { Alert } from "@/components/ui/alert";
@@ -30,10 +30,12 @@ import {
updateProject,
updateProjectStatus
} from "../api";
import type { CreateSessionResult } from "../api";
import { currentDay, currentMonth, formatDate, formatDateTime, formatMinutes, formatTimeRange } from "../format";
import { ticketHasRecurringBilling } from "../recurring-billings";
import type { AuthUser, BillingStatus, PeriodType, ProjectSummary, ProjectTeamspaceEntry, SessionEntry, TicketDetailPeriodType, TicketPeriod, WorkType } from "../types";
import { ZammadPriorityBadge, ZammadStateBadge } from "../zammad-meta";
import { zammadArticleTypeFromValue, zammadArticleTypeLabel, zammadArticleTypeOptions, zammadArticleTypeValue, type ZammadArticleTypeId } from "../zammad-article-types";
import { cn } from "@/lib/utils";
type TicketDetailPageProps = {
@@ -63,6 +65,7 @@ type TicketSessionFormState = {
activity: string;
billingStatus: BillingStatus;
createZammadArticle: boolean;
zammadArticleTypeId: ZammadArticleTypeId;
workType: WorkType;
day: string;
startTime: string;
@@ -96,6 +99,7 @@ function emptyTicketSessionForm(): TicketSessionFormState {
activity: "",
billingStatus: null,
createZammadArticle: false,
zammadArticleTypeId: 6,
workType: "support",
day: currentDay(),
startTime: "09:00",
@@ -103,6 +107,19 @@ function emptyTicketSessionForm(): TicketSessionFormState {
};
}
function notifyZammadArticleResult(result: CreateSessionResult) {
if (result.zammadArticle?.status === "sent") {
toast.success("Zammad-Notiz übertragen");
return;
}
if (result.zammadArticle?.status === "queued") {
toast.warning("Zammad-Notiz geparkt", {
description: result.zammadArticle.error ?? "Die Session wurde gespeichert. Die Notiz kann im Profil später erneut gesendet werden."
});
}
}
function projectEntryId() {
if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") {
return crypto.randomUUID();
@@ -599,16 +616,21 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onS
useEffect(() => {
setTicketSession((current) => {
if (current.activity || current.billingStatus || current.createZammadArticle === currentUser.zammad_session_note_default_enabled) {
if (
current.activity ||
current.billingStatus ||
(current.createZammadArticle === currentUser.zammad_session_note_default_enabled && current.zammadArticleTypeId === currentUser.zammad_article_type_id)
) {
return current;
}
return {
...current,
createZammadArticle: currentUser.zammad_session_note_default_enabled
createZammadArticle: currentUser.zammad_session_note_default_enabled,
zammadArticleTypeId: currentUser.zammad_article_type_id
};
});
}, [currentUser.zammad_session_note_default_enabled]);
}, [currentUser.zammad_article_type_id, currentUser.zammad_session_note_default_enabled]);
useEffect(() => {
setProjectDescriptionExpanded(false);
@@ -1031,7 +1053,7 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onS
setSavingTicketSession(true);
try {
await createSession({
const result = await createSession({
ticketNumber: ticket.ticket_number,
organizationId: effectiveOrganizationId,
activity: ticketSession.activity,
@@ -1041,11 +1063,13 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onS
durationSeconds,
projectTeamspaceEntryId: effectiveWorkType === "consulting" && requiresProjectTeamspaceEntry ? ticketSession.projectTeamspaceEntryId : null,
billingStatus: ticketSession.billingStatus,
createZammadArticle: ticketSession.createZammadArticle
createZammadArticle: ticketSession.createZammadArticle,
zammadArticleTypeId: ticketSession.zammadArticleTypeId
});
toast.success("Session nachgetragen", {
description: "Der Eintrag wurde diesem Ticket zugeordnet. Betroffene Abschlüsse wurden wieder geöffnet."
});
notifyZammadArticleResult(result);
setTicketSession({
...emptyTicketSessionForm(),
organizationId: ticket.organization_id ?? "",
@@ -1054,7 +1078,8 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onS
workType: ticket.work_type ?? "support",
day: ticketSession.day,
billingStatus: null,
createZammadArticle: currentUser.zammad_session_note_default_enabled
createZammadArticle: currentUser.zammad_session_note_default_enabled,
zammadArticleTypeId: currentUser.zammad_article_type_id
});
await load();
} catch (error) {
@@ -1842,7 +1867,16 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onS
<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>
{session.zammad_article_status ? (
<span className="inline-flex items-center gap-1">
{session.zammad_article_status === "sent" ? (
<Send className="size-3.5 text-emerald-500" aria-hidden="true" />
) : null}
<span>{zammadArticleTypeLabel(session.zammad_article_type_id)}</span>
</span>
) : (
<span>{session.work_type === "support" ? "Support" : "Consulting"}</span>
)}
<span>Erfasst {formatDateTime(session.created_at)}</span>
</div>
</div>
@@ -2009,6 +2043,25 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onS
<span className="block text-xs text-muted-foreground">Überträgt die Tätigkeit inklusive time_unit nach Zammad oder parkt sie bei Fehlern.</span>
</span>
</label>
{ticketSession.createZammadArticle ? (
<div className="space-y-2 lg:col-span-5">
<label className="text-sm font-medium" htmlFor="ticket-session-zammad-article-type">
Zammad-Typ
</label>
<select
id="ticket-session-zammad-article-type"
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={zammadArticleTypeValue(ticketSession.zammadArticleTypeId)}
onChange={(event) => setTicketSession({ ...ticketSession, zammadArticleTypeId: zammadArticleTypeFromValue(event.currentTarget.value) })}
>
{zammadArticleTypeOptions.map((option) => (
<option key={option.value || "empty"} value={option.value}>
{option.label}
</option>
))}
</select>
</div>
) : null}
<Button type="submit" disabled={savingTicketSession}>
<Save className="size-4" />
{savingTicketSession ? "Speichert..." : "Nachtragen"}
+95 -24
View File
@@ -12,10 +12,12 @@ import { Textarea } from "@/components/ui/textarea";
import { HelpLink } from "@/components/HelpLink";
import { OrganizationSelect } from "@/components/OrganizationSelect";
import { createSession, getRecurringBillings, lookupTicket } from "../api";
import type { CreateSessionResult } from "../api";
import { formatTimer } from "../format";
import { isRecurringBillingTicketNumber, ticketHasRecurringBilling } from "../recurring-billings";
import { activeElapsedMs, createClientId, pauseEntry, resumeEntry, ticketPattern, type TimerEntry } from "../timers";
import type { AuthUser, BillingStatus, RecurringBilling, TicketMeta, WorkType } from "../types";
import { zammadArticleTypeFromValue, zammadArticleTypeOptions, zammadArticleTypeValue, type ZammadArticleTypeId } from "../zammad-article-types";
type ManualSessionFormState = {
ticketNumber: string;
@@ -25,6 +27,7 @@ type ManualSessionFormState = {
activity: string;
billingStatus: BillingStatus;
createZammadArticle: boolean;
zammadArticleTypeId: ZammadArticleTypeId;
workType: WorkType;
day: string;
startTime: string;
@@ -45,6 +48,7 @@ function emptyManualSession(): ManualSessionFormState {
activity: "",
billingStatus: null,
createZammadArticle: false,
zammadArticleTypeId: 6,
workType: "support",
day: currentDay(),
startTime: "09:00",
@@ -60,6 +64,7 @@ type FinishedSession = {
billingStatus: BillingStatus;
promptBillingStatus: boolean;
createZammadArticle: boolean;
zammadArticleTypeId: ZammadArticleTypeId;
startedAt: string;
endedAt: string;
durationSeconds: number;
@@ -115,6 +120,19 @@ function BillingStatusSelect({
);
}
function notifyZammadArticleResult(result: CreateSessionResult) {
if (result.zammadArticle?.status === "sent") {
toast.success("Zammad-Notiz übertragen");
return;
}
if (result.zammadArticle?.status === "queued") {
toast.warning("Zammad-Notiz geparkt", {
description: result.zammadArticle.error ?? "Die Session wurde gespeichert. Die Notiz kann im Profil später erneut gesendet werden."
});
}
}
export function TimerPage({ timers, setTimers, selectedTimerId, setSelectedTimerId, tick, currentUser }: TimerPageProps) {
const [ticketNumber, setTicketNumber] = useState("");
const [finished, setFinished] = useState<FinishedSession | null>(null);
@@ -225,16 +243,23 @@ export function TimerPage({ timers, setTimers, selectedTimerId, setSelectedTimer
useEffect(() => {
setManualSession((current) => {
if (current.ticketNumber || current.activity || current.organizationId || current.billingStatus || current.createZammadArticle === currentUser.zammad_session_note_default_enabled) {
if (
current.ticketNumber ||
current.activity ||
current.organizationId ||
current.billingStatus ||
(current.createZammadArticle === currentUser.zammad_session_note_default_enabled && current.zammadArticleTypeId === currentUser.zammad_article_type_id)
) {
return current;
}
return {
...current,
createZammadArticle: currentUser.zammad_session_note_default_enabled
createZammadArticle: currentUser.zammad_session_note_default_enabled,
zammadArticleTypeId: currentUser.zammad_article_type_id
};
});
}, [currentUser.zammad_session_note_default_enabled]);
}, [currentUser.zammad_article_type_id, currentUser.zammad_session_note_default_enabled]);
async function addTimer(source: "local" | "zammad" = "local") {
const normalizedTicketNumber = ticketNumber.trim();
@@ -464,6 +489,7 @@ export function TimerPage({ timers, setTimers, selectedTimerId, setSelectedTimer
billingStatus: timer.billingStatus,
promptBillingStatus: currentUser.session_billing_prompt_enabled && timer.billingStatus === null,
createZammadArticle: currentUser.zammad_session_note_default_enabled,
zammadArticleTypeId: currentUser.zammad_article_type_id,
startedAt: new Date(stoppedTimer.startedAt).toISOString(),
endedAt: new Date(now).toISOString(),
durationSeconds,
@@ -522,7 +548,7 @@ export function TimerPage({ timers, setTimers, selectedTimerId, setSelectedTimer
setSaving(true);
try {
await createSession({
const result = await createSession({
ticketNumber: finished.ticketNumber,
startedAt: finished.startedAt,
endedAt: finished.endedAt,
@@ -533,12 +559,14 @@ export function TimerPage({ timers, setTimers, selectedTimerId, setSelectedTimer
zammadLookup: Boolean(finished.ticket?.zammad_ticket_id),
projectTeamspaceEntryId: finishedRequiresTeamspaceEntry ? projectTeamspaceEntryId : null,
billingStatus: finished.billingStatus,
createZammadArticle: finished.createZammadArticle
createZammadArticle: finished.createZammadArticle,
zammadArticleTypeId: finished.zammadArticleTypeId
});
toast.success("Session gespeichert", {
description: `${finished.ticketNumber} wurde mit ${finished.roundedMinutes} Minute(n) erfasst.`
});
notifyZammadArticleResult(result);
setTimers((current) => current.filter((timer) => timer.id !== finished.timerId));
setFinished(null);
setOrganizationId("");
@@ -601,7 +629,7 @@ export function TimerPage({ timers, setTimers, selectedTimerId, setSelectedTimer
setSavingManualSession(true);
try {
await createSession({
const result = await createSession({
ticketNumber: manualSession.ticketNumber.trim(),
organizationId: manualSession.organizationId,
activity: manualSession.activity,
@@ -612,15 +640,18 @@ export function TimerPage({ timers, setTimers, selectedTimerId, setSelectedTimer
zammadLookup: Boolean(manualLookupTicket?.zammad_ticket_id),
projectTeamspaceEntryId: requiresTeamspaceEntry(manualLookupTicket) ? manualSession.projectTeamspaceEntryId : null,
billingStatus: manualSession.billingStatus,
createZammadArticle: manualSession.createZammadArticle
createZammadArticle: manualSession.createZammadArticle,
zammadArticleTypeId: manualSession.zammadArticleTypeId
});
toast.success("Session nachgetragen", {
description: manualLookupTicket?.is_project ? "Der Eintrag wurde dem Projekt zugeordnet." : "Der Eintrag ist in deiner Ticketliste offen."
});
notifyZammadArticleResult(result);
setManualSession({
...emptyManualSession(),
day: manualSession.day,
createZammadArticle: currentUser.zammad_session_note_default_enabled
createZammadArticle: currentUser.zammad_session_note_default_enabled,
zammadArticleTypeId: currentUser.zammad_article_type_id
});
} catch (error) {
toast.error("Session konnte nicht nachgetragen werden", {
@@ -935,6 +966,25 @@ export function TimerPage({ timers, setTimers, selectedTimerId, setSelectedTimer
<span className="block text-xs text-muted-foreground">Überträgt die Tätigkeit inklusive time_unit nach Zammad oder parkt sie bei Fehlern.</span>
</span>
</label>
{manualSession.createZammadArticle ? (
<div className="space-y-2 lg:col-span-5">
<label className="text-sm font-medium" htmlFor="manual-zammad-article-type">
Zammad-Typ
</label>
<select
id="manual-zammad-article-type"
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={zammadArticleTypeValue(manualSession.zammadArticleTypeId)}
onChange={(event) => setManualSession({ ...manualSession, zammadArticleTypeId: zammadArticleTypeFromValue(event.currentTarget.value) })}
>
{zammadArticleTypeOptions.map((option) => (
<option key={option.value || "empty"} value={option.value}>
{option.label}
</option>
))}
</select>
</div>
) : null}
<Button type="submit" disabled={savingManualSession || manualTicketIsRecurringBilling}>
<Upload className="size-4" />
{savingManualSession ? "Speichert..." : "Nachtragen"}
@@ -1006,23 +1056,44 @@ export function TimerPage({ timers, setTimers, selectedTimerId, setSelectedTimer
/>
</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 className="space-y-3 rounded-md border bg-muted/30 p-3">
<div className="flex items-start gap-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>
{finished.createZammadArticle ? (
<div className="space-y-2">
<label className="text-sm font-medium" htmlFor="finished-zammad-article-type">
Zammad-Typ
</label>
<select
id="finished-zammad-article-type"
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={zammadArticleTypeValue(finished.zammadArticleTypeId)}
onChange={(event) => setFinished((current) => (current ? { ...current, zammadArticleTypeId: zammadArticleTypeFromValue(event.currentTarget.value) } : current))}
>
{zammadArticleTypeOptions.map((option) => (
<option key={option.value || "empty"} value={option.value}>
{option.label}
</option>
))}
</select>
</div>
) : null}
</div>
) : null}
{finished?.promptBillingStatus ? (
+24
View File
@@ -0,0 +1,24 @@
export const zammadArticleTypeOptions = [
{ value: "4", label: "Dienstleistung" },
{ value: "1", label: "Koordination, Kommunikation und Dokumentation" },
{ value: "6", label: "Nicht kategorisiert" },
{ value: "2", label: "Schulungen, Workshops und Meetings" },
{ value: "5", label: "Support" },
{ value: "3", label: "Vor- und Nacharbeiten" }
] as const;
export type ZammadArticleTypeId = number;
export function zammadArticleTypeValue(typeId: ZammadArticleTypeId | null | undefined) {
return String(typeId ?? 6);
}
export function zammadArticleTypeFromValue(value: string): ZammadArticleTypeId {
return Number(value || 6);
}
export function zammadArticleTypeLabel(typeId: ZammadArticleTypeId | null | undefined) {
const value = zammadArticleTypeValue(typeId);
return zammadArticleTypeOptions.find((option) => option.value === value)?.label ?? "Nicht kategorisiert";
}