This commit is contained in:
michael committed 2026-10-08 09:03:15 +02:00
1 parent fc0dfd0556
commit 649dd6f028
20 files changed
+3725 -988

No files matched your search

+48 -2
View File
@@ -1,5 +1,5 @@
import { ChangeEvent, FormEvent, useEffect, useState } from "react";
import { CheckCircle2, DatabaseBackup, DatabaseZap, Save, Shield, Upload, UserPlus } from "lucide-react";
import { CheckCircle2, DatabaseBackup, DatabaseZap, Save, Shield, Unplug, Upload, UserPlus } from "lucide-react";
import { toast } from "sonner";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
@@ -14,9 +14,11 @@ import {
createAdminUser,
downloadDatabaseExport,
getAdminUsers,
getTeamspaceSettings,
getZammadSettings,
restoreDatabaseBackup,
saveZammadSettings,
saveTeamspaceSettings,
syncZammadOrganizations,
updateAdminUser,
validateDatabaseBackup,
@@ -54,8 +56,10 @@ export function AdminUsersPage({ currentUser }: { currentUser: AuthUser }) {
const [zammadBaseUrl, setZammadBaseUrl] = useState("");
const [zammadApiKey, setZammadApiKey] = useState("");
const [hasZammadApiKey, setHasZammadApiKey] = useState(false);
const [teamspaceBaseUrl, setTeamspaceBaseUrl] = useState("");
const [syncingOrganizations, setSyncingOrganizations] = useState(false);
const [savingZammadSettings, setSavingZammadSettings] = useState(false);
const [savingTeamspaceSettings, setSavingTeamspaceSettings] = useState(false);
const [exportingDatabase, setExportingDatabase] = useState(false);
const [restoreSql, setRestoreSql] = useState("");
const [restoreValidation, setRestoreValidation] = useState<RestoreValidationResult | null>(null);
@@ -68,12 +72,13 @@ export function AdminUsersPage({ currentUser }: { currentUser: AuthUser }) {
async function load(options: { preserveEdits?: boolean } = {}) {
setLoading(true);
try {
const [usersResult, zammadSettingsResult] = await Promise.all([getAdminUsers(), getZammadSettings()]);
const [usersResult, zammadSettingsResult, teamspaceSettingsResult] = await Promise.all([getAdminUsers(), getZammadSettings(), getTeamspaceSettings()]);
setUsers(usersResult.users);
if (!options.preserveEdits) {
setZammadBaseUrl(zammadSettingsResult.settings.baseUrl);
setHasZammadApiKey(zammadSettingsResult.settings.hasApiKey);
setTeamspaceBaseUrl(teamspaceSettingsResult.settings.baseUrl);
}
setForms((current) =>
@@ -212,6 +217,26 @@ export function AdminUsersPage({ currentUser }: { currentUser: AuthUser }) {
}
}
async function saveTeamspaceAccess() {
setSavingTeamspaceSettings(true);
try {
const result = await saveTeamspaceSettings({
baseUrl: teamspaceBaseUrl
});
setTeamspaceBaseUrl(result.settings.baseUrl);
toast.success("Teamspace-Zugang gespeichert", {
description: "URL ist hinterlegt."
});
} catch (error) {
toast.error("Teamspace-Zugang konnte nicht gespeichert werden", {
description: error instanceof Error ? error.message : "Unbekannter Fehler"
});
} finally {
setSavingTeamspaceSettings(false);
}
}
async function exportDatabase() {
setExportingDatabase(true);
@@ -370,6 +395,27 @@ export function AdminUsersPage({ currentUser }: { currentUser: AuthUser }) {
</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="grid gap-3 xl:grid-cols-[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>
<Input
id="teamspace-base-url"
placeholder="https://app1.teamspace.de"
value={teamspaceBaseUrl}
onChange={(event) => setTeamspaceBaseUrl(event.currentTarget.value)}
/>
</div>
<Button type="button" variant="secondary" disabled={savingTeamspaceSettings} onClick={() => void saveTeamspaceAccess()}>
<Save className="size-4" />
{savingTeamspaceSettings ? "Speichert..." : "URL speichern"}
</Button>
</div>
</div>
</CardContent>
</Card>
+10 -3
View File
@@ -14,9 +14,10 @@ import { HelpLink } from "@/components/HelpLink";
import { MultiFilterDropdown, type MultiFilterOption } from "@/components/MultiFilterDropdown";
import { downloadMonthExport, getPeriodOverview } from "../api";
import { currentDay, currentMonth, formatDateTime, formatMinutes } from "../format";
import type { PeriodOverview, PeriodType, TicketSummary } from "../types";
import type { AuthUser, PeriodOverview, PeriodType, TicketSummary } from "../types";
type AnalysisPageProps = {
currentUser: AuthUser;
onNavigate: (to: string) => void;
};
@@ -327,7 +328,7 @@ function groupTicketSections(tickets: TicketSummary[], groupBy: TicketGroupBy):
});
}
export function AnalysisPage({ onNavigate }: AnalysisPageProps) {
export function AnalysisPage({ currentUser, onNavigate }: AnalysisPageProps) {
const [periodType, setPeriodType] = useState<PeriodType>("month");
const [period, setPeriod] = useState(currentMonth());
const [overview, setOverview] = useState<PeriodOverview | null>(null);
@@ -438,11 +439,17 @@ export function AnalysisPage({ onNavigate }: AnalysisPageProps) {
}
const totals = overview?.totals;
const teamspaceTimeTrackingEnabled = currentUser.teamspace_time_tracking_enabled ?? true;
const periodLabel = periodType === "month" ? "Monat" : "Tag";
const metricCards: Array<{ label: string; value: string | number; detail?: string; icon: LucideIcon }> = [
{ label: "Tickets", value: totals?.tickets ?? 0, icon: Ticket },
{ label: "Sessions", value: totals?.sessions ?? 0, icon: Users },
{ label: "Abgerechnet", value: formatMinutes(totals?.minutes ?? 0), detail: `Teamspace ${formatMinutes(totals?.crmBilledMinutes ?? 0)}`, icon: Clock3 },
{
label: "Abgerechnet",
value: formatMinutes(totals?.minutes ?? 0),
detail: teamspaceTimeTrackingEnabled ? `Teamspace ${formatMinutes(totals?.crmBilledMinutes ?? 0)}` : "Ist aus Teamspace",
icon: Clock3
},
{ label: "Abrechenbar", value: formatMinutes(totals?.billableMinutes ?? 0), icon: UserCheck },
{ label: "Offen", value: totals?.openSessions ?? 0, detail: formatMinutes(totals?.openMinutes ?? 0), icon: UserCheck }
];
+73 -48
View File
@@ -66,9 +66,10 @@ import type {
DashboardFavorite,
DashboardPreference,
DashboardSortDirection,
DashboardWidget,
DashboardWidgetType,
PeriodOverview,
DashboardWidget,
DashboardWidgetType,
AuthUser,
PeriodOverview,
ProjectTeamspaceEntry,
ProjectSummary,
RecurringBilling,
@@ -78,6 +79,7 @@ import type {
} from "../types";
type DashboardPageProps = {
currentUser: AuthUser;
onNavigate: (to: string) => void;
};
@@ -885,7 +887,7 @@ function sortedFavorites(favorites: DashboardFavorite[], metas: Record<string, T
});
}
export function DashboardPage({ onNavigate }: DashboardPageProps) {
export function DashboardPage({ currentUser, onNavigate }: DashboardPageProps) {
const gridRef = useRef<HTMLDivElement | null>(null);
const [preference, setPreference] = useState<DashboardPreference>(() => normalizePreference({}));
const [loading, setLoading] = useState(true);
@@ -1007,11 +1009,12 @@ export function DashboardPage({ onNavigate }: DashboardPageProps) {
};
}, [favoriteMetas, preference.favorites]);
const maxGridRow = useMemo(
() => Math.max(6, dragPreview ? dragPreview.y + dragPreview.h + 1 : 0, ...preference.widgets.map((widget) => widget.y + widget.h + 1)),
[dragPreview, preference.widgets]
);
const dashboardStats = useMemo(() => {
const maxGridRow = useMemo(
() => Math.max(6, dragPreview ? dragPreview.y + dragPreview.h + 1 : 0, ...preference.widgets.map((widget) => widget.y + widget.h + 1)),
[dragPreview, preference.widgets]
);
const teamspaceTimeTrackingEnabled = currentUser.teamspace_time_tracking_enabled ?? true;
const dashboardStats = useMemo(() => {
const monthTotals = monthOverview?.totals;
const dayTotals = todayOverview?.totals;
const overBudget = projects.filter((project) => project.hour_budget_minutes !== null && project.hour_budget_minutes !== undefined && (project.consumption_minutes ?? 0) > project.hour_budget_minutes);
@@ -1048,17 +1051,18 @@ export function DashboardPage({ onNavigate }: DashboardPageProps) {
dayTotals,
statistics: statisticsOverview,
openRatings: monthOverview?.openSessions ?? [],
todayTickets: todayOverview?.tickets ?? [],
todayOpenRatings: todayOverview?.openSessions ?? [],
dayControlTickets,
fixedBillingTickets,
teamspaceDelta: (monthTotals?.minutes ?? 0) - (monthTotals?.crmBilledMinutes ?? 0),
overBudget,
todayTickets: todayOverview?.tickets ?? [],
todayOpenRatings: todayOverview?.openSessions ?? [],
dayControlTickets,
fixedBillingTickets,
teamspaceTimeTrackingEnabled,
teamspaceDelta: teamspaceTimeTrackingEnabled ? (monthTotals?.minutes ?? 0) - (monthTotals?.crmBilledMinutes ?? 0) : 0,
overBudget,
nearlyFull,
openTodos: todos.filter((todo) => todo.status === "open"),
overdueTodos: todos.filter((todo) => todo.status === "open" && todo.ends_on && todo.ends_on < currentDay())
};
}, [dayControlTickets, monthOverview, projects, recurringBillings, statisticsOverview, todayOverview, todos]);
}, [dayControlTickets, monthOverview, projects, recurringBillings, statisticsOverview, teamspaceTimeTrackingEnabled, todayOverview, todos]);
function updateWidget(widgetId: string, changes: Partial<DashboardWidget>) {
setPreference((current) => ({
@@ -1384,9 +1388,10 @@ function useDashboardStatsPlaceholder() {
monthTotals: null as PeriodOverview["totals"] | null | undefined,
dayTotals: null as PeriodOverview["totals"] | null | undefined,
statistics: null as StatisticsOverview | null,
openRatings: [] as PeriodOverview["openSessions"],
fixedBillingTickets: new Set<string>(),
teamspaceDelta: 0,
openRatings: [] as PeriodOverview["openSessions"],
fixedBillingTickets: new Set<string>(),
teamspaceTimeTrackingEnabled: true,
teamspaceDelta: 0,
overBudget: [] as ProjectSummary[],
nearlyFull: [] as ProjectSummary[],
todayTickets: [] as PeriodOverview["tickets"],
@@ -1559,17 +1564,22 @@ function WidgetContent(props: WidgetContentProps) {
);
}
if (widget.type === "teamspace") {
const delta = stats.teamspaceDelta;
return (
<MetricGrid
items={[
{ label: "TS-Zeit Monat", value: formatMinutes(stats.monthTotals?.crmBilledMinutes ?? 0), icon: LayoutDashboard },
{ label: "Abgerechnet - TS", value: `${delta >= 0 ? "+" : "-"}${formatMinutes(Math.abs(delta))}`, icon: ArrowUp, tone: delta === 0 ? "normal" : delta > 0 ? "warning" : "good" }
]}
/>
);
}
if (widget.type === "teamspace") {
const delta = stats.teamspaceDelta;
return (
<MetricGrid
items={stats.teamspaceTimeTrackingEnabled
? [
{ label: "TS-Zeit Monat", value: formatMinutes(stats.monthTotals?.crmBilledMinutes ?? 0), icon: LayoutDashboard },
{ label: "Abgerechnet - TS", value: `${delta >= 0 ? "+" : "-"}${formatMinutes(Math.abs(delta))}`, icon: ArrowUp, tone: delta === 0 ? "normal" : delta > 0 ? "warning" : "good" }
]
: [
{ label: "TS-Erfassung", value: "Aus", icon: LayoutDashboard, tone: "normal" },
{ label: "Monatsprüfung", value: "Status", icon: CheckCircle2, tone: "good" }
]}
/>
);
}
if (widget.type === "openRatings") {
const filterValues = readFilterValues(widget.config, ["today", "older3", "fixedBilling", "manualBilling"]);
@@ -1684,6 +1694,7 @@ type DayControlListItem = {
function DayControlWidget({ stats, projects, todos, onNavigate }: WidgetContentProps) {
const today = currentDay();
const teamspaceTimeTrackingEnabled = stats.teamspaceTimeTrackingEnabled;
const overdueTodos = stableSort(
todos.filter((todo) => todo.status === "open" && todo.ends_on && todo.ends_on < today),
(left, right) => compareOptionalDate(left.ends_on, right.ends_on, "asc") || compareText(left.title, right.title)
@@ -1701,12 +1712,21 @@ function DayControlWidget({ stats, projects, todos, onNavigate }: WidgetContentP
);
const todayTickets = stableSort(stats.todayTickets, (left, right) => compareText(left.ticket_number, right.ticket_number));
const todayOpenRatings = stableSort(stats.todayOpenRatings, (left, right) => compareOptionalDate(left.started_at, right.started_at, "asc") || compareText(left.ticket_number, right.ticket_number));
const teamspaceReviewTickets = todayTickets.filter((ticket) => ticket.open_count === 0 && teamspaceDeltaMinutes(ticket) > 0);
const afterworkTickets = todayTickets.filter((ticket) => ticket.open_count > 0 || teamspaceDeltaMinutes(ticket) > 0 || (ticket.total_minutes === 0 && (ticket.billable_count > 0 || ticket.non_billable_count > 0)));
const teamspaceReviewTickets = teamspaceTimeTrackingEnabled ? todayTickets.filter((ticket) => ticket.open_count === 0 && teamspaceDeltaMinutes(ticket) > 0) : [];
const afterworkTickets = todayTickets.filter((ticket) =>
ticket.open_count > 0
|| (teamspaceTimeTrackingEnabled && teamspaceDeltaMinutes(ticket) > 0)
|| (ticket.total_minutes === 0 && (ticket.billable_count > 0 || ticket.non_billable_count > 0))
);
const budgetAttention = stableSort(
projects.filter((project) => {
const percent = projectConsumptionPercent(project);
return !project.closed_at && (Boolean(percent && percent >= 90) || Number(project.unassigned_teamspace_session_count ?? 0) > 0 || Boolean(project.close_review_required));
return !project.closed_at && (
Boolean(percent && percent >= 90)
|| Number(project.unassigned_teamspace_session_count ?? 0) > 0
|| Number(project.unassigned_teamspace_subentry_count ?? 0) > 0
|| Boolean(project.close_review_required)
);
}),
(left, right) => (compareNumber(projectConsumptionPercent(right) ?? 0, projectConsumptionPercent(left) ?? 0)) || compareText(left.ticket_number, right.ticket_number)
);
@@ -1748,12 +1768,14 @@ function DayControlWidget({ stats, projects, todos, onNavigate }: WidgetContentP
done: waitingTickets.length === 0,
action: waitingTickets.length === 0 ? "Keine fälligen Wiedervorlagen." : `${waitingTickets.length} Wiedervorlage(n) bearbeiten oder neues Datum setzen.`
},
{
key: "teamspace",
label: "Teamspace geprüft",
done: teamspaceReviewTickets.length === 0,
action: teamspaceReviewTickets.length === 0 ? "Teamspace passt für heute." : `${teamspaceReviewTickets.length} Teamspace-Abgleich(e) korrigieren.`
},
{
key: "teamspace",
label: teamspaceTimeTrackingEnabled ? "Teamspace geprüft" : "TS-Erfassung aus",
done: teamspaceReviewTickets.length === 0,
action: teamspaceTimeTrackingEnabled
? teamspaceReviewTickets.length === 0 ? "Teamspace passt für heute." : `${teamspaceReviewTickets.length} Teamspace-Abgleich(e) korrigieren.`
: "Monatsprüfung läuft über Session-Status."
},
{
key: "budget",
label: "Budget geprüft",
@@ -1791,8 +1813,8 @@ function DayControlWidget({ stats, projects, todos, onNavigate }: WidgetContentP
key: `teamspace-review-${ticket.id}`,
tone: "info" as const,
title: ticket.title || ticket.ticket_number,
detail: `${ticket.ticket_number} · TT ${formatMinutes(ticket.total_minutes)} · Teamspace ${formatMinutes(ticket.crm_billed_minutes ?? 0)}`,
note: dayControlTodaySessionReason(ticket),
detail: `${ticket.ticket_number} · TT ${formatMinutes(ticket.total_minutes)} · Teamspace ${formatMinutes(ticket.crm_billed_minutes ?? 0)}`,
note: dayControlTodaySessionReason(ticket, teamspaceTimeTrackingEnabled),
onClick: () => onNavigate(`/tickets/${ticket.id}?month=${currentMonth()}`)
}))
].slice(0, 8);
@@ -1836,8 +1858,8 @@ function DayControlWidget({ stats, projects, todos, onNavigate }: WidgetContentP
items={afterworkTickets.slice(0, 4).map((ticket) => ({
key: `today-ticket-${ticket.id}`,
title: ticket.title || ticket.ticket_number,
detail: `${ticket.ticket_number} · ${formatMinutes(ticket.total_minutes + ticket.non_billable_minutes + ticket.open_minutes)} · ${ticket.session_count} Session(s)`,
note: dayControlTodaySessionReason(ticket),
detail: `${ticket.ticket_number} · ${formatMinutes(ticket.total_minutes + ticket.non_billable_minutes + ticket.open_minutes)} · ${ticket.session_count} Session(s)`,
note: dayControlTodaySessionReason(ticket, teamspaceTimeTrackingEnabled),
onClick: () => onNavigate(`/tickets/${ticket.id}?month=${currentMonth()}`)
}))}
/>
@@ -1860,17 +1882,20 @@ function DayControlWidget({ stats, projects, todos, onNavigate }: WidgetContentP
items={budgetAttention.slice(0, 4).map((project) => {
const percent = projectConsumptionPercent(project);
const hasUnassignedSessions = Number(project.unassigned_teamspace_session_count ?? 0) > 0;
const hasUnassignedSubentries = Number(project.unassigned_teamspace_subentry_count ?? 0) > 0;
const detail = project.close_review_required
? project.zammad_merged_from_ticket_id || project.zammad_state_id !== "4" ? "Merge prüfen" : "Abschluss prüfen"
: hasUnassignedSessions
? `${project.unassigned_teamspace_session_count} Session(s) ohne TS-Zuordnung`
: hasUnassignedSubentries
? `${project.unassigned_teamspace_subentry_count} Session(s) ohne Unterposition`
: `${percent ?? 0}% Kontingent verbraucht`;
const note = project.close_review_required
? project.zammad_merged_from_ticket_id || project.zammad_state_id !== "4"
? "Lokale Projektdaten aus dem alten Ticket prüfen und in Zammad nachtragen."
: "Abschluss in TT/Teamspace prüfen und ggf. Umbuchungen klären."
: hasUnassignedSessions
? "Sessions einem Teamspace-Projekt zuordnen."
: hasUnassignedSessions || hasUnassignedSubentries
? "Sessions über die Session-Bearbeitung zuordnen."
: "Kontingent kritisch: Budget, Verbrauch und weitere Arbeit prüfen.";
return {
@@ -1888,18 +1913,18 @@ function DayControlWidget({ stats, projects, todos, onNavigate }: WidgetContentP
);
}
function dayControlTodaySessionReason(ticket: PeriodOverview["tickets"][number]) {
function dayControlTodaySessionReason(ticket: PeriodOverview["tickets"][number], teamspaceTimeTrackingEnabled = true) {
if (ticket.open_count > 0) {
return `${ticket.open_count} offene Bewertung(en): Bewertungsstatus setzen.`;
}
const crmMinutes = ticket.crm_billed_minutes ?? 0;
const deltaMinutes = teamspaceDeltaMinutes(ticket);
if (ticket.total_minutes > 0 && deltaMinutes > 0) {
if (teamspaceTimeTrackingEnabled && ticket.total_minutes > 0 && deltaMinutes > 0) {
return `Teamspace nachtragen: ${formatMinutes(deltaMinutes)} fehlen.`;
}
if (ticket.total_minutes > 0 && deltaMinutes === 0) {
if (teamspaceTimeTrackingEnabled && ticket.total_minutes > 0 && deltaMinutes === 0) {
return `Teamspace ok: ${formatMinutes(crmMinutes)} erfasst.`;
}
+93
View File
@@ -24,9 +24,13 @@ export function ProfilePage({ currentUser, onUserUpdated }: ProfilePageProps) {
const [confirmPassword, setConfirmPassword] = useState("");
const [zammadApiToken, setZammadApiToken] = useState("");
const [zammadTokenDirty, setZammadTokenDirty] = useState(false);
const [teamspaceDeviceId, setTeamspaceDeviceId] = useState("");
const [teamspaceDevicePassword, setTeamspaceDevicePassword] = useState("");
const [teamspaceCredentialsDirty, setTeamspaceCredentialsDirty] = useState(false);
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 [teamspaceTimeTrackingEnabled, setTeamspaceTimeTrackingEnabled] = useState(currentUser.teamspace_time_tracking_enabled ?? true);
const [zammadSessionNoteDefaultEnabled, setZammadSessionNoteDefaultEnabled] = useState(currentUser.zammad_session_note_default_enabled);
const [zammadTimeUnitRoundingEnabled, setZammadTimeUnitRoundingEnabled] = useState(currentUser.zammad_time_unit_rounding_enabled);
const [zammadTimeUnitRoundingMinutes, setZammadTimeUnitRoundingMinutes] = useState(currentUser.zammad_time_unit_rounding_minutes);
@@ -46,9 +50,13 @@ export function ProfilePage({ currentUser, onUserUpdated }: ProfilePageProps) {
setDisplayName(currentUser.display_name);
setZammadApiToken("");
setZammadTokenDirty(false);
setTeamspaceDeviceId("");
setTeamspaceDevicePassword("");
setTeamspaceCredentialsDirty(false);
setZammadEmailSessionWebhookEnabled(currentUser.zammad_email_session_webhook_enabled);
setZammadAtossEmailSyncEnabled(currentUser.zammad_atoss_email_sync_enabled);
setSessionBillingPromptEnabled(currentUser.session_billing_prompt_enabled);
setTeamspaceTimeTrackingEnabled(currentUser.teamspace_time_tracking_enabled ?? true);
setZammadSessionNoteDefaultEnabled(currentUser.zammad_session_note_default_enabled);
setZammadTimeUnitRoundingEnabled(currentUser.zammad_time_unit_rounding_enabled);
setZammadTimeUnitRoundingMinutes(currentUser.zammad_time_unit_rounding_minutes);
@@ -134,11 +142,13 @@ export function ProfilePage({ currentUser, onUserUpdated }: ProfilePageProps) {
currentPassword: wantsPasswordChange ? currentPassword : undefined,
newPassword: wantsPasswordChange ? newPassword : undefined,
...(currentUser.role === "user" && zammadTokenDirty ? { zammadApiToken } : {}),
...(currentUser.role === "user" && teamspaceCredentialsDirty ? { teamspaceDeviceId, teamspaceDevicePassword } : {}),
...(currentUser.role === "user"
? {
zammadEmailSessionWebhookEnabled,
zammadAtossEmailSyncEnabled,
sessionBillingPromptEnabled,
teamspaceTimeTrackingEnabled,
zammadSessionNoteDefaultEnabled,
zammadTimeUnitRoundingEnabled,
zammadTimeUnitRoundingMinutes,
@@ -160,6 +170,9 @@ export function ProfilePage({ currentUser, onUserUpdated }: ProfilePageProps) {
setConfirmPassword("");
setZammadApiToken("");
setZammadTokenDirty(false);
setTeamspaceDeviceId("");
setTeamspaceDevicePassword("");
setTeamspaceCredentialsDirty(false);
toast.success("Profil gespeichert");
} catch (error) {
toast.error("Profil konnte nicht gespeichert werden", {
@@ -301,6 +314,86 @@ export function ProfilePage({ currentUser, onUserUpdated }: ProfilePageProps) {
) : null}
</div>
</div>
<div className="mt-4 rounded-md border bg-background p-3">
<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="grid gap-3 md:grid-cols-[minmax(180px,220px)_minmax(220px,1fr)_180px]">
<div className="space-y-2">
<label className="text-sm font-medium" htmlFor="profile-teamspace-device-id">
Geräte-ID
</label>
<Input
id="profile-teamspace-device-id"
value={teamspaceDeviceId}
placeholder={currentUser.has_teamspace_credentials ? "hinterlegt" : "Geräte-ID"}
autoComplete="off"
onChange={(event) => {
setTeamspaceDeviceId(event.currentTarget.value);
setTeamspaceCredentialsDirty(true);
}}
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium" htmlFor="profile-teamspace-device-password">
Gerätepasswort
</label>
<Input
id="profile-teamspace-device-password"
type="password"
value={teamspaceDevicePassword}
placeholder={currentUser.has_teamspace_credentials ? "••••••••••••••••" : "Token aus Teamspace einfügen"}
autoComplete="off"
onChange={(event) => {
setTeamspaceDevicePassword(event.currentTarget.value);
setTeamspaceCredentialsDirty(true);
}}
/>
<p className="text-xs text-muted-foreground">Die Teamspace-ID wird nur geprüft, wenn du neue Zugangsdaten speicherst.</p>
</div>
<div className="space-y-2">
<label className="text-sm font-medium" htmlFor="profile-teamspace-user-id">
Teamspace User-ID
</label>
<Input id="profile-teamspace-user-id" value={currentUser.teamspace_user_id ?? "nicht verknüpft"} readOnly />
{currentUser.has_teamspace_credentials ? (
<Button
type="button"
size="sm"
variant="secondary"
className="w-full"
onClick={() => {
setTeamspaceDeviceId("");
setTeamspaceDevicePassword("");
setTeamspaceCredentialsDirty(true);
}}
>
<X className="size-4" />
Zugang entfernen
</Button>
) : null}
</div>
</div>
</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">
<Clock3 className="size-4 text-muted-foreground" />
<div>
<label className="text-sm font-medium" htmlFor="profile-teamspace-time-tracking">
Teamspace-Zeiten in TT erfassen
</label>
<p className="text-xs text-muted-foreground">
Wenn aus, werden keine Teamspace-Tageswerte in TT erfasst; Monatsprüfung und Abschluss laufen nur über Session-Status.
</p>
</div>
</div>
<Switch
id="profile-teamspace-time-tracking"
checked={teamspaceTimeTrackingEnabled}
onCheckedChange={setTeamspaceTimeTrackingEnabled}
/>
</div>
<div className="mt-4 flex items-center justify-between gap-3 rounded-md border bg-background p-3">
<div className="flex items-center gap-2">
<MailPlus className="size-4 text-muted-foreground" />
+82 -78
View File
@@ -1,4 +1,4 @@
import { ArrowDown, ArrowUp, CheckCircle2, Eye, EyeOff, MoreHorizontal, Pencil, Play, Plus, RotateCcw, Save, Search, SlidersHorizontal, Star, Trash2 } from "lucide-react";
import { AlertCircle, ArrowDown, ArrowUp, CheckCircle2, Eye, EyeOff, MoreHorizontal, Pencil, Play, Plus, RotateCcw, Save, Search, SlidersHorizontal, Star, Trash2 } from "lucide-react";
import { FormEvent, useEffect, useMemo, useState } from "react";
import { toast } from "sonner";
@@ -19,7 +19,7 @@ import { acknowledgeProjectReview, addFavoriteTicket, createProject, getProjects
import { currentMonth, formatMinutes } from "../format";
import { projectHasRecurringBilling } from "../recurring-billings";
import { ticketPattern } from "../timers";
import type { ProjectSummary, ProjectTeamspaceEntry, TicketMeta } from "../types";
import type { AuthUser, ProjectSummary, ProjectTeamspaceEntry, TicketMeta } from "../types";
import {
matchesZammadPriorityFilter,
matchesZammadStateFilter,
@@ -36,6 +36,7 @@ import {
} from "../zammad-meta";
type ProjectsPageProps = {
currentUser: AuthUser;
onNavigate: (to: string) => void;
onStartTimer: (ticketNumber: string) => Promise<boolean>;
};
@@ -411,21 +412,11 @@ function projectOrganizationKey(project: ProjectSummary) {
return project.organization_id ? `id:${project.organization_id}` : `name:${projectOrganizationLabel(project)}`;
}
function projectBudget(project: ProjectSummary, standardTeamspaceOnly = false) {
if (standardTeamspaceOnly) {
return defaultProjectTeamspaceEntry(project)?.hour_budget_minutes ?? null;
}
function projectBudget(project: ProjectSummary, _standardTeamspaceOnly = false) {
return project.hour_budget_minutes ?? null;
}
function projectConsumption(project: ProjectSummary, standardTeamspaceOnly = false) {
if (standardTeamspaceOnly) {
const defaultEntry = defaultProjectTeamspaceEntry(project);
return defaultEntry ? projectEntryConsumption(defaultEntry) : 0;
}
function projectConsumption(project: ProjectSummary, _standardTeamspaceOnly = false) {
return Number(project.consumption_minutes ?? 0);
}
@@ -746,6 +737,7 @@ type ProjectFormFieldsProps = {
form: ProjectForm;
setForm: (form: ProjectForm) => void;
organizationLocked: boolean;
showManualConsumption?: boolean;
lookupStatus: "idle" | "loading" | "found" | "not-found";
zammadLookupBusy?: boolean;
saving: boolean;
@@ -767,10 +759,9 @@ function normalizeProjectTeamspaceEntryForms(entries: ProjectTeamspaceEntryForm[
function parseProjectTeamspaceEntryForms(entries: ProjectTeamspaceEntryForm[]) {
const normalizedEntries = normalizeProjectTeamspaceEntryForms(entries);
const parsedEntries = normalizedEntries.map((entry) => {
const hourBudgetMinutes = parseHoursInput(entry.hourBudget);
const manualConsumptionMinutes = parseHoursInput(entry.manualConsumption);
if (hourBudgetMinutes === undefined || manualConsumptionMinutes === undefined) {
if (manualConsumptionMinutes === undefined) {
return undefined;
}
@@ -779,8 +770,8 @@ function parseProjectTeamspaceEntryForms(entries: ProjectTeamspaceEntryForm[]) {
teamspaceProject: entry.teamspaceProject.trim() || null,
teamspaceDescription: entry.teamspaceDescription.trim() || null,
isDefault: entry.isDefault,
hourBudgetMinutes,
budgetSyncDisabled: entry.budgetSyncDisabled,
hourBudgetMinutes: null,
budgetSyncDisabled: false,
manualConsumptionMinutes: manualConsumptionMinutes ?? 0
};
});
@@ -806,10 +797,12 @@ function defaultProjectTeamspacePayload(entries: ReturnType<typeof parseProjectT
function ProjectTeamspaceEntriesFields({
entries,
onChange
onChange,
showManualConsumption = true
}: {
entries: ProjectTeamspaceEntryForm[];
onChange: (entries: ProjectTeamspaceEntryForm[]) => void;
showManualConsumption?: boolean;
}) {
const normalizedEntries = normalizeProjectTeamspaceEntryForms(entries);
@@ -836,7 +829,15 @@ function ProjectTeamspaceEntriesFields({
</div>
<div className="space-y-2">
{normalizedEntries.map((entry, index) => (
<div key={entry.clientId} className="grid gap-2 rounded-lg border bg-muted/10 p-3 lg:grid-cols-[minmax(180px,1fr)_minmax(160px,0.9fr)_130px_150px_130px_120px_36px] lg:items-end">
<div
key={entry.clientId}
className={cn(
"grid gap-2 rounded-lg border bg-muted/10 p-3 lg:items-end",
showManualConsumption
? "lg:grid-cols-[minmax(180px,1fr)_minmax(160px,0.9fr)_110px_120px_36px]"
: "lg:grid-cols-[minmax(180px,1fr)_minmax(160px,0.9fr)_120px_36px]"
)}
>
<div className="space-y-1.5">
<label className="text-xs font-medium text-muted-foreground" htmlFor={`project-teamspace-${entry.clientId}`}>
Teamspace-Projekt {entry.isDefault ? "(Standard)" : ""}
@@ -858,32 +859,19 @@ function ProjectTeamspaceEntriesFields({
onChange={(event) => updateEntry(entry.clientId, { teamspaceDescription: event.currentTarget.value })}
/>
</div>
<div className="space-y-1.5">
<label className="text-xs font-medium text-muted-foreground" htmlFor={`project-budget-${entry.clientId}`}>
Budget h
</label>
<Input
id={`project-budget-${entry.clientId}`}
inputMode="decimal"
value={entry.hourBudget}
onChange={(event) => updateEntry(entry.clientId, { hourBudget: event.currentTarget.value, budgetSyncDisabled: true })}
/>
</div>
<label className="flex min-h-9 items-center gap-2 rounded-md border bg-background px-3 text-xs">
<Checkbox checked={entry.budgetSyncDisabled} onCheckedChange={(checked) => updateEntry(entry.clientId, { budgetSyncDisabled: checked === true })} />
<span>Budget schützen</span>
</label>
<div className="space-y-1.5">
<label className="text-xs font-medium text-muted-foreground" htmlFor={`project-manual-${entry.clientId}`}>
Manueller Verbrauch h
</label>
<Input
id={`project-manual-${entry.clientId}`}
inputMode="decimal"
value={entry.manualConsumption}
onChange={(event) => updateEntry(entry.clientId, { manualConsumption: event.currentTarget.value })}
/>
</div>
{showManualConsumption ? (
<div className="space-y-1.5">
<label className="text-xs font-medium text-muted-foreground" htmlFor={`project-teamspace-manual-${entry.clientId}`}>
Manuell h
</label>
<Input
id={`project-teamspace-manual-${entry.clientId}`}
inputMode="decimal"
value={entry.manualConsumption}
onChange={(event) => updateEntry(entry.clientId, { manualConsumption: event.currentTarget.value })}
/>
</div>
) : null}
<Button type="button" size="sm" variant={entry.isDefault ? "default" : "secondary"} onClick={() => setDefault(entry.clientId)}>
{entry.isDefault ? "Standard" : "Als Standard"}
</Button>
@@ -894,14 +882,11 @@ function ProjectTeamspaceEntriesFields({
</div>
))}
</div>
<p className="text-xs text-muted-foreground">
Der Standard-Eintrag bekommt die aus Teamspace gebuchten Tageswerte. Manueller Verbrauch zählt nur im Projektverbrauch.
</p>
</div>
);
}
function ProjectFormFields({ form, setForm, organizationLocked, lookupStatus, zammadLookupBusy = false, saving, submitLabel, onZammadLookup, onSubmit }: ProjectFormFieldsProps) {
function ProjectFormFields({ form, setForm, organizationLocked, showManualConsumption = true, lookupStatus, zammadLookupBusy = false, saving, submitLabel, onZammadLookup, onSubmit }: ProjectFormFieldsProps) {
return (
<form className="space-y-4" onSubmit={onSubmit}>
<div className="grid gap-3 lg:grid-cols-[180px_minmax(240px,1fr)_180px]">
@@ -975,7 +960,11 @@ function ProjectFormFields({ form, setForm, organizationLocked, lookupStatus, za
</div>
</div>
<ProjectTeamspaceEntriesFields entries={form.teamspaceEntries} onChange={(teamspaceEntries) => setForm({ ...form, teamspaceEntries })} />
<ProjectTeamspaceEntriesFields
entries={form.teamspaceEntries}
onChange={(teamspaceEntries) => setForm({ ...form, teamspaceEntries })}
showManualConsumption={showManualConsumption}
/>
<div className="space-y-2">
<label className="text-sm font-medium" htmlFor="project-description">
@@ -994,7 +983,7 @@ function ProjectFormFields({ form, setForm, organizationLocked, lookupStatus, za
);
}
export function ProjectsPage({ onNavigate, onStartTimer }: ProjectsPageProps) {
export function ProjectsPage({ currentUser, onNavigate, onStartTimer }: ProjectsPageProps) {
const [projects, setProjects] = useState<ProjectSummary[]>([]);
const [hiddenProjectCount, setHiddenProjectCount] = useState(0);
const [totalProjectCount, setTotalProjectCount] = useState(0);
@@ -1011,6 +1000,7 @@ export function ProjectsPage({ onNavigate, onStartTimer }: ProjectsPageProps) {
const [savingProjectId, setSavingProjectId] = useState<string | null>(null);
const [savingProjectStatusId, setSavingProjectStatusId] = useState<string | null>(null);
const [viewSettings, setViewSettings] = useState<ProjectViewSettings>(() => readStoredProjectViewSettings());
const teamspaceTimeTrackingEnabled = currentUser.teamspace_time_tracking_enabled ?? true;
async function load() {
setLoading(true);
@@ -1189,8 +1179,8 @@ export function ProjectsPage({ onNavigate, onStartTimer }: ProjectsPageProps) {
...entry,
teamspaceProject: entry.teamspaceProject || result.ticket?.project_name || "",
teamspaceDescription: entry.teamspaceDescription,
hourBudget: entry.hourBudget || formatHoursInput(result.ticket?.project_hour_budget_minutes ?? null),
budgetSyncDisabled: entry.hourBudget ? entry.budgetSyncDisabled : false
hourBudget: "",
budgetSyncDisabled: false
}
: entry;
});
@@ -1626,6 +1616,7 @@ export function ProjectsPage({ onNavigate, onStartTimer }: ProjectsPageProps) {
form={form}
setForm={setForm}
organizationLocked={organizationLocked}
showManualConsumption={teamspaceTimeTrackingEnabled}
lookupStatus={lookupStatus}
zammadLookupBusy={zammadLookupBusy}
saving={saving}
@@ -1663,8 +1654,11 @@ export function ProjectsPage({ onNavigate, onStartTimer }: ProjectsPageProps) {
const budgetMinutes = projectBudget(project, viewSettings.standardTeamspaceOnly);
const remaining = projectRemaining(project, viewSettings.standardTeamspaceOnly);
const usedPercent = projectConsumptionPercent(project, viewSettings.standardTeamspaceOnly);
const budgetExceeded = budgetMinutes !== null && budgetMinutes > 0 && consumptionMinutes > budgetMinutes;
const showBudgetedTeamspaceEntries = !viewSettings.standardTeamspaceOnly && budgetedTeamspaceEntries.length > 1;
const canEditProject = project.can_edit === true;
const unassignedTeamspaceEntryCount = Number(project.unassigned_teamspace_session_count ?? 0);
const unassignedTeamspaceSubentryCount = 0;
return (
<div key={project.id} className="border-b last:border-b-0">
@@ -1688,7 +1682,8 @@ export function ProjectsPage({ onNavigate, onStartTimer }: ProjectsPageProps) {
{project.closed_at ? <Badge variant="secondary">Geschlossen</Badge> : <Badge variant="success">Offen</Badge>}
{!canEditProject ? <Badge variant="outline">geteilt</Badge> : null}
{project.close_review_required ? <Badge variant="warning">TS/TT prüfen</Badge> : null}
{(project.unassigned_teamspace_session_count ?? 0) > 0 ? <Badge variant="warning">{project.unassigned_teamspace_session_count} ohne TS</Badge> : null}
{unassignedTeamspaceEntryCount > 0 ? <Badge variant="warning">{unassignedTeamspaceEntryCount} ohne TS</Badge> : null}
{unassignedTeamspaceSubentryCount > 0 ? <Badge variant="warning">{unassignedTeamspaceSubentryCount} ohne Pos.</Badge> : null}
</div>
<div className="flex flex-wrap gap-1.5">
<p className="w-full truncate text-sm font-medium xl:hidden">Zammad</p>
@@ -1700,9 +1695,10 @@ export function ProjectsPage({ onNavigate, onStartTimer }: ProjectsPageProps) {
</div>
<div className="min-w-0">
<p className="truncate text-sm font-medium xl:hidden">Verbrauch/Gesamt</p>
<p className={cn("text-sm font-semibold", remaining !== null && remaining < 0 ? "text-destructive" : undefined)}>
{formatDecimalHours(consumptionMinutes)} / {budgetMinutes ? formatDecimalHours(budgetMinutes) : "offen"}
</p>
<p className={cn("inline-flex items-center gap-1 text-sm font-semibold", remaining !== null && remaining < 0 ? "text-destructive" : undefined)}>
{budgetExceeded ? <AlertCircle className="size-3.5 text-amber-500" aria-label="Budgetüberschreitung" /> : null}
{formatDecimalHours(consumptionMinutes)} / {budgetMinutes ? formatDecimalHours(budgetMinutes) : "offen"}
</p>
{showBudgetedTeamspaceEntries ? <p className="text-xs text-muted-foreground">{budgetedTeamspaceEntries.length} Budgets</p> : null}
</div>
<div className="min-w-0 text-sm">
@@ -1769,18 +1765,24 @@ export function ProjectsPage({ onNavigate, onStartTimer }: ProjectsPageProps) {
</div>
</div>
<div className="space-y-2 px-4 pb-3">
{usedPercent !== null ? (
<div className="h-1.5 overflow-hidden rounded-full bg-muted" title={`${usedPercent}% Verbrauch`} aria-label={`${usedPercent}% Verbrauch`}>
<div className={cn("h-full rounded-full", projectConsumptionBarClass(usedPercent))} style={{ width: `${Math.min(100, usedPercent)}%` }} />
</div>
) : (
{usedPercent !== null ? (
<div className="flex items-center gap-1">
<div className="h-1.5 flex-1 overflow-hidden rounded-full bg-muted" title={`${usedPercent}% Verbrauch`} aria-label={`${usedPercent}% Verbrauch`}>
<div className={cn("h-full rounded-full", projectConsumptionBarClass(usedPercent))} style={{ width: `${Math.min(100, usedPercent)}%` }} />
</div>
{budgetExceeded ? <AlertCircle className="size-3.5 shrink-0 text-amber-500" aria-label="Budgetüberschreitung" /> : null}
</div>
) : (
<div className="h-1.5 overflow-hidden rounded-full bg-muted/30">
<div className="h-full rounded-full bg-muted-foreground/10" style={{ width: "0%" }} />
</div>
)}
{(project.unassigned_teamspace_session_count ?? 0) > 0 ? (
{unassignedTeamspaceEntryCount > 0 || unassignedTeamspaceSubentryCount > 0 ? (
<p className="rounded-md border border-amber-500/30 bg-amber-500/10 px-2 py-1 text-xs text-amber-700 dark:text-amber-300">
{project.unassigned_teamspace_session_count} Session(s) haben keinen Teamspace-Eintrag.
{[
unassignedTeamspaceEntryCount > 0 ? `${unassignedTeamspaceEntryCount} Session(s) ohne Teamspace-Projekt` : null,
unassignedTeamspaceSubentryCount > 0 ? `${unassignedTeamspaceSubentryCount} Session(s) ohne Unterposition` : null
].filter(Boolean).join(" · ")}
</p>
) : null}
{project.close_review_required ? (
@@ -1788,8 +1790,8 @@ export function ProjectsPage({ onNavigate, onStartTimer }: ProjectsPageProps) {
<span>
{project.zammad_merged_from_ticket_id || project.zammad_state_id !== "4"
? project.zammad_merged_from_ticket_id
? `Dieses Projekt wurde aus ${project.zammad_merged_from_ticket_number ?? `Zammad-ID ${project.zammad_merged_from_ticket_id}`} übernommen. Teamspace-Projekte und Budget bleiben lokal erhalten; bitte in Zammad prüfen und nachtragen.`
: "Dieses Projekt steht zur TS/TT-Prüfung, obwohl das Zammad-Ticket offen ist. Teamspace-Projekte und Budget bleiben lokal erhalten; bitte in Zammad prüfen und nachtragen."
? `Dieses Projekt wurde aus ${project.zammad_merged_from_ticket_number ?? `Zammad-ID ${project.zammad_merged_from_ticket_id}`} übernommen. Teamspace-Projekte bleiben lokal erhalten; bitte in Zammad prüfen und nachtragen.`
: "Dieses Projekt steht zur TS/TT-Prüfung, obwohl das Zammad-Ticket offen ist. Teamspace-Projekte bleiben lokal erhalten; bitte in Zammad prüfen und nachtragen."
: "Zammad meldet das Consulting-Ticket als geschlossen. Bitte Zeiten in Teamspace und TicketTracker prüfen und das Projekt danach schließen."}
</span>
<Button
@@ -1808,9 +1810,10 @@ export function ProjectsPage({ onNavigate, onStartTimer }: ProjectsPageProps) {
{showBudgetedTeamspaceEntries ? (
<div className="grid gap-1.5 md:grid-cols-2">
{budgetedTeamspaceEntries.map((entry) => {
const entryPercent = projectEntryPercent(entry);
const entryConsumption = projectEntryConsumption(entry);
const label = projectTeamspaceEntryLabel(entry);
const entryPercent = projectEntryPercent(entry);
const entryConsumption = projectEntryConsumption(entry);
const label = projectTeamspaceEntryLabel(entry);
const entryBudgetExceeded = Number(entry.hour_budget_minutes ?? 0) > 0 && entryConsumption > Number(entry.hour_budget_minutes ?? 0);
return (
<div key={entry.id} className="min-w-0 rounded-md border bg-background/50 px-2 py-1">
@@ -1818,11 +1821,11 @@ export function ProjectsPage({ onNavigate, onStartTimer }: ProjectsPageProps) {
<span className="truncate text-muted-foreground">
{label}
{entry.is_default ? " · Standard" : ""}
{entry.budget_sync_disabled ? " · Budget geschützt" : ""}
</span>
<span className="shrink-0 font-medium">
{formatMinutes(entryConsumption)} / {entry.hour_budget_minutes ? formatMinutes(entry.hour_budget_minutes) : "offen"}
</span>
<span className="inline-flex shrink-0 items-center gap-1 font-medium">
{entryBudgetExceeded ? <AlertCircle className="size-3.5 text-amber-500" aria-label="Budgetüberschreitung" /> : null}
{formatMinutes(entryConsumption)} / {entry.hour_budget_minutes ? formatMinutes(entry.hour_budget_minutes) : "offen"}
</span>
</div>
<div className="h-1 overflow-hidden rounded-full bg-muted">
<div
@@ -1854,10 +1857,11 @@ export function ProjectsPage({ onNavigate, onStartTimer }: ProjectsPageProps) {
<Input type="date" value={editForm.startsOn} onChange={(event) => setEditForm({ ...editForm, startsOn: event.currentTarget.value })} />
<Input type="date" value={editForm.endsOn} onChange={(event) => setEditForm({ ...editForm, endsOn: event.currentTarget.value })} />
</div>
<ProjectTeamspaceEntriesFields
entries={editForm.teamspaceEntries}
onChange={(teamspaceEntries) => setEditForm({ ...editForm, teamspaceEntries })}
/>
<ProjectTeamspaceEntriesFields
entries={editForm.teamspaceEntries}
onChange={(teamspaceEntries) => setEditForm({ ...editForm, teamspaceEntries })}
showManualConsumption={teamspaceTimeTrackingEnabled}
/>
<Textarea className="min-h-24" value={editForm.description} onChange={(event) => setEditForm({ ...editForm, description: event.currentTarget.value })} />
<div className="flex flex-wrap justify-end gap-2">
<Button
+106 -53
View File
@@ -27,9 +27,10 @@ import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@
import { HelpLink } from "@/components/HelpLink";
import { getProjectFollowups, getStatisticsOverview } from "../api";
import { currentMonth, formatDate, formatDateTime, formatMinutes } from "../format";
import type { StatisticsDailyBucket, StatisticsGroup, StatisticsOverview, StatisticsProjectFollowup, StatisticsProjectFollowupOverview, StatisticsTicket, WorkType } from "../types";
import type { AuthUser, StatisticsDailyBucket, StatisticsGroup, StatisticsOverview, StatisticsProjectFollowup, StatisticsProjectFollowupOverview, StatisticsTicket, WorkType } from "../types";
type StatisticsPageProps = {
currentUser: AuthUser;
onNavigate: (to: string) => void;
};
@@ -109,7 +110,7 @@ function TimeComparison({ trackedMinutes, crmMinutes, sessions }: { trackedMinut
);
}
function ColorLegend() {
function ColorLegend({ showDifference = true }: { showDifference?: boolean }) {
return (
<div className="flex flex-wrap gap-x-4 gap-y-2 rounded-md border bg-muted/20 p-3 text-xs text-muted-foreground">
<span className="inline-flex items-center gap-1.5">
@@ -128,14 +129,18 @@ function ColorLegend() {
<span className="size-2 rounded-sm bg-indigo-500 dark:bg-indigo-400" />
<span className={nonBillableTextClass}>Nicht abrechenbar</span>
</span>
<span className="inline-flex items-center gap-1.5">
<span className="size-2 rounded-full bg-emerald-500 dark:bg-emerald-400" />
<span className="text-emerald-700 dark:text-emerald-300">Differenz plus oder ausgeglichen</span>
</span>
<span className="inline-flex items-center gap-1.5">
<span className="size-2 rounded-full bg-red-500 dark:bg-red-400" />
<span className="text-red-700 dark:text-red-300">Differenz minus</span>
</span>
{showDifference ? (
<>
<span className="inline-flex items-center gap-1.5">
<span className="size-2 rounded-full bg-emerald-500 dark:bg-emerald-400" />
<span className="text-emerald-700 dark:text-emerald-300">Differenz plus oder ausgeglichen</span>
</span>
<span className="inline-flex items-center gap-1.5">
<span className="size-2 rounded-full bg-red-500 dark:bg-red-400" />
<span className="text-red-700 dark:text-red-300">Differenz minus</span>
</span>
</>
) : null}
<span className="inline-flex items-center gap-1.5">
<span className="size-2 rounded-full bg-amber-500 dark:bg-amber-400" />
<span className={openTextClass}>offen</span>
@@ -210,14 +215,14 @@ function buildMonthSeries(month: string, buckets: StatisticsDailyBucket[]) {
});
}
function DailyEffortChart({ month, buckets }: { month: string; buckets: StatisticsDailyBucket[] }) {
function DailyEffortChart({ month, buckets, showTeamspaceLine = true }: { month: string; buckets: StatisticsDailyBucket[]; showTeamspaceLine?: boolean }) {
const series = buildMonthSeries(month, buckets);
const width = 900;
const height = 220;
const padding = { top: 18, right: 20, bottom: 36, left: 64 };
const innerWidth = width - padding.left - padding.right;
const innerHeight = height - padding.top - padding.bottom;
const maxMinutes = niceCeilMinutes(Math.max(...series.map((bucket) => Math.max(bucket.totalMinutes, bucket.crmBilledMinutes)), 1));
const maxMinutes = niceCeilMinutes(Math.max(...series.map((bucket) => Math.max(bucket.totalMinutes, showTeamspaceLine ? bucket.crmBilledMinutes : 0)), 1));
const ticks = Array.from({ length: 5 }).map((_, index) => maxMinutes - (index * maxMinutes) / 4);
const labelStep = Math.max(1, Math.ceil(series.length / 9));
const barWidth = Math.max(5, Math.min(16, innerWidth / Math.max(series.length, 1) / 2));
@@ -231,11 +236,11 @@ function DailyEffortChart({ month, buckets }: { month: string; buckets: Statisti
crmY: padding.top + (1 - bucket.crmBilledMinutes / maxMinutes) * innerHeight
};
});
const crmPath = buildChartPath(points.filter((point) => point.bucket.crmBilledMinutes > 0).map((point) => ({ x: point.x, y: point.crmY })));
const crmPath = showTeamspaceLine ? buildChartPath(points.filter((point) => point.bucket.crmBilledMinutes > 0).map((point) => ({ x: point.x, y: point.crmY }))) : "";
return (
<div className="h-56 w-full overflow-hidden rounded-md border bg-background">
{series.some((bucket) => bucket.totalMinutes > 0 || bucket.crmBilledMinutes > 0) ? (
{series.some((bucket) => bucket.totalMinutes > 0 || (showTeamspaceLine && bucket.crmBilledMinutes > 0)) ? (
<svg viewBox={`0 0 ${width} ${height}`} className="h-full w-full">
{ticks.map((tick) => {
const y = padding.top + (1 - tick / maxMinutes) * innerHeight;
@@ -258,11 +263,11 @@ function DailyEffortChart({ month, buckets }: { month: string; buckets: Statisti
) : null;
})}
{crmPath ? <path d={crmPath} fill="none" stroke="currentColor" strokeWidth="2.5" className={teamspaceStrokeClass} /> : null}
{points
.filter((point) => point.bucket.crmBilledMinutes > 0)
.map((point) => (
<circle key={`${point.bucket.day}-crm`} cx={point.x} cy={point.crmY} r="3" className={`fill-background ${teamspaceStrokeClass}`} stroke="currentColor" strokeWidth="2" />
))}
{showTeamspaceLine ? points
.filter((point) => point.bucket.crmBilledMinutes > 0)
.map((point) => (
<circle key={`${point.bucket.day}-crm`} cx={point.x} cy={point.crmY} r="3" className={`fill-background ${teamspaceStrokeClass}`} stroke="currentColor" strokeWidth="2" />
)) : null}
{points.map((point, index) =>
index % labelStep === 0 || index === points.length - 1 ? (
<text key={`${point.bucket.day}-label`} x={point.x} y={height - 11} textAnchor="middle" className="fill-muted-foreground text-[11px]">
@@ -312,7 +317,17 @@ function MetricCard({
);
}
function OrganizationRow({ organization, maxMinutes, totalMinutes }: { organization: StatisticsGroup; maxMinutes: number; totalMinutes: number }) {
function OrganizationRow({
organization,
maxMinutes,
totalMinutes,
showDifference = true
}: {
organization: StatisticsGroup;
maxMinutes: number;
totalMinutes: number;
showDifference?: boolean;
}) {
const monthlyShare = percentage(organization.total_minutes, totalMinutes);
return (
@@ -327,9 +342,11 @@ function OrganizationRow({ organization, maxMinutes, totalMinutes }: { organizat
<TableCell>
<TimeComparison trackedMinutes={organization.total_minutes} crmMinutes={organization.crm_billed_minutes} />
</TableCell>
<TableCell>
<DeltaBadge minutes={organization.crm_delta_minutes} />
</TableCell>
{showDifference ? (
<TableCell>
<DeltaBadge minutes={organization.crm_delta_minutes} />
</TableCell>
) : null}
<TableCell className="min-w-[150px]">
<Progress value={percentage(organization.total_minutes, maxMinutes)} />
<div className="mt-1 text-xs text-muted-foreground">{monthlyShare}% vom Monat</div>
@@ -338,7 +355,17 @@ function OrganizationRow({ organization, maxMinutes, totalMinutes }: { organizat
);
}
function TicketHotspot({ ticket, month, onNavigate }: { ticket: StatisticsTicket; month: string; onNavigate: (to: string) => void }) {
function TicketHotspot({
ticket,
month,
showDifference = true,
onNavigate
}: {
ticket: StatisticsTicket;
month: string;
showDifference?: boolean;
onNavigate: (to: string) => void;
}) {
return (
<div className="grid gap-2 rounded-md border bg-background p-3 sm:grid-cols-[1fr_auto] sm:items-center">
<div className="min-w-0">
@@ -353,7 +380,7 @@ function TicketHotspot({ ticket, month, onNavigate }: { ticket: StatisticsTicket
<span className="text-muted-foreground">{ticket.billed_sessions} abgerechnet</span>
<span className="text-muted-foreground">{ticket.billable_sessions} abrechenbar</span>
<span>{ticket.active_days} Tag(e)</span>
<span className={deltaTextClass(ticket.crm_delta_minutes)}>{formatTeamspaceDelta(ticket.crm_delta_minutes)}</span>
{showDifference ? <span className={deltaTextClass(ticket.crm_delta_minutes)}>{formatTeamspaceDelta(ticket.crm_delta_minutes)}</span> : null}
</div>
</div>
<Button variant="secondary" size="sm" onClick={() => onNavigate(`/tickets/${ticket.ticket_id}?month=${month}`)}>
@@ -421,7 +448,7 @@ function ProjectFollowupRow({ project, onNavigate }: { project: StatisticsProjec
);
}
export function StatisticsPage({ onNavigate }: StatisticsPageProps) {
export function StatisticsPage({ currentUser, onNavigate }: StatisticsPageProps) {
const [month, setMonth] = useState(currentMonth());
const [stats, setStats] = useState<StatisticsOverview | null>(null);
const [followups, setFollowups] = useState<StatisticsProjectFollowupOverview | null>(null);
@@ -523,6 +550,7 @@ export function StatisticsPage({ onNavigate }: StatisticsPageProps) {
}
const totals = stats?.totals;
const teamspaceTimeTrackingEnabled = currentUser.teamspace_time_tracking_enabled ?? true;
const billedSessions = totals?.billedSessions ?? 0;
const billedMinutes = totals?.billedMinutes ?? 0;
const billableSessions = totals?.billableSessions ?? 0;
@@ -558,7 +586,11 @@ export function StatisticsPage({ onNavigate }: StatisticsPageProps) {
<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">Statistiken</h2>
<p className="text-sm text-muted-foreground">Monatswerte, Teamspace-Abgleich, Organisationen und offene Nacharbeit.</p>
<p className="text-sm text-muted-foreground">
{teamspaceTimeTrackingEnabled
? "Monatswerte, Teamspace-Abgleich, Organisationen und offene Nacharbeit."
: "Monatswerte, Session-Status, Organisationen und Teamspace-Istwerte."}
</p>
</div>
<div className="grid gap-1 sm:w-[300px]">
<div className="flex items-center justify-between gap-2">
@@ -607,7 +639,9 @@ export function StatisticsPage({ onNavigate }: StatisticsPageProps) {
<MetricCard
label="Teamspace"
value={formatMinutes(totals?.crmBilledMinutes ?? 0)}
detail={<span className={deltaTextClass(totals?.crmDeltaMinutes ?? 0)}>Differenz {formatTeamspaceDelta(totals?.crmDeltaMinutes ?? 0)}</span>}
detail={teamspaceTimeTrackingEnabled
? <span className={deltaTextClass(totals?.crmDeltaMinutes ?? 0)}>Differenz {formatTeamspaceDelta(totals?.crmDeltaMinutes ?? 0)}</span>
: "Ist-Werte aus Teamspace"}
icon={TrendingUp}
valueClassName={teamspaceTextClass}
helpAnchor="statistik-teamspace"
@@ -633,17 +667,21 @@ export function StatisticsPage({ onNavigate }: StatisticsPageProps) {
<CardHeader className="gap-3 p-4 sm:flex-row sm:items-start sm:justify-between sm:space-y-0">
<div>
<CardTitle>Monatsverlauf und Abrechnungsqualität</CardTitle>
<CardDescription>Abgerechnete Zeit pro Tag, Teamspace-Linie und Bewertungsstand in einem Überblick.</CardDescription>
<CardDescription>
{teamspaceTimeTrackingEnabled
? "Abgerechnete Zeit pro Tag, 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">
<Badge variant="outline">{crmCoverage}% Teamspace-Abdeckung</Badge>
{teamspaceTimeTrackingEnabled ? <Badge variant="outline">{crmCoverage}% Teamspace-Abdeckung</Badge> : <Badge variant="outline">Teamspace als Ist-Quelle</Badge>}
<HelpLink anchor="statistik-verlauf" label="Hilfe zu Monatsverlauf und Abrechnungsqualität" />
</div>
</CardHeader>
<CardContent className="grid gap-4 px-4 pb-4 xl:grid-cols-[1.55fr_1fr]">
<div className="space-y-3">
<DailyEffortChart month={month} buckets={stats?.dailySeries ?? []} />
<ColorLegend />
<DailyEffortChart month={month} buckets={stats?.dailySeries ?? []} showTeamspaceLine={teamspaceTimeTrackingEnabled} />
<ColorLegend showDifference={teamspaceTimeTrackingEnabled} />
{strongestDay ? <p className="text-xs text-muted-foreground">Stärkster abgerechneter Tag: {formatDate(`${strongestDay.day}T00:00:00`)} mit {formatMinutes(strongestDay.total_minutes)}</p> : null}
</div>
@@ -661,17 +699,27 @@ export function StatisticsPage({ onNavigate }: StatisticsPageProps) {
</div>
</div>
<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-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>
{teamspaceTimeTrackingEnabled ? (
<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-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>
) : (
<div className="space-y-2 rounded-md border bg-muted/20 p-3">
<p className="text-sm font-medium">Teamspace als Ist-Quelle</p>
<p className="text-xs text-muted-foreground">
Für diesen Nutzer werden keine Teamspace-Zeiten in TT erfasst. Die Monatsprüfung bewertet nur den Session-Status.
</p>
<TimeComparison trackedMinutes={totals?.minutes ?? 0} crmMinutes={totals?.crmBilledMinutes ?? 0} sessions={totals?.billedSessions ?? 0} />
</div>
)}
<div className="grid grid-cols-2 gap-2 text-sm">
<div className="rounded-md border bg-muted/20 p-3">
@@ -715,7 +763,11 @@ export function StatisticsPage({ onNavigate }: StatisticsPageProps) {
<CardHeader className="gap-3 p-4 sm:flex-row sm:items-start sm:justify-between sm:space-y-0">
<div>
<CardTitle>Statistik pro Organisation</CardTitle>
<CardDescription>Kundenaufwand inklusive Teamspace-Differenz und Anteil an der abgerechneten Monatszeit.</CardDescription>
<CardDescription>
{teamspaceTimeTrackingEnabled
? "Kundenaufwand inklusive Teamspace-Differenz und Anteil an der abgerechneten Monatszeit."
: "Kundenaufwand und Anteil an der abgerechneten Monatszeit."}
</CardDescription>
</div>
<div className="flex items-center gap-2">
<Badge variant="outline">
@@ -732,7 +784,7 @@ export function StatisticsPage({ onNavigate }: StatisticsPageProps) {
<TableHead>Organisation</TableHead>
<TableHead>Abgerechnet</TableHead>
<TableHead>Zeiten</TableHead>
<TableHead>Differenz</TableHead>
{teamspaceTimeTrackingEnabled ? <TableHead>Differenz</TableHead> : null}
<TableHead>Anteil abgerechnet</TableHead>
</TableRow>
</TableHeader>
@@ -740,10 +792,11 @@ export function StatisticsPage({ onNavigate }: StatisticsPageProps) {
{visibleOrganizations.map((organization) => (
<OrganizationRow
key={organization.organization_id ?? organization.organization_name}
organization={organization}
maxMinutes={maxOrganizationMinutes}
totalMinutes={totals?.minutes ?? 0}
/>
organization={organization}
maxMinutes={maxOrganizationMinutes}
totalMinutes={totals?.minutes ?? 0}
showDifference={teamspaceTimeTrackingEnabled}
/>
))}
</TableBody>
</Table>
@@ -756,14 +809,14 @@ export function StatisticsPage({ onNavigate }: StatisticsPageProps) {
<p className="truncate font-medium">{organization.organization_name ?? "Keine Organisation"}</p>
<p className="text-xs text-muted-foreground">{organization.tickets} Ticket(s) · {organization.billed_sessions} abgerechnet · {organization.billable_sessions} abrechenbar</p>
</div>
<DeltaBadge minutes={organization.crm_delta_minutes} />
{teamspaceTimeTrackingEnabled ? <DeltaBadge minutes={organization.crm_delta_minutes} /> : null}
</div>
<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="text-muted-foreground">{percentage(organization.total_minutes, totals?.minutes ?? 0)}% vom Monat</span>
<span className={deltaTextClass(organization.crm_delta_minutes)}>{formatTeamspaceDelta(organization.crm_delta_minutes)}</span>
{teamspaceTimeTrackingEnabled ? <span className={deltaTextClass(organization.crm_delta_minutes)}>{formatTeamspaceDelta(organization.crm_delta_minutes)}</span> : null}
</div>
</div>
))}
@@ -825,7 +878,7 @@ export function StatisticsPage({ onNavigate }: StatisticsPageProps) {
<p className="font-semibold">{workTypeLabel(group.work_type)}</p>
<p className="text-xs text-muted-foreground">{group.tickets} Ticket(s), {group.billed_sessions} abgerechnet, {group.billable_sessions} abrechenbar</p>
</div>
<DeltaBadge minutes={group.crm_delta_minutes} />
{teamspaceTimeTrackingEnabled ? <DeltaBadge minutes={group.crm_delta_minutes} /> : null}
</div>
<Progress value={percentage(group.total_minutes, totals?.minutes ?? 0)} />
<div className="flex flex-wrap gap-x-3 gap-y-1 text-xs">
@@ -863,7 +916,7 @@ export function StatisticsPage({ onNavigate }: StatisticsPageProps) {
</CardHeader>
<CardContent className="space-y-2 px-4 pb-4">
{(stats?.tickets ?? []).map((ticket) => (
<TicketHotspot key={ticket.ticket_id} ticket={ticket} month={month} onNavigate={onNavigate} />
<TicketHotspot key={ticket.ticket_id} ticket={ticket} month={month} showDifference={teamspaceTimeTrackingEnabled} onNavigate={onNavigate} />
))}
{stats && stats.tickets.length === 0 ? <p className="py-8 text-center text-sm text-muted-foreground">Keine Tickets im gewählten Monat.</p> : null}
</CardContent>
File diff suppressed because it is too large. Load diff
+8 -4
View File
@@ -321,6 +321,8 @@ export function TimerPage({ timers, setTimers, selectedTimerId, setSelectedTimer
const runningTimer = timers.find((timer) => timer.phase === "running") ?? null;
const selectedManualTeamspaceEntry = ticketTeamspaceEntries(manualLookupTicket).find((entry) => entry.id === manualSession.projectTeamspaceEntryId) ?? null;
const selectedFinishedTeamspaceEntry = ticketTeamspaceEntries(finished?.ticket).find((entry) => entry.id === projectTeamspaceEntryId) ?? null;
const manualRequiresTeamspaceEntryForForm = requiresTeamspaceEntry(manualLookupTicket);
const finishedRequiresTeamspaceEntryForForm = requiresTeamspaceEntry(finished?.ticket);
useEffect(() => {
let cancelled = false;
@@ -825,7 +827,9 @@ export function TimerPage({ timers, setTimers, selectedTimerId, setSelectedTimer
return;
}
if (requiresTeamspaceEntry(manualLookupTicket) && !manualSession.projectTeamspaceEntryId) {
const manualRequiresTeamspaceEntry = requiresTeamspaceEntry(manualLookupTicket);
if (manualRequiresTeamspaceEntry && !manualSession.projectTeamspaceEntryId) {
toast.error("Teamspace-Eintrag wählen", {
description: "Dieses Projekt hat mehrere Teamspace-Einträge."
});
@@ -864,7 +868,7 @@ export function TimerPage({ timers, setTimers, selectedTimerId, setSelectedTimer
endedAt: endedAt.toISOString(),
durationSeconds,
zammadLookup: Boolean(manualLookupTicket?.zammad_ticket_id),
projectTeamspaceEntryId: requiresTeamspaceEntry(manualLookupTicket) ? manualSession.projectTeamspaceEntryId : null,
projectTeamspaceEntryId: manualRequiresTeamspaceEntry ? manualSession.projectTeamspaceEntryId : null,
billingStatus: manualSession.billingStatus,
createZammadArticle: manualSession.createZammadArticle && (activityValue.activity.trim().length > 0 || Boolean(activityValue.zammadArticleBody)),
zammadArticleTypeId: manualSession.zammadArticleTypeId
@@ -1117,7 +1121,7 @@ export function TimerPage({ timers, setTimers, selectedTimerId, setSelectedTimer
</div>
</div>
{requiresTeamspaceEntry(manualLookupTicket) ? (
{manualRequiresTeamspaceEntryForForm ? (
<div className="space-y-2">
<label className="text-sm font-medium" htmlFor="manual-teamspace-entry">Teamspace-Eintrag</label>
<select
@@ -1379,7 +1383,7 @@ export function TimerPage({ timers, setTimers, selectedTimerId, setSelectedTimer
</select>
</div>
)}
{requiresTeamspaceEntry(finished?.ticket) ? (
{finishedRequiresTeamspaceEntryForForm ? (
<div className="space-y-2">
<label className="text-sm font-medium" htmlFor="finished-teamspace-entry">Teamspace-Eintrag</label>
<select
+129 -23
View File
@@ -17,7 +17,9 @@ import {
previewExistingZammadTickets,
previewOwnZammadConsultingTickets,
previewZammadMailSessions,
syncTeamspaceProjectIds,
waitForZammadSyncJob,
type TeamspaceProjectIdSyncResult,
type ZammadSyncExistingFields,
type ZammadSyncChange,
type ZammadSyncJob,
@@ -40,7 +42,7 @@ const defaultFields: ZammadSyncExistingFields = {
organization: true,
type: true,
projectName: true,
budget: true,
budget: false,
overrideBudget: false,
pendingTodo: false
};
@@ -50,7 +52,6 @@ const fieldOptions: Array<{ key: Exclude<keyof ZammadSyncExistingFields, "overri
{ key: "organization", label: "Organisation", description: "Lokale Organisation anhand der Zammad-ID setzen." },
{ key: "type", label: "Typ", description: "Support/Consulting übernehmen, Projekte anlegen oder entfernen." },
{ key: "projectName", label: "Projektbezeichnung", description: "Teamspace-Projekt bei Consulting überschreiben." },
{ key: "budget", label: "Budget", description: "Teamspace-Stundenkontingent überschreiben." },
{ key: "pendingTodo", label: "Termin-ToDo", description: "Aus Wartend/Abschluss mit Datum ein ToDo erzeugen." }
];
@@ -429,10 +430,6 @@ function formatChangeValue(field: string, value: unknown) {
return formatDurationSeconds(value);
}
if (field === "Teamspace-Budget") {
return formatMinutes(value);
}
if (field === "Teamspace-Projekte" || field === "Teamspace-Prüfung" || field === "Merge-Projektdaten") {
return formatTeamspaceProjectList(value) || "-";
}
@@ -498,7 +495,7 @@ function changeAllowedByFields(item: ZammadSyncItem, change: ZammadSyncChange, f
}
if (change.field === "Teamspace-Budget") {
return fields.budget && consultingProjectCanBeTouched;
return false;
}
if (change.field === "ToDo") {
@@ -711,7 +708,6 @@ function changesFromExistingPreviewCache(item: ZammadSyncItem, fields: ZammadSyn
const zammadTeamspaceProjects = cachedStringList(zammad, "teamspaceProjects");
const localTeamspaceProjectList = localTeamspaceProjects.length ? localTeamspaceProjects : [cachedValue(project, "teamspaceProject")].filter((entry): entry is string => typeof entry === "string");
const zammadTeamspaceProjectList = zammadTeamspaceProjects.length ? zammadTeamspaceProjects : splitTeamspaceProjects(cachedValue(zammad, "teamspaceProject"));
const canSyncBudget = fields.budget && (cachedValue(project, "defaultBudgetSyncDisabled") !== true || fields.overrideBudget);
const changes: ZammadSyncChange[] = [];
addCachedChange(changes, "Zammad-Priorität", cachedValue(local, "zammadPriorityId"), cachedValue(zammad, "priorityId"));
@@ -753,10 +749,6 @@ function changesFromExistingPreviewCache(item: ZammadSyncItem, fields: ZammadSyn
addCachedTeamspaceProjectChanges(changes, localTeamspaceProjectList, zammadTeamspaceProjectList);
}
if (canSyncBudget) {
addCachedChange(changes, "Teamspace-Budget", cachedValue(project, "defaultHourBudgetMinutes") ?? cachedValue(project, "hourBudgetMinutes"), cachedValue(zammad, "teamspaceBudgetMinutes"));
}
if (zammadStateId === 4 && cachedValue(project, "closedAt") === null && cachedValue(project, "closeReviewRequired") !== true) {
changes.push({ field: "TS/TT-Prüfung", from: cachedValue(project, "closeReviewRequired") ?? false, to: true });
}
@@ -920,8 +912,7 @@ function zammadDetails(item: ZammadSyncItem) {
`Owner ${formatValue(zammad.ownerId)}`,
`Org ${formatValue(zammad.organizationId)}`,
`Titel ${formatValue(zammad.title)}`,
`Projekt ${formatValue(formatTeamspaceProjectList(zammad.teamspaceProjects) || zammad.teamspaceProject)}`,
`Budget ${formatMinutes(zammad.teamspaceBudgetMinutes)}`
`Projekt ${formatValue(formatTeamspaceProjectList(zammad.teamspaceProjects) || zammad.teamspaceProject)}`
];
}
@@ -1105,11 +1096,14 @@ export function ZammadSyncPage({ currentUser }: ZammadSyncPageProps) {
syncingMail
} = pageState;
const [storageReady, setStorageReady] = useState(false);
const [syncingTeamspaceProjectIds, setSyncingTeamspaceProjectIds] = useState(false);
const [teamspaceProjectIdResult, setTeamspaceProjectIdResult] = useState<TeamspaceProjectIdSyncResult | null>(null);
const displayedExistingResult = useMemo(() => applyFieldsToExistingPreview(existingResult, fields), [existingResult, fields]);
const selectedExistingCount = useMemo(() => selectedTicketNumbers(displayedExistingResult, existingSelection).length, [displayedExistingResult, existingSelection]);
const selectedImportCount = useMemo(() => selectedTicketNumbers(importResult, importSelection).length, [importResult, importSelection]);
const selectedMailCount = useMemo(() => selectedArticleIds(mailResult, mailSelection).length, [mailResult, mailSelection]);
const zammadReady = currentUser.has_zammad_api_token && Boolean(currentUser.zammad_user_id);
const teamspaceReady = currentUser.has_teamspace_credentials && Boolean(currentUser.teamspace_user_id);
useEffect(() => {
setStorageReady(false);
@@ -1552,6 +1546,28 @@ export function ZammadSyncPage({ currentUser }: ZammadSyncPageProps) {
}
}
async function runTeamspaceProjectIdSync() {
if (syncingTeamspaceProjectIds) {
return;
}
setSyncingTeamspaceProjectIds(true);
try {
const { result } = await syncTeamspaceProjectIds();
setTeamspaceProjectIdResult(result);
toast.success("Teamspace-Projekt-IDs synchronisiert", {
description: `${result.updated} aktualisiert, ${result.alreadyCurrent} bereits aktuell, ${result.subentriesSynced} Unterposition(en), ${result.missing + result.ambiguous} ohne eindeutige Zuordnung.`
});
} catch (error) {
toast.error("Teamspace-Projekt-IDs konnten nicht synchronisiert werden", {
description: error instanceof Error ? error.message : "Unbekannter Fehler"
});
} finally {
setSyncingTeamspaceProjectIds(false);
}
}
function toggleExisting(ticketNumber: string, checked: boolean) {
setZammadSyncPageState((current) => ({
existingSelection: { ...current.existingSelection, [ticketNumber]: checked }
@@ -1617,6 +1633,105 @@ export function ZammadSyncPage({ currentUser }: ZammadSyncPageProps) {
</Alert>
) : null}
<Card>
<CardHeader className="p-4">
<div className="flex items-start justify-between gap-3">
<div>
<CardTitle className="flex items-center gap-2">
<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>
</div>
<Badge variant={teamspaceReady ? "success" : "secondary"}>{teamspaceReady ? "bereit" : "nicht bereit"}</Badge>
</div>
</CardHeader>
<CardContent className="space-y-3 px-4 pb-4">
{!teamspaceReady ? (
<Alert variant="warning">
<AlertCircle className="size-4" />
<AlertTitle>Teamspace-Zugang fehlt</AlertTitle>
<AlertDescription>Bitte im Profil Geräte-ID und Gerätepasswort speichern, damit die Teamspace User-ID bekannt ist.</AlertDescription>
</Alert>
) : null}
<div className="flex justify-end">
<Button type="button" disabled={!teamspaceReady || syncingTeamspaceProjectIds} onClick={() => void runTeamspaceProjectIdSync()}>
<RefreshCw className="size-4" />
{syncingTeamspaceProjectIds ? "Synchronisiert..." : "Teamspace-IDs, Budgets und Positionen synchronisieren"}
</Button>
</div>
{teamspaceProjectIdResult ? (
<div className="space-y-3">
<div className="grid gap-2 sm:grid-cols-3 xl:grid-cols-9">
{[
["TT-Einträge", teamspaceProjectIdResult.checkedEntries],
["TS gelesen", teamspaceProjectIdResult.remoteProjectsScanned],
["TS Positionen gelesen", teamspaceProjectIdResult.remoteSubprojectsScanned],
["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">
<span className="block text-xs text-muted-foreground">{label}</span>
<span className="text-lg font-semibold">{value}</span>
</div>
))}
</div>
{teamspaceProjectIdResult.duplicateRemoteProjectIds.length > 0 ? (
<Alert variant="warning">
<AlertCircle className="size-4" />
<AlertTitle>Nicht eindeutige Teamspace projectIds</AlertTitle>
<AlertDescription>{teamspaceProjectIdResult.duplicateRemoteProjectIds.slice(0, 8).join(", ")}</AlertDescription>
</Alert>
) : null}
<div className="max-h-80 overflow-auto rounded-md border">
<Table className="w-full table-fixed">
<colgroup>
<col className="w-36" />
<col className="w-44" />
<col className="w-28" />
<col className="w-32" />
<col />
</colgroup>
<TableHeader>
<TableRow>
<TableHead>Ticket</TableHead>
<TableHead>Teamspace-ID</TableHead>
<TableHead>Status</TableHead>
<TableHead>API-ID</TableHead>
<TableHead>Hinweis</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{teamspaceProjectIdResult.items.slice(0, 100).map((item) => (
<TableRow key={item.entryId}>
<TableCell className="truncate font-medium" title={item.ticketNumber}>{item.ticketNumber}</TableCell>
<TableCell className="truncate" title={item.teamspaceProject}>{item.teamspaceProject}</TableCell>
<TableCell>
<Badge variant={item.status === "updated" ? "success" : item.status === "current" ? "info" : "warning"}>
{item.status === "updated" ? "Aktualisiert" : item.status === "current" ? "Aktuell" : item.status === "missing" ? "Fehlt" : "Mehrdeutig"}
</Badge>
</TableCell>
<TableCell className="truncate" title={item.apiProjectId ?? ""}>{item.apiProjectId ?? "-"}</TableCell>
<TableCell className="truncate text-xs text-muted-foreground" title={item.message ?? item.apiProjectUrl ?? ""}>
{item.message ?? item.apiProjectUrl ?? "-"}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
{teamspaceProjectIdResult.items.length > 100 ? (
<p className="text-xs text-muted-foreground">Weitere {teamspaceProjectIdResult.items.length - 100} Einträge wurden synchronisiert und sind hier nicht gelistet.</p>
) : null}
</div>
) : null}
</CardContent>
</Card>
<Card>
<CardHeader className="p-4">
<div className="flex items-start justify-between gap-3">
@@ -1642,15 +1757,6 @@ export function ZammadSyncPage({ currentUser }: ZammadSyncPageProps) {
</label>
))}
</div>
{fields.budget ? (
<label className={cn("flex min-w-0 gap-3 rounded-md border p-3", fields.overrideBudget ? "bg-muted/30" : "bg-background")}>
<Checkbox checked={fields.overrideBudget} onCheckedChange={(checked) => setField("overrideBudget", checked === true)} />
<span className="min-w-0">
<span className="block text-sm font-medium">Budget-Schutz im Sync ignorieren</span>
<span className="block text-xs text-muted-foreground">Aus: nur ungeschützte Budgets aktualisieren. An: Zammad überschreibt auch geschützte Budgets.</span>
</span>
</label>
) : null}
<div className="flex flex-wrap justify-end gap-2">
{syncingExisting ? (
<Button type="button" variant="outline" onClick={resetRunningState}>