Final
This commit is contained in:
1 parent
fc0dfd0556
commit
649dd6f028
20 files changed
+3725
-988
No files matched your search
@@ -1628,11 +1628,11 @@ export function App() {
|
||||
currentUser={currentUser}
|
||||
/>
|
||||
) : null}
|
||||
{!isAdmin && route.page === "dashboard" ? <DashboardPage onNavigate={navigate} /> : null}
|
||||
{!isAdmin && route.page === "analysis" ? <AnalysisPage onNavigate={navigate} /> : null}
|
||||
{!isAdmin && route.page === "dashboard" ? <DashboardPage currentUser={currentUser} onNavigate={navigate} /> : null}
|
||||
{!isAdmin && route.page === "analysis" ? <AnalysisPage currentUser={currentUser} onNavigate={navigate} /> : null}
|
||||
{!isAdmin && route.page === "monthly-close" ? <MonthlyClosePage onNavigate={navigate} /> : null}
|
||||
{!isAdmin && route.page === "statistics" ? <StatisticsPage onNavigate={navigate} /> : null}
|
||||
{!isAdmin && route.page === "projects" ? <ProjectsPage onNavigate={navigate} onStartTimer={startQuickTimer} /> : null}
|
||||
{!isAdmin && route.page === "statistics" ? <StatisticsPage currentUser={currentUser} onNavigate={navigate} /> : null}
|
||||
{!isAdmin && route.page === "projects" ? <ProjectsPage currentUser={currentUser} onNavigate={navigate} onStartTimer={startQuickTimer} /> : null}
|
||||
{!isAdmin && route.page === "todos" ? <TodosPage onNavigate={navigate} /> : null}
|
||||
{!isAdmin && route.page === "recurring" ? <RecurringBillingsPage /> : null}
|
||||
{!isAdmin && route.page === "zammad-sync" ? <ZammadSyncPage currentUser={currentUser} /> : null}
|
||||
|
||||
+103
-2
@@ -7,6 +7,7 @@ import type {
|
||||
Organization,
|
||||
PeriodOverview,
|
||||
PeriodType,
|
||||
ProjectTeamspaceSubentry,
|
||||
ProjectSummary,
|
||||
RecurringBilling,
|
||||
StatisticsOverview,
|
||||
@@ -125,6 +126,51 @@ export type ZammadSyncJob = {
|
||||
finishedAt: string | null;
|
||||
};
|
||||
|
||||
export type TeamspaceProjectIdSyncItem = {
|
||||
entryId: string;
|
||||
projectId: string;
|
||||
ticketNumber: string;
|
||||
teamspaceProject: string;
|
||||
previousApiProjectId: string | null;
|
||||
apiProjectId: string | null;
|
||||
apiProjectUrl: string | null;
|
||||
status: "updated" | "current" | "missing" | "ambiguous";
|
||||
message: string | null;
|
||||
};
|
||||
|
||||
export type TeamspaceProjectIdSyncResult = {
|
||||
checkedEntries: number;
|
||||
matched: number;
|
||||
updated: number;
|
||||
alreadyCurrent: number;
|
||||
missing: number;
|
||||
ambiguous: number;
|
||||
cleared: number;
|
||||
remoteProjectsScanned: number;
|
||||
remoteProjectIds: number;
|
||||
remoteSubprojectsScanned: number;
|
||||
subentriesSynced: number;
|
||||
subentriesRemoved: number;
|
||||
duplicateRemoteProjectIds: string[];
|
||||
items: TeamspaceProjectIdSyncItem[];
|
||||
};
|
||||
|
||||
export type TeamspaceProjectSubentrySyncResult = {
|
||||
projectId: string;
|
||||
checkedEntries: number;
|
||||
remoteProjectsScanned: number;
|
||||
subentriesSynced: number;
|
||||
subentriesRemoved: number;
|
||||
};
|
||||
|
||||
export type TeamspaceAllSubentrySyncResult = {
|
||||
checkedProjects: number;
|
||||
checkedEntries: number;
|
||||
remoteProjectsScanned: number;
|
||||
subentriesSynced: number;
|
||||
subentriesRemoved: number;
|
||||
};
|
||||
|
||||
export type TicketZammadMailSessionItem = {
|
||||
articleId: number;
|
||||
ticketNumber: string;
|
||||
@@ -248,8 +294,11 @@ export function updateCurrentUser(payload: {
|
||||
currentPassword?: string;
|
||||
newPassword?: string;
|
||||
zammadApiToken?: string;
|
||||
teamspaceDeviceId?: string;
|
||||
teamspaceDevicePassword?: string;
|
||||
zammadEmailSessionWebhookEnabled?: boolean;
|
||||
sessionBillingPromptEnabled?: boolean;
|
||||
teamspaceTimeTrackingEnabled?: boolean;
|
||||
zammadSessionNoteDefaultEnabled?: boolean;
|
||||
zammadTimeUnitRoundingEnabled?: boolean;
|
||||
zammadTimeUnitRoundingMinutes?: number;
|
||||
@@ -432,6 +481,17 @@ export function saveZammadSettings(payload: { baseUrl: string; apiKey?: string }
|
||||
});
|
||||
}
|
||||
|
||||
export function getTeamspaceSettings() {
|
||||
return request<{ settings: { baseUrl: string } }>("/api/admin/teamspace/settings");
|
||||
}
|
||||
|
||||
export function saveTeamspaceSettings(payload: { baseUrl: string }) {
|
||||
return request<{ settings: { baseUrl: string } }>("/api/admin/teamspace/settings", {
|
||||
method: "PUT",
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
}
|
||||
|
||||
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", {
|
||||
method: "POST",
|
||||
@@ -537,6 +597,39 @@ export function applyZammadMailSessionsFromPreview(previewJobId: string, article
|
||||
}, onJobStarted);
|
||||
}
|
||||
|
||||
export function syncTeamspaceProjectIds() {
|
||||
return request<{ result: TeamspaceProjectIdSyncResult }>("/api/teamspace/project-ids/sync", {
|
||||
method: "POST",
|
||||
timeoutMs: 5 * 60 * 1000
|
||||
});
|
||||
}
|
||||
|
||||
export function syncProjectTeamspaceSubentries(projectId: string) {
|
||||
return request<{ result: TeamspaceProjectSubentrySyncResult }>(`/api/projects/${projectId}/teamspace-subentries/sync`, {
|
||||
method: "POST",
|
||||
timeoutMs: 5 * 60 * 1000
|
||||
});
|
||||
}
|
||||
|
||||
export function syncAllTeamspaceSubentries() {
|
||||
return request<{ result: TeamspaceAllSubentrySyncResult }>("/api/teamspace/subentries/sync", {
|
||||
method: "POST",
|
||||
timeoutMs: 5 * 60 * 1000
|
||||
});
|
||||
}
|
||||
|
||||
export function updateProjectTeamspaceSubentry(
|
||||
subentryId: string,
|
||||
payload: {
|
||||
alias: string;
|
||||
}
|
||||
) {
|
||||
return request<{ subentry: ProjectTeamspaceSubentry }>(`/api/project-teamspace-subentries/${subentryId}`, {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
}
|
||||
|
||||
export function getRecurringBillings() {
|
||||
return request<{ recurringBillings: RecurringBilling[] }>("/api/recurring-billings");
|
||||
}
|
||||
@@ -861,10 +954,18 @@ export function updateSessionDetails(
|
||||
});
|
||||
}
|
||||
|
||||
export function updateTicketDayBilling(ticketId: string, day: string, billedMinutes: number | null, projectTeamspaceEntryId?: string | null) {
|
||||
export function updateTicketDayBilling(
|
||||
ticketId: string,
|
||||
day: string,
|
||||
billedMinutes: number | null,
|
||||
projectTeamspaceEntryId?: string | null
|
||||
) {
|
||||
return request(`/api/tickets/${ticketId}/day-billings/${day}`, {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify({ billedMinutes, projectTeamspaceEntryId: projectTeamspaceEntryId ?? null })
|
||||
body: JSON.stringify({
|
||||
billedMinutes,
|
||||
projectTeamspaceEntryId: projectTeamspaceEntryId ?? null
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -68,7 +68,7 @@ function AccordionContent({
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"h-(--radix-accordion-content-height) pt-0 pb-2.5 [&_a]:underline [&_a]:underline-offset-3 [&_a]:hover:text-foreground [&_p:not(:last-child)]:mb-4",
|
||||
"pt-0 pb-2.5 [&_a]:underline [&_a]:underline-offset-3 [&_a]:hover:text-foreground [&_p:not(:last-child)]:mb-4",
|
||||
className
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -12,8 +12,11 @@ export type AuthUser = {
|
||||
active: boolean;
|
||||
zammad_user_id: string | null;
|
||||
has_zammad_api_token: boolean;
|
||||
teamspace_user_id: string | null;
|
||||
has_teamspace_credentials: boolean;
|
||||
zammad_email_session_webhook_enabled: boolean;
|
||||
session_billing_prompt_enabled: boolean;
|
||||
teamspace_time_tracking_enabled: boolean;
|
||||
zammad_session_note_default_enabled: boolean;
|
||||
zammad_time_unit_rounding_enabled: boolean;
|
||||
zammad_time_unit_rounding_minutes: number;
|
||||
@@ -218,6 +221,7 @@ export type ProjectSummary = {
|
||||
teamspace_entry_count?: number;
|
||||
teamspace_entries?: ProjectTeamspaceEntry[];
|
||||
unassigned_teamspace_session_count?: number;
|
||||
unassigned_teamspace_subentry_count?: number;
|
||||
close_review_required?: boolean;
|
||||
hidden_for_user?: boolean;
|
||||
};
|
||||
@@ -226,7 +230,10 @@ export type ProjectTeamspaceEntry = {
|
||||
id: string;
|
||||
project_id?: string;
|
||||
teamspace_project: string | null;
|
||||
teamspace_api_project_id?: string | null;
|
||||
teamspace_api_project_url?: string | null;
|
||||
teamspace_description?: string | null;
|
||||
subentries?: ProjectTeamspaceSubentry[];
|
||||
is_default: boolean;
|
||||
hour_budget_minutes: number | null;
|
||||
budget_sync_disabled?: boolean;
|
||||
@@ -236,6 +243,27 @@ export type ProjectTeamspaceEntry = {
|
||||
display_order?: number;
|
||||
};
|
||||
|
||||
export type ProjectTeamspaceSubentry = {
|
||||
id: string;
|
||||
project_teamspace_entry_id?: string;
|
||||
teamspace_api_project_id: string;
|
||||
teamspace_api_project_url: string | null;
|
||||
teamspace_project_id: string | null;
|
||||
billing_type: string | null;
|
||||
name: string | null;
|
||||
alias: string;
|
||||
description: string;
|
||||
progress: number | null;
|
||||
planned_minutes: number | null;
|
||||
sum_minutes: number | null;
|
||||
planned_minutes_billable: number | null;
|
||||
sum_minutes_billable: number | null;
|
||||
planned_income: number | string | null;
|
||||
sum_income: number | string | null;
|
||||
consumption_minutes?: number;
|
||||
display_order: number;
|
||||
};
|
||||
|
||||
export type TodoPriority = "low" | "medium" | "high" | "urgent";
|
||||
export type TodoStatus = "open" | "done";
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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 }
|
||||
];
|
||||
|
||||
@@ -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.`;
|
||||
}
|
||||
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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}>
|
||||
|
||||
Reference in new issue
Block a user