1.2.0-rc1

This commit is contained in:
michael committed 2026-10-15 08:09:47 +02:00
1 parent 45ff46b7eb
commit fa9c122250
30 files changed
+6354 -989

No files matched your search

+37 -4
View File
@@ -1,5 +1,6 @@
import {
CalendarDays,
Building2,
CheckCircle2,
ClipboardCheck,
Command,
@@ -87,8 +88,10 @@ import { isRecurringBillingTicketNumber, ticketHasRecurringBilling } from "./rec
import { activeElapsedMs, mergeTimerActivityDrafts, readStoredTimers, storageKeyForUser, ticketPattern, type TimerEntry } from "./timers";
import { formatTodoPeriod, isTodoActiveOnDay, isTodoFixedOnDay, isTodoOverdue } from "./todos";
import { AdminUsersPage } from "./views/AdminUsersPage";
import { AdminOrganizationsPage } from "./views/AdminOrganizationsPage";
import { AnalysisPage } from "./views/AnalysisPage";
import { DashboardPage } from "./views/DashboardPage";
import { DashboardTimersPage } from "./views/DashboardTimersPage";
import { FaqPage } from "./views/FaqPage";
import { LoginPage } from "./views/LoginPage";
import { MonthlyClosePage } from "./views/MonthlyClosePage";
@@ -225,6 +228,10 @@ function routeFromPath(pathWithSearch: string) {
}
if (pathname.startsWith("/dashboard")) {
if (pathname.startsWith("/dashboard/timers")) {
return { page: "dashboard-timers" as const };
}
return { page: "dashboard" as const };
}
@@ -260,6 +267,10 @@ function routeFromPath(pathWithSearch: string) {
return { page: "admin-users" as const };
}
if (pathname.startsWith("/admin/organizations")) {
return { page: "admin-organizations" as const };
}
if (pathname.startsWith("/profile")) {
return { page: "profile" as const };
}
@@ -827,6 +838,7 @@ export function App() {
const [timersLoaded, setTimersLoaded] = useState(false);
const [timerOwnerId, setTimerOwnerId] = useState<string | null>(null);
const [selectedTimerId, setSelectedTimerId] = useState<string | null>(null);
const [finishRequestTimerId, setFinishRequestTimerId] = useState<string | null>(null);
const [tick, setTick] = useState(Date.now());
const [openTickets, setOpenTickets] = useState<OpenTicketNavItem[]>([]);
const [ticketReturnPaths, setTicketReturnPaths] = useState<Record<string, string>>({});
@@ -1160,7 +1172,7 @@ export function App() {
return;
}
if (route.page === "admin-users" || route.page === "profile" || route.page === "faq") {
if (route.page === "admin-users" || route.page === "admin-organizations" || route.page === "profile" || route.page === "faq") {
return;
}
@@ -1390,13 +1402,15 @@ export function App() {
...(currentUser.role === "admin"
? [
{ type: "item" as const, href: "/admin/users", label: "Administration", icon: Shield, active: path.startsWith("/admin/users") },
{ type: "item" as const, href: "/admin/organizations", label: "Organisationen", icon: Building2, active: path.startsWith("/admin/organizations") },
{ type: "separator" as const, key: "before-profile" },
{ type: "item" as const, href: "/profile", label: "Profil", icon: UserCog, active: path.startsWith("/profile") },
{ type: "separator" as const, key: "before-faq" },
{ type: "item" as const, href: "/faq", label: "FAQ", icon: CircleHelp, active: path.startsWith("/faq") }
]
: [
{ type: "item" as const, href: "/dashboard", label: "Dashboard", icon: LayoutDashboard, active: path.startsWith("/dashboard") },
{ type: "item" as const, href: "/dashboard", label: "Dashboard", icon: LayoutDashboard, active: path === "/dashboard" },
{ type: "item" as const, href: "/dashboard/timers", label: "Timer-Übersicht", icon: Timer, active: path.startsWith("/dashboard/timers") },
{ type: "separator" as const, key: "after-dashboard" },
{ type: "item" as const, href: "/timer", label: "Session starten", icon: Timer, active: path.startsWith("/timer") || path === "/" },
{ type: "item" as const, href: "/analysis", label: "Ticket-Sessions", icon: Ticket, active: path.startsWith("/analysis") },
@@ -1567,7 +1581,7 @@ export function App() {
<SidebarMenuItem>
<SidebarMenuButton tooltip="Worklog">
<FolderGit />
<span className="text-xs text-muted-foreground">v1.1.0-rc1</span>
<span className="text-xs text-muted-foreground">v1.2.0-rc1</span>
</SidebarMenuButton>
</SidebarMenuItem>
</SidebarMenu>
@@ -1662,9 +1676,27 @@ export function App() {
setSelectedTimerId={setSelectedTimerId}
tick={tick}
currentUser={currentUser}
finishRequestId={finishRequestTimerId}
onFinishRequestHandled={() => setFinishRequestTimerId(null)}
/>
) : null}
{!isAdmin && route.page === "dashboard" ? <DashboardPage currentUser={currentUser} onNavigate={navigate} /> : null}
{!isAdmin && route.page === "dashboard-timers" ? (
<DashboardTimersPage
timers={timers}
setTimers={setTimers}
selectedTimerId={selectedTimerId}
setSelectedTimerId={setSelectedTimerId}
tick={tick}
currentUser={currentUser}
onFinishTimer={(timerId) => {
setSelectedTimerId(timerId);
setFinishRequestTimerId(timerId);
navigate("/timer");
}}
onOpenActiveTimer={() => navigate("/timer")}
/>
) : null}
{!isAdmin && route.page === "analysis" ? <AnalysisPage currentUser={currentUser} onNavigate={navigate} /> : null}
{!isAdmin && route.page === "monthly-close" ? <MonthlyClosePage currentUser={currentUser} onNavigate={navigate} /> : null}
{!isAdmin && route.page === "statistics" ? <StatisticsPage currentUser={currentUser} onNavigate={navigate} /> : null}
@@ -1675,6 +1707,7 @@ export function App() {
{route.page === "faq" ? <FaqPage /> : null}
{route.page === "profile" ? <ProfilePage currentUser={currentUser} onUserUpdated={setCurrentUser} /> : null}
{route.page === "admin-users" && isAdmin ? <AdminUsersPage currentUser={currentUser} /> : null}
{route.page === "admin-organizations" && isAdmin ? <AdminOrganizationsPage /> : null}
{route.page === "admin-users" && !isAdmin ? (
<TimerPage
timers={timers}
@@ -1697,7 +1730,7 @@ export function App() {
returnPath={ticketReturnPaths[route.ticketId] ?? openTickets.find((ticket) => ticket.ticketId === route.ticketId)?.returnPath ?? "/analysis"}
/>
) : null}
{isAdmin && route.page !== "admin-users" && route.page !== "profile" && route.page !== "faq" ? <AdminUsersPage currentUser={currentUser} /> : null}
{isAdmin && route.page !== "admin-users" && route.page !== "admin-organizations" && route.page !== "profile" && route.page !== "faq" ? <AdminUsersPage currentUser={currentUser} /> : null}
</div>
</main>
</SidebarInset>
+123 -2
View File
@@ -19,6 +19,7 @@ import type {
TodoPriority,
TodoStatus,
WorkType,
WorkSegment,
ZammadSessionArticleQueueItem
} from "./types";
import type { TimerEntry, TimerPhase } from "./timers";
@@ -32,10 +33,12 @@ type CreateSessionPayload = {
startedAt: string;
endedAt: string;
durationSeconds: number;
workSegments?: WorkSegment[];
zammadLookup?: boolean;
projectTeamspaceEntryId?: string | null;
billingStatus?: BillingStatus;
createZammadArticle?: boolean;
zammadArticleDateHeader?: boolean;
zammadArticleTypeId?: number;
};
@@ -330,8 +333,10 @@ export function updateCurrentUser(payload: {
teamspaceDevicePassword?: string;
zammadEmailSessionWebhookEnabled?: boolean;
sessionBillingPromptEnabled?: boolean;
sessionWorkSegmentsEnabled?: boolean;
teamspaceTimeTrackingEnabled?: boolean;
zammadSessionNoteDefaultEnabled?: boolean;
zammadSessionNoteDateHeaderEnabled?: boolean;
zammadTimeUnitRoundingEnabled?: boolean;
zammadTimeUnitRoundingMinutes?: number;
zammadAtossEmailSyncEnabled?: boolean;
@@ -436,6 +441,37 @@ export function updateAdminUser(
});
}
export type TeamspaceInitialActualsSyncResult = {
userId: string;
fromMonth: string;
toMonth: string;
checkedEntries: number;
matchedEntries: number;
updatedProjectIds: number;
missingEntries: number;
ambiguousEntries: number;
monthsChecked: number;
monthsSkippedClosed: number;
teamspaceTimeEntriesScanned: number;
updatedManualEntries: number;
importedManualMinutes: number;
existingDayBillingMinutes: number;
};
export function syncAdminUserTeamspaceInitialActuals(userId: string, payload: { fromMonth?: string; toMonth?: string }) {
return request<{ result: TeamspaceInitialActualsSyncResult }>(`/api/admin/users/${userId}/teamspace-initial-actuals/sync`, {
method: "POST",
timeoutMs: 10 * 60 * 1000,
body: JSON.stringify(payload)
});
}
export function unlockAdminUserTeamspaceInitialActuals(userId: string) {
return request<{ user: AdminUser }>(`/api/admin/users/${userId}/teamspace-initial-actuals/unlock`, {
method: "POST"
});
}
export async function downloadDatabaseExport() {
const response = await fetch("/api/admin/export/database", {
credentials: "same-origin"
@@ -502,6 +538,33 @@ export function getOrganizations(search = "") {
return request<{ organizations: Organization[] }>(`/api/organizations${params.size ? `?${params}` : ""}`);
}
export function getAdminOrganizations(options: {
search?: string;
page?: number;
pageSize?: number | "all";
missingAssignment?: boolean;
} = {}) {
const params = new URLSearchParams();
if (options.search?.trim()) {
params.set("search", options.search.trim());
}
if (options.page) {
params.set("page", String(options.page));
}
if (options.pageSize) {
params.set("pageSize", String(options.pageSize));
}
if (options.missingAssignment) {
params.set("missingAssignment", "true");
}
return request<{ organizations: Organization[]; total: number; page: number; pageSize: number | "all" }>(`/api/admin/organizations${params.size ? `?${params}` : ""}`);
}
export function getZammadSettings() {
return request<{ settings: { baseUrl: string; hasApiKey: boolean } }>("/api/admin/zammad/settings");
}
@@ -526,16 +589,61 @@ export function saveTeamspaceSettings(payload: { baseUrl: string; credentialsUse
export type TeamspaceProjectExclusion = {
teamspace_project: string;
teamspace_api_project_id: string | null;
teamspace_api_project_ids: string[] | null;
teamspace_name: string | null;
customer_name: string | null;
atoss_support_default: boolean;
atoss_support_valid_from: string | null;
atoss_support_valid_to: string | null;
customer_owner_option_id: string | null;
hotline_contract_option_id: string | null;
note: string;
};
export type OrganizationAttributeOption = {
id: string;
zammad_attribute_id: string;
attribute_name: "customer_owner" | "hotline_contract";
option_key: string;
display_name: string;
active: boolean;
};
export type TeamspaceAssignmentRule = {
id?: string;
name: string;
active: boolean;
priority: number;
customer_owner_option_id: string | null;
hotline_contract_option_id: string | null;
support_enabled: boolean;
consulting_enabled: boolean;
teamspace_project: string;
created_at?: string;
updated_at?: string;
};
export function getOrganizationAttributeOptions() {
return request<{ options: OrganizationAttributeOption[] }>("/api/admin/zammad/organization-attribute-options");
}
export function getTeamspaceAssignmentRules() {
return request<{ rules: TeamspaceAssignmentRule[] }>("/api/admin/teamspace/assignment-rules");
}
export function saveTeamspaceAssignmentRules(rules: TeamspaceAssignmentRule[]) {
return request<{ rules: TeamspaceAssignmentRule[] }>("/api/admin/teamspace/assignment-rules", {
method: "PUT",
body: JSON.stringify({ rules })
});
}
export function getTeamspaceProjectExclusions() {
return request<{ exclusions: TeamspaceProjectExclusion[] }>("/api/admin/teamspace/project-exclusions");
}
export function saveTeamspaceProjectExclusions(projects: string[]) {
export function saveTeamspaceProjectExclusions(projects: Array<Pick<TeamspaceProjectExclusion, "teamspace_project" | "atoss_support_default" | "atoss_support_valid_from" | "atoss_support_valid_to" | "customer_owner_option_id" | "hotline_contract_option_id">>) {
return request<{ exclusions: TeamspaceProjectExclusion[] }>("/api/admin/teamspace/project-exclusions", {
method: "PUT",
body: JSON.stringify({ projects })
@@ -543,7 +651,7 @@ export function saveTeamspaceProjectExclusions(projects: string[]) {
}
export function syncZammadOrganizations(payload: { baseUrl?: string; apiKey?: string }) {
return request<{ synced: number; skipped: number; removed: number; unlinkedTickets: number; unlinkedSessions: number }>("/api/admin/zammad/organizations/sync", {
return request<{ synced: number; attributeOptions: number; skipped: number; removed: number; unlinkedTickets: number; unlinkedSessions: number }>("/api/admin/zammad/organizations/sync", {
method: "POST",
body: JSON.stringify(payload)
});
@@ -712,6 +820,9 @@ export function createRecurringBilling(payload: {
organizationId: string;
activity: string;
workType: WorkType;
teamspaceProject?: string | null;
hourBudgetMinutes?: number | null;
manualConsumptionMinutes?: number | null;
recurrenceType: "weekly" | "every_n_weeks";
intervalValue: number;
validFrom: string;
@@ -737,6 +848,9 @@ export function updateRecurringBilling(
organizationId: string;
activity: string;
workType: WorkType;
teamspaceProject?: string | null;
hourBudgetMinutes?: number | null;
manualConsumptionMinutes?: number | null;
recurrenceType: "weekly" | "every_n_weeks";
intervalValue: number;
validFrom: string;
@@ -1008,6 +1122,13 @@ export function updateTicket(
});
}
export function updateTicketAtossSupport(ticketId: string, atossSupport: boolean) {
return request<{ ticket: { atoss_support: boolean } }>(`/api/tickets/${ticketId}/atoss-support`, {
method: "PATCH",
body: JSON.stringify({ atossSupport })
});
}
export function updateSessionDetails(
sessionId: string,
payload: {
+91 -85
View File
@@ -1,5 +1,5 @@
import { CalendarDays, CheckCircle2, Circle, GripVertical, Pencil, Plus, Save, Trash2, X } from "lucide-react";
import { FormEvent, useState } from "react";
import { type FormEvent, type ReactNode, useState } from "react";
import { toast } from "sonner";
import { Badge } from "@/components/ui/badge";
@@ -40,9 +40,11 @@ type TodoPanelProps = {
onNavigate?: (to: string) => void;
reorderEnabled?: boolean;
onReorder?: (draggedTodoId: string, targetTodoId: string) => void;
toolbar?: ReactNode;
compact?: boolean;
};
export function TodoPanel({ ticketId, todos, onChanged, showCreate = true, showTicketMeta = false, onNavigate, reorderEnabled = false, onReorder }: TodoPanelProps) {
export function TodoPanel({ ticketId, todos, onChanged, showCreate = true, showTicketMeta = false, onNavigate, reorderEnabled = false, onReorder, toolbar, compact = false }: TodoPanelProps) {
const [title, setTitle] = useState("");
const [description, setDescription] = useState("");
const [priority, setPriority] = useState<TodoPriority>("medium");
@@ -156,97 +158,100 @@ export function TodoPanel({ ticketId, todos, onChanged, showCreate = true, showT
}
return (
<div className="space-y-3">
{showCreate && ticketId ? (
<div className="flex justify-end">
<Dialog open={createOpen} onOpenChange={setCreateOpen}>
<DialogTrigger asChild>
<Button type="button" size="sm">
<Plus className="size-4" />
ToDo anlegen
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>ToDo anlegen</DialogTitle>
<DialogDescription>Neue Aufgabe zu diesem Ticket oder Projekt erfassen.</DialogDescription>
</DialogHeader>
<form className="space-y-4" onSubmit={submitTodo}>
<div className="space-y-2">
<label className="text-sm font-medium" htmlFor={`todo-title-${ticketId}`}>
Aufgabe
</label>
<Input id={`todo-title-${ticketId}`} value={title} onChange={(event) => setTitle(event.currentTarget.value)} autoFocus required />
</div>
<div className="space-y-2">
<label className="text-sm font-medium" htmlFor={`todo-priority-${ticketId}`}>
Priorität
</label>
<select
id={`todo-priority-${ticketId}`}
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={priority}
onChange={(event) => setPriority(event.currentTarget.value as TodoPriority)}
>
{Object.entries(priorityLabels).map(([value, label]) => (
<option key={value} value={value}>
{label}
</option>
))}
</select>
</div>
<div className="grid gap-3 sm:grid-cols-2">
<div className={compact ? "space-y-2" : "space-y-3"}>
{toolbar || (showCreate && ticketId) ? (
<div className={cn("flex flex-wrap items-center gap-2", toolbar ? "justify-start" : "justify-end")}>
<div className="min-w-0">{toolbar}</div>
{showCreate && ticketId ? (
<Dialog open={createOpen} onOpenChange={setCreateOpen}>
<DialogTrigger asChild>
<Button type="button" size="sm">
<Plus className="size-4" />
ToDo anlegen
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>ToDo anlegen</DialogTitle>
<DialogDescription>Neue Aufgabe zu diesem Ticket oder Projekt erfassen.</DialogDescription>
</DialogHeader>
<form className="space-y-4" onSubmit={submitTodo}>
<div className="space-y-2">
<label className="text-sm font-medium" htmlFor={`todo-starts-on-${ticketId}`}>
Umsetzung von
<label className="text-sm font-medium" htmlFor={`todo-title-${ticketId}`}>
Aufgabe
</label>
<div className="flex gap-2">
<Input id={`todo-starts-on-${ticketId}`} type="date" value={startsOn} onChange={(event) => setStartsOn(event.currentTarget.value)} />
{startsOn ? (
<Button type="button" size="icon" variant="ghost" className="shrink-0" onClick={() => setStartsOn("")} aria-label="Von-Datum entfernen">
<X className="size-4" />
</Button>
) : null}
<Input id={`todo-title-${ticketId}`} value={title} onChange={(event) => setTitle(event.currentTarget.value)} autoFocus required />
</div>
<div className="space-y-2">
<label className="text-sm font-medium" htmlFor={`todo-priority-${ticketId}`}>
Priorität
</label>
<select
id={`todo-priority-${ticketId}`}
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={priority}
onChange={(event) => setPriority(event.currentTarget.value as TodoPriority)}
>
{Object.entries(priorityLabels).map(([value, label]) => (
<option key={value} value={value}>
{label}
</option>
))}
</select>
</div>
<div className="grid gap-3 sm:grid-cols-2">
<div className="space-y-2">
<label className="text-sm font-medium" htmlFor={`todo-starts-on-${ticketId}`}>
Umsetzung von
</label>
<div className="flex gap-2">
<Input id={`todo-starts-on-${ticketId}`} type="date" value={startsOn} onChange={(event) => setStartsOn(event.currentTarget.value)} />
{startsOn ? (
<Button type="button" size="icon" variant="ghost" className="shrink-0" onClick={() => setStartsOn("")} aria-label="Von-Datum entfernen">
<X className="size-4" />
</Button>
) : null}
</div>
</div>
<div className="space-y-2">
<label className="text-sm font-medium" htmlFor={`todo-ends-on-${ticketId}`}>
Umsetzung bis
</label>
<div className="flex gap-2">
<Input id={`todo-ends-on-${ticketId}`} type="date" value={endsOn} onChange={(event) => setEndsOn(event.currentTarget.value)} />
{endsOn ? (
<Button type="button" size="icon" variant="ghost" className="shrink-0" onClick={() => setEndsOn("")} aria-label="Bis-Datum entfernen">
<X className="size-4" />
</Button>
) : null}
</div>
</div>
</div>
<div className="space-y-2">
<label className="text-sm font-medium" htmlFor={`todo-ends-on-${ticketId}`}>
Umsetzung bis
<label className="text-sm font-medium" htmlFor={`todo-description-${ticketId}`}>
Details
</label>
<div className="flex gap-2">
<Input id={`todo-ends-on-${ticketId}`} type="date" value={endsOn} onChange={(event) => setEndsOn(event.currentTarget.value)} />
{endsOn ? (
<Button type="button" size="icon" variant="ghost" className="shrink-0" onClick={() => setEndsOn("")} aria-label="Bis-Datum entfernen">
<X className="size-4" />
</Button>
) : null}
</div>
<Textarea
id={`todo-description-${ticketId}`}
className="min-h-24"
value={description}
onChange={(event) => setDescription(event.currentTarget.value)}
/>
</div>
</div>
<div className="space-y-2">
<label className="text-sm font-medium" htmlFor={`todo-description-${ticketId}`}>
Details
</label>
<Textarea
id={`todo-description-${ticketId}`}
className="min-h-24"
value={description}
onChange={(event) => setDescription(event.currentTarget.value)}
/>
</div>
<div className="flex justify-end">
<Button type="submit" disabled={saving}>
<Plus className="size-4" />
{saving ? "Speichert..." : "Anlegen"}
</Button>
</div>
</form>
</DialogContent>
</Dialog>
<div className="flex justify-end">
<Button type="submit" disabled={saving}>
<Plus className="size-4" />
{saving ? "Speichert..." : "Anlegen"}
</Button>
</div>
</form>
</DialogContent>
</Dialog>
) : null}
</div>
) : null}
<div className="space-y-2">
<div className={compact ? "space-y-1.5" : "space-y-2"}>
{todos.map((todo) => {
const isEditing = editingId === todo.id;
const periodLabel = formatTodoPeriod(todo);
@@ -256,7 +261,8 @@ export function TodoPanel({ ticketId, todos, onChanged, showCreate = true, showT
<div
key={todo.id}
className={cn(
"rounded-md border bg-card/50 p-3 transition-colors",
"rounded-md border bg-card/50 transition-colors",
compact ? "p-2" : "p-3",
overdue && "border-red-500/50 bg-red-500/10",
todo.status === "done" && "opacity-70",
reorderEnabled && draggingId === todo.id && "border-primary/50 bg-primary/5"
@@ -393,7 +399,7 @@ export function TodoPanel({ ticketId, todos, onChanged, showCreate = true, showT
})}
</div>
{todos.length === 0 ? <p className="rounded-md border bg-muted/20 p-4 text-center text-sm text-muted-foreground">Keine ToDos vorhanden.</p> : null}
{todos.length === 0 ? <p className={cn("rounded-md border bg-muted/20 text-center text-sm text-muted-foreground", compact ? "p-3" : "p-4")}>Keine ToDos vorhanden.</p> : null}
</div>
);
}
@@ -0,0 +1,16 @@
import { Icon, type IconNode, type LucideProps } from "lucide-react";
const listClockIconNode: IconNode = [
["path", { d: "M3 6h.01", key: "1es8zl" }],
["path", { d: "M3 12h.01", key: "1v72e3" }],
["path", { d: "M3 18h.01", key: "1h3o6d" }],
["path", { d: "M7 6h10", key: "1b9f9n" }],
["path", { d: "M7 12h5", key: "1f8yah" }],
["path", { d: "M7 18h3", key: "1ul03r" }],
["circle", { cx: "17", cy: "17", r: "4", key: "y9qv7f" }],
["path", { d: "M17 15v2l1.5 1.5", key: "d4d9bm" }]
];
export function ListClockIcon(props: LucideProps) {
return <Icon iconNode={listClockIconNode} {...props} />;
}
+16 -2
View File
@@ -158,12 +158,16 @@ export const helpSections: HelpSection[] = [
quickItems: [
"Ticketnummer, Organisation und Art sind bearbeitbar.",
"Organisationen kommen aus der lokalen Zammad-Synchronisation.",
"Atoss Support ist ein bewusster Fallback am gesamten Support-Ticket.",
"Die Art wiederkehrender Sessions kommt aus der Regel."
],
details: [
"Ticketnummern können korrigiert werden, falls beim Starten oder Nachtragen ein falscher Bezug gewählt wurde.",
"Die Organisation wird immer über die Suchliste gewählt. Dadurch sind spätere Auswertungen pro Organisation zuverlässig.",
"Änderungen an Organisation und Art werden auf vorhandene manuelle Sessions dieses Tickets übernommen.",
"Zammad-Verwaltung und automatische Teamspace-Zuordnungen werden kompakt als Badges in den Ticketdaten angezeigt.",
"Die automatische TS-Zuordnung prüft zuerst ein am Ticket hinterlegtes Projekt und danach die Zuordnungsregeln aus dem Adminbereich. Der Haken Atoss Support greift nur, wenn beides fehlt.",
"Die Auswahl Atoss Support wird nur bei Support-Tickets ohne Projekt- oder Regelzuordnung angezeigt. Ist sie aktiviert, werden alle Sessions des Tickets anhand ihres Datums dem dafür im Adminbereich gültigen Atoss-Support-Projekt zugewiesen. Ohne Haken bleibt das Ticket bewusst unzugeordnet und muss manuell geprüft werden.",
"Bei wiederkehrenden Sessions ist die Art nicht direkt an der Session änderbar. Sie wird ausschließlich über die fixe Abrechnungsregel gesteuert.",
"Wenn ein Ticket bereits durch einen anderen Benutzer bekannt ist, können dessen Ticketdaten beim Beenden eines Timers übernommen werden. Die Session selbst bleibt trotzdem deinem Benutzer zugeordnet."
]
@@ -341,7 +345,9 @@ export const helpSections: HelpSection[] = [
description: "Der Monatsabschluss ist die Kontrollseite für offene Bewertungen und Teamspace-Punkte vor dem Abschluss.",
quickItems: [
"Bewertungen sind Pflicht für den Abschluss.",
"Tage ohne Teamspace-Wert können zur Kenntnis genommen werden.",
"Alle Prüfpunkte werden pro Ticket in einem Eintrag zusammengeführt.",
"Tage ohne Teamspace-Wert können innerhalb des Tickets zur Kenntnis genommen werden.",
"Support und ausgenommene Consulting-Projekte werden über Ticket#XXXXXX pro Tag mit der TS-API abgeglichen.",
"Der Monat kann wieder geöffnet werden."
],
details: [
@@ -351,7 +357,11 @@ export const helpSections: HelpSection[] = [
"Zur Kenntnis genommene Einträge können über die Checkbox Zur Kenntnis anzeigen wieder eingeblendet werden.",
"Die Kenntnisnahme kann pro Eintrag über Zurücknehmen rückgängig gemacht werden.",
"Die Einstellung, ob zur Kenntnis genommene Einträge angezeigt werden, bleibt im Browser gespeichert.",
"Offene Bewertungen werden nach Ticket gruppiert. Ein Klick auf Ticket öffnen führt zur Bewertung im Ticket.",
"Die Prüfliste führt offene Bewertungen, fehlende Teamspace-Tageswerte sowie Monats- und Tagesabweichungen nach Ticket zusammen. Dadurch erscheint jedes Ticket nur einmal.",
"Die Ticketzeile zeigt die Prüfarten als farbige Badges in festen Spalten. Die betroffenen Tage mit TT-Teamspace-Wert, TS-API-Wert und Differenz stehen direkt im Ticketdetail. Ein Klick auf Ticket öffnen führt dorthin.",
"Ein hinterlegter TT-Teamspace-Wert ist nur gültig, wenn es an diesem Ticket-Tag mindestens eine als Abgerechnet bewertete Session gibt. Andernfalls zeigt die Prüfliste einen Fehler und nennt zusätzlich vorhandene abrechenbare, nicht abrechenbare oder unbewertete Sessions.",
"Der ticketgenaue TS-Abgleich erkennt Teamspace-Unterpositionen, deren Bezeichnung mit Ticket#XXXXXX beginnt. Er vergleicht deren API-Tageswerte mit den in TicketTracker gepflegten Teamspace-Zeiten an den Session-Tagen desselben Tickets.",
"Der ticketgenaue Abgleich gilt für Support-Tickets und für Consulting-Tickets auf einem in der Administration ausgenommenen Teamspace-Projekt. Angezeigt werden sowohl fehlende Zeit in Teamspace als auch fehlende Zeit in TicketTracker.",
"Es gibt keinen Tagesabschluss. Tagesansichten dienen nur der Kontrolle."
]
},
@@ -561,6 +571,10 @@ export const helpSections: HelpSection[] = [
"Admins können bestehende Benutzer bearbeiten, Passwörter setzen und Benutzer deaktivieren.",
"Der feste Admin-Zugang kann nicht deaktiviert oder zur normalen User-Rolle herabgestuft werden.",
"Der Data-Sync speichert Zammad-Zugangsdaten und synchronisiert Organisationen in die lokale Datenbank.",
"Beim Organisationsabgleich werden zuerst die Zammad-Auswahloptionen für Zugeordnet und Hotline synchronisiert. In den globalen Teamspace-Projektausnahmen können diese Optionen anschließend pro Projekt ausgewählt werden.",
"Automatische TS-Zuordnungsregeln kombinieren optional Zugeordnet und Hotline mit Support und/oder Consulting sowie einem Zielprojekt. Sind beide Attribute gesetzt, müssen beide passen; bei mehreren Treffern gewinnt die kleinste Priorität.",
"Beim Zammad-Sync gilt die Reihenfolge: Ein konkretes TS-Projekt hat Vorrang. Genau ein Projekt wird allen Sessions automatisch zugeordnet; bei mehreren Projekten erfolgt die Auswahl beim Speichern einer Session manuell. Nur ohne konkretes Projekt wird eine passende Regel angewendet und allen Sessions des Tickets zugeordnet.",
"Die im Sync ermittelte Regelzuordnung wird im Ticketdetail angezeigt. Greift keine Regel, bleibt das Ticket ohne automatische TS-Zuordnung.",
"Der Zammad-Ticket-Sync aktualisiert Titel, Organisation, Typ, Zammad-Status, Zammad-Priorität sowie Teamspace-Projekte und Budgets je nach gewählter Option.",
"Zammad-Projektwerte können mehrere Teamspace-IDs enthalten. TicketTracker erkennt zusammenhängende IDs und legt entsprechend mehrere Teamspace-Einträge an.",
"Beim Budget-Sync bleiben geschützte TicketTracker-Budgets unverändert. Die Option Budget-Schutz im Sync ignorieren überschreibt auch geschützte Budgets mit Zammad-Werten.",
+31 -1
View File
@@ -1,4 +1,4 @@
import type { BillingStatus } from "./types";
import type { BillingStatus, WorkSegment } from "./types";
export type TimerPhase = "running" | "paused";
@@ -16,6 +16,7 @@ export type TimerEntry = {
startedAt: number;
pausedTotalMs: number;
pausedAt: number | null;
workSegments?: WorkSegment[];
phase: TimerPhase;
};
@@ -58,6 +59,34 @@ export function resumeEntry(timer: TimerEntry, now = Date.now()): TimerEntry {
};
}
function normalizeWorkSegments(value: unknown): WorkSegment[] {
if (!Array.isArray(value)) {
return [];
}
return value
.map((segment) => {
if (!segment || typeof segment !== "object") {
return null;
}
const raw = segment as Partial<WorkSegment>;
if (typeof raw.startedAt !== "string" || Number.isNaN(new Date(raw.startedAt).getTime())) {
return null;
}
if (raw.endedAt !== null && raw.endedAt !== undefined && (typeof raw.endedAt !== "string" || Number.isNaN(new Date(raw.endedAt).getTime()))) {
return null;
}
return {
startedAt: new Date(raw.startedAt).toISOString(),
endedAt: raw.endedAt ? new Date(raw.endedAt).toISOString() : null,
};
})
.filter((segment): segment is WorkSegment => Boolean(segment));
}
export function normalizeTimers(timers: TimerEntry[]) {
let runningSeen = false;
const now = Date.now();
@@ -70,6 +99,7 @@ export function normalizeTimers(timers: TimerEntry[]) {
activity: typeof timer.activity === "string" ? timer.activity : "",
zammadArticleBody: typeof timer.zammadArticleBody === "string" ? timer.zammadArticleBody : "",
billingStatus: timer.billingStatus === "billed" ? "billable" : timer.billingStatus === "billable" || timer.billingStatus === "non_billable" ? timer.billingStatus : null,
workSegments: normalizeWorkSegments(timer.workSegments),
};
if (timer.phase !== "running") {
+74 -2
View File
@@ -16,8 +16,10 @@ export type AuthUser = {
has_teamspace_credentials: boolean;
zammad_email_session_webhook_enabled: boolean;
session_billing_prompt_enabled: boolean;
session_work_segments_enabled: boolean;
teamspace_time_tracking_enabled: boolean;
zammad_session_note_default_enabled: boolean;
zammad_session_note_date_header_enabled: boolean;
zammad_time_unit_rounding_enabled: boolean;
zammad_time_unit_rounding_minutes: number;
zammad_atoss_email_sync_enabled: boolean;
@@ -73,10 +75,19 @@ export type DashboardDayTicket = {
};
export type AdminUser = AuthUser & {
teamspace_initial_actuals_synced_at: string | null;
teamspace_initial_actuals_synced_by: string | null;
teamspace_initial_actuals_unlocked_at: string | null;
teamspace_initial_actuals_unlocked_by: string | null;
created_at: string;
updated_at: string;
};
export type WorkSegment = {
startedAt: string;
endedAt: string | null;
};
export type ZammadSessionArticleQueueItem = {
id: string;
session_id: string;
@@ -98,7 +109,15 @@ export type Organization = {
id: string;
zammad_id: string;
name: string;
customer_owner_option_id?: string | null;
customer_owner?: string | null;
customer_owner_display_name?: string | null;
hotline_contract_option_id?: string | null;
hotline_contract?: string | null;
hotline_contract_display_name?: string | null;
synced_at: string;
created_at?: string;
updated_at?: string;
};
export type RecurringBillingSlot = {
@@ -119,6 +138,9 @@ export type RecurringBilling = {
organization_name: string;
activity: string;
work_type: WorkType;
teamspace_project: string | null;
hour_budget_minutes: number | null;
manual_consumption_minutes: number;
recurrence_type: "weekly" | "every_n_weeks";
interval_value: number;
valid_from: string;
@@ -138,6 +160,12 @@ export type TicketMeta = {
organization_name: string | null;
customer_name: string | null;
work_type: WorkType | null;
atoss_support?: boolean;
can_manage_atoss_support?: boolean;
teamspace_assignment_rule_id?: string | null;
teamspace_assignment_rule_name?: string | null;
teamspace_assignment_project?: string | null;
teamspace_assignment_synced_at?: string | null;
zammad_ticket_id?: string | null;
zammad_group_id?: string | null;
zammad_priority_id?: string | null;
@@ -333,7 +361,11 @@ export type TeamspaceMonthActual = {
export type TeamspaceMonthActualPosition = {
apiProjectId: string;
projectId: string | null;
mainProjectId?: string | null;
label: string;
description?: string | null;
ticketNumber?: string | null;
customerName?: string | null;
isSubentry: boolean;
billingType?: string | null;
minutes: number;
@@ -398,6 +430,27 @@ export type TicketDayBilling = {
billed_minutes: number;
};
export type TicketTeamspaceDay = {
day: string;
tracked_minutes: number;
crm_billed_minutes: number;
teamspace_entry_count: number;
delta_minutes: number;
session_count: number;
billed_session_count: number;
billable_session_count: number;
non_billable_session_count: number;
unrated_session_count: number;
has_tt_value: boolean;
missing_tt_value: boolean;
tt_value_without_billed_session: boolean;
acknowledged: boolean;
acknowledged_at: string | null;
comparison_enabled: boolean;
teamspace_excluded: boolean;
work_type: WorkType | null;
};
export type TicketAvailableMonth = {
month: string;
session_count: number;
@@ -417,6 +470,7 @@ export type SessionEntry = {
ended_at: string;
duration_seconds: number;
rounded_minutes: number;
work_segments: WorkSegment[];
billing_status: BillingStatus;
billing_updated_at: string | null;
created_at: string;
@@ -448,6 +502,12 @@ export type TicketPeriod = {
organization_name: string | null;
customer_name: string | null;
work_type: WorkType | null;
atoss_support?: boolean;
can_manage_atoss_support?: boolean;
teamspace_assignment_rule_id?: string | null;
teamspace_assignment_rule_name?: string | null;
teamspace_assignment_project?: string | null;
teamspace_assignment_synced_at?: string | null;
zammad_ticket_id?: string | null;
zammad_group_id?: string | null;
zammad_priority_id?: string | null;
@@ -466,6 +526,7 @@ export type TicketPeriod = {
};
sessions: SessionEntry[];
dayBillings: TicketDayBilling[];
ticketTeamspaceDays?: TicketTeamspaceDay[];
project?: ProjectSummary | null;
todos?: TicketTodo[];
teamspaceMonthActuals?: TeamspaceMonthActual[];
@@ -562,15 +623,25 @@ export type StatisticsCrmDay = {
};
export type StatisticsTeamspaceDay = {
ticket_id: string;
ticket_number: string;
ticket_id: string | null;
ticket_number: string | null;
organization_name: string;
day: string;
tracked_minutes: number;
crm_billed_minutes: number;
teamspace_entry_count: number;
delta_minutes: number;
session_count: number;
project_teamspace_project: string | null;
api_project_id?: string | null;
teamspace_project_id?: string | null;
teamspace_excluded?: boolean;
work_type?: WorkType | null;
tt_value_without_billed_session?: boolean;
billed_session_count?: number;
billable_session_count?: number;
non_billable_session_count?: number;
unrated_session_count?: number;
};
export type StatisticsTeamspaceMonthMismatch = {
@@ -651,6 +722,7 @@ export type StatisticsOverview = {
acknowledgedMissingCrmDays: StatisticsCrmDay[];
crmMismatches: StatisticsCrmDay[];
teamspaceMonthMismatches: StatisticsTeamspaceMonthMismatch[];
ticketTeamspaceMismatches: StatisticsTeamspaceDay[];
};
teamspaceDays: StatisticsTeamspaceDay[];
};
@@ -0,0 +1,330 @@
import { FormEvent, useEffect, useMemo, useRef, useState } from "react";
import { Building2, DatabaseZap, RefreshCcw, Save, Search } from "lucide-react";
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 { Input } from "@/components/ui/input";
import { NativeSelect, NativeSelectOption } from "@/components/ui/native-select";
import { Pagination, PaginationContent, PaginationItem, PaginationNext, PaginationPrevious } from "@/components/ui/pagination";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { HelpLink } from "@/components/HelpLink";
import { getAdminOrganizations, getZammadSettings, saveZammadSettings, syncZammadOrganizations } from "../api";
import { formatDateTime } from "../format";
import type { Organization } from "../types";
type PageSize = 10 | 20 | 50 | 100 | "all";
const pageSizeOptions: PageSize[] = [10, 20, 50, 100, "all"];
function pageSizeFromValue(value: string): PageSize {
if (value === "all") {
return "all";
}
const parsed = Number(value);
return pageSizeOptions.includes(parsed as PageSize) ? parsed as PageSize : 20;
}
function emptyText(value: string | null | undefined) {
return value?.trim() || "leer";
}
export function AdminOrganizationsPage() {
const [organizations, setOrganizations] = useState<Organization[]>([]);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState<PageSize>(20);
const [search, setSearch] = useState("");
const [missingAssignment, setMissingAssignment] = useState(false);
const [zammadBaseUrl, setZammadBaseUrl] = useState("");
const [zammadApiKey, setZammadApiKey] = useState("");
const [hasZammadApiKey, setHasZammadApiKey] = useState(false);
const [loading, setLoading] = useState(false);
const [savingZammadSettings, setSavingZammadSettings] = useState(false);
const [syncingOrganizations, setSyncingOrganizations] = useState(false);
const loadRequestId = useRef(0);
const pageCount = pageSize === "all" ? 1 : Math.max(1, Math.ceil(total / pageSize));
const safePage = Math.min(page, pageCount);
const rangeStart = total === 0 ? 0 : pageSize === "all" ? 1 : (safePage - 1) * pageSize + 1;
const rangeEnd = pageSize === "all" ? total : Math.min(safePage * pageSize, total);
const activeFilterCount = useMemo(
() => [search.trim(), missingAssignment].filter(Boolean).length,
[missingAssignment, search]
);
async function loadSettings() {
const result = await getZammadSettings();
setZammadBaseUrl(result.settings.baseUrl);
setHasZammadApiKey(result.settings.hasApiKey);
}
async function loadOrganizations() {
const requestId = loadRequestId.current + 1;
loadRequestId.current = requestId;
setLoading(true);
try {
const result = await getAdminOrganizations({
search,
page: safePage,
pageSize,
missingAssignment
});
if (requestId === loadRequestId.current) {
setOrganizations(result.organizations);
setTotal(result.total);
}
} catch (error) {
if (requestId === loadRequestId.current) {
toast.error("Organisationen konnten nicht geladen werden", {
description: error instanceof Error ? error.message : "Unbekannter Fehler"
});
}
} finally {
if (requestId === loadRequestId.current) {
setLoading(false);
}
}
}
useEffect(() => {
void loadSettings();
}, []);
useEffect(() => {
setPage(1);
}, [search, pageSize, missingAssignment]);
useEffect(() => {
void loadOrganizations();
}, [safePage, pageSize, search, missingAssignment]);
async function saveZammadAccess() {
setSavingZammadSettings(true);
try {
const result = await saveZammadSettings({
baseUrl: zammadBaseUrl,
apiKey: zammadApiKey.trim() || undefined
});
setZammadBaseUrl(result.settings.baseUrl);
setHasZammadApiKey(result.settings.hasApiKey);
setZammadApiKey("");
toast.success("Zammad-Zugang gespeichert", {
description: result.settings.hasApiKey ? "URL und API-Key sind hinterlegt." : "URL wurde gespeichert."
});
} catch (error) {
toast.error("Zammad-Zugang konnte nicht gespeichert werden", {
description: error instanceof Error ? error.message : "Unbekannter Fehler"
});
} finally {
setSavingZammadSettings(false);
}
}
async function syncOrganizations(event: FormEvent) {
event.preventDefault();
setSyncingOrganizations(true);
try {
const result = await syncZammadOrganizations({
baseUrl: zammadBaseUrl,
apiKey: zammadApiKey.trim() || undefined
});
toast.success("Organisationen synchronisiert", {
description: `${result.attributeOptions} Attributoptionen und ${result.synced} Organisationen gespeichert, ${result.removed} Organisationen entfernt, ${result.unlinkedTickets + result.unlinkedSessions} Verknüpfung(en) gelöst.`
});
setHasZammadApiKey(true);
setZammadApiKey("");
await loadOrganizations();
} catch (error) {
toast.error("Zammad-Sync fehlgeschlagen", {
description: error instanceof Error ? error.message : "Unbekannter Fehler"
});
} finally {
setSyncingOrganizations(false);
}
}
return (
<div className="space-y-4">
<div className="flex flex-col gap-3 lg:flex-row lg:items-end lg:justify-between">
<div>
<h2 className="text-xl font-semibold tracking-normal sm:text-2xl">Organisationsabgleich</h2>
<p className="text-sm text-muted-foreground">Zammad-Organisationen synchronisieren, prüfen und nach fehlenden Zuordnungen filtern.</p>
</div>
<Badge variant={activeFilterCount > 0 ? "secondary" : "outline"}>{activeFilterCount} Filter aktiv</Badge>
</div>
<Card>
<CardHeader className="gap-3 p-4 sm:flex-row sm:items-start sm:justify-between sm:space-y-0">
<div>
<CardTitle>Zammad-Sync</CardTitle>
<CardDescription>Zugang speichern und Organisationen aus Zammad in die lokale Datenbank übernehmen.</CardDescription>
</div>
<HelpLink anchor="organisationen" label="Hilfe zum Organisationsabgleich" />
</CardHeader>
<CardContent className="px-4 pb-4">
<form className="grid gap-3 lg:grid-cols-[minmax(220px,1fr)_minmax(220px,1fr)_auto_auto] lg:items-end" onSubmit={syncOrganizations}>
<div className="space-y-2">
<label className="text-sm font-medium" htmlFor="zammad-base-url">Zammad URL</label>
<Input
id="zammad-base-url"
placeholder="https://zammad.example.de"
value={zammadBaseUrl}
onChange={(event) => setZammadBaseUrl(event.currentTarget.value)}
required
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium" htmlFor="zammad-api-key">API-Key</label>
<Input
id="zammad-api-key"
type="password"
placeholder={hasZammadApiKey ? "••••••••••••••••" : ""}
value={zammadApiKey}
onChange={(event) => setZammadApiKey(event.currentTarget.value)}
/>
</div>
<Button type="button" variant="secondary" disabled={savingZammadSettings} onClick={() => void saveZammadAccess()}>
<Save className="size-4" />
{savingZammadSettings ? "Speichert..." : "Zugang speichern"}
</Button>
<Button type="submit" disabled={syncingOrganizations}>
<DatabaseZap className="size-4" />
{syncingOrganizations ? "Synchronisiert..." : "Synchronisieren"}
</Button>
</form>
</CardContent>
</Card>
<Card>
<CardHeader className="gap-3 p-4 xl:flex-row xl:items-end xl:justify-between xl:space-y-0">
<div>
<CardTitle>Organisationen</CardTitle>
<CardDescription>{loading ? "Lädt..." : `${rangeStart}-${rangeEnd} von ${total} Organisation(en)`}</CardDescription>
</div>
<div className="grid gap-2 md:grid-cols-[minmax(220px,1fr)_auto_auto] md:items-end">
<div className="space-y-1">
<label className="text-xs font-medium text-muted-foreground" htmlFor="organization-search">Suche</label>
<div className="relative">
<Search className="pointer-events-none absolute left-2.5 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
<Input
id="organization-search"
className="pl-8"
placeholder="Name, ID, Zuordnung, Hotline..."
value={search}
onChange={(event) => setSearch(event.currentTarget.value)}
/>
</div>
</div>
<label className="flex h-9 items-center gap-2 rounded-md border bg-background px-3 text-sm">
<Checkbox checked={missingAssignment} onCheckedChange={(checked) => setMissingAssignment(checked === true)} />
Zuordnung fehlt
</label>
<div className="space-y-1">
<label className="text-xs font-medium text-muted-foreground" htmlFor="organization-page-size">Anzeigen</label>
<NativeSelect
id="organization-page-size"
className="w-28"
value={String(pageSize)}
onChange={(event) => setPageSize(pageSizeFromValue(event.currentTarget.value))}
>
{pageSizeOptions.map((option) => (
<NativeSelectOption key={String(option)} value={String(option)}>
{option === "all" ? "Alle" : option}
</NativeSelectOption>
))}
</NativeSelect>
</div>
</div>
</CardHeader>
<CardContent className="space-y-3 px-4 pb-4">
<div className="overflow-x-auto rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead className="min-w-20">Zammad-ID</TableHead>
<TableHead className="min-w-[260px]">Organisation</TableHead>
<TableHead className="min-w-[180px]">Zugeordnet</TableHead>
<TableHead className="min-w-[160px]">Hotline</TableHead>
<TableHead className="min-w-[150px]">Synchronisiert</TableHead>
<TableHead className="min-w-[150px]">Geändert</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{organizations.map((organization) => (
<TableRow key={organization.zammad_id}>
<TableCell className="font-mono text-xs">{organization.zammad_id}</TableCell>
<TableCell>
<div className="flex min-w-0 items-center gap-2">
<Building2 className="size-4 shrink-0 text-muted-foreground" />
<span className="truncate font-medium">{organization.name}</span>
</div>
</TableCell>
<TableCell className={organization.customer_owner ? "" : "text-muted-foreground"}>{emptyText(organization.customer_owner)}</TableCell>
<TableCell className={organization.hotline_contract ? "" : "text-muted-foreground"}>{emptyText(organization.hotline_contract)}</TableCell>
<TableCell className="text-xs text-muted-foreground">{formatDateTime(organization.synced_at)}</TableCell>
<TableCell className="text-xs text-muted-foreground">{organization.updated_at ? formatDateTime(organization.updated_at) : "-"}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
{organizations.length === 0 ? (
<div className="grid place-items-center gap-2 px-4 py-10 text-center text-sm text-muted-foreground">
<Building2 className="size-8" />
Keine Organisationen für die aktuellen Filter.
</div>
) : null}
</div>
{pageSize !== "all" ? (
<Pagination className="justify-end">
<PaginationContent>
<PaginationItem>
<PaginationPrevious
href="#"
text="Zurück"
aria-disabled={safePage <= 1}
className={safePage <= 1 ? "pointer-events-none opacity-50" : undefined}
onClick={(event) => {
event.preventDefault();
setPage((current) => Math.max(1, current - 1));
}}
/>
</PaginationItem>
<PaginationItem>
<span className="flex h-9 items-center px-3 text-sm text-muted-foreground">
Seite {safePage} von {pageCount}
</span>
</PaginationItem>
<PaginationItem>
<PaginationNext
href="#"
text="Weiter"
aria-disabled={safePage >= pageCount}
className={safePage >= pageCount ? "pointer-events-none opacity-50" : undefined}
onClick={(event) => {
event.preventDefault();
setPage((current) => Math.min(pageCount, current + 1));
}}
/>
</PaginationItem>
</PaginationContent>
</Pagination>
) : null}
<Button type="button" variant="secondary" size="sm" disabled={loading} onClick={() => void loadOrganizations()}>
<RefreshCcw className="size-4" />
Aktualisieren
</Button>
</CardContent>
</Card>
</div>
);
}
+398 -92
View File
@@ -1,5 +1,5 @@
import { ChangeEvent, FormEvent, useEffect, useState } from "react";
import { CheckCircle2, DatabaseBackup, DatabaseZap, Plus, Save, Shield, Trash2, Unplug, Upload, UserPlus } from "lucide-react";
import { CheckCircle2, DatabaseBackup, Plus, RotateCcw, Save, Shield, TimerReset, Trash2, Unplug, Upload, UserPlus } from "lucide-react";
import { toast } from "sonner";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
@@ -14,17 +14,21 @@ import {
createAdminUser,
downloadDatabaseExport,
getAdminUsers,
getOrganizationAttributeOptions,
getTeamspaceAssignmentRules,
getTeamspaceSettings,
getTeamspaceProjectExclusions,
getZammadSettings,
restoreDatabaseBackup,
saveZammadSettings,
saveTeamspaceSettings,
saveTeamspaceAssignmentRules,
saveTeamspaceProjectExclusions,
syncZammadOrganizations,
syncAdminUserTeamspaceInitialActuals,
unlockAdminUserTeamspaceInitialActuals,
updateAdminUser,
validateDatabaseBackup,
type TeamspaceProjectExclusion,
type TeamspaceAssignmentRule,
type OrganizationAttributeOption,
type RestoreValidationResult
} from "../api";
import type { AdminUser, AuthUser } from "../types";
@@ -56,17 +60,15 @@ export function AdminUsersPage({ currentUser }: { currentUser: AuthUser }) {
const [users, setUsers] = useState<AdminUser[]>([]);
const [forms, setForms] = useState<Record<string, UserFormState>>({});
const [newUser, setNewUser] = useState<UserFormState>(emptyForm);
const [zammadBaseUrl, setZammadBaseUrl] = useState("");
const [zammadApiKey, setZammadApiKey] = useState("");
const [hasZammadApiKey, setHasZammadApiKey] = useState(false);
const [teamspaceBaseUrl, setTeamspaceBaseUrl] = useState("");
const [teamspaceCredentialsUserId, setTeamspaceCredentialsUserId] = useState("");
const [teamspaceProjectExclusions, setTeamspaceProjectExclusions] = useState<TeamspaceProjectExclusion[]>([]);
const [organizationAttributeOptions, setOrganizationAttributeOptions] = useState<OrganizationAttributeOption[]>([]);
const [teamspaceAssignmentRules, setTeamspaceAssignmentRules] = useState<TeamspaceAssignmentRule[]>([]);
const [newTeamspaceProjectExclusion, setNewTeamspaceProjectExclusion] = useState("");
const [syncingOrganizations, setSyncingOrganizations] = useState(false);
const [savingZammadSettings, setSavingZammadSettings] = useState(false);
const [savingTeamspaceSettings, setSavingTeamspaceSettings] = useState(false);
const [savingTeamspaceProjectExclusions, setSavingTeamspaceProjectExclusions] = useState(false);
const [savingTeamspaceAssignmentRules, setSavingTeamspaceAssignmentRules] = useState(false);
const [exportingDatabase, setExportingDatabase] = useState(false);
const [restoreSql, setRestoreSql] = useState("");
const [restoreValidation, setRestoreValidation] = useState<RestoreValidationResult | null>(null);
@@ -74,25 +76,29 @@ export function AdminUsersPage({ currentUser }: { currentUser: AuthUser }) {
const [restoringDatabase, setRestoringDatabase] = useState(false);
const [loading, setLoading] = useState(false);
const [savingId, setSavingId] = useState<string | null>(null);
const [syncingInitialActualsId, setSyncingInitialActualsId] = useState<string | null>(null);
const [unlockingInitialActualsId, setUnlockingInitialActualsId] = useState<string | null>(null);
const [initialActualsFromMonthByUser, setInitialActualsFromMonthByUser] = useState<Record<string, string>>({});
const [creating, setCreating] = useState(false);
async function load(options: { preserveEdits?: boolean } = {}) {
setLoading(true);
try {
const [usersResult, zammadSettingsResult, teamspaceSettingsResult, teamspaceProjectExclusionsResult] = await Promise.all([
const [usersResult, teamspaceSettingsResult, teamspaceProjectExclusionsResult, organizationAttributeOptionsResult, teamspaceAssignmentRulesResult] = await Promise.all([
getAdminUsers(),
getZammadSettings(),
getTeamspaceSettings(),
getTeamspaceProjectExclusions()
getTeamspaceProjectExclusions(),
getOrganizationAttributeOptions(),
getTeamspaceAssignmentRules()
]);
setUsers(usersResult.users);
if (!options.preserveEdits) {
setZammadBaseUrl(zammadSettingsResult.settings.baseUrl);
setHasZammadApiKey(zammadSettingsResult.settings.hasApiKey);
setTeamspaceBaseUrl(teamspaceSettingsResult.settings.baseUrl);
setTeamspaceCredentialsUserId(teamspaceSettingsResult.settings.credentialsUserId ?? "");
setTeamspaceProjectExclusions(teamspaceProjectExclusionsResult.exclusions);
setOrganizationAttributeOptions(organizationAttributeOptionsResult.options);
setTeamspaceAssignmentRules(teamspaceAssignmentRulesResult.rules);
}
setForms((current) =>
@@ -185,49 +191,64 @@ export function AdminUsersPage({ currentUser }: { currentUser: AuthUser }) {
}
}
async function syncOrganizations(event: FormEvent) {
event.preventDefault();
setSyncingOrganizations(true);
function initialActualsLocked(user: AdminUser) {
return Boolean(
user.teamspace_initial_actuals_synced_at &&
(!user.teamspace_initial_actuals_unlocked_at || user.teamspace_initial_actuals_unlocked_at <= user.teamspace_initial_actuals_synced_at)
);
}
function initialActualsStatus(user: AdminUser) {
if (initialActualsLocked(user)) {
return `ausgeführt ${new Date(user.teamspace_initial_actuals_synced_at as string).toLocaleDateString("de-DE")}`;
}
if (user.teamspace_initial_actuals_unlocked_at) {
return "entsperrt";
}
return "bereit";
}
async function syncInitialActuals(user: AdminUser) {
if (!window.confirm(`Teamspace-Istzeiten für ${user.display_name} initial als manuelle TS-Zeiten übernehmen? Vorhandene Tageswerte bleiben unverändert.`)) {
return;
}
setSyncingInitialActualsId(user.id);
try {
const result = await syncZammadOrganizations({
baseUrl: zammadBaseUrl,
apiKey: zammadApiKey.trim() || undefined
const result = await syncAdminUserTeamspaceInitialActuals(user.id, {
fromMonth: initialActualsFromMonthByUser[user.id] || undefined
});
toast.success("Organisationen synchronisiert", {
description: `${result.synced} gespeichert, ${result.removed} entfernt, ${result.unlinkedTickets + result.unlinkedSessions} Verknüpfung(en) gelöst.`
toast.success("TS-Istzeiten übernommen", {
description: `${result.result.updatedManualEntries} TS-Eintrag(e), ${Math.round(result.result.importedManualMinutes / 60 * 100) / 100} Std., Zeitraum ${result.result.fromMonth} bis ${result.result.toMonth}.`
});
setHasZammadApiKey(true);
setZammadApiKey("");
await load();
} catch (error) {
toast.error("Zammad-Sync fehlgeschlagen", {
toast.error("TS-Istzeiten konnten nicht übernommen werden", {
description: error instanceof Error ? error.message : "Unbekannter Fehler"
});
} finally {
setSyncingOrganizations(false);
setSyncingInitialActualsId(null);
}
}
async function saveZammadAccess() {
setSavingZammadSettings(true);
async function unlockInitialActuals(user: AdminUser) {
if (!window.confirm(`Initialen Teamspace-Istzeiten-Sync für ${user.display_name} wieder freigeben?`)) {
return;
}
setUnlockingInitialActualsId(user.id);
try {
const result = await saveZammadSettings({
baseUrl: zammadBaseUrl,
apiKey: zammadApiKey.trim() || undefined
});
setZammadBaseUrl(result.settings.baseUrl);
setHasZammadApiKey(result.settings.hasApiKey);
setZammadApiKey("");
toast.success("Zammad-Zugang gespeichert", {
description: result.settings.hasApiKey ? "URL und API-Key sind hinterlegt." : "URL wurde gespeichert."
});
await unlockAdminUserTeamspaceInitialActuals(user.id);
toast.success("Initialsync entsperrt");
await load({ preserveEdits: true });
} catch (error) {
toast.error("Zammad-Zugang konnte nicht gespeichert werden", {
toast.error("Initialsync konnte nicht entsperrt werden", {
description: error instanceof Error ? error.message : "Unbekannter Fehler"
});
} finally {
setSavingZammadSettings(false);
setUnlockingInitialActualsId(null);
}
}
@@ -274,6 +295,13 @@ export function AdminUsersPage({ currentUser }: { currentUser: AuthUser }) {
teamspace_project: project,
teamspace_name: null,
customer_name: null,
teamspace_api_project_id: null,
teamspace_api_project_ids: [],
atoss_support_default: false,
atoss_support_valid_from: null,
atoss_support_valid_to: null,
customer_owner_option_id: null,
hotline_contract_option_id: null,
note: ""
}
]);
@@ -284,11 +312,26 @@ export function AdminUsersPage({ currentUser }: { currentUser: AuthUser }) {
setTeamspaceProjectExclusions((current) => current.filter((entry) => entry.teamspace_project !== project));
}
function updateTeamspaceExclusion(project: string, patch: Partial<TeamspaceProjectExclusion>) {
setTeamspaceProjectExclusions((current) =>
current.map((entry) => entry.teamspace_project === project ? { ...entry, ...patch } : entry)
);
}
async function saveTeamspaceExclusions() {
setSavingTeamspaceProjectExclusions(true);
try {
const projects = teamspaceProjectExclusions.map((entry) => entry.teamspace_project.trim()).filter(Boolean);
const projects = teamspaceProjectExclusions
.map((entry) => ({
teamspace_project: entry.teamspace_project.trim(),
atoss_support_default: entry.atoss_support_default,
atoss_support_valid_from: entry.atoss_support_valid_from || null,
atoss_support_valid_to: entry.atoss_support_valid_to || null,
customer_owner_option_id: entry.customer_owner_option_id || null,
hotline_contract_option_id: entry.hotline_contract_option_id || null
}))
.filter((entry) => entry.teamspace_project);
const result = await saveTeamspaceProjectExclusions(projects);
setTeamspaceProjectExclusions(result.exclusions);
toast.success("Teamspace-Ausnahmen gespeichert", {
@@ -303,6 +346,59 @@ export function AdminUsersPage({ currentUser }: { currentUser: AuthUser }) {
}
}
function addTeamspaceAssignmentRule() {
const highestPriority = teamspaceAssignmentRules.reduce((highest, rule) => Math.max(highest, rule.priority), 0);
const defaultTarget = teamspaceProjectExclusions[0] ?? null;
setTeamspaceAssignmentRules((current) => [
...current,
{
name: `Regel ${current.length + 1}`,
active: true,
priority: highestPriority + 10,
customer_owner_option_id: defaultTarget?.customer_owner_option_id ?? null,
hotline_contract_option_id: defaultTarget?.hotline_contract_option_id ?? null,
support_enabled: true,
consulting_enabled: false,
teamspace_project: defaultTarget?.teamspace_project ?? ""
}
]);
}
function updateTeamspaceAssignmentRule(index: number, patch: Partial<TeamspaceAssignmentRule>) {
setTeamspaceAssignmentRules((current) => current.map((rule, currentIndex) => currentIndex === index ? { ...rule, ...patch } : rule));
}
function removeTeamspaceAssignmentRule(index: number) {
setTeamspaceAssignmentRules((current) => current.filter((_, currentIndex) => currentIndex !== index));
}
function updateTeamspaceAssignmentRuleTarget(index: number, teamspaceProject: string) {
const target = teamspaceProjectExclusions.find((entry) => entry.teamspace_project === teamspaceProject);
updateTeamspaceAssignmentRule(index, {
teamspace_project: teamspaceProject,
customer_owner_option_id: target?.customer_owner_option_id ?? null,
hotline_contract_option_id: target?.hotline_contract_option_id ?? null
});
}
async function saveAssignmentRules() {
setSavingTeamspaceAssignmentRules(true);
try {
const result = await saveTeamspaceAssignmentRules(teamspaceAssignmentRules);
setTeamspaceAssignmentRules(result.rules);
toast.success("Automatische Zuordnungsregeln gespeichert", {
description: `${result.rules.length} Regel(n) gespeichert. Kleinere Prioritätswerte werden zuerst geprüft.`
});
} catch (error) {
toast.error("Zuordnungsregeln konnten nicht gespeichert werden", {
description: error instanceof Error ? error.message : "Unbekannter Fehler"
});
} finally {
setSavingTeamspaceAssignmentRules(false);
}
}
async function exportDatabase() {
setExportingDatabase(true);
@@ -408,6 +504,16 @@ export function AdminUsersPage({ currentUser }: { currentUser: AuthUser }) {
}
const regularUserCount = users.filter((user) => user.role === "user").length;
const customerOwnerOptions = organizationAttributeOptions.filter((option) => option.attribute_name === "customer_owner");
const hotlineContractOptions = organizationAttributeOptions.filter((option) => option.attribute_name === "hotline_contract");
function organizationAttributeOptionLabel(option: OrganizationAttributeOption) {
const label = option.display_name === option.option_key
? option.display_name
: `${option.display_name} (${option.option_key})`;
return option.active ? label : `${label} · nicht mehr in Zammad`;
}
return (
<div className="space-y-4">
@@ -416,56 +522,20 @@ export function AdminUsersPage({ currentUser }: { currentUser: AuthUser }) {
<p className="text-sm text-muted-foreground">Systemverwaltung für Benutzer, Zammad-Organisationen und restore-fähige Backups.</p>
</div>
<div className="grid min-w-0 gap-4 2xl:grid-cols-[minmax(0,1.2fr)_minmax(340px,0.8fr)]">
<div className="grid min-w-0 gap-4">
<Card className="min-w-0">
<CardHeader className="p-4">
<div className="flex items-start justify-between gap-3">
<div className="flex items-center gap-2">
<DatabaseZap className="size-5 text-muted-foreground" />
<CardTitle>Data-Sync</CardTitle>
<Unplug className="size-5 text-muted-foreground" />
<CardTitle>Teamspace API</CardTitle>
</div>
<HelpLink anchor="organisationen" label="Hilfe zum Zammad-Sync" />
<HelpLink anchor="admin" label="Hilfe zu Teamspace API" />
</div>
<CardDescription>Zammad-Zugang speichern und Organisationen in die lokale TicketTracker-Datenbank übernehmen.</CardDescription>
<CardDescription>Teamspace-Zugang für Adminabfragen und globale Projekt-Ausnahmen verwalten.</CardDescription>
</CardHeader>
<CardContent className="px-4 pb-4">
<form className="grid gap-3 xl:grid-cols-2" onSubmit={syncOrganizations}>
<div className="space-y-2">
<label className="text-sm font-medium" htmlFor="zammad-base-url">Zammad URL</label>
<Input
id="zammad-base-url"
placeholder="https://zammad.example.de"
value={zammadBaseUrl}
onChange={(event) => setZammadBaseUrl(event.currentTarget.value)}
required
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium" htmlFor="zammad-api-key">API-Key</label>
<Input
id="zammad-api-key"
type="password"
placeholder={hasZammadApiKey ? "••••••••••••••••" : ""}
value={zammadApiKey}
onChange={(event) => setZammadApiKey(event.currentTarget.value)}
/>
</div>
<div className="flex flex-wrap gap-2 xl:col-span-2">
<Button type="button" variant="secondary" disabled={savingZammadSettings} onClick={() => void saveZammadAccess()}>
<Save className="size-4" />
{savingZammadSettings ? "Speichert..." : "Zugang speichern"}
</Button>
<Button type="submit" disabled={syncingOrganizations}>
<DatabaseZap className="size-4" />
{syncingOrganizations ? "Synchronisiert..." : "Organisationen synchronisieren"}
</Button>
</div>
</form>
<div className="mt-4 border-t pt-4">
<div className="mb-3 flex items-center gap-2">
<Unplug className="size-4 text-muted-foreground" />
<p className="text-sm font-medium">Teamspace API</p>
</div>
<div className="space-y-4">
<div className="grid gap-3 xl:grid-cols-[minmax(220px,1fr)_minmax(220px,1fr)_auto] xl:items-end">
<div className="space-y-2">
<label className="text-sm font-medium" htmlFor="teamspace-base-url">Teamspace URL</label>
@@ -502,7 +572,7 @@ export function AdminUsersPage({ currentUser }: { currentUser: AuthUser }) {
<div>
<p className="text-sm font-medium">TS-Projekte ohne Differenz-/Budgetprüfung</p>
<p className="text-xs text-muted-foreground">
Sync und Gesamtverbrauch laufen weiter, aber TS/TT-Differenzen, Budgetbalken und Budgetverbrauch werden für diese TS-Projekte ausgeblendet. Beim Speichern werden Name und Kunde aus Teamspace geladen.
Sync und Gesamtverbrauch laufen weiter, aber TS/TT-Differenzen, Budgetbalken und Budgetverbrauch werden für diese TS-Projekte ausgeblendet. Zugeordnet und Hotline stammen aus den Zammad-Organisationsattributen.
</p>
</div>
<div className="flex flex-col gap-2 sm:flex-row">
@@ -523,22 +593,54 @@ export function AdminUsersPage({ currentUser }: { currentUser: AuthUser }) {
Hinzufügen
</Button>
</div>
<div className="overflow-hidden rounded-md border bg-background">
<div className="overflow-x-auto rounded-md border bg-background">
{teamspaceProjectExclusions.length === 0 ? (
<p className="px-3 py-4 text-sm text-muted-foreground">Noch keine Teamspace-Projekte ausgenommen.</p>
) : (
<div className="divide-y">
<div className="min-w-[1120px] divide-y">
{teamspaceProjectExclusions.map((entry) => (
<div key={entry.teamspace_project} className="grid gap-2 px-3 py-2 sm:grid-cols-[140px_minmax(0,1fr)_minmax(0,1fr)_auto] sm:items-center">
<div key={entry.teamspace_project} className="grid grid-cols-[180px_minmax(280px,1fr)_minmax(240px,0.9fr)_210px_210px_2.5rem] items-center gap-4 px-4 py-3">
<div className="font-mono text-sm font-semibold">{entry.teamspace_project}</div>
<div className="min-w-0">
<p className="truncate text-sm">{entry.teamspace_name ?? "Name wird beim Speichern aus Teamspace geladen"}</p>
<p className="text-xs text-muted-foreground sm:hidden">{entry.customer_name ?? "Kunde wird beim Speichern aus Teamspace geladen"}</p>
<p className="truncate text-sm font-medium">{entry.teamspace_name ?? "Name wird beim Speichern aus Teamspace geladen"}</p>
{entry.teamspace_api_project_ids?.length ? <p className="text-xs text-muted-foreground">{entry.teamspace_api_project_ids.length} API-ID(s)</p> : null}
</div>
<div className="hidden min-w-0 text-sm text-muted-foreground sm:block">
<span className="truncate">{entry.customer_name ?? "Kunde wird beim Speichern aus Teamspace geladen"}</span>
<div className="min-w-0 text-sm text-muted-foreground">
<p className="truncate">{entry.customer_name ?? "Kunde wird beim Speichern aus Teamspace geladen"}</p>
</div>
<Button type="button" variant="ghost" size="icon" className="justify-self-start sm:justify-self-end" onClick={() => removeTeamspaceExclusion(entry.teamspace_project)}>
<div className="space-y-1">
<label className="text-xs text-muted-foreground" htmlFor={`customer-owner-${entry.teamspace_project}`}>Zugeordnet</label>
<select
id={`customer-owner-${entry.teamspace_project}`}
className="h-9 w-full rounded-md border border-input bg-background px-3 text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring"
value={entry.customer_owner_option_id ?? ""}
onChange={(event) => updateTeamspaceExclusion(entry.teamspace_project, { customer_owner_option_id: event.currentTarget.value || null })}
>
<option value="">Nicht zugeordnet</option>
{customerOwnerOptions.map((option) => (
<option key={option.id} value={option.id} disabled={!option.active && option.id !== entry.customer_owner_option_id}>
{organizationAttributeOptionLabel(option)}
</option>
))}
</select>
</div>
<div className="space-y-1">
<label className="text-xs text-muted-foreground" htmlFor={`hotline-contract-${entry.teamspace_project}`}>Hotline</label>
<select
id={`hotline-contract-${entry.teamspace_project}`}
className="h-9 w-full rounded-md border border-input bg-background px-3 text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring"
value={entry.hotline_contract_option_id ?? ""}
onChange={(event) => updateTeamspaceExclusion(entry.teamspace_project, { hotline_contract_option_id: event.currentTarget.value || null })}
>
<option value="">Nicht zugeordnet</option>
{hotlineContractOptions.map((option) => (
<option key={option.id} value={option.id} disabled={!option.active && option.id !== entry.hotline_contract_option_id}>
{organizationAttributeOptionLabel(option)}
</option>
))}
</select>
</div>
<Button type="button" variant="ghost" size="icon" className="justify-self-end" onClick={() => removeTeamspaceExclusion(entry.teamspace_project)}>
<Trash2 className="size-4" />
<span className="sr-only">Teamspace-Projekt entfernen</span>
</Button>
@@ -552,6 +654,135 @@ export function AdminUsersPage({ currentUser }: { currentUser: AuthUser }) {
{savingTeamspaceProjectExclusions ? "Speichert..." : "Ausnahmen speichern"}
</Button>
</div>
<div className="space-y-3 rounded-md border bg-muted/20 p-3">
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div>
<p className="text-sm font-medium">Automatische TS-Zuordnung</p>
<p className="text-xs text-muted-foreground">
Angegebene Bedingungen innerhalb einer Regel gelten gemeinsam. Beim Auswählen des Zielprojekts werden dessen hinterlegte Zugeordnet-/Hotline-Werte übernommen und können danach angepasst werden. Die erste passende aktive Regel gewinnt. Regeln werden beim Zammad-Sync nur angewendet, wenn das Ticket keine konkrete TS-Projektnummer besitzt; anschließend werden alle Sessions dem Zielprojekt zugeordnet.
</p>
</div>
<Button type="button" variant="secondary" onClick={addTeamspaceAssignmentRule}>
<Plus className="size-4" />
Regel hinzufügen
</Button>
</div>
<div className="overflow-x-auto rounded-md border bg-background">
{teamspaceAssignmentRules.length === 0 ? (
<p className="px-3 py-4 text-sm text-muted-foreground">Noch keine automatische Zuordnungsregel angelegt.</p>
) : (
<div className="min-w-[1580px] divide-y">
{teamspaceAssignmentRules.map((rule, index) => {
const targetExists = teamspaceProjectExclusions.some((entry) => entry.teamspace_project === rule.teamspace_project);
return (
<div key={rule.id ?? `new-${index}`} className="grid grid-cols-[230px_100px_220px_220px_300px_240px_110px_2.5rem] items-end gap-3 px-4 py-3">
<div className="space-y-1">
<label className="text-xs text-muted-foreground" htmlFor={`assignment-rule-name-${rule.id ?? index}`}>Regelname</label>
<Input
id={`assignment-rule-name-${rule.id ?? index}`}
className="h-9"
value={rule.name}
onChange={(event) => updateTeamspaceAssignmentRule(index, { name: event.currentTarget.value })}
/>
</div>
<div className="space-y-1">
<label className="text-xs text-muted-foreground" htmlFor={`assignment-rule-priority-${rule.id ?? index}`}>Priorität</label>
<Input
id={`assignment-rule-priority-${rule.id ?? index}`}
className="h-9"
type="number"
min={0}
max={100000}
value={rule.priority}
onChange={(event) => updateTeamspaceAssignmentRule(index, { priority: Number(event.currentTarget.value) || 0 })}
/>
</div>
<div className="space-y-1">
<label className="text-xs text-muted-foreground" htmlFor={`assignment-rule-owner-${rule.id ?? index}`}>Zugeordnet ist</label>
<select
id={`assignment-rule-owner-${rule.id ?? index}`}
className="h-9 w-full rounded-md border border-input bg-background px-3 text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring"
value={rule.customer_owner_option_id ?? ""}
onChange={(event) => updateTeamspaceAssignmentRule(index, { customer_owner_option_id: event.currentTarget.value || null })}
>
<option value="">Bedingung nicht verwenden</option>
{customerOwnerOptions.map((option) => (
<option key={option.id} value={option.id} disabled={!option.active && option.id !== rule.customer_owner_option_id}>
{organizationAttributeOptionLabel(option)}
</option>
))}
</select>
</div>
<div className="space-y-1">
<label className="text-xs text-muted-foreground" htmlFor={`assignment-rule-hotline-${rule.id ?? index}`}>Hotline ist</label>
<select
id={`assignment-rule-hotline-${rule.id ?? index}`}
className="h-9 w-full rounded-md border border-input bg-background px-3 text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring"
value={rule.hotline_contract_option_id ?? ""}
onChange={(event) => updateTeamspaceAssignmentRule(index, { hotline_contract_option_id: event.currentTarget.value || null })}
>
<option value="">Bedingung nicht verwenden</option>
{hotlineContractOptions.map((option) => (
<option key={option.id} value={option.id} disabled={!option.active && option.id !== rule.hotline_contract_option_id}>
{organizationAttributeOptionLabel(option)}
</option>
))}
</select>
</div>
<div className="flex h-9 items-center gap-4 rounded-md border bg-background px-3">
<label className="flex items-center gap-2 text-sm">
<Switch
checked={rule.support_enabled}
onCheckedChange={(checked) => updateTeamspaceAssignmentRule(index, { support_enabled: checked })}
/>
Support
</label>
<label className="flex items-center gap-2 text-sm">
<Switch
checked={rule.consulting_enabled}
onCheckedChange={(checked) => updateTeamspaceAssignmentRule(index, { consulting_enabled: checked })}
/>
Consulting
</label>
</div>
<div className="space-y-1">
<label className="text-xs text-muted-foreground" htmlFor={`assignment-rule-target-${rule.id ?? index}`}>Ziel-TS-Projekt</label>
<select
id={`assignment-rule-target-${rule.id ?? index}`}
className="h-9 w-full rounded-md border border-input bg-background px-3 font-mono text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring"
value={rule.teamspace_project}
onChange={(event) => updateTeamspaceAssignmentRuleTarget(index, event.currentTarget.value)}
>
<option value="">Bitte wählen</option>
{!targetExists && rule.teamspace_project ? <option value={rule.teamspace_project}>{rule.teamspace_project} · nicht mehr vorhanden</option> : null}
{teamspaceProjectExclusions.map((entry) => (
<option key={entry.teamspace_project} value={entry.teamspace_project}>{entry.teamspace_project}</option>
))}
</select>
</div>
<label className="flex h-9 items-center gap-2 text-sm">
<Switch checked={rule.active} onCheckedChange={(checked) => updateTeamspaceAssignmentRule(index, { active: checked })} />
Aktiv
</label>
<Button type="button" variant="ghost" size="icon" onClick={() => removeTeamspaceAssignmentRule(index)}>
<Trash2 className="size-4" />
<span className="sr-only">Zuordnungsregel entfernen</span>
</Button>
</div>
);
})}
</div>
)}
</div>
<div className="flex flex-wrap items-center gap-3">
<Button type="button" variant="secondary" disabled={savingTeamspaceAssignmentRules} onClick={() => void saveAssignmentRules()}>
<Save className="size-4" />
{savingTeamspaceAssignmentRules ? "Speichert..." : "Zuordnungsregeln speichern"}
</Button>
<p className="text-xs text-muted-foreground">Zielprojekte müssen zuvor in der Ausnahmen-Liste gespeichert worden sein.</p>
</div>
</div>
</div>
</CardContent>
</Card>
@@ -671,6 +902,7 @@ export function AdminUsersPage({ currentUser }: { currentUser: AuthUser }) {
<TableHead>Rolle</TableHead>
<TableHead>Status</TableHead>
<TableHead>Neues Passwort</TableHead>
<TableHead>TS-Istzeiten</TableHead>
<TableHead />
</TableRow>
</TableHeader>
@@ -705,6 +937,45 @@ export function AdminUsersPage({ currentUser }: { currentUser: AuthUser }) {
onChange={(event) => updateForm(user.id, { password: event.currentTarget.value })}
/>
</TableCell>
<TableCell>
{isFixedAdmin ? (
<span className="text-sm text-muted-foreground">-</span>
) : (
<div className="grid min-w-[280px] gap-2">
<div className="flex items-center gap-2">
<Badge variant={initialActualsLocked(user) ? "secondary" : "outline"}>{initialActualsStatus(user)}</Badge>
{!user.has_teamspace_credentials || !user.teamspace_user_id ? <Badge variant="warning">TS-Zugang fehlt</Badge> : null}
</div>
<div className="grid grid-cols-[minmax(120px,1fr)_auto_auto] gap-2">
<Input
className="h-8"
type="month"
value={initialActualsFromMonthByUser[user.id] ?? ""}
onChange={(event) => setInitialActualsFromMonthByUser((current) => ({ ...current, [user.id]: event.currentTarget.value }))}
disabled={initialActualsLocked(user) || syncingInitialActualsId === user.id}
/>
<Button
size="sm"
variant="secondary"
disabled={initialActualsLocked(user) || !user.has_teamspace_credentials || !user.teamspace_user_id || syncingInitialActualsId === user.id}
onClick={() => void syncInitialActuals(user)}
>
<TimerReset className="size-4" />
{syncingInitialActualsId === user.id ? "Läuft..." : "Sync"}
</Button>
<Button
size="sm"
variant="ghost"
disabled={!initialActualsLocked(user) || unlockingInitialActualsId === user.id}
onClick={() => void unlockInitialActuals(user)}
>
<RotateCcw className="size-4" />
Entsperren
</Button>
</div>
</div>
)}
</TableCell>
<TableCell className="text-right">
<Button size="sm" onClick={() => void saveUser(user)} disabled={savingId === user.id}>
<Save className="size-4" />
@@ -740,6 +1011,41 @@ export function AdminUsersPage({ currentUser }: { currentUser: AuthUser }) {
value={form.password}
onChange={(event) => updateForm(user.id, { password: event.currentTarget.value })}
/>
{!isFixedAdmin ? (
<div className="space-y-2 rounded-md border bg-muted/20 p-2">
<div className="flex flex-wrap items-center gap-2">
<span className="text-sm font-medium">TS-Istzeiten</span>
<Badge variant={initialActualsLocked(user) ? "secondary" : "outline"}>{initialActualsStatus(user)}</Badge>
{!user.has_teamspace_credentials || !user.teamspace_user_id ? <Badge variant="warning">TS-Zugang fehlt</Badge> : null}
</div>
<div className="grid gap-2 sm:grid-cols-[minmax(140px,1fr)_auto_auto]">
<Input
type="month"
value={initialActualsFromMonthByUser[user.id] ?? ""}
onChange={(event) => setInitialActualsFromMonthByUser((current) => ({ ...current, [user.id]: event.currentTarget.value }))}
disabled={initialActualsLocked(user) || syncingInitialActualsId === user.id}
/>
<Button
size="sm"
variant="secondary"
disabled={initialActualsLocked(user) || !user.has_teamspace_credentials || !user.teamspace_user_id || syncingInitialActualsId === user.id}
onClick={() => void syncInitialActuals(user)}
>
<TimerReset className="size-4" />
{syncingInitialActualsId === user.id ? "Läuft..." : "Sync"}
</Button>
<Button
size="sm"
variant="ghost"
disabled={!initialActualsLocked(user) || unlockingInitialActualsId === user.id}
onClick={() => void unlockInitialActuals(user)}
>
<RotateCcw className="size-4" />
Entsperren
</Button>
</div>
</div>
) : null}
<div className="flex items-center justify-between gap-2">
<label className="flex items-center gap-2 text-sm">
<Switch checked={form.active} disabled={isFixedAdmin} onCheckedChange={(active) => updateForm(user.id, { active })} />
+1 -1
View File
@@ -194,7 +194,7 @@ function TeamspaceDeviationIcon({ ticket }: { ticket: TicketSummary }) {
return <span className="inline-block size-4" aria-hidden="true" />;
}
const title = deltaMinutes > 0 ? `${formatMinutes(deltaMinutes)} fehlt in TT` : `${formatMinutes(Math.abs(deltaMinutes))} fehlt in TS`;
const title = deltaMinutes > 0 ? `${formatMinutes(deltaMinutes)} mehr in TS API` : `${formatMinutes(Math.abs(deltaMinutes))} mehr in TT-Teamspace`;
return (
<span title={`${formatSignedMinutes(deltaMinutes)} · ${title}`} aria-label={`Teamspace-Abweichung: ${title}`}>
+179
View File
@@ -0,0 +1,179 @@
import { useMemo, useState } from "react";
import type { Dispatch, SetStateAction } from "react";
import { FolderGit, Pause, Play, RotateCcw, Square, Timer } from "lucide-react";
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 { deleteActiveTimer, updateActiveTimer } from "../api";
import { formatDateTime, formatTimer } from "../format";
import { activeElapsedMs, mergeTimerActivityDrafts, removeTimerActivityDraft, type TimerEntry } from "../timers";
import type { AuthUser } from "../types";
type DashboardTimersPageProps = {
timers: TimerEntry[];
setTimers: Dispatch<SetStateAction<TimerEntry[]>>;
selectedTimerId: string | null;
setSelectedTimerId: Dispatch<SetStateAction<string | null>>;
tick: number;
currentUser: AuthUser;
onFinishTimer: (timerId: string) => void;
onOpenActiveTimer: () => void;
};
export function DashboardTimersPage({
timers,
setTimers,
selectedTimerId,
setSelectedTimerId,
tick,
currentUser,
onFinishTimer,
onOpenActiveTimer
}: DashboardTimersPageProps) {
const [busyTimerId, setBusyTimerId] = useState<string | null>(null);
const sortedTimers = useMemo(
() =>
[...timers].sort((first, second) => {
if (first.phase === second.phase) {
return first.ticketNumber.localeCompare(second.ticketNumber);
}
return first.phase === "running" ? -1 : 1;
}),
[timers]
);
const runningTimer = timers.find((timer) => timer.phase === "running") ?? null;
async function setTimerPhase(timer: TimerEntry, phase: "running" | "paused") {
setBusyTimerId(timer.id);
try {
const result = await updateActiveTimer(timer.id, { phase });
setTimers(mergeTimerActivityDrafts(currentUser.id, result.timers));
setSelectedTimerId(timer.id);
} catch (error) {
toast.error("Timer konnte nicht aktualisiert werden", {
description: error instanceof Error ? error.message : "Unbekannter Fehler"
});
} finally {
setBusyTimerId(null);
}
}
async function discardTimer(timer: TimerEntry) {
const confirmed = window.confirm(`Timer ${timer.ticketNumber} verwerfen? Alle bisherigen Änderungen und Notizen an diesem Timer gehen verloren.`);
if (!confirmed) {
return;
}
setBusyTimerId(timer.id);
try {
const result = await deleteActiveTimer(timer.id);
removeTimerActivityDraft(currentUser.id, timer.id);
setTimers(mergeTimerActivityDrafts(currentUser.id, result.timers));
toast.success("Timer verworfen", {
description: timer.ticketNumber
});
} catch (error) {
toast.error("Timer konnte nicht verworfen werden", {
description: error instanceof Error ? error.message : "Unbekannter Fehler"
});
} finally {
setBusyTimerId(null);
}
}
return (
<div className="space-y-4">
<div className="flex flex-col gap-2 sm:flex-row sm:items-end sm:justify-between">
<div>
<h2 className="text-xl font-semibold tracking-normal sm:text-2xl">Timer-Übersicht</h2>
<p className="text-sm text-muted-foreground">Aktiven Timer und pausierte Timer zentral umschalten.</p>
</div>
{runningTimer ? <Badge variant="success">Aktiv: {runningTimer.ticketNumber}</Badge> : timers.length > 0 ? <Badge variant="warning">Alle pausiert</Badge> : <Badge variant="info">Kein Timer</Badge>}
</div>
<div className="grid gap-3">
{sortedTimers.map((timer) => {
const isRunning = timer.phase === "running";
const isSelected = selectedTimerId === timer.id;
const elapsedSeconds = Math.floor(activeElapsedMs(timer, tick) / 1000);
return (
<Card key={timer.id} className={isSelected ? "border-primary/60" : ""}>
<CardContent className="grid gap-3 p-4 lg:grid-cols-[minmax(0,1fr)_180px_auto] lg:items-center">
<button
type="button"
className={isRunning ? "min-w-0 text-left underline-offset-4 hover:underline" : "min-w-0 cursor-default text-left"}
onClick={() => {
setSelectedTimerId(timer.id);
if (isRunning) {
onOpenActiveTimer();
}
}}
>
<div className="flex min-w-0 flex-wrap items-center gap-2">
<span className={isRunning ? "size-2 rounded-full bg-emerald-500" : "size-2 rounded-full bg-muted-foreground/50"} />
<span className="font-semibold">{timer.ticketNumber}</span>
<Badge variant={isRunning ? "success" : "secondary"}>{isRunning ? "aktiv" : "pausiert"}</Badge>
{timer.workType ? <Badge variant="outline">{timer.workType === "support" ? "Support" : "Consulting"}</Badge> : null}
</div>
<p className="mt-1 truncate text-sm text-muted-foreground">
{timer.isProject ? <FolderGit className="mr-1 inline size-3.5" /> : null}
{timer.organizationName ?? timer.projectName ?? "Ticketdaten noch nicht geladen"}
</p>
{timer.activity.trim() ? <p className="mt-1 line-clamp-2 text-sm">{timer.activity}</p> : null}
<p className="mt-1 text-xs text-muted-foreground">Gestartet: {formatDateTime(new Date(timer.startedAt).toISOString())}</p>
</button>
<div className="rounded-md border bg-muted/20 px-3 py-2">
<p className="text-xs text-muted-foreground">Gemessen</p>
<p className="font-mono text-2xl font-semibold tracking-normal">{formatTimer(elapsedSeconds)}</p>
</div>
<div className="flex flex-wrap gap-2 lg:justify-end">
{isRunning ? (
<Button type="button" variant="secondary" disabled={busyTimerId === timer.id} onClick={() => void setTimerPhase(timer, "paused")}>
<Pause className="size-4" />
Pausieren
</Button>
) : (
<Button type="button" disabled={busyTimerId === timer.id} onClick={() => void setTimerPhase(timer, "running")}>
<Play className="size-4" />
Aktivieren
</Button>
)}
<Button type="button" variant="destructive" onClick={() => onFinishTimer(timer.id)}>
<Square className="size-4" />
Stoppen
</Button>
<Button type="button" variant="ghost" disabled={busyTimerId === timer.id} onClick={() => void discardTimer(timer)}>
<RotateCcw className="size-4" />
Verwerfen
</Button>
</div>
</CardContent>
</Card>
);
})}
{sortedTimers.length === 0 ? (
<Card>
<CardContent className="grid place-items-center gap-2 p-8 text-center">
<Timer className="size-8 text-muted-foreground" />
<div>
<p className="font-medium">Keine Timer vorbereitet</p>
<p className="text-sm text-muted-foreground">Starte oben einen Timer oder nutze den Schnellstart in der Kopfzeile.</p>
</div>
</CardContent>
</Card>
) : null}
</div>
</div>
);
}
+210 -256
View File
@@ -7,12 +7,10 @@ import {
FileWarning,
Lock,
LockOpen,
Sparkles,
} from "lucide-react";
import { useEffect, useMemo, useRef, useState } from "react";
import { toast } from "sonner";
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion";
import { Alert } from "@/components/ui/alert";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
@@ -21,8 +19,8 @@ import { Checkbox } from "@/components/ui/checkbox";
import { Input } from "@/components/ui/input";
import { CopyTicketButton } from "@/components/CopyTicketButton";
import { HelpLink } from "@/components/HelpLink";
import { acknowledgeMissingTicketDayBilling, closePeriod, getStatisticsOverview, reopenPeriod } from "../api";
import { currentMonth, currentWeek, formatDate, formatDateTime, formatMinutes } from "../format";
import { closePeriod, getStatisticsOverview, reopenPeriod } from "../api";
import { currentMonth, currentWeek, formatDateTime, formatMinutes } from "../format";
import type { AuthUser, StatisticsCrmDay, StatisticsOpenSession, StatisticsOverview, StatisticsTeamspaceDay, StatisticsTeamspaceMonthMismatch } from "../types";
type MonthlyClosePageProps = {
@@ -30,8 +28,6 @@ type MonthlyClosePageProps = {
onNavigate: (to: string) => void;
};
const trackedTextClass = "text-sky-700 dark:text-sky-300";
const teamspaceTextClass = "text-emerald-700 dark:text-emerald-300";
const showAcknowledgedMissingCrmStorageKey = "tickettracker.monthlyClose.showAcknowledgedMissingCrm";
const monthlyCloseViewModeStorageKey = "tickettracker.monthlyClose.viewMode";
@@ -45,28 +41,16 @@ function readMonthlyCloseViewMode(): MonthlyCloseViewMode {
return window.localStorage.getItem(monthlyCloseViewModeStorageKey) === "week" ? "week" : "month";
}
function formatSignedMinutes(minutes: number) {
if (minutes === 0) {
return "ausgeglichen";
}
return `${minutes > 0 ? "+" : "-"}${formatMinutes(Math.abs(minutes))}`;
}
function formatTeamspaceDelta(minutes: number) {
return formatSignedMinutes(minutes);
}
function formatMissingTarget(minutes: number, teamspaceTimeTrackingEnabled: boolean) {
if (minutes === 0) {
return "ausgeglichen";
}
if (minutes > 0) {
return `${formatMinutes(minutes)} fehlt ${teamspaceTimeTrackingEnabled ? "in TT" : "in den abgerechneten Zeiten"}`;
return `${formatMinutes(minutes)} mehr in TS API`;
}
return `${formatMinutes(Math.abs(minutes))} fehlt in TS`;
return `${formatMinutes(Math.abs(minutes))} mehr ${teamspaceTimeTrackingEnabled ? "in TT-Teamspace" : "in den abgerechneten Zeiten"}`;
}
function StatusCard({ label, value, detail, tone }: { label: string; value: string | number; detail: string; tone?: "ok" | "warn" | "neutral" }) {
@@ -77,104 +61,177 @@ function StatusCard({ label, value, detail, tone }: { label: string; value: stri
<CardContent className="p-3">
<p className="text-xs font-medium text-muted-foreground">{label}</p>
<p className={`text-xl font-semibold tracking-normal ${valueClassName}`}>{value}</p>
<p className="text-xs text-muted-foreground ">{detail}</p>
<p className="text-xs text-muted-foreground whitespace-pre-line">{detail}</p>
</CardContent>
</Card>
);
}
type OpenTicketGroup = {
type MonthlyCloseTicketDay = {
day: string;
missingCrm: StatisticsCrmDay | null;
ticketMismatch: StatisticsTeamspaceDay | null;
};
type MonthlyCloseTicketGroup = {
ticketId: string;
ticketNumber: string;
organizationName: string;
month: string;
openCount: number;
totalMinutes: number;
};
type OpenReviewGroup = OpenTicketGroup & {
mismatch: StatisticsTeamspaceMonthMismatch | null;
days: MonthlyCloseTicketDay[];
};
function groupOpenSessions(sessions: StatisticsOpenSession[]) {
const groups = new Map<string, OpenTicketGroup>();
type MutableMonthlyCloseTicketGroup = Omit<MonthlyCloseTicketGroup, "days"> & {
days: Map<string, MonthlyCloseTicketDay>;
};
for (const session of sessions) {
const existing = groups.get(session.ticket_id);
const monthlyCloseTicketGridClassName =
"xl:grid-cols-[minmax(10rem,1fr)_minmax(4.25rem,7rem)_minmax(4.25rem,7rem)_minmax(4.25rem,7rem)_minmax(4.25rem,7rem)_minmax(4.25rem,7rem)_11.5rem]";
function buildMonthlyCloseTicketGroups(
sessions: StatisticsOpenSession[],
monthMismatches: StatisticsTeamspaceMonthMismatch[],
ticketDayMismatches: StatisticsTeamspaceDay[],
missingCrmDays: StatisticsCrmDay[],
fallbackMonth: string
) {
const groups = new Map<string, MutableMonthlyCloseTicketGroup>();
function ensureGroup(ticketId: string, ticketNumber: string, organizationName: string, month: string) {
const existing = groups.get(ticketId);
if (existing) {
existing.openCount += 1;
existing.totalMinutes += session.rounded_minutes;
continue;
if (!existing.organizationName && organizationName) {
existing.organizationName = organizationName;
}
return existing;
}
groups.set(session.ticket_id, {
ticketId: session.ticket_id,
ticketNumber: session.ticket_number,
organizationName: session.organization_name,
month: session.started_at.slice(0, 7),
openCount: 1,
totalMinutes: session.rounded_minutes
});
}
return Array.from(groups.values()).sort((left, right) => left.ticketNumber.localeCompare(right.ticketNumber, "de", { numeric: true, sensitivity: "base" }));
}
function buildOpenReviewGroups(openGroups: OpenTicketGroup[], mismatches: StatisticsTeamspaceMonthMismatch[], month: string) {
const groups = new Map<string, OpenReviewGroup>();
for (const group of openGroups) {
groups.set(group.ticketId, { ...group, mismatch: null });
}
for (const mismatch of mismatches) {
const existing = groups.get(mismatch.ticket_id);
if (existing) {
existing.mismatch = mismatch;
continue;
}
groups.set(mismatch.ticket_id, {
ticketId: mismatch.ticket_id,
ticketNumber: mismatch.ticket_number,
organizationName: mismatch.organization_name,
const group: MutableMonthlyCloseTicketGroup = {
ticketId,
ticketNumber,
organizationName,
month,
openCount: 0,
totalMinutes: 0,
mismatch
});
mismatch: null,
days: new Map()
};
groups.set(ticketId, group);
return group;
}
return Array.from(groups.values()).sort((left, right) => left.ticketNumber.localeCompare(right.ticketNumber, "de", { numeric: true, sensitivity: "base" }));
for (const session of sessions) {
const group = ensureGroup(session.ticket_id, session.ticket_number, session.organization_name, session.started_at.slice(0, 7));
group.openCount += 1;
group.totalMinutes += session.rounded_minutes;
}
for (const mismatch of monthMismatches) {
ensureGroup(mismatch.ticket_id, mismatch.ticket_number, mismatch.organization_name, fallbackMonth).mismatch = mismatch;
}
for (const mismatch of ticketDayMismatches) {
if (!mismatch.ticket_id || !mismatch.ticket_number) {
continue;
}
const group = ensureGroup(mismatch.ticket_id, mismatch.ticket_number, mismatch.organization_name, mismatch.day.slice(0, 7));
const day = group.days.get(mismatch.day) ?? { day: mismatch.day, missingCrm: null, ticketMismatch: null };
day.ticketMismatch = mismatch;
group.days.set(mismatch.day, day);
}
for (const missingCrm of missingCrmDays) {
const group = ensureGroup(missingCrm.ticket_id, missingCrm.ticket_number, missingCrm.organization_name, missingCrm.day.slice(0, 7));
const day = group.days.get(missingCrm.day) ?? { day: missingCrm.day, missingCrm: null, ticketMismatch: null };
day.missingCrm = missingCrm;
group.days.set(missingCrm.day, day);
}
return Array.from(groups.values())
.map((group): MonthlyCloseTicketGroup => ({
...group,
days: Array.from(group.days.values()).sort((left, right) => left.day.localeCompare(right.day))
}))
.sort((left, right) => left.ticketNumber.localeCompare(right.ticketNumber, "de", { numeric: true, sensitivity: "base" }));
}
function OpenReviewItem({
function MonthlyCloseTicketItem({
group,
teamspaceTimeTrackingEnabled,
onNavigate
}: {
group: OpenReviewGroup;
teamspaceTimeTrackingEnabled: boolean;
group: MonthlyCloseTicketGroup;
onNavigate: (to: string) => void;
}) {
const missingDayCount = group.days.filter((day) => day.missingCrm && !day.missingCrm.acknowledged).length;
const acknowledgedDayCount = group.days.filter((day) => day.missingCrm?.acknowledged).length;
const mismatchDayCount = group.days.filter((day) => day.ticketMismatch && day.ticketMismatch.delta_minutes !== 0).length;
const invalidTeamspaceValueDayCount = group.days.filter((day) => day.ticketMismatch?.tt_value_without_billed_session).length;
const monthDeltaMinutes = group.mismatch?.teamspace_month_delta_minutes ?? 0;
const monthDeltaLabel = monthDeltaMinutes > 0
? `TS +${formatMinutes(monthDeltaMinutes)}`
: `TT +${formatMinutes(Math.abs(monthDeltaMinutes))}`;
const metricClassName = (visible: boolean) => `${visible ? "flex" : "hidden xl:flex"} min-h-7 items-center justify-between gap-2 rounded-md bg-muted/30 px-2 xl:min-h-5 xl:justify-center xl:rounded-none xl:bg-transparent xl:px-0`;
return (
<div className="rounded-md border bg-background px-3 py-2">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0 space-y-1">
<div className="flex flex-wrap items-center gap-2">
<span className="font-medium">{group.ticketNumber}</span>
<CopyTicketButton ticketNumber={group.ticketNumber} />
{group.openCount > 0 ? <Badge variant="warning" className="text-xxs">{group.openCount} Bewertung(en)</Badge> : null}
{group.mismatch ? <Badge variant="warning" className="text-xxs">{formatMissingTarget(group.mismatch.teamspace_month_delta_minutes, teamspaceTimeTrackingEnabled)}</Badge> : null}
</div>
<p className="truncate text-sm text-muted-foreground">{group.organizationName}</p>
<div className="flex flex-wrap gap-x-3 gap-y-1 text-xs">
{group.openCount > 0 ? <span className={trackedTextClass}>{formatMinutes(group.totalMinutes)} offen zu bewerten</span> : null}
</div>
</div>
<Button className="h-8 shrink-0" variant="secondary" size="sm" onClick={() => onNavigate(`/tickets/${group.ticketId}?month=${group.month}`)}>
<div className={`grid grid-cols-1 items-center gap-1.5 rounded-md border bg-background px-3 py-2.5 sm:grid-cols-2 xl:gap-2 ${monthlyCloseTicketGridClassName}`}>
<div className="min-w-0 sm:col-span-2 xl:col-span-1">
<p className="font-medium">{group.ticketNumber}</p>
<p className="truncate text-xs text-muted-foreground">{group.organizationName}</p>
</div>
<div className={metricClassName(group.openCount > 0)}>
<span className="text-[10px] font-medium uppercase tracking-wide text-muted-foreground xl:hidden">Offene Bewertungen</span>
{group.openCount > 0 ? (
<Badge className="bg-amber-500/12 text-amber-800 dark:bg-amber-500/20 dark:text-amber-200" title={`${group.openCount} offene Bewertung(en) · ${formatMinutes(group.totalMinutes)}`}>
{group.openCount}
</Badge>
) : null}
</div>
<div className={metricClassName(Boolean(group.mismatch))}>
<span className="text-[10px] font-medium uppercase tracking-wide text-muted-foreground xl:hidden">Monatsabweichung</span>
{group.mismatch ? (
<Badge className="bg-orange-500/12 text-orange-800 dark:bg-orange-500/20 dark:text-orange-200" title="Monatsabweichung zwischen TT-Teamspace und TS-API">
{monthDeltaLabel}
</Badge>
) : null}
</div>
<div className={metricClassName(missingDayCount > 0 || acknowledgedDayCount > 0)}>
<span className="text-[10px] font-medium uppercase tracking-wide text-muted-foreground xl:hidden">Fehlender TT-Wert</span>
<span className="flex flex-wrap items-center justify-end gap-1 xl:flex-col xl:justify-center">
{missingDayCount > 0 ? (
<Badge className="bg-sky-500/12 text-sky-800 dark:bg-sky-500/20 dark:text-sky-200" title={`${missingDayCount} Tag(e) ohne TT-Teamspace-Wert`}>
TT {missingDayCount}
</Badge>
) : null}
{acknowledgedDayCount > 0 ? (
<Badge className="bg-emerald-500/12 text-emerald-800 dark:bg-emerald-500/20 dark:text-emerald-200" title={`${acknowledgedDayCount} zur Kenntnis genommene(r) Tag(e)`}>
✓ {acknowledgedDayCount}
</Badge>
) : null}
</span>
</div>
<div className={metricClassName(mismatchDayCount > 0)}>
<span className="text-[10px] font-medium uppercase tracking-wide text-muted-foreground xl:hidden">Tagesabweichung</span>
{mismatchDayCount > 0 ? (
<Badge className="bg-violet-500/12 text-violet-800 dark:bg-violet-500/20 dark:text-violet-200" title={`${mismatchDayCount} Tag(e) mit Abweichung zwischen TT und TS`}>
{mismatchDayCount}
</Badge>
) : null}
</div>
<div className={metricClassName(invalidTeamspaceValueDayCount > 0)}>
<span className="text-[10px] font-medium uppercase tracking-wide text-muted-foreground xl:hidden">TT-Wert ohne Abrechnung</span>
{invalidTeamspaceValueDayCount > 0 ? (
<Badge variant="destructive" title={`${invalidTeamspaceValueDayCount} Tag(e) mit TT-Teamspace-Wert, aber ohne abgerechnete Session`}>{invalidTeamspaceValueDayCount}</Badge>
) : null}
</div>
<div className="flex shrink-0 items-center justify-end gap-1.5 pt-0.5 sm:col-span-2 xl:col-span-1 xl:pt-0">
<CopyTicketButton ticketNumber={group.ticketNumber} />
<Button className="h-8" variant="secondary" size="sm" onClick={() => onNavigate(`/tickets/${group.ticketId}?month=${group.month}`)}>
Ticket öffnen
<ExternalLink className="size-4" />
</Button>
@@ -183,77 +240,6 @@ function OpenReviewItem({
);
}
function CrmDayItem({
item,
busy,
onSetAcknowledgement,
onNavigate
}: {
item: StatisticsCrmDay;
busy: boolean;
onSetAcknowledgement: (item: StatisticsCrmDay, acknowledged: boolean) => void;
onNavigate: (to: string) => void;
}) {
const isAcknowledged = Boolean(item.acknowledged);
return (
<div className={`rounded-md border bg-background px-3 py-2 ${isAcknowledged ? "opacity-75" : ""}`}>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0 space-y-1">
<div className="flex flex-wrap items-center gap-2">
<span className="font-medium">{item.ticket_number}</span>
<CopyTicketButton ticketNumber={item.ticket_number} />
<Badge variant="outline">{formatDate(`${item.day}T00:00:00`)}</Badge>
<Badge variant={isAcknowledged ? "success" : "warning"}>{isAcknowledged ? "zur Kenntnis" : "offen"}</Badge>
</div>
<p className="truncate text-sm text-muted-foreground">{item.organization_name}</p>
<p className="flex flex-wrap gap-x-3 gap-y-1 text-xs">
<span className={trackedTextClass}>Abgerechnet {formatMinutes(item.tracked_minutes)}</span>
<span className={teamspaceTextClass}>Teamspace {formatMinutes(item.crm_billed_minutes)}</span>
{typeof item.delta_minutes === "number" ? <span className="text-muted-foreground">Differenz {formatTeamspaceDelta(item.delta_minutes)}</span> : null}
</p>
</div>
<div className="flex shrink-0 flex-wrap justify-end gap-2">
<Button className="h-8" variant={isAcknowledged ? "secondary" : "ghost"} size="sm" disabled={busy} onClick={() => onSetAcknowledgement(item, !isAcknowledged)}>
{isAcknowledged ? "Zurücknehmen" : "Zur Kenntnis"}
</Button>
<Button className="h-8" variant="secondary" size="sm" onClick={() => onNavigate(`/tickets/${item.ticket_id}?month=${item.day.slice(0, 7)}`)}>
Tag öffnen
<ExternalLink className="size-4" />
</Button>
</div>
</div>
</div>
);
}
function TeamspaceDayItem({ item, onNavigate }: { item: StatisticsTeamspaceDay; onNavigate: (to: string) => void }) {
return (
<div className="grid gap-3 rounded-md border bg-background px-3 py-2 lg:grid-cols-[minmax(8rem,0.75fr)_minmax(0,1.65fr)_minmax(8rem,0.6fr)] lg:items-center">
<div className="flex flex-wrap items-center gap-2">
<Badge variant="outline">{formatDate(`${item.day}T00:00:00`)}</Badge>
</div>
<div className="min-w-0 space-y-1">
<div className="flex min-w-0 flex-wrap items-center gap-2">
<button
type="button"
className="font-medium underline-offset-4 hover:underline"
onClick={() => onNavigate(`/tickets/${item.ticket_id}?month=${item.day.slice(0, 7)}`)}
>
{item.ticket_number}
</button>
<CopyTicketButton ticketNumber={item.ticket_number} />
{item.project_teamspace_project ? <Badge variant="secondary">{item.project_teamspace_project}</Badge> : null}
</div>
<p className="truncate text-sm text-muted-foreground">{item.organization_name}</p>
</div>
<div className="text-sm">
<Badge variant="success">{formatMinutes(item.crm_billed_minutes)}</Badge>
</div>
</div>
);
}
export function MonthlyClosePage({ currentUser, onNavigate }: MonthlyClosePageProps) {
const [viewMode, setViewMode] = useState<MonthlyCloseViewMode>(() => readMonthlyCloseViewMode());
const [month, setMonth] = useState(currentMonth());
@@ -262,7 +248,6 @@ export function MonthlyClosePage({ currentUser, onNavigate }: MonthlyClosePagePr
const [loading, setLoading] = useState(false);
const [closing, setClosing] = useState(false);
const [reopening, setReopening] = useState(false);
const [acknowledgingKeys, setAcknowledgingKeys] = useState<Set<string>>(() => new Set());
const [showAcknowledgedMissingCrm, setShowAcknowledgedMissingCrm] = useState(false);
const [showAcknowledgedMissingCrmLoaded, setShowAcknowledgedMissingCrmLoaded] = useState(false);
const loadRequestId = useRef(0);
@@ -374,58 +359,47 @@ export function MonthlyClosePage({ currentUser, onNavigate }: MonthlyClosePagePr
}
}
async function setMissingCrmAcknowledgement(item: StatisticsCrmDay, acknowledged: boolean) {
const key = `${item.ticket_id}:${item.day}`;
setAcknowledgingKeys((current) => new Set(current).add(key));
try {
await acknowledgeMissingTicketDayBilling(item.ticket_id, item.day, acknowledged);
toast.success(acknowledged ? "Teamspace-Prüfung abgehakt" : "Kenntnisnahme zurückgenommen", {
description: `${item.ticket_number} am ${formatDate(`${item.day}T00:00:00`)}`
});
await load();
} catch (error) {
toast.error(acknowledged ? "Konnte nicht abgehakt werden" : "Konnte nicht zurückgenommen werden", {
description: error instanceof Error ? error.message : "Unbekannter Fehler"
});
} finally {
setAcknowledgingKeys((current) => {
const next = new Set(current);
next.delete(key);
return next;
});
}
}
const openSessions = stats?.attention.openSessions ?? [];
const missingCrmDays = stats?.attention.missingCrmDays ?? [];
const acknowledgedMissingCrmDays = stats?.attention.acknowledgedMissingCrmDays ?? [];
const teamspaceMonthMismatches = stats?.attention.teamspaceMonthMismatches ?? [];
const teamspaceDays = stats?.teamspaceDays ?? [];
const ticketTeamspaceMismatches = stats?.attention.ticketTeamspaceMismatches ?? [];
const billedMinutes = stats?.totals.billedMinutes ?? 0;
const ttTeamspaceMinutes = stats?.totals.crmBilledMinutes ?? 0;
const teamspaceApiBillableMinutes = stats?.totals.teamspaceApiBillableMinutes ?? 0;
const teamspaceApiDeltaMinutes = stats?.totals.teamspaceApiDeltaMinutes ?? 0;
const openTicketGroups = useMemo(() => groupOpenSessions(openSessions), [openSessions]);
const openReviewGroups = useMemo(() => buildOpenReviewGroups(openTicketGroups, teamspaceMonthMismatches, month), [month, openTicketGroups, teamspaceMonthMismatches]);
const visibleMissingCrmDays = useMemo(() => {
const days = showAcknowledgedMissingCrm ? [...missingCrmDays, ...acknowledgedMissingCrmDays] : [...missingCrmDays];
return days.sort((left, right) => left.day.localeCompare(right.day) || left.ticket_number.localeCompare(right.ticket_number, "de", { numeric: true, sensitivity: "base" }));
}, [acknowledgedMissingCrmDays, missingCrmDays, showAcknowledgedMissingCrm]);
const activeReviewTicketGroups = useMemo(
() => buildMonthlyCloseTicketGroups(openSessions, teamspaceMonthMismatches, ticketTeamspaceMismatches, missingCrmDays, month),
[missingCrmDays, month, openSessions, teamspaceMonthMismatches, ticketTeamspaceMismatches]
);
const visibleReviewTicketGroups = useMemo(
() => buildMonthlyCloseTicketGroups(openSessions, teamspaceMonthMismatches, ticketTeamspaceMismatches, visibleMissingCrmDays, month),
[month, openSessions, teamspaceMonthMismatches, ticketTeamspaceMismatches, visibleMissingCrmDays]
);
const teamspaceAttentionTicketCount = activeReviewTicketGroups.filter((group) => group.mismatch || group.days.length > 0).length;
const missingCrmTicketCount = new Set(missingCrmDays.map((item) => item.ticket_id)).size;
const ticketTeamspaceDeviationCount = ticketTeamspaceMismatches.filter((item) => item.delta_minutes !== 0).length;
const invalidTeamspaceValueDayCount = ticketTeamspaceMismatches.filter((item) => item.tt_value_without_billed_session).length;
const canClose = Boolean(isMonthView && stats && !stats.closed && stats.totals.sessions > 0 && openSessions.length === 0);
const checklist = useMemo(
() => [
{
label: "Offene Prüfungen",
ok: openReviewGroups.length === 0,
value: openReviewGroups.length,
detail: openReviewGroups.length === 0 ? "Keine offenen Prüfungen." : `${openReviewGroups.length} Ticket(s) prüfen`
label: "Tickets zu prüfen",
ok: activeReviewTicketGroups.length === 0,
value: activeReviewTicketGroups.length,
detail: activeReviewTicketGroups.length === 0 ? "Keine offenen Prüfpunkte." : `${activeReviewTicketGroups.length} Ticket(s) mit mindestens einem Prüfpunkt`
},
{
label: "Teamspace geprüft",
ok: missingCrmDays.length === 0 && teamspaceMonthMismatches.length === 0,
value: missingCrmDays.length + teamspaceMonthMismatches.length,
detail: missingCrmDays.length === 0 && teamspaceMonthMismatches.length === 0 ? "Keine offenen Teamspace-Prüfpunkte." : `${teamspaceMonthMismatches.length} Monatsabweichung(en), ${acknowledgedMissingCrmDays.length} zur Kenntnis genommen`
ok: teamspaceAttentionTicketCount === 0,
value: teamspaceAttentionTicketCount,
detail: teamspaceAttentionTicketCount === 0
? "Keine offenen Teamspace-Prüfpunkte."
: `${missingCrmDays.length} fehlende Tageswerte, ${ticketTeamspaceDeviationCount} Tagesabweichungen, ${invalidTeamspaceValueDayCount} TS-Werte ohne Abrechnung, ${teamspaceMonthMismatches.length} Monatsabweichungen`
},
{
label: isMonthView ? "Monat abschließbar" : "Wochenansicht",
@@ -434,7 +408,7 @@ export function MonthlyClosePage({ currentUser, onNavigate }: MonthlyClosePagePr
detail: isMonthView ? (stats?.closed ? "Monat ist gesperrt." : canClose ? "Kann abgeschlossen werden." : "Bewertungen prüfen.") : "Abschluss bleibt monatlich."
}
],
[acknowledgedMissingCrmDays.length, canClose, isMonthView, missingCrmDays.length, openReviewGroups.length, stats?.closed, teamspaceMonthMismatches.length]
[activeReviewTicketGroups.length, canClose, invalidTeamspaceValueDayCount, isMonthView, missingCrmDays.length, stats?.closed, teamspaceAttentionTicketCount, teamspaceMonthMismatches.length, ticketTeamspaceDeviationCount]
);
return (
@@ -442,7 +416,7 @@ export function MonthlyClosePage({ currentUser, onNavigate }: MonthlyClosePagePr
<div className="flex flex-col gap-3 xl:flex-row xl:items-end xl:justify-between">
<div>
<h2 className="text-2xl font-semibold tracking-normal">Monatsabschluss</h2>
<p className="text-sm text-muted-foreground">Offene Bewertungen und Teamspace-Abgleich für {periodAttentionLabel}; der Abschluss selbst bleibt monatlich.</p>
<p className="text-sm text-muted-foreground">Offene Bewertungen und TS-API-Abgleich für {periodAttentionLabel}; der Abschluss selbst bleibt monatlich.</p>
</div>
<div className="grid gap-2 sm:grid-cols-[auto_minmax(220px,300px)_auto] sm:items-end">
<div className="space-y-1">
@@ -509,21 +483,21 @@ export function MonthlyClosePage({ currentUser, onNavigate }: MonthlyClosePagePr
detail={`${formatMinutes(stats?.totals.minutes ?? 0)} abgerechnet`}
/>
<StatusCard
label="Teamspace-Werte"
label="TT-Teamspace"
value={formatMinutes(ttTeamspaceMinutes)}
detail={`aus TS API ${formatMinutes(teamspaceApiBillableMinutes)}\n${formatMissingTarget(teamspaceApiDeltaMinutes, teamspaceTimeTrackingEnabled)}`}
tone={teamspaceApiDeltaMinutes !== 0 ? "warn" : "ok"}
/>
<StatusCard
label="Offene Prüfungen"
value={openReviewGroups.length}
detail={`${openSessions.length} Bewertung(en)\n${teamspaceMonthMismatches.length}TS-Abweichung(en)`}
tone={openReviewGroups.length > 0 ? "warn" : "ok"}
label="Tickets zu prüfen"
value={activeReviewTicketGroups.length}
detail={`${openSessions.length} offene Bewertung(en)\n${teamspaceAttentionTicketCount} Ticket(s) mit TS-Prüfpunkten`}
tone={activeReviewTicketGroups.length > 0 ? "warn" : "ok"}
/>
<StatusCard
label="Ohne Teamspace"
value={missingCrmDays.length}
detail={`${acknowledgedMissingCrmDays.length} zur Kenntnis genommen`}
label="Ohne TT-Teamspace"
value={missingCrmTicketCount}
detail={`${missingCrmDays.length} Tag(e) · ${acknowledgedMissingCrmDays.length} zur Kenntnis`}
tone={missingCrmDays.length > 0 ? "warn" : "ok"}
/>
<StatusCard
@@ -561,8 +535,11 @@ export function MonthlyClosePage({ currentUser, onNavigate }: MonthlyClosePagePr
<CardHeader className="p-4">
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div>
<CardTitle>Aufräumen</CardTitle>
<CardDescription>Alles, was für {periodAttentionLabel} noch Aufmerksamkeit braucht.</CardDescription>
<CardTitle className="flex items-center gap-2">
<FileWarning className="size-4 text-muted-foreground" />
Prüfliste nach Ticket
</CardTitle>
<CardDescription>Offene Bewertungen, fehlende Tageswerte sowie Monats- und Tagesabweichungen – je Ticket einmal in festen Statusspalten.</CardDescription>
</div>
<div className="flex flex-wrap items-center gap-2">
<div className="inline-flex h-8 items-center gap-2 rounded-md border bg-background px-3 text-sm">
@@ -577,63 +554,40 @@ export function MonthlyClosePage({ currentUser, onNavigate }: MonthlyClosePagePr
</div>
</div>
</CardHeader>
<CardContent className="grid gap-4 px-4 pb-4 xl:grid-cols-2">
<section className="space-y-2">
<div className="flex min-h-8 items-center gap-2 text-sm font-medium">
<FileWarning className="size-4 text-muted-foreground" />
Offene Prüfungen
<CardContent className="space-y-3 px-4 pb-4">
<div className="flex flex-wrap items-center justify-between gap-2 text-xs text-muted-foreground">
<span>{visibleReviewTicketGroups.length} Ticket(s) angezeigt</span>
<span>Tagesdetails stehen im jeweiligen Ticket</span>
</div>
{visibleReviewTicketGroups.length > 0 ? (
<div className="space-y-2">
<div className={`hidden items-end gap-2 border border-transparent px-3 text-[11px] font-medium leading-tight uppercase tracking-wide text-muted-foreground xl:grid ${monthlyCloseTicketGridClassName}`}>
<span>Ticket</span>
<span className="text-center">Offene<br />Bewertungen</span>
<span className="text-center">Monatsabweichung<br />TT / TS</span>
<span className="text-center">Fehlender<br />TT-Wert</span>
<span className="text-center">Tagesabweichung<br />TT / TS</span>
<span className="text-center">TT-Wert ohne<br />Abrechnung</span>
<span className="sr-only">Aktionen</span>
</div>
{visibleReviewTicketGroups.map((group) => (
<MonthlyCloseTicketItem
key={group.ticketId}
group={group}
onNavigate={onNavigate}
/>
))}
</div>
{openReviewGroups.map((group) => (
<OpenReviewItem key={group.ticketId} group={group} teamspaceTimeTrackingEnabled={teamspaceTimeTrackingEnabled} onNavigate={onNavigate} />
))}
{openReviewGroups.length === 0 ? <p className="rounded-md border bg-muted/20 p-3 text-sm text-muted-foreground">Keine offenen Prüfungen.</p> : null}
</section>
<section className="space-y-2">
<div className="flex min-h-8 items-center gap-2 text-sm font-medium">
<Sparkles className="size-4 text-muted-foreground" />
Tage ohne Teamspace-Wert
</div>
{visibleMissingCrmDays.map((item) => (
<CrmDayItem
key={`${item.ticket_id}-${item.day}-missing`}
item={item}
busy={acknowledgingKeys.has(`${item.ticket_id}:${item.day}`)}
onSetAcknowledgement={setMissingCrmAcknowledgement}
onNavigate={onNavigate}
/>
))}
{visibleMissingCrmDays.length === 0 ? (
<p className="rounded-md border bg-muted/20 p-3 text-sm text-muted-foreground">
{acknowledgedMissingCrmDays.length > 0 ? "Alle offenen Punkte sind zur Kenntnis genommen." : "Alle abrechenbaren Tage haben einen Teamspace-Wert."}
</p>
) : null}
</section>
) : (
<p className="rounded-md border bg-muted/20 p-3 text-sm text-muted-foreground">
{acknowledgedMissingCrmDays.length > 0 && !showAcknowledgedMissingCrm
? "Keine offenen Prüfpunkte. Zur Kenntnis genommene Tageswerte können eingeblendet werden."
: "Keine offenen Prüfpunkte für diesen Zeitraum."}
</p>
)}
</CardContent>
</Card>
<Accordion type="single" collapsible className="rounded-md border bg-card px-4">
<AccordionItem value="teamspace-entries" className="border-b-0">
<AccordionTrigger className="py-3 hover:no-underline">
<span className="flex min-w-0 flex-wrap items-center gap-2">
<span>Teamspace-Einträge</span>
<Badge variant="outline">{teamspaceDays.length} Eintrag(e)</Badge>
</span>
</AccordionTrigger>
<AccordionContent className="space-y-2 pb-4">
{teamspaceDays.length > 0 ? (
<div className="max-h-[28rem] space-y-2 overflow-y-auto pr-1">
{teamspaceDays.map((item) => (
<TeamspaceDayItem key={`${item.ticket_id}-${item.day}`} item={item} onNavigate={onNavigate} />
))}
</div>
) : (
<p className="rounded-md border bg-muted/20 p-3 text-sm text-muted-foreground">Für {periodAttentionLabel} sind noch keine Teamspace-Werte zu abgerechneten Ticket-Tagen vorhanden.</p>
)}
</AccordionContent>
</AccordionItem>
</Accordion>
{stats && stats.totals.sessions === 0 ? (
<Card>
<CardContent className="grid place-items-center gap-2 p-8 text-center">
+41
View File
@@ -6,6 +6,7 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/com
import { Input } from "@/components/ui/input";
import { Switch } from "@/components/ui/switch";
import { HelpLink } from "@/components/HelpLink";
import { ListClockIcon } from "@/components/icons/ListClockIcon";
import { getZammadSessionArticleQueue, retryZammadSessionArticleQueue, updateCurrentUser } from "../api";
import { formatDateTime, formatMinutes } from "../format";
import type { AuthUser, ZammadSessionArticleQueueItem } from "../types";
@@ -30,8 +31,10 @@ export function ProfilePage({ currentUser, onUserUpdated }: ProfilePageProps) {
const [zammadEmailSessionWebhookEnabled, setZammadEmailSessionWebhookEnabled] = useState(currentUser.zammad_email_session_webhook_enabled);
const [zammadAtossEmailSyncEnabled, setZammadAtossEmailSyncEnabled] = useState(currentUser.zammad_atoss_email_sync_enabled);
const [sessionBillingPromptEnabled, setSessionBillingPromptEnabled] = useState(currentUser.session_billing_prompt_enabled);
const [sessionWorkSegmentsEnabled, setSessionWorkSegmentsEnabled] = useState(currentUser.session_work_segments_enabled);
const [teamspaceTimeTrackingEnabled, setTeamspaceTimeTrackingEnabled] = useState(currentUser.teamspace_time_tracking_enabled ?? true);
const [zammadSessionNoteDefaultEnabled, setZammadSessionNoteDefaultEnabled] = useState(currentUser.zammad_session_note_default_enabled);
const [zammadSessionNoteDateHeaderEnabled, setZammadSessionNoteDateHeaderEnabled] = useState(currentUser.zammad_session_note_date_header_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);
@@ -56,8 +59,10 @@ export function ProfilePage({ currentUser, onUserUpdated }: ProfilePageProps) {
setZammadEmailSessionWebhookEnabled(currentUser.zammad_email_session_webhook_enabled);
setZammadAtossEmailSyncEnabled(currentUser.zammad_atoss_email_sync_enabled);
setSessionBillingPromptEnabled(currentUser.session_billing_prompt_enabled);
setSessionWorkSegmentsEnabled(currentUser.session_work_segments_enabled);
setTeamspaceTimeTrackingEnabled(currentUser.teamspace_time_tracking_enabled ?? true);
setZammadSessionNoteDefaultEnabled(currentUser.zammad_session_note_default_enabled);
setZammadSessionNoteDateHeaderEnabled(currentUser.zammad_session_note_date_header_enabled);
setZammadTimeUnitRoundingEnabled(currentUser.zammad_time_unit_rounding_enabled);
setZammadTimeUnitRoundingMinutes(currentUser.zammad_time_unit_rounding_minutes);
setZammadArticleTypeId(currentUser.zammad_article_type_id ?? 6);
@@ -148,8 +153,10 @@ export function ProfilePage({ currentUser, onUserUpdated }: ProfilePageProps) {
zammadEmailSessionWebhookEnabled,
zammadAtossEmailSyncEnabled,
sessionBillingPromptEnabled,
sessionWorkSegmentsEnabled,
teamspaceTimeTrackingEnabled,
zammadSessionNoteDefaultEnabled,
zammadSessionNoteDateHeaderEnabled,
zammadTimeUnitRoundingEnabled,
zammadTimeUnitRoundingMinutes,
zammadArticleTypeId,
@@ -442,6 +449,22 @@ export function ProfilePage({ currentUser, onUserUpdated }: ProfilePageProps) {
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">
<ListClockIcon className="size-4 text-muted-foreground" />
<div>
<label className="text-sm font-medium" htmlFor="profile-session-work-segments">
Arbeitsbereiche bei Pausen speichern
</label>
<p className="text-xs text-muted-foreground">Beim Pausieren und Fortsetzen die konkreten Von-bis-Zeiten an der Session merken.</p>
</div>
</div>
<Switch
id="profile-session-work-segments"
checked={sessionWorkSegmentsEnabled}
onCheckedChange={setSessionWorkSegmentsEnabled}
/>
</div>
<div className="mt-3 space-y-3 rounded-md border bg-background p-3">
<div className="flex items-center justify-between gap-3">
<div>
@@ -547,6 +570,24 @@ export function ProfilePage({ currentUser, onUserUpdated }: ProfilePageProps) {
}}
/>
</div>
<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">
<FileText className="size-4 text-muted-foreground" />
<div>
<label className="text-sm font-medium" htmlFor="profile-zammad-date-header">
Datum bei nachgetragenen Sessions voranstellen
</label>
<p className="text-xs text-muted-foreground">Bei Einträgen vor heute wird nur in der Zammad-Notiz das Datum mit Leerzeile eingefügt.</p>
</div>
</div>
<Switch
id="profile-zammad-date-header"
checked={zammadSessionNoteDateHeaderEnabled}
onCheckedChange={setZammadSessionNoteDateHeaderEnabled}
/>
</div>
</div>
{zammadSessionNoteDefaultEnabled ? (
<div className="mt-3 space-y-3">
<div className="rounded-md border bg-background p-3">
+15
View File
@@ -95,6 +95,7 @@ const projectStaticFilterOptions: ProjectFilterOption[] = [
{ value: "open", label: "TT Offen" },
{ value: "closed", label: "TT Geschl." },
{ value: "hidden", label: "Ausgebl." },
{ value: "teamspaceExcluded", label: "TS ausgenommen" },
{ value: "budgeted", label: "Mit Kontingent" },
{ value: "withoutBudget", label: "Ohne Kontingent" },
{ value: "remaining", label: "Mit Rest" },
@@ -499,6 +500,10 @@ function projectFilterGroup(filter: string) {
return "visibility";
}
if (filter === "teamspaceExcluded") {
return "teamspaceExclusion";
}
if (filter.startsWith("zammadState:")) {
return "zammadState";
}
@@ -523,6 +528,10 @@ function matchesSingleProjectFilter(project: ProjectSummary, filter: string, sta
return Boolean(project.hidden_for_user);
}
if (filter === "teamspaceExcluded") {
return projectBudgetExcluded(project);
}
if (filter.startsWith("zammadState:")) {
return matchesZammadStateFilter(project.zammad_state_id, filter.slice("zammadState:".length) as Parameters<typeof matchesZammadStateFilter>[1]);
}
@@ -558,6 +567,12 @@ function matchesProjectFilters(project: ProjectSummary, filters: string[], organ
return false;
}
const showTeamspaceExcluded = filters.includes("teamspaceExcluded");
if (projectBudgetExcluded(project) !== showTeamspaceExcluded) {
return false;
}
return groupedProjectFilterMatch(filters, projectFilterGroup, (filter) => matchesSingleProjectFilter(project, filter, standardTeamspaceOnly));
}
@@ -26,6 +26,9 @@ type RecurringFormState = {
organizationName: string | null;
activity: string;
workType: WorkType;
teamspaceProject: string;
hourBudgetHours: string;
manualConsumptionHours: string;
recurrenceType: "weekly" | "every_n_weeks";
intervalValue: number;
validFrom: string;
@@ -50,6 +53,9 @@ function emptyRecurringForm(): RecurringFormState {
organizationName: null,
activity: "",
workType: "support",
teamspaceProject: "",
hourBudgetHours: "",
manualConsumptionHours: "",
recurrenceType: "weekly",
intervalValue: 1,
validFrom: currentDay(),
@@ -118,6 +124,9 @@ export function RecurringBillingsPage() {
organizationName: billing.organization_name,
activity: billing.activity,
workType: billing.work_type,
teamspaceProject: billing.teamspace_project ?? "",
hourBudgetHours: billing.hour_budget_minutes === null ? "" : durationHoursLabel(billing.hour_budget_minutes),
manualConsumptionHours: billing.manual_consumption_minutes > 0 ? durationHoursLabel(billing.manual_consumption_minutes) : "",
recurrenceType: billing.recurrence_type,
intervalValue: billing.recurrence_type === "weekly" ? 1 : billing.interval_value,
validFrom: billing.valid_from,
@@ -162,8 +171,26 @@ export function RecurringBillingsPage() {
}
let slots: Array<{ id?: string; weekday: number | null; startTime: string; durationMinutes: number }>;
let hourBudgetMinutes: number | null;
let manualConsumptionMinutes: number;
try {
const parseOptionalHours = (value: string, label: string) => {
if (!value.trim()) {
return null;
}
const hours = Number(value.replace(",", "."));
if (!Number.isFinite(hours) || hours < 0) {
throw new Error(`${label} prüfen`);
}
return Math.round(hours * 60);
};
hourBudgetMinutes = parseOptionalHours(recurringForm.hourBudgetHours, "Budget");
manualConsumptionMinutes = parseOptionalHours(recurringForm.manualConsumptionHours, "Manuelle Stunden") ?? 0;
slots = recurringForm.slots.map((slot) => {
const hours = Number(slot.durationHours.replace(",", "."));
@@ -191,6 +218,9 @@ export function RecurringBillingsPage() {
organizationId: recurringForm.organizationId,
activity: recurringForm.activity,
workType: recurringForm.workType,
teamspaceProject: recurringForm.teamspaceProject.trim() || null,
hourBudgetMinutes,
manualConsumptionMinutes,
recurrenceType: recurringForm.recurrenceType,
intervalValue: recurringForm.recurrenceType === "weekly" ? 1 : recurringForm.intervalValue,
validFrom: recurringForm.validFrom,
@@ -355,6 +385,44 @@ export function RecurringBillingsPage() {
) : null}
</div>
<div className="grid gap-3 lg:grid-cols-[minmax(220px,1fr)_180px_180px]">
<div className="space-y-2">
<label className="text-sm font-medium" htmlFor="recurring-teamspace-project">
TS-Projekt (optional)
</label>
<Input
id="recurring-teamspace-project"
value={recurringForm.teamspaceProject}
onChange={(event) => setRecurringForm({ ...recurringForm, teamspaceProject: event.currentTarget.value })}
placeholder="Teamspace-Projekt"
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium" htmlFor="recurring-budget-hours">
Budget in Stunden (optional)
</label>
<Input
id="recurring-budget-hours"
inputMode="decimal"
value={recurringForm.hourBudgetHours}
onChange={(event) => setRecurringForm({ ...recurringForm, hourBudgetHours: event.currentTarget.value })}
placeholder="z. B. 20"
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium" htmlFor="recurring-manual-hours">
Manuelle Stunden (optional)
</label>
<Input
id="recurring-manual-hours"
inputMode="decimal"
value={recurringForm.manualConsumptionHours}
onChange={(event) => setRecurringForm({ ...recurringForm, manualConsumptionHours: event.currentTarget.value })}
placeholder="z. B. 2,5"
/>
</div>
</div>
<div className="space-y-2">
<label className="text-sm font-medium" htmlFor="recurring-activity">
Tätigkeit
@@ -473,6 +541,9 @@ export function RecurringBillingsPage() {
{billing.valid_until ? ` bis ${formatDateLabel(billing.valid_until)}` : ""}
</p>
<div className="mt-1 flex flex-wrap gap-1">
{billing.teamspace_project ? <Badge variant="secondary">TS {billing.teamspace_project}</Badge> : null}
{billing.hour_budget_minutes !== null ? <Badge variant="outline">Budget {formatMinutes(billing.hour_budget_minutes)}</Badge> : null}
{billing.manual_consumption_minutes > 0 ? <Badge variant="outline">Manuell {formatMinutes(billing.manual_consumption_minutes)}</Badge> : null}
{billing.slots.map((slot) => (
<Badge key={slot.id} variant="outline">
{slot.weekday === null ? "Starttag" : weekdays.find((weekday) => weekday.value === slot.weekday)?.label} · {slot.start_time?.slice(0, 5)} · {formatMinutes(slot.duration_minutes)}
+16 -16
View File
@@ -147,21 +147,21 @@ function DeltaBadge({ minutes }: { minutes: number }) {
function deltaDescription(minutes: number) {
if (minutes > 0) {
return "Teamspace liegt über der abgerechneten Zeit";
return "TT-Teamspace liegt über der Session-Zeit";
}
if (minutes < 0) {
return "Teamspace liegt unter der abgerechneten Zeit";
return "TT-Teamspace liegt unter der Session-Zeit";
}
return "Teamspace und abgerechnete Zeit sind ausgeglichen";
return "TT-Teamspace und Session-Zeit sind ausgeglichen";
}
function TimeComparison({ trackedMinutes, crmMinutes, sessions }: { trackedMinutes: number; crmMinutes: number; sessions?: number }) {
return (
<div className="space-y-0.5 text-sm">
<div className={`font-semibold ${trackedTextClass}`}>Abgerechnet {formatMinutes(trackedMinutes)}</div>
<div className={`font-semibold ${teamspaceTextClass}`}>Teamspace {formatMinutes(crmMinutes)}</div>
<div className={`font-semibold ${trackedTextClass}`}>Session-Zeit {formatMinutes(trackedMinutes)}</div>
<div className={`font-semibold ${teamspaceTextClass}`}>TT-Teamspace {formatMinutes(crmMinutes)}</div>
{typeof sessions === "number" ? <div className="text-xs text-muted-foreground">{sessions} Session(s)</div> : null}
</div>
);
@@ -176,7 +176,7 @@ function ColorLegend({ showDifference = true }: { showDifference?: boolean }) {
</span>
<span className="inline-flex items-center gap-1.5">
<span className={`h-0.5 w-4 ${teamspaceDotClass}`} />
<span className={teamspaceTextClass}>Teamspace</span>
<span className={teamspaceTextClass}>TT-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" />
@@ -433,7 +433,7 @@ function TicketHotspot({
<p className="truncate text-sm text-muted-foreground">{ticket.organization_name ?? "Keine Organisation"}</p>
<div className="mt-1 flex flex-wrap gap-x-3 gap-y-1 text-xs">
<span className={trackedTextClass}>Abgerechnet {formatMinutes(ticket.total_minutes)}</span>
<span className={teamspaceTextClass}>Teamspace {formatMinutes(ticket.crm_billed_minutes)}</span>
<span className={teamspaceTextClass}>TT-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>
@@ -653,7 +653,7 @@ export function StatisticsPage({ currentUser, onNavigate }: StatisticsPageProps)
<h2 className="text-2xl font-semibold tracking-normal">Statistiken</h2>
<p className="text-sm text-muted-foreground">
{teamspaceTimeTrackingEnabled
? "Monatswerte, Teamspace-Abgleich, Organisationen und offene Nacharbeit."
? "Monatswerte, TT-Teamspace-Abgleich, Organisationen und offene Nacharbeit."
: "Monatswerte, Session-Status, Organisationen und Teamspace-Istwerte."}
</p>
</div>
@@ -702,7 +702,7 @@ export function StatisticsPage({ currentUser, onNavigate }: StatisticsPageProps)
helpLabel="Hilfe zu Bewertungen"
/>
<MetricCard
label="Teamspace"
label="TT-Teamspace"
value={formatMinutes(totals?.crmBilledMinutes ?? 0)}
detail={teamspaceTimeTrackingEnabled
? <span className={deltaTextClass(totals?.crmDeltaMinutes ?? 0)}>Differenz {formatTeamspaceDelta(totals?.crmDeltaMinutes ?? 0)}</span>
@@ -734,12 +734,12 @@ export function StatisticsPage({ currentUser, onNavigate }: StatisticsPageProps)
<CardTitle>Monatsverlauf und Abrechnungsqualität</CardTitle>
<CardDescription>
{teamspaceTimeTrackingEnabled
? "Abgerechnete Zeit pro Tag, Teamspace-Linie und Bewertungsstand in einem Überblick."
? "Abgerechnete Zeit pro Tag, TT-Teamspace-Linie und Bewertungsstand in einem Überblick."
: "Abgerechnete Zeit pro Tag und Bewertungsstand in einem Überblick."}
</CardDescription>
</div>
<div className="flex items-center gap-2">
{teamspaceTimeTrackingEnabled ? <Badge variant="outline">{crmCoverage}% Teamspace-Abdeckung</Badge> : <Badge variant="outline">Teamspace als Ist-Quelle</Badge>}
{teamspaceTimeTrackingEnabled ? <Badge variant="outline">{crmCoverage}% TT-Teamspace-Abdeckung</Badge> : <Badge variant="outline">Teamspace als Ist-Quelle</Badge>}
<HelpLink anchor="statistik-verlauf" label="Hilfe zu Monatsverlauf und Abrechnungsqualität" />
</div>
</CardHeader>
@@ -768,13 +768,13 @@ export function StatisticsPage({ currentUser, onNavigate }: StatisticsPageProps)
<div className="space-y-2 rounded-md border bg-muted/20 p-3">
<div className="flex items-start justify-between gap-3">
<div>
<p className="text-sm font-medium">Teamspace-Abgleich</p>
<p className="text-sm font-medium">TT-Teamspace-Abgleich</p>
<p className="text-xs text-muted-foreground">{deltaDescription(totals?.crmDeltaMinutes ?? 0)}</p>
</div>
<DeltaBadge minutes={totals?.crmDeltaMinutes ?? 0} />
</div>
<TimeComparison trackedMinutes={totals?.minutes ?? 0} crmMinutes={totals?.crmBilledMinutes ?? 0} sessions={totals?.billedSessions ?? 0} />
<div className="text-xs text-muted-foreground">Teamspace-Abdeckung: {crmCoverage}% der abgerechneten Zeit</div>
<div className="text-xs text-muted-foreground">TT-Teamspace-Abdeckung: {crmCoverage}% der Session-Zeit</div>
</div>
) : (
<div className="space-y-2 rounded-md border bg-muted/20 p-3">
@@ -812,7 +812,7 @@ export function StatisticsPage({ currentUser, onNavigate }: StatisticsPageProps)
<p className="truncate font-semibold">{topOrganization.organization_name}</p>
<p className="flex flex-wrap gap-x-3 gap-y-1 text-xs">
<span className={trackedTextClass}>Abgerechnet {formatMinutes(topOrganization.total_minutes)}</span>
<span className={teamspaceTextClass}>Teamspace {formatMinutes(topOrganization.crm_billed_minutes)}</span>
<span className={teamspaceTextClass}>TT-Teamspace {formatMinutes(topOrganization.crm_billed_minutes)}</span>
</p>
</div>
) : null}
@@ -879,7 +879,7 @@ export function StatisticsPage({ currentUser, onNavigate }: StatisticsPageProps)
<Progress value={percentage(organization.total_minutes, maxOrganizationMinutes)} />
<div className="flex flex-wrap gap-x-3 gap-y-1 text-xs">
<span className={trackedTextClass}>Abgerechnet {formatMinutes(organization.total_minutes)}</span>
<span className={teamspaceTextClass}>Teamspace {formatMinutes(organization.crm_billed_minutes)}</span>
<span className={teamspaceTextClass}>TT-Teamspace {formatMinutes(organization.crm_billed_minutes)}</span>
<span className="text-muted-foreground">{percentage(organization.total_minutes, totals?.minutes ?? 0)}% vom Monat</span>
{teamspaceTimeTrackingEnabled ? <span className={deltaTextClass(organization.crm_delta_minutes)}>{formatTeamspaceDelta(organization.crm_delta_minutes)}</span> : null}
</div>
@@ -948,7 +948,7 @@ export function StatisticsPage({ currentUser, onNavigate }: StatisticsPageProps)
<Progress value={percentage(group.total_minutes, totals?.minutes ?? 0)} />
<div className="flex flex-wrap gap-x-3 gap-y-1 text-xs">
<span className={trackedTextClass}>Abgerechnet {formatMinutes(group.total_minutes)}</span>
<span className={teamspaceTextClass}>Teamspace {formatMinutes(group.crm_billed_minutes)}</span>
<span className={teamspaceTextClass}>TT-Teamspace {formatMinutes(group.crm_billed_minutes)}</span>
</div>
<div className="grid gap-2 text-xs text-muted-foreground sm:grid-cols-3">
<span>Abr. {group.billed_sessions} / {formatMinutes(group.billed_minutes)}</span>
+310 -136
View File
@@ -12,10 +12,12 @@ import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { CopyTicketButton } from "@/components/CopyTicketButton";
import { HelpLink } from "@/components/HelpLink";
import { ListClockIcon } from "@/components/icons/ListClockIcon";
import { OrganizationSelect } from "@/components/OrganizationSelect";
import { readRichActivityEditorValue, RichActivityEditor } from "@/components/RichActivityEditor";
import { TodoPanel } from "@/components/TodoPanel";
import {
acknowledgeMissingTicketDayBilling,
acknowledgeProjectReview,
createSession,
deleteProject,
@@ -34,6 +36,7 @@ import {
updateSessionBilling,
updateSessionDetails,
updateTicket,
updateTicketAtossSupport,
updateTicketDayBilling,
updateProject,
updateProjectStatus
@@ -41,7 +44,7 @@ import {
import type { CreateSessionResult, TicketZammadMailSessionItem, TicketZammadMailSessionResult } from "../api";
import { currentDay, currentMonth, formatDate, formatDateTime, formatMinutes, formatTimeRange } from "../format";
import { ticketHasRecurringBilling } from "../recurring-billings";
import type { AuthUser, BillingStatus, PeriodType, ProjectSummary, ProjectTeamspaceEntry, ProjectTeamspaceSubentry, SessionEntry, TicketDetailPeriodType, TicketPeriod, WorkType } from "../types";
import type { AuthUser, BillingStatus, PeriodType, ProjectSummary, ProjectTeamspaceEntry, ProjectTeamspaceSubentry, SessionEntry, TicketDetailPeriodType, TicketPeriod, TicketTeamspaceDay, WorkType } from "../types";
import { ZammadPriorityBadge, ZammadStateBadge } from "../zammad-meta";
import { zammadArticleTypeFromValue, zammadArticleTypeLabel, zammadArticleTypeOptions, zammadArticleTypeValue, type ZammadArticleTypeId } from "../zammad-article-types";
import { getLocalStorageValue, setLocalStorageValue } from "@/lib/local-storage.client";
@@ -152,6 +155,20 @@ function formatSignedMinutes(minutes: number) {
return `${minutes > 0 ? "+" : "-"}${formatMinutes(Math.abs(minutes))}`;
}
function formatSegmentClock(value: string) {
const date = new Date(value);
if (Number.isNaN(date.getTime())) {
return "--:--";
}
return date.toLocaleTimeString("de-DE", { hour: "2-digit", minute: "2-digit" });
}
function formatWorkSegmentRange(segment: SessionEntry["work_segments"][number]) {
return `${formatSegmentClock(segment.startedAt)} - ${segment.endedAt ? formatSegmentClock(segment.endedAt) : "läuft"}`;
}
function formatTeamspaceMissingTarget(minutes: number, teamspaceTimeTrackingEnabled: boolean) {
if (minutes === 0) {
return "ausgeglichen";
@@ -958,6 +975,7 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onS
const [loadingId, setLoadingId] = useState<string | null>(null);
const [deletingId, setDeletingId] = useState<string | null>(null);
const [savingTicket, setSavingTicket] = useState(false);
const [savingAtossSupport, setSavingAtossSupport] = useState(false);
const [savingFavorite, setSavingFavorite] = useState(false);
const [isFavorite, setIsFavorite] = useState(false);
const [editTicketNumber, setEditTicketNumber] = useState("");
@@ -980,6 +998,7 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onS
const [sendingZammadSessionId, setSendingZammadSessionId] = useState<string | null>(null);
const [dayBillingInputs, setDayBillingInputs] = useState<Record<string, string>>({});
const [savingDayBillingKey, setSavingDayBillingKey] = useState<string | null>(null);
const [acknowledgingTeamspaceDay, setAcknowledgingTeamspaceDay] = useState<string | null>(null);
const [selectedTeamspaceEntryId, setSelectedTeamspaceEntryId] = useState<string | null>(null);
const [ticketSession, setTicketSession] = useState<TicketSessionFormState>(() => emptyTicketSessionForm());
const [savingTicketSession, setSavingTicketSession] = useState(false);
@@ -1042,6 +1061,24 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onS
}
}
async function setMissingTeamspaceDayAcknowledgement(day: TicketTeamspaceDay, acknowledged: boolean) {
setAcknowledgingTeamspaceDay(day.day);
try {
await acknowledgeMissingTicketDayBilling(ticketId, day.day, acknowledged);
toast.success(acknowledged ? "Teamspace-Prüfung abgehakt" : "Kenntnisnahme zurückgenommen", {
description: formatDate(`${day.day}T00:00:00`)
});
await load();
} catch (error) {
toast.error(acknowledged ? "Konnte nicht abgehakt werden" : "Konnte nicht zurückgenommen werden", {
description: error instanceof Error ? error.message : "Unbekannter Fehler"
});
} finally {
setAcknowledgingTeamspaceDay(null);
}
}
useEffect(() => {
void load();
}, [periodType, period, ticketId, globalMonthFilter, globalStatusFilter, globalSourceFilter]);
@@ -1127,6 +1164,7 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onS
const ticket = data?.ticket;
const project = data?.project ?? null;
const ticketSessionEffectiveWorkType = ticket?.organization_id && ticket.work_type ? ticket.work_type : ticketSession.workType;
const isRecurringBillingTicket = ticketHasRecurringBilling(ticket);
const projectDescription = project ? combinedProjectDescription(project) : "";
const projectEntries = useMemo(() => (project ? projectTeamspaceEntries(project) : []), [project]);
@@ -1173,10 +1211,10 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onS
const hasMultipleProjectTeamspaceEntries = projectEntries.length > 1;
const hasProjectTeamspaceApiIds = projectEntries.some((entry) => Boolean(entry.teamspace_api_project_id));
const showTeamspaceSubentryCard = Boolean(project && hasProjectTeamspaceApiIds);
const showTeamspaceSubentryCard = false;
const usesTeamspaceSubentries = false;
const budgetExcluded = project ? projectBudgetExcluded(project) : false;
const budgetConsumptionExcluded = budgetExcluded || isRecurringBillingTicket;
const budgetConsumptionExcluded = budgetExcluded;
const showBudgetedProjectEntries = budgetedProjectEntries.length > 1;
const requiresProjectTeamspaceEntry = Boolean(project && hasMultipleProjectTeamspaceEntries);
const teamspaceTimeTrackingEnabled = currentUser.teamspace_time_tracking_enabled ?? true;
@@ -1196,7 +1234,13 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onS
|| project?.owner_username
|| (project?.zammad_owner_id ? `Zammad-Owner ${project.zammad_owner_id}` : null)
|| zammadOwnerLabel;
const currentZammadUserLabel = currentUser.zammad_user_id ? `Zammad-User ${currentUser.zammad_user_id}` : "dein Zammad-User";
const canChooseAtossSupportFallback = Boolean(
ticket?.work_type === "support"
&& !isRecurringBillingTicket
&& !project
&& !ticket.teamspace_assignment_rule_id
&& !ticket.teamspace_assignment_project
);
const isProject = Boolean(project);
const isGlobalTicket = periodType === "all";
const isPeriodClosed = Boolean(data?.closed);
@@ -1217,7 +1261,7 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onS
? "im gesamten Ticketverlauf"
: `im Monat ${globalMonthFilter}`
: `im ${periodLabel.toLowerCase()} ${period}`;
const teamspaceLabel = isGlobalTicket || periodType === "month" ? "Teamspace gesamt" : "Teamspace Tag";
const teamspaceLabel = isGlobalTicket || periodType === "month" ? "Für Teamspace gesamt" : "Für Teamspace Tag";
const availableMonths = data?.availableMonths ?? [];
const selectedMonthHasSessions = availableMonths.some((month) => month.month === globalMonthFilter);
const defaultProjectEntryId = projectEntries.find((entry) => entry.is_default)?.id ?? null;
@@ -1272,12 +1316,28 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onS
return billingMap;
}, [teamspaceMonthActuals]);
const ticketTeamspaceDayByDayMap = useMemo(
() => new Map((data?.ticketTeamspaceDays ?? []).map((day) => [day.day, day])),
[data?.ticketTeamspaceDays]
);
const teamspaceDayDeviation = (day: string) => {
const ticketComparison = ticketTeamspaceDayByDayMap.get(day);
if (ticketComparison) {
return {
ttMinutes: Number(ticketComparison.tracked_minutes ?? 0),
tsMinutes: Number(ticketComparison.crm_billed_minutes ?? 0),
deltaMinutes: Number(ticketComparison.delta_minutes ?? 0),
comparison: ticketComparison
};
}
if (!project || ticket?.work_type === "support" || budgetExcluded) {
return {
ttMinutes: 0,
tsMinutes: 0,
deltaMinutes: 0
deltaMinutes: 0,
comparison: null
};
}
@@ -1287,7 +1347,8 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onS
return {
ttMinutes,
tsMinutes,
deltaMinutes: tsMinutes - ttMinutes
deltaMinutes: tsMinutes - ttMinutes,
comparison: null
};
};
const unassignedTeamspaceSessions = useMemo(
@@ -1397,13 +1458,36 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onS
});
}
for (const comparison of data?.ticketTeamspaceDays ?? []) {
const hasAttention = Number(comparison.delta_minutes ?? 0) !== 0
|| comparison.tt_value_without_billed_session
|| comparison.missing_tt_value;
if (!hasAttention || Array.from(groups.values()).some((group) => group.day === comparison.day)) {
continue;
}
const key = teamspaceBillingKey(comparison.day, null);
groups.set(key, {
key,
day: comparison.day,
projectTeamspaceEntryId: null,
label: formatDate(`${comparison.day}T00:00:00`),
totalMinutes: 0,
billableMinutes: 0,
nonBillableMinutes: 0,
openMinutes: 0,
sessions: []
});
}
return Array.from(groups.values())
.map((group) => ({
...group,
sessions: [...group.sessions].sort((left, right) => new Date(right.started_at).getTime() - new Date(left.started_at).getTime())
}))
.sort((left, right) => right.key.localeCompare(left.key));
}, [activeTeamspaceEntryId, data?.sessions, projectEntries, usesTeamspaceEntryTabs, usesTeamspaceSubentries]);
}, [activeTeamspaceEntryId, data?.sessions, data?.ticketTeamspaceDays, projectEntries, usesTeamspaceEntryTabs, usesTeamspaceSubentries]);
const sessionIds = useMemo(() => sessionGroups.flatMap((group) => group.sessions.map((session) => session.id)), [sessionGroups]);
const allSessionsExpanded = sessionIds.length > 0 && sessionIds.every((sessionId) => expandedSessionIds.includes(sessionId));
const periodTotalMinutes = sessionGroups.reduce((sum, group) => sum + group.totalMinutes, 0);
@@ -1697,6 +1781,29 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onS
}
}
async function setTicketAtossSupport(enabled: boolean) {
if (!ticket || ticket.work_type !== "support" || ticket.can_manage_atoss_support !== true) {
return;
}
setSavingAtossSupport(true);
try {
await updateTicketAtossSupport(ticket.id, enabled);
toast.success(enabled ? "Atoss-Support-Fallback aktiviert" : "Atoss-Support-Fallback deaktiviert", {
description: enabled
? "Der Fallback greift nur, wenn weder ein Projekt noch eine Zuordnungsregel vorhanden ist."
: "Ohne Projekt oder Regel bleibt das Ticket künftig unzugeordnet."
});
await load();
} catch (error) {
toast.error("Atoss-Support-Fallback konnte nicht gespeichert werden", {
description: error instanceof Error ? error.message : "Unbekannter Fehler"
});
} finally {
setSavingAtossSupport(false);
}
}
function openProjectEditor() {
if (!project) {
return;
@@ -1946,6 +2053,7 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onS
projectTeamspaceEntryId: effectiveWorkType === "consulting" && requiresProjectTeamspaceEntry ? ticketSession.projectTeamspaceEntryId : null,
billingStatus: ticketSession.billingStatus,
createZammadArticle: ticketSession.createZammadArticle && hasActivityContent(activityValue.activity, activityValue.zammadArticleBody),
zammadArticleDateHeader: true,
zammadArticleTypeId: ticketSession.zammadArticleTypeId
});
toast.success("Session nachgetragen", {
@@ -2235,50 +2343,35 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onS
</Alert>
) : null}
{ticket?.is_foreign_zammad_owner ? (
<Alert className="py-3 text-sm">
<AlertCircle className="size-4" />
<AlertTitle>Anderer Zammad-Owner</AlertTitle>
<AlertDescription>
Dieses Ticket gehört in Zammad {zammadOwnerLabel ? `${zammadOwnerLabel}` : "einem anderen Owner"}; du bist {currentZammadUserLabel}. Du kannst deine Sessions sehen, Projekt- und Ticketdaten sind für dich geschützt.
</AlertDescription>
</Alert>
) : null}
{ticketManagedByZammad ? (
<Alert className="py-3 text-sm">
<AlertCircle className="size-4" />
<AlertTitle>Ticketdaten werden von Zammad verwaltet</AlertTitle>
<AlertDescription>
Nummer, Art, Titel und Organisation wurden durch den Zammad-Sync übernommen und können in TicketTracker nicht manuell geändert werden.
</AlertDescription>
</Alert>
) : null}
<div className="grid gap-4">
<Card>
<CardHeader className="p-4 pb-2">
<CardHeader className="p-3 pb-2">
<div className="flex items-start justify-between gap-3">
<div>
<CardTitle>Ticketdaten</CardTitle>
<CardDescription>
{canEditTicketData
? "Änderungen an Organisation und Art werden auf alle vorhandenen Sessions dieses Tickets übernommen."
: ticketManagedByZammad
? "Nummer, Art, Titel und Organisation kommen aus Zammad und sind lokal gesperrt."
: "Nur der Owner kann Ticketdaten ändern."}
</CardDescription>
</div>
<CardTitle className="flex min-w-0 flex-wrap items-center gap-2">
Ticketdaten
{ticket?.is_foreign_zammad_owner ? (
<Badge variant="warning" title={zammadOwnerLabel ? `Zammad-Owner: ${zammadOwnerLabel}` : "Anderer Zammad-Owner"}>Fremder Zammad-Owner</Badge>
) : null}
{ticketManagedByZammad ? <Badge variant="secondary">Zammad-verwaltet</Badge> : null}
{isRecurringBillingTicket ? <Badge variant="secondary">Fixabrechnung</Badge> : null}
{ticket?.teamspace_assignment_project ? (
<Badge variant="success" title={ticket.teamspace_assignment_rule_name ?? "Automatische Zuordnung"}>
TS automatisch: {ticket.teamspace_assignment_project}
</Badge>
) : null}
{canChooseAtossSupportFallback && ticket?.atoss_support ? <Badge variant="secondary">Atoss-Fallback</Badge> : null}
{canChooseAtossSupportFallback && !ticket?.atoss_support ? <Badge variant="warning">Keine TS-Zuordnung</Badge> : null}
</CardTitle>
<HelpLink anchor="ticketdaten" label="Hilfe zu Ticketdaten" />
</div>
</CardHeader>
<CardContent className="px-4 pb-3">
<form className="grid gap-3 md:grid-cols-2 xl:grid-cols-[140px_145px_minmax(220px,1fr)_minmax(240px,1.2fr)_150px] xl:items-end" onSubmit={saveTicketData}>
<div className="space-y-2">
<CardContent className="px-3 pb-3">
<form className="grid gap-2 md:grid-cols-2 xl:grid-cols-[140px_145px_minmax(220px,1fr)_minmax(240px,1.2fr)_150px] xl:items-end" onSubmit={saveTicketData}>
<div className="space-y-1">
<label className="text-sm font-medium" htmlFor="edit-ticket-number">Ticketnummer</label>
<Input id="edit-ticket-number" value={editTicketNumber} disabled={!canEditTicketData} onChange={(event) => setEditTicketNumber(event.currentTarget.value)} required />
</div>
<div className="space-y-2">
<div className="space-y-1">
<label className="text-sm font-medium" htmlFor="edit-ticket-work-type">Art</label>
<select
id="edit-ticket-work-type"
@@ -2291,11 +2384,11 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onS
<option value="consulting">Consulting</option>
</select>
</div>
<div className="space-y-2">
<div className="space-y-1">
<label className="text-sm font-medium" htmlFor="edit-ticket-title">Titel</label>
<Input id="edit-ticket-title" value={editTicketTitle} disabled={!canEditTicketData} onChange={(event) => setEditTicketTitle(event.currentTarget.value)} placeholder="optional" />
</div>
<div className="space-y-2">
<div className="space-y-1">
<label className="text-sm font-medium" htmlFor="edit-ticket-organization">Organisation</label>
{ticket?.customer_name && !ticket.organization_id ? (
<p className="text-xs text-muted-foreground">Bisheriger Freitext: {ticket.customer_name}</p>
@@ -2316,6 +2409,17 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onS
{savingTicket ? "Speichert..." : "Speichern"}
</Button>
</form>
{canChooseAtossSupportFallback ? (
<div className="mt-2 flex items-center gap-2 rounded-md border bg-muted/20 px-2.5 py-2">
<Checkbox
id="ticket-atoss-support"
checked={Boolean(ticket?.atoss_support)}
disabled={ticket?.can_manage_atoss_support !== true || savingAtossSupport}
onCheckedChange={(checked) => void setTicketAtossSupport(checked === true)}
/>
<label className="text-sm font-medium" htmlFor="ticket-atoss-support">Atoss-Support-Fallback</label>
</div>
) : null}
</CardContent>
</Card>
</div>
@@ -2332,13 +2436,13 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onS
{project.closed_at ? <Badge variant="secondary">Geschlossen</Badge> : <Badge variant="success">Offen</Badge>}
<ZammadStateBadge stateId={project.zammad_state_id} />
<ZammadPriorityBadge priorityId={project.zammad_priority_id} />
{!canEditProject ? <Badge variant="outline">geteilt</Badge> : null}
{!canEditProject ? <Badge variant="outline">{isRecurringBillingTicket ? "Fixabrechnung" : "geteilt"}</Badge> : null}
{project.close_review_required ? <Badge variant="warning">TS/TT prüfen</Badge> : null}
{unassignedTeamspaceEntryCount > 0 ? <Badge variant="warning">{unassignedTeamspaceEntryCount} ohne TS</Badge> : null}
{unassignedTeamspaceSubentryCount > 0 ? <Badge variant="warning">{unassignedTeamspaceSubentryCount} ohne Pos.</Badge> : null}
</CardTitle>
<CardDescription>
{!canEditProject && projectOwnerLabel
{!canEditProject && !isRecurringBillingTicket && projectOwnerLabel
? `Eigentümer: ${projectOwnerLabel}`
: showBudgetedProjectEntries
? `${budgetedProjectEntries.length} budgetierte Teamspace-Einträge`
@@ -2346,20 +2450,24 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onS
</CardDescription>
</div>
<div className="flex shrink-0 items-center gap-1">
<Button
type="button"
size="sm"
variant="secondary"
disabled={!canEditProject || savingProjectStatus}
onClick={() => void setProjectClosed(!project.closed_at)}
>
{project.closed_at ? <RotateCcw className="size-4" /> : <CheckCircle2 className="size-4" />}
{project.closed_at ? "Öffnen" : "Schließen"}
</Button>
<Button type="button" size="sm" variant="secondary" disabled={!canEditProject} onClick={openProjectEditor}>
<Pencil className="size-4" />
Bearbeiten
</Button>
{!isRecurringBillingTicket ? (
<>
<Button
type="button"
size="sm"
variant="secondary"
disabled={!canEditProject || savingProjectStatus}
onClick={() => void setProjectClosed(!project.closed_at)}
>
{project.closed_at ? <RotateCcw className="size-4" /> : <CheckCircle2 className="size-4" />}
{project.closed_at ? "Öffnen" : "Schließen"}
</Button>
<Button type="button" size="sm" variant="secondary" disabled={!canEditProject} onClick={openProjectEditor}>
<Pencil className="size-4" />
Bearbeiten
</Button>
</>
) : null}
<HelpLink anchor="projekte" label="Hilfe zu Projekten" />
</div>
</div>
@@ -2407,6 +2515,7 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onS
<Badge variant="secondary">Zeitraum {project.starts_on || "offen"} - {project.ends_on || "offen"}</Badge>
{budgetConsumptionExcluded ? <Badge variant="outline">Budgetprüfung ausgenommen</Badge> : <Badge variant="secondary">Kontingent {projectBudgetMinutes ? formatMinutes(projectBudgetMinutes) : "offen"}</Badge>}
<Badge variant="secondary">Verbrauch {formatMinutes(projectConsumptionMinutes)}</Badge>
{project.manual_consumption_minutes > 0 ? <Badge variant="outline">Manuell {formatMinutes(project.manual_consumption_minutes)}</Badge> : null}
{budgetConsumptionExcluded ? null : <Badge variant={projectRemainingMinutes !== null && projectRemainingMinutes < 0 ? "destructive" : "secondary"}>{projectRemainingLabel(projectRemainingMinutes)}</Badge>}
{!budgetConsumptionExcluded && showBudgetedProjectEntries ? <Badge variant="outline">{budgetedProjectEntries.length} Budgets</Badge> : null}
</div>
@@ -2416,9 +2525,7 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onS
<Info className="size-4" />
<AlertTitle>Budgetverbrauch nicht auswertbar</AlertTitle>
<AlertDescription>
{budgetExcluded
? "Dieses Teamspace-Projekt ist systemweit von Differenz- und Budgetprüfung ausgenommen. Der Verbrauch zählt weiter in Summen."
: "Dieses Projekt ist eine Fixabrechnung. Der Verbrauch zählt weiter in Summen."}
Dieses Teamspace-Projekt ist systemweit von Differenz- und Budgetprüfung ausgenommen. Der Verbrauch zählt weiter in Summen.
</AlertDescription>
</Alert>
) : (
@@ -2591,16 +2698,22 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onS
</Dialog>
<Card>
<CardHeader className="p-4">
<CardHeader className="p-3 pb-2">
<div className="flex items-start justify-between gap-3">
<div>
<CardTitle className="flex items-center gap-2">
<ClipboardList className="size-5 text-muted-foreground" />
ToDos
</CardTitle>
<CardDescription>Aufgaben zu diesem Ticket oder Projekt verwalten.</CardDescription>
</div>
<div className="flex shrink-0 items-center gap-2">
<CardTitle className="flex items-center gap-2">
<ClipboardList className="size-4 text-muted-foreground" />
ToDos
</CardTitle>
<HelpLink anchor="todos" label="Hilfe zu ToDos" />
</div>
</CardHeader>
<CardContent className="px-3 pb-3">
<TodoPanel
ticketId={ticket?.id}
todos={visibleTodos}
onChanged={load}
compact
toolbar={(
<div className="flex rounded-md border bg-muted/20 p-0.5">
{(["open", "all", "done"] as const).map((value) => (
<Button
@@ -2615,12 +2728,8 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onS
</Button>
))}
</div>
<HelpLink anchor="todos" label="Hilfe zu ToDos" />
</div>
</div>
</CardHeader>
<CardContent className="px-4 pb-4">
<TodoPanel ticketId={ticket?.id} todos={visibleTodos} onChanged={load} />
)}
/>
</CardContent>
</Card>
</div>
@@ -2839,7 +2948,7 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onS
</div>
<div className="flex flex-wrap items-center gap-2">
<div className="flex items-center gap-2 rounded-md border bg-muted/30 px-3 py-2 text-sm">
<span className="text-muted-foreground">Abgerechnet</span>
<span className="text-muted-foreground">Abgerechnet (netto)</span>
<span className="font-semibold">{formatMinutes(periodTotalMinutes)}</span>
</div>
<div className="flex items-center gap-2 rounded-md border bg-muted/30 px-3 py-2 text-sm">
@@ -2941,6 +3050,10 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onS
{sessionGroups.map((group) => {
const dayDeviation = teamspaceDayDeviation(group.day);
const hasDayDeviation = dayDeviation.deltaMinutes !== 0;
const dayComparison = dayDeviation.comparison;
const hasInvalidTeamspaceValue = Boolean(dayComparison?.tt_value_without_billed_session);
const hasMissingTeamspaceValue = Boolean(dayComparison?.missing_tt_value);
const showDayTeamspaceDetails = hasDayDeviation || hasInvalidTeamspaceValue || hasMissingTeamspaceValue;
return (
<div key={group.key} className="rounded-md border bg-background">
@@ -2951,39 +3064,30 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onS
</p>
<p className="text-xs text-muted-foreground">{group.sessions.length} Session(s)</p>
</div>
<Button
type="button"
size="sm"
variant="secondary"
className="h-8 min-w-32 justify-center justify-self-start"
title="Teamspace-Aufbereitung"
onClick={() => openTeamspacePreparation(group)}
>
<ClipboardList className="size-3.5" />
TS
</Button>
{group.sessions.length > 0 ? (
<Button
type="button"
size="sm"
variant="secondary"
className="h-8 min-w-32 justify-center justify-self-start"
title="Teamspace-Aufbereitung"
onClick={() => openTeamspacePreparation(group)}
>
<ClipboardList className="size-3.5" />
TS
</Button>
) : <span />}
<div className="flex min-w-0 flex-wrap gap-x-3 gap-y-1 text-xs text-muted-foreground">
<span>Abrechb. {formatMinutes(group.billableMinutes)}</span>
<span>Abger. {formatMinutes(group.totalMinutes)}</span>
<span>Nicht {formatMinutes(group.nonBillableMinutes)}</span>
</div>
<div className="flex justify-start lg:justify-center">
{hasDayDeviation ? (
<AlertCircle
className="size-4 shrink-0 text-amber-500"
aria-label="Teamspace-Tagesabweichung"
/>
) : (
<span className="h-4 w-4" aria-hidden="true" />
)}
</div>
<div className="min-w-0 text-xs lg:text-right">
<p className={cn("font-semibold", hasDayDeviation ? "text-amber-600 dark:text-amber-300" : "text-muted-foreground")}>
{formatTeamspaceDifferenceTarget(dayDeviation.deltaMinutes, teamspaceTimeTrackingEnabled)}
</p>
</div>
<div className="flex min-w-0 items-center justify-start gap-2 lg:justify-end">
{teamspaceTimeTrackingEnabled ? (
{teamspaceTimeTrackingEnabled && group.sessions.length > 0 ? (
<div className="flex min-w-0 items-center gap-1.5 text-xs text-muted-foreground">
<label htmlFor={`crm-billing-${group.key}`}>Teamspace</label>
<Input
@@ -3027,17 +3131,69 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onS
<span className="sr-only">{savingDayBillingKey === group.key ? "Speichert..." : "Speichern"}</span>
</Button>
</div>
) : (
) : group.sessions.length > 0 ? (
<span className="text-xs text-muted-foreground">Teamspace-Erfassung aus</span>
)}
) : null}
</div>
</div>
{showDayTeamspaceDetails ? (
<div className="flex flex-col gap-2 border-b bg-muted/10 px-3 py-2 sm:flex-row sm:items-center sm:justify-between">
<div className="flex min-w-0 flex-wrap items-center gap-2">
{hasMissingTeamspaceValue ? (
<Badge className={dayComparison?.acknowledged
? "bg-emerald-500/12 text-emerald-800 dark:bg-emerald-500/20 dark:text-emerald-200"
: "bg-sky-500/12 text-sky-800 dark:bg-sky-500/20 dark:text-sky-200"}
>
{dayComparison?.acknowledged ? "Fehlender TT-Wert zur Kenntnis" : "TT-Teamspace-Wert fehlt"}
</Badge>
) : null}
{hasInvalidTeamspaceValue ? (
<Badge variant="destructive">Fehler: TT-Teamspace-Wert ohne abgerechneten Eintrag</Badge>
) : null}
{hasDayDeviation ? (
<>
<Badge className="bg-cyan-500/12 text-cyan-800 dark:bg-cyan-500/20 dark:text-cyan-200">
TT-Teamspace {formatMinutes(dayDeviation.ttMinutes)}
</Badge>
<Badge className="bg-violet-500/12 text-violet-800 dark:bg-violet-500/20 dark:text-violet-200">
TS-API {formatMinutes(dayDeviation.tsMinutes)}
</Badge>
<Badge className="bg-orange-500/12 text-orange-800 dark:bg-orange-500/20 dark:text-orange-200">
{formatTeamspaceDifferenceTarget(dayDeviation.deltaMinutes, teamspaceTimeTrackingEnabled)}
</Badge>
</>
) : null}
{hasInvalidTeamspaceValue ? (
<span className="text-xs text-destructive">
Kein abgerechneter Eintrag
{Number(dayComparison?.non_billable_session_count ?? 0) > 0 ? ` · ${dayComparison?.non_billable_session_count} nicht abrechenbar` : ""}
{Number(dayComparison?.billable_session_count ?? 0) > 0 ? ` · ${dayComparison?.billable_session_count} abrechenbar` : ""}
{Number(dayComparison?.unrated_session_count ?? 0) > 0 ? ` · ${dayComparison?.unrated_session_count} ohne Bewertung` : ""}
</span>
) : null}
</div>
{hasMissingTeamspaceValue && dayComparison ? (
<Button
type="button"
size="sm"
variant={dayComparison.acknowledged ? "secondary" : "ghost"}
className="h-8 shrink-0"
disabled={isPeriodClosed || acknowledgingTeamspaceDay === group.day}
onClick={() => void setMissingTeamspaceDayAcknowledgement(dayComparison, !dayComparison.acknowledged)}
>
{dayComparison.acknowledged ? "Zurücknehmen" : "Zur Kenntnis"}
</Button>
) : null}
</div>
) : null}
<div className="space-y-1.5 p-2 sm:p-3 lg:px-0">
{group.sessions.map((session) => {
const expanded = expandedSessionIds.includes(session.id);
const zammadSent = session.zammad_article_status === "sent";
const zammadPending = session.zammad_article_status === "pending";
const zammadMailImported = Boolean(session.zammad_source_article_id);
const workSegments = Array.isArray(session.work_segments) ? session.work_segments : [];
const hasWorkSegments = workSegments.length > 1;
const canSendZammad = !zammadMailImported && hasActivityContent(session.activity, session.zammad_article_body);
const sendingZammad = sendingZammadSessionId === session.id;
const billedMissingTeamspaceEntry =
@@ -3048,7 +3204,7 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onS
return (
<div key={session.id} className="rounded-md border bg-card/40">
<div className="grid gap-2 p-2 lg:grid-cols-[220px_minmax(0,150px)_minmax(260px,1fr)_220px] lg:items-center">
<div className="grid gap-2 p-2 lg:grid-cols-[220px_minmax(0,170px)_minmax(260px,1fr)_220px] lg:items-center">
<button
type="button"
className="flex min-w-0 items-center gap-2 rounded-md text-left text-sm text-muted-foreground hover:cursor-pointer hover:text-foreground focus-visible:outline-none focus-visible:ring-3 focus-visible:ring-ring/50"
@@ -3072,9 +3228,16 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onS
</Badge>
) : null}
</button>
<div className="flex min-w-0 flex-col items-start gap-1">
<Badge variant="outline" className="w-fit">{formatMinutes(session.rounded_minutes)}</Badge>
{session.recurring_billing_id ? <Badge variant="secondary">fix</Badge> : null}
<div className="grid min-w-0 grid-cols-[minmax(0,max-content)_2rem] items-start gap-2">
<div className="flex min-w-0 flex-col items-start gap-1">
<Badge variant="outline" className="w-fit">{formatMinutes(session.rounded_minutes)}</Badge>
{session.recurring_billing_id ? <Badge variant="secondary">fix</Badge> : null}
</div>
<div className="flex size-8 items-center justify-center">
{hasWorkSegments ? (
<ListClockIcon className="size-4 text-muted-foreground" aria-label="Arbeitsbereiche gespeichert" />
) : null}
</div>
</div>
<BillingButtons
session={session}
@@ -3124,31 +3287,42 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onS
</div>
{expanded ? (
<div className="border-t px-3 pb-3 pt-2">
<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>
{zammadMailImported ? (
<span className="inline-flex items-center gap-1">
<MailCheck className="size-3.5 text-sky-500" aria-hidden="true" />
<span>Zammad-Mail importiert · Article {session.zammad_source_article_id}</span>
</span>
) : null}
{!zammadMailImported ? (
<span className="inline-flex items-center gap-1">
{zammadSent ? <Send className="size-3.5 text-emerald-500" aria-hidden="true" /> : zammadPending ? <RotateCcw className="size-3.5 text-amber-500" aria-hidden="true" /> : <Send className="size-3.5 text-muted-foreground/60" aria-hidden="true" />}
<span>
{zammadSent
? `Zammad übertragen · ${zammadArticleTypeLabel(session.zammad_article_type_id)}`
: zammadPending
? `Zammad geparkt · ${zammadArticleTypeLabel(session.zammad_article_type_id)}`
: canSendZammad
? "Nicht an Zammad übertragen"
: "Keine Zammad-Notiz (Tätigkeit leer)"}
</span>
</span>
) : null}
<span>Erfasst {formatDateTime(session.created_at)}</span>
<div className="grid gap-3 lg:grid-cols-[minmax(0,1fr)_auto]">
<div className="min-w-0">
<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>
{zammadMailImported ? (
<span className="inline-flex items-center gap-1">
<MailCheck className="size-3.5 text-sky-500" aria-hidden="true" />
<span>Zammad-Mail importiert · Article {session.zammad_source_article_id}</span>
</span>
) : null}
{!zammadMailImported ? (
<span className="inline-flex items-center gap-1">
{zammadSent ? <Send className="size-3.5 text-emerald-500" aria-hidden="true" /> : zammadPending ? <RotateCcw className="size-3.5 text-amber-500" aria-hidden="true" /> : <Send className="size-3.5 text-muted-foreground/60" aria-hidden="true" />}
<span>
{zammadSent
? `Zammad übertragen · ${zammadArticleTypeLabel(session.zammad_article_type_id)}`
: zammadPending
? `Zammad geparkt · ${zammadArticleTypeLabel(session.zammad_article_type_id)}`
: canSendZammad
? "Nicht an Zammad übertragen"
: "Keine Zammad-Notiz (Tätigkeit leer)"}
</span>
</span>
) : null}
<span>Erfasst {formatDateTime(session.created_at)}</span>
</div>
</div>
{hasWorkSegments ? (
<div className="space-y-1 text-right text-xs text-muted-foreground">
{workSegments.map((segment, index) => (
<div key={`${segment.startedAt}-${segment.endedAt ?? "open"}-${index}`}>{formatWorkSegmentRange(segment)}</div>
))}
</div>
) : null}
</div>
</div>
) : null}
+28 -3
View File
@@ -17,7 +17,7 @@ import type { CreateSessionResult } from "../api";
import { formatTimer } from "../format";
import { isRecurringBillingTicketNumber, ticketHasRecurringBilling } from "../recurring-billings";
import { activeElapsedMs, mergeTimerActivityDrafts, removeTimerActivityDraft, ticketPattern, type TimerEntry, writeTimerActivityDraft } from "../timers";
import type { AuthUser, BillingStatus, RecurringBilling, TicketMeta, WorkType } from "../types";
import type { AuthUser, BillingStatus, RecurringBilling, TicketMeta, WorkSegment, WorkType } from "../types";
import { zammadArticleTypeFromValue, zammadArticleTypeOptions, zammadArticleTypeValue, type ZammadArticleTypeId } from "../zammad-article-types";
type ManualSessionFormState = {
@@ -77,6 +77,7 @@ type FinishedSession = {
endedAt: string;
durationSeconds: number;
roundedMinutes: number;
workSegments: WorkSegment[];
};
function ticketTeamspaceEntries(ticket: TicketMeta | null | undefined) {
@@ -104,6 +105,8 @@ type TimerPageProps = {
setSelectedTimerId: Dispatch<SetStateAction<string | null>>;
tick: number;
currentUser: AuthUser;
finishRequestId?: string | null;
onFinishRequestHandled?: () => void;
};
function BillingStatusSelect({
@@ -297,7 +300,7 @@ function InlineImageActivityEditor({
);
}
export function TimerPage({ timers, setTimers, selectedTimerId, setSelectedTimerId, tick, currentUser }: TimerPageProps) {
export function TimerPage({ timers, setTimers, selectedTimerId, setSelectedTimerId, tick, currentUser, finishRequestId, onFinishRequestHandled }: TimerPageProps) {
const [ticketNumber, setTicketNumber] = useState("");
const [finished, setFinished] = useState<FinishedSession | null>(null);
const [finishingId, setFinishingId] = useState<string | null>(null);
@@ -680,6 +683,7 @@ export function TimerPage({ timers, setTimers, selectedTimerId, setSelectedTimer
});
setTimers(mergeTimerActivityDrafts(currentUser.id, timerResult.timers));
setSelectedTimerId(timer.id);
const finishedTimer = timerResult.timers.find((entry) => entry.id === timer.id) ?? timer;
const result = await lookupTicket(timer.ticketNumber);
const ticket = result.ticket;
@@ -711,7 +715,8 @@ export function TimerPage({ timers, setTimers, selectedTimerId, setSelectedTimer
startedAt: new Date(timer.startedAt).toISOString(),
endedAt: new Date(now).toISOString(),
durationSeconds,
roundedMinutes
roundedMinutes,
workSegments: currentUser.session_work_segments_enabled ? finishedTimer.workSegments ?? [] : []
});
} catch (error) {
toast.error("Ticket konnte nicht geprüft werden", {
@@ -722,6 +727,22 @@ export function TimerPage({ timers, setTimers, selectedTimerId, setSelectedTimer
}
}
useEffect(() => {
if (!finishRequestId) {
return;
}
const timerExists = timers.some((timer) => timer.id === finishRequestId);
onFinishRequestHandled?.();
if (!timerExists) {
return;
}
setSelectedTimerId(finishRequestId);
void finishTimer(finishRequestId);
}, [finishRequestId]);
async function submitSession(event: FormEvent) {
event.preventDefault();
@@ -768,6 +789,7 @@ export function TimerPage({ timers, setTimers, selectedTimerId, setSelectedTimer
startedAt: finished.startedAt,
endedAt: finished.endedAt,
durationSeconds: finished.durationSeconds,
workSegments: finished.workSegments,
organizationId: effectiveOrganizationId,
activity: activityValue.activity,
zammadArticleBody: activityValue.zammadArticleBody || null,
@@ -875,6 +897,7 @@ export function TimerPage({ timers, setTimers, selectedTimerId, setSelectedTimer
projectTeamspaceEntryId: manualRequiresTeamspaceEntry ? manualSession.projectTeamspaceEntryId : null,
billingStatus: manualSession.billingStatus,
createZammadArticle: manualSession.createZammadArticle && hasActivityContent(activityValue.activity, activityValue.zammadArticleBody),
zammadArticleDateHeader: true,
zammadArticleTypeId: manualSession.zammadArticleTypeId
});
toast.success("Session nachgetragen", {
@@ -897,6 +920,8 @@ export function TimerPage({ timers, setTimers, selectedTimerId, setSelectedTimer
}
}
const finishedEffectiveWorkType = finished?.ticket?.organization_id && finished.ticket.work_type ? finished.ticket.work_type : workType;
return (
<div className="space-y-4">
<section className="space-y-4">
+3 -6
View File
@@ -924,7 +924,7 @@ export function ZammadSyncPage({ currentUser }: ZammadSyncPageProps) {
const { result } = await syncTeamspaceProjectIds(zammadSyncPageState.teamspaceSyncMonth);
setZammadSyncPageState({ teamspaceProjectIdResult: result });
toast.success("Teamspace-Projekt-IDs synchronisiert", {
description: `${result.updated} aktualisiert, ${result.alreadyCurrent} bereits aktuell, ${result.subentriesSynced} Unterposition(en), TS ${formatMinutes(result.teamspaceTimeBillableMinutes)} im Monat ${result.month}.`
description: `${result.updated} aktualisiert, ${result.alreadyCurrent} bereits aktuell, TS ${formatMinutes(result.teamspaceTimeBillableMinutes)} im Monat ${result.month}.`
});
} catch (error) {
toast.error("Teamspace-Projekt-IDs konnten nicht synchronisiert werden", {
@@ -1113,7 +1113,7 @@ export function ZammadSyncPage({ currentUser }: ZammadSyncPageProps) {
<DatabaseZap className="size-5 text-muted-foreground" />
Teamspace-Projekt-IDs
</CardTitle>
<CardDescription>Offene Consulting-Projekte gegen die Teamspace-API auflösen und API-ID, Gesamtbudget sowie Unterpositionen aus Teamspace speichern.</CardDescription>
<CardDescription>Offene Consulting-Projekte gegen die Teamspace-API auflösen und Hauptprojekt-ID sowie Gesamtbudget aus Teamspace speichern.</CardDescription>
</div>
<Badge variant={teamspaceReady ? "success" : "secondary"}>{teamspaceReady ? "bereit" : "nicht bereit"}</Badge>
</div>
@@ -1140,7 +1140,7 @@ export function ZammadSyncPage({ currentUser }: ZammadSyncPageProps) {
</div>
<Button type="button" disabled={!teamspaceReady || individualSyncDisabled} onClick={() => void runTeamspaceProjectIdSync()}>
<RefreshCw className="size-4" />
{syncingTeamspaceProjectIds ? "Synchronisiert..." : "Teamspace-IDs, Budgets und Positionen synchronisieren"}
{syncingTeamspaceProjectIds ? "Synchronisiert..." : "Teamspace-IDs und Budgets synchronisieren"}
</Button>
</div>
{teamspaceProjectIdResult ? (
@@ -1150,7 +1150,6 @@ export function ZammadSyncPage({ currentUser }: ZammadSyncPageProps) {
["Monat", teamspaceProjectIdResult.month],
["TT-Einträge", teamspaceProjectIdResult.checkedEntries],
["TS gelesen", teamspaceProjectIdResult.remoteProjectsScanned],
["TS Positionen gelesen", teamspaceProjectIdResult.remoteSubprojectsScanned],
["TS Zeiten gelesen", teamspaceProjectIdResult.teamspaceTimeEntriesScanned],
["TS Zeiten zugeordnet", teamspaceProjectIdResult.teamspaceTimeEntriesMapped],
["TS Monat", formatMinutes(teamspaceProjectIdResult.teamspaceTimeBillableMinutes)],
@@ -1158,8 +1157,6 @@ export function ZammadSyncPage({ currentUser }: ZammadSyncPageProps) {
["Treffer", teamspaceProjectIdResult.matched],
["IDs aktualisiert", teamspaceProjectIdResult.updated],
["Aktuell", teamspaceProjectIdResult.alreadyCurrent],
["Positionen", teamspaceProjectIdResult.subentriesSynced],
["Entfernt", teamspaceProjectIdResult.subentriesRemoved],
["Offen", teamspaceProjectIdResult.missing + teamspaceProjectIdResult.ambiguous]
].map(([label, value]) => (
<div key={label} className="rounded-md border bg-muted/20 px-3 py-2">