This commit is contained in:
michael committed 2026-09-29 13:26:18 +02:00
1 parent c89d4825e2
commit 62794f72f1
8 files changed
+402 -78

No files matched your search

+6 -1
View File
@@ -808,6 +808,7 @@ function projectTeamspaceEntries(project: ProjectSummary): ProjectTeamspaceEntry
{
id: `${project.id}-default`,
teamspace_project: project.teamspace_project,
teamspace_description: "",
is_default: true,
hour_budget_minutes: project.hour_budget_minutes,
manual_consumption_minutes: project.manual_consumption_minutes ?? 0,
@@ -818,6 +819,10 @@ function projectTeamspaceEntries(project: ProjectSummary): ProjectTeamspaceEntry
];
}
function projectTeamspaceEntryLabel(entry: ProjectTeamspaceEntry) {
return entry.teamspace_description?.trim() || entry.teamspace_project || (entry.is_default ? "Standard" : "Ohne Teamspace-ID");
}
function budgetedProjectTeamspaceEntries(project: ProjectSummary) {
return projectTeamspaceEntries(project).filter((entry) => Number(entry.hour_budget_minutes ?? 0) > 0);
}
@@ -2348,7 +2353,7 @@ function ProjectBudgetWidget({ widget, editing, projects, stats, onNavigate, onU
<span key={entry.id} className="block">
<span className="flex min-w-0 items-center justify-between gap-2 text-[10px] text-muted-foreground">
<span className="truncate">
{entry.teamspace_project || (entry.is_default ? "Standard" : "Ohne Teamspace-ID")}
{projectTeamspaceEntryLabel(entry)}
{entry.is_default ? " · Standard" : ""}
</span>
<span className="shrink-0">
+28 -4
View File
@@ -54,6 +54,7 @@ type ProjectForm = {
type ProjectTeamspaceEntryForm = {
clientId: string;
teamspaceProject: string;
teamspaceDescription: string;
hourBudget: string;
budgetSyncDisabled: boolean;
manualConsumption: string;
@@ -142,6 +143,7 @@ function emptyProjectTeamspaceEntry(isDefault = false): ProjectTeamspaceEntryFor
return {
clientId: projectEntryId(),
teamspaceProject: "",
teamspaceDescription: "",
hourBudget: "",
budgetSyncDisabled: false,
manualConsumption: "",
@@ -328,6 +330,7 @@ function projectTeamspaceEntries(project: ProjectSummary): ProjectTeamspaceEntry
{
id: `${project.id}-default`,
teamspace_project: project.teamspace_project,
teamspace_description: "",
is_default: true,
hour_budget_minutes: project.hour_budget_minutes,
manual_consumption_minutes: project.manual_consumption_minutes ?? 0,
@@ -338,6 +341,10 @@ function projectTeamspaceEntries(project: ProjectSummary): ProjectTeamspaceEntry
];
}
function projectTeamspaceEntryLabel(entry: { teamspace_project: string | null; teamspace_description?: string | null; is_default: boolean }) {
return entry.teamspace_description?.trim() || entry.teamspace_project || (entry.is_default ? "Standard" : "Ohne Teamspace-ID");
}
function budgetedProjectTeamspaceEntries(project: ProjectSummary) {
return projectTeamspaceEntries(project).filter((entry) => Number(entry.hour_budget_minutes ?? 0) > 0);
}
@@ -457,7 +464,7 @@ function projectMatchesQuery(project: ProjectSummary, query: string) {
project.teamspace_project ?? "",
zammadStateLabel(project.zammad_state_id),
zammadPriorityLabel(project.zammad_priority_id),
...projectTeamspaceEntries(project).map((entry) => entry.teamspace_project ?? ""),
...projectTeamspaceEntries(project).flatMap((entry) => [entry.teamspace_project ?? "", entry.teamspace_description ?? ""]),
project.description,
project.goals
];
@@ -662,7 +669,8 @@ function projectGroupMeta(project: ProjectSummary, groupBy: ProjectGroupBy, stan
}
if (groupBy === "teamspace") {
const label = defaultProjectTeamspaceEntry(project)?.teamspace_project || "Ohne Teamspace-ID";
const defaultEntry = defaultProjectTeamspaceEntry(project);
const label = defaultEntry ? projectTeamspaceEntryLabel(defaultEntry) : "Ohne Teamspace-ID";
return { key: `teamspace:${label.toLocaleLowerCase("de-DE")}`, label, sortLabel: label, rank: 0 };
}
@@ -714,6 +722,7 @@ function formFromProject(project: ProjectSummary): ProjectForm {
const entries = projectTeamspaceEntries(project).map((entry, index) => ({
clientId: entry.id || projectEntryId(),
teamspaceProject: entry.teamspace_project ?? "",
teamspaceDescription: entry.teamspace_description ?? "",
hourBudget: formatHoursInput(entry.hour_budget_minutes),
budgetSyncDisabled: entry.budget_sync_disabled ?? false,
manualConsumption: formatHoursInput(entry.manual_consumption_minutes),
@@ -768,6 +777,7 @@ function parseProjectTeamspaceEntryForms(entries: ProjectTeamspaceEntryForm[]) {
return {
id: /^\d+$/.test(entry.clientId) ? entry.clientId : null,
teamspaceProject: entry.teamspaceProject.trim() || null,
teamspaceDescription: entry.teamspaceDescription.trim() || null,
isDefault: entry.isDefault,
hourBudgetMinutes,
budgetSyncDisabled: entry.budgetSyncDisabled,
@@ -782,6 +792,7 @@ function parseProjectTeamspaceEntryForms(entries: ProjectTeamspaceEntryForm[]) {
return parsedEntries as Array<{
id?: string | null;
teamspaceProject: string | null;
teamspaceDescription: string | null;
isDefault: boolean;
hourBudgetMinutes: number | null;
budgetSyncDisabled: boolean;
@@ -825,7 +836,7 @@ function ProjectTeamspaceEntriesFields({
</div>
<div className="space-y-2">
{normalizedEntries.map((entry, index) => (
<div key={entry.clientId} className="grid gap-2 rounded-lg border bg-muted/10 p-3 lg:grid-cols-[minmax(180px,1fr)_130px_150px_130px_120px_36px] lg:items-end">
<div key={entry.clientId} className="grid gap-2 rounded-lg border bg-muted/10 p-3 lg:grid-cols-[minmax(180px,1fr)_minmax(160px,0.9fr)_130px_150px_130px_120px_36px] lg:items-end">
<div className="space-y-1.5">
<label className="text-xs font-medium text-muted-foreground" htmlFor={`project-teamspace-${entry.clientId}`}>
Teamspace-Projekt {entry.isDefault ? "(Standard)" : ""}
@@ -836,6 +847,17 @@ function ProjectTeamspaceEntriesFields({
onChange={(event) => updateEntry(entry.clientId, { teamspaceProject: event.currentTarget.value })}
/>
</div>
<div className="space-y-1.5">
<label className="text-xs font-medium text-muted-foreground" htmlFor={`project-teamspace-description-${entry.clientId}`}>
Alias
</label>
<Input
id={`project-teamspace-description-${entry.clientId}`}
value={entry.teamspaceDescription}
placeholder="z.B. Cloud-Umzug"
onChange={(event) => updateEntry(entry.clientId, { teamspaceDescription: event.currentTarget.value })}
/>
</div>
<div className="space-y-1.5">
<label className="text-xs font-medium text-muted-foreground" htmlFor={`project-budget-${entry.clientId}`}>
Budget h
@@ -1153,6 +1175,7 @@ export function ProjectsPage({ onNavigate, onStartTimer }: ProjectsPageProps) {
? zammadEntries.map((entry, index) => ({
clientId: entry.id || projectEntryId(),
teamspaceProject: entry.teamspace_project ?? "",
teamspaceDescription: entry.teamspace_description ?? "",
hourBudget: formatHoursInput(entry.hour_budget_minutes),
budgetSyncDisabled: entry.budget_sync_disabled ?? false,
manualConsumption: "",
@@ -1165,6 +1188,7 @@ export function ProjectsPage({ onNavigate, onStartTimer }: ProjectsPageProps) {
? {
...entry,
teamspaceProject: entry.teamspaceProject || result.ticket?.project_name || "",
teamspaceDescription: entry.teamspaceDescription,
hourBudget: entry.hourBudget || formatHoursInput(result.ticket?.project_hour_budget_minutes ?? null),
budgetSyncDisabled: entry.hourBudget ? entry.budgetSyncDisabled : false
}
@@ -1754,7 +1778,7 @@ export function ProjectsPage({ onNavigate, onStartTimer }: ProjectsPageProps) {
{budgetedTeamspaceEntries.map((entry) => {
const entryPercent = projectEntryPercent(entry);
const entryConsumption = projectEntryConsumption(entry);
const label = entry.teamspace_project || (entry.is_default ? "Standard" : "Ohne Teamspace-ID");
const label = projectTeamspaceEntryLabel(entry);
return (
<div key={entry.id} className="min-w-0 rounded-md border bg-background/50 px-2 py-1">
+41 -14
View File
@@ -12,7 +12,7 @@ import { Textarea } from "@/components/ui/textarea";
import { CopyTicketButton } from "@/components/CopyTicketButton";
import { HelpLink } from "@/components/HelpLink";
import { OrganizationSelect } from "@/components/OrganizationSelect";
import { RichActivityEditor } from "@/components/RichActivityEditor";
import { readRichActivityEditorValue, RichActivityEditor } from "@/components/RichActivityEditor";
import { TodoPanel } from "@/components/TodoPanel";
import {
createSession,
@@ -240,14 +240,18 @@ function budgetedProjectTeamspaceEntries(project: ProjectSummary) {
return projectTeamspaceEntries(project).filter((entry) => Number(entry.hour_budget_minutes ?? 0) > 0);
}
function teamspaceEntryOptionLabel(entry: { teamspace_project: string | null; is_default: boolean }) {
const label = entry.teamspace_project || "Ohne Teamspace-ID";
function teamspaceEntryDisplayLabel(entry: { teamspace_project: string | null; teamspace_description?: string | null; is_default: boolean }) {
return entry.teamspace_description?.trim() || entry.teamspace_project || "Ohne Teamspace-ID";
}
function teamspaceEntryOptionLabel(entry: { teamspace_project: string | null; teamspace_description?: string | null; is_default: boolean }) {
const label = teamspaceEntryDisplayLabel(entry);
return entry.is_default ? `${label} (Standard)` : label;
}
function teamspaceEntryDescription(entry: { teamspace_description?: string | null } | null | undefined) {
return entry?.teamspace_description?.trim() || "";
function teamspaceEntryDescription(entry: { teamspace_project?: string | null; teamspace_description?: string | null } | null | undefined) {
return entry?.teamspace_description?.trim() ? entry.teamspace_project?.trim() || "" : "";
}
function teamspaceBillingKey(day: string, projectTeamspaceEntryId: string | null | undefined) {
@@ -495,6 +499,19 @@ function teamspacePreparationText(ticket: TicketPeriod["ticket"] | null | undefi
return [`${ticketNumber} - ${customerName}`, "", activities, "", "Tickethandling und Dokumentation"].join("\n");
}
function SessionActivityBody({ session }: { session: SessionEntry }) {
if (session.zammad_article_body) {
return (
<div
className="break-words text-sm font-medium [&_blockquote]:border-l-2 [&_blockquote]:pl-3 [&_br]:block [&_code]:rounded-sm [&_code]:bg-muted [&_code]:px-1 [&_h1]:text-xl [&_h1]:font-semibold [&_h2]:text-lg [&_h2]:font-semibold [&_h3]:text-base [&_h3]:font-semibold [&_img]:my-2 [&_img]:max-h-80 [&_img]:max-w-full [&_img]:rounded-md [&_ol]:ml-5 [&_ol]:list-decimal [&_p]:my-1 [&_pre]:whitespace-pre-wrap [&_pre]:rounded-md [&_pre]:bg-muted [&_pre]:p-2 [&_strike]:line-through [&_ul]:ml-5 [&_ul]:list-disc"
dangerouslySetInnerHTML={{ __html: session.zammad_article_body }}
/>
);
}
return <p className="whitespace-pre-wrap break-words text-sm font-medium">{session.activity || "Keine Tätigkeit erfasst."}</p>;
}
function toDateLocalValue(value: string) {
const date = new Date(value);
const offsetMs = date.getTimezoneOffset() * 60_000;
@@ -921,7 +938,7 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onS
return [
...projectEntries.map((entry) => ({
id: entry.id,
label: entry.teamspace_project || (entry.is_default ? "Standard" : "Ohne Teamspace-ID"),
label: teamspaceEntryDisplayLabel(entry),
description: teamspaceEntryDescription(entry),
entry
})),
@@ -1446,18 +1463,22 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onS
setSavingTicketSession(true);
try {
const activityValue = readRichActivityEditorValue("ticket-session-activity") ?? {
activity: ticketSession.activity,
zammadArticleBody: ticketSession.zammadArticleBody
};
const result = await createSession({
ticketNumber: ticket.ticket_number,
organizationId: effectiveOrganizationId,
activity: ticketSession.activity,
zammadArticleBody: ticketSession.zammadArticleBody || null,
activity: activityValue.activity,
zammadArticleBody: activityValue.zammadArticleBody || null,
workType: effectiveWorkType,
startedAt: startedAt.toISOString(),
endedAt: endedAt.toISOString(),
durationSeconds,
projectTeamspaceEntryId: effectiveWorkType === "consulting" && requiresProjectTeamspaceEntry ? ticketSession.projectTeamspaceEntryId : null,
billingStatus: ticketSession.billingStatus,
createZammadArticle: ticketSession.createZammadArticle,
createZammadArticle: ticketSession.createZammadArticle && (activityValue.activity.trim().length > 0 || Boolean(activityValue.zammadArticleBody)),
zammadArticleTypeId: ticketSession.zammadArticleTypeId
});
toast.success("Session nachgetragen", {
@@ -1563,10 +1584,14 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onS
setSavingSessionDetails(true);
try {
const activityValue = readRichActivityEditorValue("session-activity") ?? {
activity: editSessionActivity,
zammadArticleBody: editSessionZammadArticleBody
};
await updateSessionDetails(editingSession.id, {
organizationId: editSessionOrganizationId,
activity: editSessionActivity,
zammadArticleBody: editSessionZammadArticleBody || null,
activity: activityValue.activity,
zammadArticleBody: activityValue.zammadArticleBody || null,
workType: editSessionWorkType,
removeProjectData,
projectTeamspaceEntryId: editSessionWorkType === "consulting" && requiresProjectTeamspaceEntry ? editSessionProjectTeamspaceEntryId : null,
@@ -1855,7 +1880,7 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onS
? `Eigentümer: ${project.owner_display_name}`
: showBudgetedProjectEntries
? `${budgetedProjectEntries.length} budgetierte Teamspace-Einträge`
: budgetedProjectEntries[0]?.teamspace_project || project.teamspace_project || "Kein Teamspace-Projekt hinterlegt"}
: budgetedProjectEntries[0] ? teamspaceEntryDisplayLabel(budgetedProjectEntries[0]) : project.teamspace_project || "Kein Teamspace-Projekt hinterlegt"}
</CardDescription>
</div>
<div className="flex shrink-0 items-center gap-1">
@@ -1927,7 +1952,7 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onS
{budgetedProjectEntries.map((entry) => {
const consumption = Number(entry.consumption_minutes ?? 0);
const percent = projectConsumptionPercent(consumption, entry.hour_budget_minutes);
const label = entry.teamspace_project || (entry.is_default ? "Standard" : "Ohne Teamspace-ID");
const label = teamspaceEntryDisplayLabel(entry);
return (
<div key={entry.id} className="rounded-md border bg-background/50 px-2 py-1">
@@ -2371,7 +2396,7 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onS
</div>
{expanded ? (
<div className="border-t px-3 pb-3 pt-2">
<p className="whitespace-pre-wrap break-words text-sm font-medium">{session.activity || "Keine Tätigkeit erfasst."}</p>
<SessionActivityBody session={session} />
<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>
@@ -2566,6 +2591,7 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onS
Tätigkeit
</label>
<RichActivityEditor
key={`ticket-session-${ticketId}`}
id="ticket-session-activity"
value={ticketSession.activity}
htmlValue={ticketSession.zammadArticleBody}
@@ -2727,6 +2753,7 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onS
<div className="space-y-2">
<label className="text-sm font-medium" htmlFor="session-activity">Tätigkeit</label>
<RichActivityEditor
key={editingSession?.id ?? "session-activity-empty"}
id="session-activity"
value={editSessionActivity}
htmlValue={editSessionZammadArticleBody}
+48 -32
View File
@@ -11,12 +11,12 @@ import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { HelpLink } from "@/components/HelpLink";
import { OrganizationSelect } from "@/components/OrganizationSelect";
import { RichActivityEditor } from "@/components/RichActivityEditor";
import { readRichActivityEditorValue, RichActivityEditor } from "@/components/RichActivityEditor";
import { createActiveTimer, createSession, deleteActiveTimer, getRecurringBillings, lookupTicket, updateActiveTimer } from "../api";
import type { CreateSessionResult } from "../api";
import { formatTimer } from "../format";
import { isRecurringBillingTicketNumber, ticketHasRecurringBilling } from "../recurring-billings";
import { activeElapsedMs, ticketPattern, type TimerEntry } from "../timers";
import { activeElapsedMs, mergeTimerActivityDrafts, removeTimerActivityDraft, ticketPattern, type TimerEntry, writeTimerActivityDraft } from "../timers";
import type { AuthUser, BillingStatus, RecurringBilling, TicketMeta, WorkType } from "../types";
import { zammadArticleTypeFromValue, zammadArticleTypeOptions, zammadArticleTypeValue, type ZammadArticleTypeId } from "../zammad-article-types";
@@ -79,14 +79,14 @@ function ticketTeamspaceEntries(ticket: TicketMeta | null | undefined) {
return ticket?.project_teamspace_entries ?? [];
}
function teamspaceEntryOptionLabel(entry: { teamspace_project: string | null; is_default: boolean }) {
const label = entry.teamspace_project || "Ohne Teamspace-ID";
function teamspaceEntryOptionLabel(entry: { teamspace_project: string | null; teamspace_description?: string | null; is_default: boolean }) {
const label = entry.teamspace_description?.trim() || entry.teamspace_project || "Ohne Teamspace-ID";
return entry.is_default ? `${label} (Standard)` : label;
}
function teamspaceEntryDescription(entry: { teamspace_description?: string | null } | null | undefined) {
return entry?.teamspace_description?.trim() || "";
function teamspaceEntryDescription(entry: { teamspace_project?: string | null; teamspace_description?: string | null } | null | undefined) {
return entry?.teamspace_description?.trim() ? entry.teamspace_project?.trim() || "" : "";
}
function requiresTeamspaceEntry(ticket: TicketMeta | null | undefined) {
@@ -496,7 +496,7 @@ export function TimerPage({ timers, setTimers, selectedTimerId, setSelectedTimer
billingStatus: null,
phase: "running"
});
setTimers(result.timers);
setTimers(mergeTimerActivityDrafts(currentUser.id, result.timers));
setSelectedTimerId(result.timers.find((timer) => timer.ticketNumber === normalizedTicketNumber)?.id ?? null);
setTicketNumber("");
} catch (error) {
@@ -583,7 +583,7 @@ export function TimerPage({ timers, setTimers, selectedTimerId, setSelectedTimer
async function activateTimer(timerId: string) {
try {
const result = await updateActiveTimer(timerId, { phase: "running" });
setTimers(result.timers);
setTimers(mergeTimerActivityDrafts(currentUser.id, result.timers));
setSelectedTimerId(timerId);
} catch (error) {
toast.error("Timer konnte nicht aktiviert werden", {
@@ -595,7 +595,7 @@ export function TimerPage({ timers, setTimers, selectedTimerId, setSelectedTimer
async function pauseTimer(timerId: string) {
try {
const result = await updateActiveTimer(timerId, { phase: "paused" });
setTimers(result.timers);
setTimers(mergeTimerActivityDrafts(currentUser.id, result.timers));
setSelectedTimerId(timerId);
} catch (error) {
toast.error("Timer konnte nicht pausiert werden", {
@@ -607,7 +607,8 @@ export function TimerPage({ timers, setTimers, selectedTimerId, setSelectedTimer
async function resetTimer(timerId: string) {
try {
const result = await deleteActiveTimer(timerId);
setTimers(result.timers);
removeTimerActivityDraft(currentUser.id, timerId);
setTimers(mergeTimerActivityDrafts(currentUser.id, result.timers));
} catch (error) {
toast.error("Timer konnte nicht entfernt werden", {
description: error instanceof Error ? error.message : "Unbekannter Fehler"
@@ -617,6 +618,7 @@ export function TimerPage({ timers, setTimers, selectedTimerId, setSelectedTimer
function updateTimerActivity(timerId: string, nextActivity: string, zammadArticleBody: string) {
activityDrafts.current.set(timerId, { activity: nextActivity, zammadArticleBody });
writeTimerActivityDraft(currentUser.id, timerId, { activity: nextActivity, zammadArticleBody });
setTimers((current) => current.map((timer) => (timer.id === timerId ? { ...timer, activity: nextActivity, zammadArticleBody } : timer)));
const existingTimeout = activitySaveTimeouts.current.get(timerId);
@@ -636,7 +638,7 @@ export function TimerPage({ timers, setTimers, selectedTimerId, setSelectedTimer
function updateTimerBillingStatus(timerId: string, billingStatus: BillingStatus) {
setTimers((current) => current.map((timer) => (timer.id === timerId ? { ...timer, billingStatus } : timer)));
void updateActiveTimer(timerId, { billingStatus }).then((result) => setTimers(result.timers)).catch((error) => {
void updateActiveTimer(timerId, { billingStatus }).then((result) => setTimers(mergeTimerActivityDrafts(currentUser.id, result.timers))).catch((error) => {
toast.error("Bewertung konnte nicht gespeichert werden", {
description: error instanceof Error ? error.message : "Unbekannter Fehler"
});
@@ -666,7 +668,7 @@ export function TimerPage({ timers, setTimers, selectedTimerId, setSelectedTimer
activity: timerActivity,
zammadArticleBody: timerZammadArticleBody || null
});
setTimers(timerResult.timers);
setTimers(mergeTimerActivityDrafts(currentUser.id, timerResult.timers));
setSelectedTimerId(timer.id);
const result = await lookupTicket(timer.ticketNumber);
@@ -674,6 +676,7 @@ export function TimerPage({ timers, setTimers, selectedTimerId, setSelectedTimer
if (ticketHasRecurringBilling(ticket, recurringBillings) || isRecurringBillingTicketNumber(timer.ticketNumber, recurringBillings)) {
const timerResult = await deleteActiveTimer(timer.id).catch(() => null);
removeTimerActivityDraft(currentUser.id, timer.id);
setTimers((current) => timerResult?.timers ?? current.filter((entry) => entry.id !== timer.id));
toast.error("Fixe Abrechnung", {
description: "Für fixe Abrechnungen dürfen keine manuellen Sessions gespeichert werden. Bitte die Definition bearbeiten."
@@ -746,19 +749,23 @@ export function TimerPage({ timers, setTimers, selectedTimerId, setSelectedTimer
setSaving(true);
try {
const activityValue = readRichActivityEditorValue("finished-activity") ?? {
activity: finished.activity,
zammadArticleBody: finished.zammadArticleBody
};
const result = await createSession({
ticketNumber: finished.ticketNumber,
startedAt: finished.startedAt,
endedAt: finished.endedAt,
durationSeconds: finished.durationSeconds,
organizationId: effectiveOrganizationId,
activity: finished.activity,
zammadArticleBody: finished.zammadArticleBody || null,
activity: activityValue.activity,
zammadArticleBody: activityValue.zammadArticleBody || null,
workType: effectiveWorkType,
zammadLookup: Boolean(finished.ticket?.zammad_ticket_id),
projectTeamspaceEntryId: finishedRequiresTeamspaceEntry ? projectTeamspaceEntryId : null,
billingStatus: finished.billingStatus,
createZammadArticle: finished.createZammadArticle && (finished.activity.trim().length > 0 || Boolean(finished.zammadArticleBody)),
createZammadArticle: finished.createZammadArticle && (activityValue.activity.trim().length > 0 || Boolean(activityValue.zammadArticleBody)),
zammadArticleTypeId: finished.zammadArticleTypeId
});
@@ -768,8 +775,10 @@ export function TimerPage({ timers, setTimers, selectedTimerId, setSelectedTimer
notifyZammadArticleResult(result);
try {
const timerResult = await deleteActiveTimer(finished.timerId);
setTimers(timerResult.timers);
removeTimerActivityDraft(currentUser.id, finished.timerId);
setTimers(mergeTimerActivityDrafts(currentUser.id, timerResult.timers));
} catch (error) {
removeTimerActivityDraft(currentUser.id, finished.timerId);
setTimers((current) => current.filter((timer) => timer.id !== finished.timerId));
toast.warning("Session gespeichert, Timer lokal entfernt", {
description: error instanceof Error ? error.message : "Der Timer konnte serverseitig nicht entfernt werden."
@@ -836,11 +845,15 @@ export function TimerPage({ timers, setTimers, selectedTimerId, setSelectedTimer
setSavingManualSession(true);
try {
const activityValue = readRichActivityEditorValue("manual-activity") ?? {
activity: manualSession.activity,
zammadArticleBody: manualSession.zammadArticleBody
};
const result = await createSession({
ticketNumber: manualSession.ticketNumber.trim(),
organizationId: manualSession.organizationId,
activity: manualSession.activity,
zammadArticleBody: manualSession.zammadArticleBody || null,
activity: activityValue.activity,
zammadArticleBody: activityValue.zammadArticleBody || null,
workType: manualSession.workType,
startedAt: startedAt.toISOString(),
endedAt: endedAt.toISOString(),
@@ -848,7 +861,7 @@ 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 && (activityValue.activity.trim().length > 0 || Boolean(activityValue.zammadArticleBody)),
zammadArticleTypeId: manualSession.zammadArticleTypeId
});
toast.success("Session nachgetragen", {
@@ -959,6 +972,7 @@ export function TimerPage({ timers, setTimers, selectedTimerId, setSelectedTimer
Tätigkeit
</label>
<RichActivityEditor
key={selectedTimer?.id ?? "timer-activity-empty"}
id="timer-activity"
value={selectedTimer?.activity ?? ""}
htmlValue={selectedTimer?.zammadArticleBody ?? ""}
@@ -1160,6 +1174,7 @@ export function TimerPage({ timers, setTimers, selectedTimerId, setSelectedTimer
<div className="space-y-2">
<label className="text-sm font-medium" htmlFor="manual-activity">Tätigkeit</label>
<RichActivityEditor
key="manual-activity"
id="manual-activity"
value={manualSession.activity}
htmlValue={manualSession.zammadArticleBody}
@@ -1207,7 +1222,7 @@ export function TimerPage({ timers, setTimers, selectedTimerId, setSelectedTimer
</section>
<Dialog open={Boolean(finished)} onOpenChange={(open) => !open && setFinished(null)}>
<DialogContent>
<DialogContent className="w-[calc(100vw-2rem)] max-w-3xl overflow-x-hidden sm:max-w-3xl">
<DialogHeader>
<DialogTitle>Session abschließen</DialogTitle>
<DialogDescription>
@@ -1215,7 +1230,7 @@ export function TimerPage({ timers, setTimers, selectedTimerId, setSelectedTimer
</DialogDescription>
</DialogHeader>
<form className="space-y-4" onSubmit={submitSession}>
<form className="min-w-0 space-y-4" onSubmit={submitSession}>
{finished && !finished.ticket?.organization_id ? (
<div className="flex justify-end">
<Button type="button" size="sm" variant="secondary" disabled={zammadLookupBusy === "finished"} onClick={() => void lookupFinishedTicketInZammad()}>
@@ -1225,9 +1240,9 @@ export function TimerPage({ timers, setTimers, selectedTimerId, setSelectedTimer
</div>
) : null}
{finished?.ticket?.organization_id && finished.ticket.work_type ? (
<div className="rounded-lg border bg-muted/40 p-3 text-sm">
<p className="font-medium">{finished.ticket.organization_name ?? finished.ticket.customer_name}</p>
<p className="text-muted-foreground">
<div className="min-w-0 rounded-lg border bg-muted/40 p-3 text-sm">
<p className="break-words font-medium">{finished.ticket.organization_name ?? finished.ticket.customer_name}</p>
<p className="break-words text-muted-foreground">
{finished.ticket.is_project ? "Projekt erkannt. Consulting" : finished.ticket.work_type === "support" ? "Support" : "Consulting"} wurde vom bestehenden Ticket übernommen.
</p>
</div>
@@ -1250,11 +1265,12 @@ export function TimerPage({ timers, setTimers, selectedTimerId, setSelectedTimer
/>
</div>
)}
<div className="space-y-2">
<div className="min-w-0 space-y-2">
<label className="text-sm font-medium" htmlFor="finished-activity">
Tätigkeit
</label>
<RichActivityEditor
key={finished?.timerId ?? "finished-activity-empty"}
id="finished-activity"
value={finished?.activity ?? ""}
htmlValue={finished?.zammadArticleBody ?? ""}
@@ -1269,8 +1285,8 @@ export function TimerPage({ timers, setTimers, selectedTimerId, setSelectedTimer
/>
</div>
{finished ? (
<div className="space-y-3 rounded-md border bg-muted/30 p-3">
<div className="flex items-start gap-3">
<div className="min-w-0 space-y-3 rounded-md border bg-muted/30 p-3">
<div className="flex min-w-0 items-start gap-3">
{(() => {
const hasZammadContent = finished.activity.trim().length > 0 || Boolean(finished.zammadArticleBody);
@@ -1282,11 +1298,11 @@ export function TimerPage({ timers, setTimers, selectedTimerId, setSelectedTimer
disabled={!hasZammadContent}
onCheckedChange={(checked) => setFinished((current) => (current ? { ...current, createZammadArticle: checked === true && (current.activity.trim().length > 0 || Boolean(current.zammadArticleBody)) } : current))}
/>
<div className="space-y-1">
<label className="text-sm font-medium" htmlFor="finished-zammad-article">
<div className="min-w-0 space-y-1">
<label className="break-words text-sm font-medium" htmlFor="finished-zammad-article">
Tätigkeit als interne Zammad-Notiz übertragen
</label>
<p className="text-xs text-muted-foreground">
<p className="break-words text-xs text-muted-foreground">
{hasZammadContent
? "Erstellt einen internen Zammad-Article mit Tätigkeit und getrackter Zeit."
: "Ohne Tätigkeit wird keine Zammad-Notiz erstellt."}
@@ -1300,13 +1316,13 @@ export function TimerPage({ timers, setTimers, selectedTimerId, setSelectedTimer
})()}
</div>
{finished.createZammadArticle ? (
<div className="space-y-2">
<div className="min-w-0 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"
className="h-10 w-full min-w-0 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))}
>