1.1.0-rc1

This commit is contained in:
michael committed 2026-10-13 10:43:53 +02:00
1 parent 649dd6f028
commit 45ff46b7eb
26 files changed
+4752 -1108

No files matched your search

+48 -12
View File
@@ -136,6 +136,10 @@ function pathWithoutSearch(value: string) {
return value.split("?")[0];
}
function openTicketKey(ticketId: string) {
return `ticket:${ticketId}`;
}
function readOpenTickets(userId: string): OpenTicketNavItem[] {
try {
const raw = localStorage.getItem(openTicketsStorageKey(userId));
@@ -145,11 +149,15 @@ function readOpenTickets(userId: string): OpenTicketNavItem[] {
}
const parsed = JSON.parse(raw) as Partial<OpenTicketNavItem>[];
const tickets: OpenTicketNavItem[] = [];
return parsed
.filter((item) => item.key && item.ticketId && item.ticketNumber && item.path)
.map((item) => ({
key: String(item.key),
for (const item of parsed) {
if (!item.ticketId || !item.ticketNumber || !item.path) {
continue;
}
const ticket: OpenTicketNavItem = {
key: openTicketKey(String(item.ticketId)),
ticketId: String(item.ticketId),
ticketNumber: String(item.ticketNumber),
periodType: item.periodType === "day" ? "day" : item.periodType === "month" ? "month" : "all",
@@ -157,7 +165,17 @@ function readOpenTickets(userId: string): OpenTicketNavItem[] {
path: String(item.path),
returnPath: typeof item.returnPath === "string" ? item.returnPath : undefined,
dirty: false
}));
};
const existingIndex = tickets.findIndex((existing) => existing.ticketId === ticket.ticketId);
if (existingIndex >= 0) {
tickets[existingIndex] = ticket;
} else {
tickets.push(ticket);
}
}
return tickets.slice(-12);
} catch {
return [];
}
@@ -1245,13 +1263,31 @@ export function App() {
const updateOpenTicket = useCallback((ticket: OpenTicketNavItem) => {
setOpenTickets((current) => {
const existingIndex = current.findIndex((item) => item.key === ticket.key);
const normalizedTicket = {
...ticket,
key: openTicketKey(ticket.ticketId)
};
const existingIndex = current.findIndex((item) => item.ticketId === normalizedTicket.ticketId || item.key === normalizedTicket.key);
if (existingIndex >= 0) {
return current.map((item, index) => (index === existingIndex ? ticket : item));
return current
.map((item, index) => {
if (item.ticketId === normalizedTicket.ticketId || item.key === normalizedTicket.key) {
return index === existingIndex
? {
...item,
...normalizedTicket,
returnPath: normalizedTicket.returnPath ?? item.returnPath
}
: null;
}
return item;
})
.filter((item): item is OpenTicketNavItem => item !== null);
}
return [...current, ticket].slice(-12);
return [...current, normalizedTicket].slice(-12);
});
}, []);
@@ -1313,7 +1349,7 @@ export function App() {
return;
}
setOpenTickets((items) => items.filter((item) => item.key !== ticket.key));
setOpenTickets((items) => items.filter((item) => item.ticketId !== ticket.ticketId && item.key !== ticket.key));
if (route.page === "ticket" && route.ticketId === ticket.ticketId) {
const returnPath = current.returnPath && pathWithoutSearch(current.returnPath) !== pathWithoutSearch(ticket.path) ? current.returnPath : "/analysis";
@@ -1472,7 +1508,7 @@ export function App() {
<div ref={openTicketsListRef} className="max-h-[clamp(8rem,calc(100svh-31rem),24rem)] overflow-y-auto overscroll-contain pr-1 [scrollbar-gutter:stable] [scrollbar-width:thin]">
<SidebarMenu>
{openTickets.map((ticket) => {
const isActive = pathWithoutSearch(path) === pathWithoutSearch(ticket.path);
const isActive = route.page === "ticket" && route.ticketId === ticket.ticketId;
return (
<SidebarMenuItem key={ticket.key}>
@@ -1531,7 +1567,7 @@ export function App() {
<SidebarMenuItem>
<SidebarMenuButton tooltip="Worklog">
<FolderGit />
<span className="text-xs text-muted-foreground">v1.0.11-rc1</span>
<span className="text-xs text-muted-foreground">v1.1.0-rc1</span>
</SidebarMenuButton>
</SidebarMenuItem>
</SidebarMenu>
@@ -1630,7 +1666,7 @@ export function App() {
) : 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 === "monthly-close" ? <MonthlyClosePage currentUser={currentUser} onNavigate={navigate} /> : 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}
+83 -10
View File
@@ -134,11 +134,35 @@ export type TeamspaceProjectIdSyncItem = {
previousApiProjectId: string | null;
apiProjectId: string | null;
apiProjectUrl: string | null;
status: "updated" | "current" | "missing" | "ambiguous";
ttMonthBillableMinutes: number;
teamspaceMonthMinutes: number;
teamspaceMonthBillableMinutes: number;
teamspaceMonthEntryCount: number;
teamspaceMonthDeltaMinutes: number;
teamspaceMonthPositions: TeamspaceProjectIdSyncPosition[];
status: "updated" | "current" | "missing" | "ambiguous" | "excluded";
message: string | null;
};
export type TeamspaceProjectIdSyncPosition = {
apiProjectId: string;
projectId: string | null;
label: string;
isSubentry: boolean;
billingType?: string | null;
minutes: number;
billableMinutes: number;
entryCount: number;
days?: Array<{
day: string;
minutes: number;
billableMinutes: number;
entryCount: number;
}>;
};
export type TeamspaceProjectIdSyncResult = {
month: string;
checkedEntries: number;
matched: number;
updated: number;
@@ -151,6 +175,10 @@ export type TeamspaceProjectIdSyncResult = {
remoteSubprojectsScanned: number;
subentriesSynced: number;
subentriesRemoved: number;
teamspaceTimeEntriesScanned: number;
teamspaceTimeEntriesMapped: number;
teamspaceTimeBillableMinutes: number;
teamspaceTimeDeltaMinutes: number;
duplicateRemoteProjectIds: string[];
items: TeamspaceProjectIdSyncItem[];
};
@@ -161,6 +189,10 @@ export type TeamspaceProjectSubentrySyncResult = {
remoteProjectsScanned: number;
subentriesSynced: number;
subentriesRemoved: number;
monthsSynced: string[];
teamspaceTimeEntriesScanned: number;
teamspaceTimeEntriesMapped: number;
teamspaceTimeBillableMinutes: number;
};
export type TeamspaceAllSubentrySyncResult = {
@@ -482,16 +514,34 @@ export function saveZammadSettings(payload: { baseUrl: string; apiKey?: string }
}
export function getTeamspaceSettings() {
return request<{ settings: { baseUrl: string } }>("/api/admin/teamspace/settings");
return request<{ settings: { baseUrl: string; credentialsUserId: string | null } }>("/api/admin/teamspace/settings");
}
export function saveTeamspaceSettings(payload: { baseUrl: string }) {
return request<{ settings: { baseUrl: string } }>("/api/admin/teamspace/settings", {
export function saveTeamspaceSettings(payload: { baseUrl: string; credentialsUserId?: string | null }) {
return request<{ settings: { baseUrl: string; credentialsUserId: string | null } }>("/api/admin/teamspace/settings", {
method: "PUT",
body: JSON.stringify(payload)
});
}
export type TeamspaceProjectExclusion = {
teamspace_project: string;
teamspace_name: string | null;
customer_name: string | null;
note: string;
};
export function getTeamspaceProjectExclusions() {
return request<{ exclusions: TeamspaceProjectExclusion[] }>("/api/admin/teamspace/project-exclusions");
}
export function saveTeamspaceProjectExclusions(projects: string[]) {
return request<{ exclusions: TeamspaceProjectExclusion[] }>("/api/admin/teamspace/project-exclusions", {
method: "PUT",
body: JSON.stringify({ projects })
});
}
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",
@@ -538,25 +588,46 @@ async function runZammadSyncJob(url: string, body: Record<string, unknown>, onJo
return { jobId: job.id, result };
}
export async function syncExistingZammadTickets(fields: ZammadSyncExistingFields, options: { dryRun?: boolean; ticketNumbers?: string[] } = {}) {
export async function syncExistingZammadTickets(fields: ZammadSyncExistingFields, options: { dryRun?: boolean; ticketNumbers?: string[] } = {}, onJobStarted?: (job: ZammadSyncJob) => void) {
const { result } = await runZammadSyncJob("/api/zammad-sync/jobs/existing", {
fields,
dryRun: options.dryRun ?? true,
ticketNumbers: options.ticketNumbers
});
}, onJobStarted);
return result;
}
export async function importOwnZammadConsultingTickets(options: { dryRun?: boolean; ticketNumbers?: string[] } = {}) {
export async function importOwnZammadConsultingTickets(options: { dryRun?: boolean; ticketNumbers?: string[] } = {}, onJobStarted?: (job: ZammadSyncJob) => void) {
const { result } = await runZammadSyncJob("/api/zammad-sync/jobs/consulting-import", {
dryRun: options.dryRun ?? true,
ticketNumbers: options.ticketNumbers
});
}, onJobStarted);
return result;
}
export function syncExistingZammadTicketsDirect(fields: ZammadSyncExistingFields, onJobStarted?: (job: ZammadSyncJob) => void) {
return runZammadSyncJob("/api/zammad-sync/jobs/existing", {
fields,
dryRun: false
}, onJobStarted);
}
export function importOwnZammadConsultingTicketsDirect(onJobStarted?: (job: ZammadSyncJob) => void) {
return runZammadSyncJob("/api/zammad-sync/jobs/consulting-import", {
dryRun: false
}, onJobStarted);
}
export function syncZammadMailSessionsDirect(daysBack: number, perPage: number, onJobStarted?: (job: ZammadSyncJob) => void) {
return runZammadSyncJob("/api/zammad-sync/jobs/mail-sessions", {
dryRun: false,
daysBack,
perPage
}, onJobStarted);
}
export function previewExistingZammadTickets(fields: ZammadSyncExistingFields, onJobStarted?: (job: ZammadSyncJob) => void) {
return runZammadSyncJob("/api/zammad-sync/jobs/existing", {
fields,
@@ -597,16 +668,18 @@ export function applyZammadMailSessionsFromPreview(previewJobId: string, article
}, onJobStarted);
}
export function syncTeamspaceProjectIds() {
export function syncTeamspaceProjectIds(month?: string) {
return request<{ result: TeamspaceProjectIdSyncResult }>("/api/teamspace/project-ids/sync", {
method: "POST",
body: JSON.stringify(month ? { month } : {}),
timeoutMs: 5 * 60 * 1000
});
}
export function syncProjectTeamspaceSubentries(projectId: string) {
export function syncProjectTeamspaceSubentries(projectId: string, months?: string[]) {
return request<{ result: TeamspaceProjectSubentrySyncResult }>(`/api/projects/${projectId}/teamspace-subentries/sync`, {
method: "POST",
body: JSON.stringify(months?.length ? { months } : {}),
timeoutMs: 5 * 60 * 1000
});
}
+6 -1
View File
@@ -54,7 +54,7 @@
--font-sans: var(--font-sans);
--breakpoint-xxl: 108.125rem;
--breakpoint-details: 85rem;
--text-xxs: 0.65rem;
}
/* Default theme styles (used when no data-theme-preset is set or when 'default' is selected).
@@ -166,6 +166,11 @@ These serve as the fallback; there is no separate default.css file. */
}
@layer utilities {
.text-xxs {
font-size: var(--text-xxs);
line-height: 0.65rem;
}
[data-theme-preset]:not([data-theme-preset="default"]) .shadow-2xs {
box-shadow: var(--shadow-2xs);
}
+59
View File
@@ -204,6 +204,7 @@ export type ProjectSummary = {
starts_on: string | null;
ends_on: string | null;
hour_budget_minutes: number | null;
teamspace_budget_excluded?: boolean;
manual_consumption_minutes: number;
teamspace_consumption_minutes?: number;
consumption_minutes?: number;
@@ -236,6 +237,7 @@ export type ProjectTeamspaceEntry = {
subentries?: ProjectTeamspaceSubentry[];
is_default: boolean;
hour_budget_minutes: number | null;
teamspace_budget_excluded?: boolean;
budget_sync_disabled?: boolean;
manual_consumption_minutes: number;
teamspace_consumption_minutes?: number;
@@ -311,6 +313,40 @@ export type TicketSummary = {
requires_closure: boolean;
closed: boolean;
closed_at: string | null;
teamspace_month_billable_minutes?: number;
teamspace_month_delta_minutes?: number;
teamspace_month_actuals?: TeamspaceMonthActual[];
};
export type TeamspaceMonthActual = {
entry_id: string;
teamspace_project: string | null;
teamspace_description: string | null;
teamspace_api_project_id: string | null;
teamspace_minutes: number;
teamspace_billable_minutes: number;
teamspace_time_entry_count: number;
delta_minutes: number;
positions: TeamspaceMonthActualPosition[];
};
export type TeamspaceMonthActualPosition = {
apiProjectId: string;
projectId: string | null;
label: string;
isSubentry: boolean;
billingType?: string | null;
minutes: number;
billableMinutes: number;
entryCount: number;
days?: TeamspaceMonthActualPositionDay[];
};
export type TeamspaceMonthActualPositionDay = {
day: string;
minutes: number;
billableMinutes: number;
entryCount: number;
};
export type OpenSession = {
@@ -432,6 +468,9 @@ export type TicketPeriod = {
dayBillings: TicketDayBilling[];
project?: ProjectSummary | null;
todos?: TicketTodo[];
teamspaceMonthActuals?: TeamspaceMonthActual[];
teamspaceMonthBillableMinutes?: number;
teamspaceMonthDeltaMinutes?: number;
canClose: boolean;
openCount: number;
ticketOpenCount: number;
@@ -472,6 +511,9 @@ export type StatisticsGroup = {
open_sessions: number;
crm_billed_minutes: number;
crm_delta_minutes: number;
teamspace_month_billable_minutes?: number;
teamspace_month_delta_minutes?: number;
teamspace_month_actuals?: TeamspaceMonthActual[];
};
export type StatisticsTicket = {
@@ -492,6 +534,9 @@ export type StatisticsTicket = {
open_sessions: number;
crm_billed_minutes: number;
crm_delta_minutes: number;
teamspace_month_billable_minutes?: number;
teamspace_month_delta_minutes?: number;
teamspace_month_actuals?: TeamspaceMonthActual[];
};
export type StatisticsOpenSession = {
@@ -528,6 +573,16 @@ export type StatisticsTeamspaceDay = {
project_teamspace_project: string | null;
};
export type StatisticsTeamspaceMonthMismatch = {
ticket_id: string;
ticket_number: string;
organization_name: string;
crm_billed_minutes: number;
teamspace_month_billable_minutes: number;
teamspace_month_delta_minutes: number;
teamspace_month_actuals: TeamspaceMonthActual[];
};
export type StatisticsProjectFollowup = {
project_id: string;
ticket_id: string;
@@ -536,6 +591,7 @@ export type StatisticsProjectFollowup = {
organization_name: string | null;
teamspace_project: string | null;
hour_budget_minutes: number | null;
teamspace_budget_excluded?: boolean;
consumption_minutes: number;
remaining_minutes: number | null;
latest_tt_update: string;
@@ -582,6 +638,8 @@ export type StatisticsOverview = {
activeDays: number;
crmBilledMinutes: number;
crmDeltaMinutes: number;
teamspaceApiBillableMinutes?: number;
teamspaceApiDeltaMinutes?: number;
};
dailySeries: StatisticsDailyBucket[];
organizations: StatisticsGroup[];
@@ -592,6 +650,7 @@ export type StatisticsOverview = {
missingCrmDays: StatisticsCrmDay[];
acknowledgedMissingCrmDays: StatisticsCrmDay[];
crmMismatches: StatisticsCrmDay[];
teamspaceMonthMismatches: StatisticsTeamspaceMonthMismatch[];
};
teamspaceDays: StatisticsTeamspaceDay[];
};
+142 -5
View File
@@ -1,5 +1,5 @@
import { ChangeEvent, FormEvent, useEffect, useState } from "react";
import { CheckCircle2, DatabaseBackup, DatabaseZap, Save, Shield, Unplug, Upload, UserPlus } from "lucide-react";
import { CheckCircle2, DatabaseBackup, DatabaseZap, Plus, Save, Shield, Trash2, Unplug, Upload, UserPlus } from "lucide-react";
import { toast } from "sonner";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
@@ -15,13 +15,16 @@ import {
downloadDatabaseExport,
getAdminUsers,
getTeamspaceSettings,
getTeamspaceProjectExclusions,
getZammadSettings,
restoreDatabaseBackup,
saveZammadSettings,
saveTeamspaceSettings,
saveTeamspaceProjectExclusions,
syncZammadOrganizations,
updateAdminUser,
validateDatabaseBackup,
type TeamspaceProjectExclusion,
type RestoreValidationResult
} from "../api";
import type { AdminUser, AuthUser } from "../types";
@@ -57,9 +60,13 @@ export function AdminUsersPage({ currentUser }: { currentUser: AuthUser }) {
const [zammadApiKey, setZammadApiKey] = useState("");
const [hasZammadApiKey, setHasZammadApiKey] = useState(false);
const [teamspaceBaseUrl, setTeamspaceBaseUrl] = useState("");
const [teamspaceCredentialsUserId, setTeamspaceCredentialsUserId] = useState("");
const [teamspaceProjectExclusions, setTeamspaceProjectExclusions] = useState<TeamspaceProjectExclusion[]>([]);
const [newTeamspaceProjectExclusion, setNewTeamspaceProjectExclusion] = useState("");
const [syncingOrganizations, setSyncingOrganizations] = useState(false);
const [savingZammadSettings, setSavingZammadSettings] = useState(false);
const [savingTeamspaceSettings, setSavingTeamspaceSettings] = useState(false);
const [savingTeamspaceProjectExclusions, setSavingTeamspaceProjectExclusions] = useState(false);
const [exportingDatabase, setExportingDatabase] = useState(false);
const [restoreSql, setRestoreSql] = useState("");
const [restoreValidation, setRestoreValidation] = useState<RestoreValidationResult | null>(null);
@@ -72,13 +79,20 @@ export function AdminUsersPage({ currentUser }: { currentUser: AuthUser }) {
async function load(options: { preserveEdits?: boolean } = {}) {
setLoading(true);
try {
const [usersResult, zammadSettingsResult, teamspaceSettingsResult] = await Promise.all([getAdminUsers(), getZammadSettings(), getTeamspaceSettings()]);
const [usersResult, zammadSettingsResult, teamspaceSettingsResult, teamspaceProjectExclusionsResult] = await Promise.all([
getAdminUsers(),
getZammadSettings(),
getTeamspaceSettings(),
getTeamspaceProjectExclusions()
]);
setUsers(usersResult.users);
if (!options.preserveEdits) {
setZammadBaseUrl(zammadSettingsResult.settings.baseUrl);
setHasZammadApiKey(zammadSettingsResult.settings.hasApiKey);
setTeamspaceBaseUrl(teamspaceSettingsResult.settings.baseUrl);
setTeamspaceCredentialsUserId(teamspaceSettingsResult.settings.credentialsUserId ?? "");
setTeamspaceProjectExclusions(teamspaceProjectExclusionsResult.exclusions);
}
setForms((current) =>
@@ -222,9 +236,11 @@ export function AdminUsersPage({ currentUser }: { currentUser: AuthUser }) {
try {
const result = await saveTeamspaceSettings({
baseUrl: teamspaceBaseUrl
baseUrl: teamspaceBaseUrl,
credentialsUserId: teamspaceCredentialsUserId || null
});
setTeamspaceBaseUrl(result.settings.baseUrl);
setTeamspaceCredentialsUserId(result.settings.credentialsUserId ?? "");
toast.success("Teamspace-Zugang gespeichert", {
description: "URL ist hinterlegt."
});
@@ -237,6 +253,56 @@ export function AdminUsersPage({ currentUser }: { currentUser: AuthUser }) {
}
}
function addTeamspaceExclusion() {
const project = newTeamspaceProjectExclusion.trim();
if (!project) {
return;
}
const exists = teamspaceProjectExclusions.some((entry) => entry.teamspace_project.toLocaleLowerCase("de") === project.toLocaleLowerCase("de"));
if (exists) {
toast.info("Teamspace-Projekt ist bereits in der Liste");
setNewTeamspaceProjectExclusion("");
return;
}
setTeamspaceProjectExclusions((current) => [
...current,
{
teamspace_project: project,
teamspace_name: null,
customer_name: null,
note: ""
}
]);
setNewTeamspaceProjectExclusion("");
}
function removeTeamspaceExclusion(project: string) {
setTeamspaceProjectExclusions((current) => current.filter((entry) => entry.teamspace_project !== project));
}
async function saveTeamspaceExclusions() {
setSavingTeamspaceProjectExclusions(true);
try {
const projects = teamspaceProjectExclusions.map((entry) => entry.teamspace_project.trim()).filter(Boolean);
const result = await saveTeamspaceProjectExclusions(projects);
setTeamspaceProjectExclusions(result.exclusions);
toast.success("Teamspace-Ausnahmen gespeichert", {
description: `${result.exclusions.length} Projektnummer(n) geprüft und gespeichert.`
});
} catch (error) {
toast.error("Teamspace-Ausnahmen konnten nicht gespeichert werden", {
description: error instanceof Error ? error.message : "Unbekannter Fehler"
});
} finally {
setSavingTeamspaceProjectExclusions(false);
}
}
async function exportDatabase() {
setExportingDatabase(true);
@@ -400,7 +466,7 @@ export function AdminUsersPage({ currentUser }: { currentUser: AuthUser }) {
<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="grid gap-3 xl:grid-cols-[minmax(220px,1fr)_minmax(220px,1fr)_auto] xl:items-end">
<div className="space-y-2">
<label className="text-sm font-medium" htmlFor="teamspace-base-url">Teamspace URL</label>
<Input
@@ -410,9 +476,80 @@ export function AdminUsersPage({ currentUser }: { currentUser: AuthUser }) {
onChange={(event) => setTeamspaceBaseUrl(event.currentTarget.value)}
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium" htmlFor="teamspace-credentials-user">Credentials für Adminabfragen</label>
<select
id="teamspace-credentials-user"
className="h-10 w-full rounded-md border border-input bg-background px-3 text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring"
value={teamspaceCredentialsUserId}
onChange={(event) => setTeamspaceCredentialsUserId(event.currentTarget.value)}
>
<option value="">Bitte wählen</option>
{users.filter((user) => user.active && user.has_teamspace_credentials).map((user) => (
<option key={user.id} value={user.id}>
{user.display_name} ({user.username})
</option>
))}
</select>
<p className="text-xs text-muted-foreground">Dieser Benutzer wird für Admin-Checks gegen Teamspace verwendet.</p>
</div>
<Button type="button" variant="secondary" disabled={savingTeamspaceSettings} onClick={() => void saveTeamspaceAccess()}>
<Save className="size-4" />
{savingTeamspaceSettings ? "Speichert..." : "URL speichern"}
{savingTeamspaceSettings ? "Speichert..." : "Einstellungen speichern"}
</Button>
</div>
<div className="mt-4 space-y-3 rounded-md border bg-muted/20 p-3">
<div>
<p className="text-sm font-medium">TS-Projekte ohne Differenz-/Budgetprüfung</p>
<p className="text-xs text-muted-foreground">
Sync und Gesamtverbrauch laufen weiter, aber TS/TT-Differenzen, Budgetbalken und Budgetverbrauch werden für diese TS-Projekte ausgeblendet. Beim Speichern werden Name und Kunde aus Teamspace geladen.
</p>
</div>
<div className="flex flex-col gap-2 sm:flex-row">
<Input
id="teamspace-project-exclusion-new"
placeholder="BP-2026-A-023"
value={newTeamspaceProjectExclusion}
onChange={(event) => setNewTeamspaceProjectExclusion(event.currentTarget.value)}
onKeyDown={(event) => {
if (event.key === "Enter") {
event.preventDefault();
addTeamspaceExclusion();
}
}}
/>
<Button type="button" variant="secondary" onClick={addTeamspaceExclusion}>
<Plus className="size-4" />
Hinzufügen
</Button>
</div>
<div className="overflow-hidden rounded-md border bg-background">
{teamspaceProjectExclusions.length === 0 ? (
<p className="px-3 py-4 text-sm text-muted-foreground">Noch keine Teamspace-Projekte ausgenommen.</p>
) : (
<div className="divide-y">
{teamspaceProjectExclusions.map((entry) => (
<div key={entry.teamspace_project} className="grid gap-2 px-3 py-2 sm:grid-cols-[140px_minmax(0,1fr)_minmax(0,1fr)_auto] sm:items-center">
<div className="font-mono text-sm font-semibold">{entry.teamspace_project}</div>
<div className="min-w-0">
<p className="truncate text-sm">{entry.teamspace_name ?? "Name wird beim Speichern aus Teamspace geladen"}</p>
<p className="text-xs text-muted-foreground sm:hidden">{entry.customer_name ?? "Kunde wird beim Speichern aus Teamspace geladen"}</p>
</div>
<div className="hidden min-w-0 text-sm text-muted-foreground sm:block">
<span className="truncate">{entry.customer_name ?? "Kunde wird beim Speichern aus Teamspace geladen"}</span>
</div>
<Button type="button" variant="ghost" size="icon" className="justify-self-start sm:justify-self-end" onClick={() => removeTeamspaceExclusion(entry.teamspace_project)}>
<Trash2 className="size-4" />
<span className="sr-only">Teamspace-Projekt entfernen</span>
</Button>
</div>
))}
</div>
)}
</div>
<Button type="button" variant="secondary" disabled={savingTeamspaceProjectExclusions} onClick={() => void saveTeamspaceExclusions()}>
<Save className="size-4" />
{savingTeamspaceProjectExclusions ? "Speichert..." : "Ausnahmen speichern"}
</Button>
</div>
</div>
+46 -2
View File
@@ -1,4 +1,4 @@
import { ArrowDown, ArrowUp, ChevronLeft, ChevronRight, Clock3, Download, ExternalLink, RotateCcw, SlidersHorizontal, Ticket, UserCheck, Users } from "lucide-react";
import { AlertCircle, ArrowDown, ArrowUp, ChevronLeft, ChevronRight, Clock3, Download, ExternalLink, RotateCcw, SlidersHorizontal, Ticket, UserCheck, Users } from "lucide-react";
import type { LucideIcon } from "lucide-react";
import { Fragment, useEffect, useMemo, useRef, useState } from "react";
import { toast } from "sonner";
@@ -168,6 +168,41 @@ function TicketStatus({ ticket }: { ticket: TicketSummary }) {
return <Badge variant="success">bewertet</Badge>;
}
function formatSignedMinutes(minutes: number) {
if (minutes === 0) {
return formatMinutes(0);
}
return `${minutes > 0 ? "+" : "-"}${formatMinutes(Math.abs(minutes))}`;
}
function teamspaceMonthDeltaMinutes(ticket: TicketSummary) {
const actuals = ticket.teamspace_month_actuals ?? [];
if (actuals.length === 0) {
return 0;
}
const tsMinutes = ticket.teamspace_month_billable_minutes ?? actuals.reduce((sum, actual) => sum + actual.teamspace_billable_minutes, 0);
return ticket.teamspace_month_delta_minutes ?? tsMinutes - ticket.crm_billed_minutes;
}
function TeamspaceDeviationIcon({ ticket }: { ticket: TicketSummary }) {
const deltaMinutes = teamspaceMonthDeltaMinutes(ticket);
if (deltaMinutes === 0) {
return <span className="inline-block size-4" aria-hidden="true" />;
}
const title = deltaMinutes > 0 ? `${formatMinutes(deltaMinutes)} fehlt in TT` : `${formatMinutes(Math.abs(deltaMinutes))} fehlt in TS`;
return (
<span title={`${formatSignedMinutes(deltaMinutes)} · ${title}`} aria-label={`Teamspace-Abweichung: ${title}`}>
<AlertCircle className="mx-auto size-4 text-amber-500" />
</span>
);
}
function ticketStatusKey(ticket: TicketSummary): "open" | "evaluated" {
return (ticket.ticket_open_count ?? ticket.open_count) > 0 ? "open" : "evaluated";
}
@@ -690,6 +725,9 @@ export function AnalysisPage({ currentUser, onNavigate }: AnalysisPageProps) {
<TableHeader>
<TableRow>
<TableHead>Ticket</TableHead>
<TableHead className="w-10 text-center">
<span className="sr-only">Teamspace-Abweichung</span>
</TableHead>
<TableHead>Organisation</TableHead>
<TableHead>Art</TableHead>
<TableHead>Sessions</TableHead>
@@ -730,6 +768,9 @@ export function AnalysisPage({ currentUser, onNavigate }: AnalysisPageProps) {
</div>
{ticket.title ? <p className="max-w-[220px] truncate text-xs text-muted-foreground">{ticket.title}</p> : null}
</TableCell>
<TableCell className="text-center">
<TeamspaceDeviationIcon ticket={ticket} />
</TableCell>
<TableCell>{ticket.customer_name ?? "-"}</TableCell>
<TableCell>{workTypeLabel(ticket.work_type)}</TableCell>
<TableCell>{ticket.session_count}</TableCell>
@@ -785,7 +826,10 @@ export function AnalysisPage({ currentUser, onNavigate }: AnalysisPageProps) {
{ticket.title ? <p className="truncate text-xs text-muted-foreground">{ticket.title}</p> : null}
<p className="text-sm text-muted-foreground">{ticket.customer_name ?? "Keine Organisation"} · {workTypeLabel(ticket.work_type)} · {ticket.session_count} Session(s)</p>
</div>
<TicketStatus ticket={ticket} />
<div className="flex shrink-0 items-center gap-2">
<TeamspaceDeviationIcon ticket={ticket} />
<TicketStatus ticket={ticket} />
</div>
</div>
<div className="grid grid-cols-2 gap-2 text-sm">
<div className="rounded-md bg-muted/50 p-2">
+19 -6
View File
@@ -722,7 +722,7 @@ function projectFilterGroup(filter: string) {
function matchesSingleProjectFilter(project: ProjectSummary, filterBy: string, fixedBillingTickets: Set<string>) {
const consumptionMinutes = project.consumption_minutes ?? 0;
const budgetMinutes = project.hour_budget_minutes ?? 0;
const budgetMinutes = projectBudgetExcluded(project) ? 0 : project.hour_budget_minutes ?? 0;
const percent = budgetMinutes > 0 ? consumptionMinutes / budgetMinutes : 0;
const hasFixedBilling = Number(project.recurring_session_count ?? 0) > 0 || hasFixedBillingForTicket(fixedBillingTickets, project.ticket_id, project.ticket_number);
@@ -792,6 +792,10 @@ function matchesProjectFilters(project: ProjectSummary, filters: string[], fixed
}
function projectConsumptionPercent(project: ProjectSummary) {
if (projectBudgetExcluded(project)) {
return null;
}
const budgetMinutes = project.hour_budget_minutes ?? 0;
if (budgetMinutes <= 0) {
@@ -821,12 +825,20 @@ function projectTeamspaceEntries(project: ProjectSummary): ProjectTeamspaceEntry
];
}
function projectBudgetExcluded(project: ProjectSummary) {
return Boolean(project.teamspace_budget_excluded || projectTeamspaceEntries(project).some((entry) => entry.teamspace_budget_excluded));
}
function projectTeamspaceEntryLabel(entry: ProjectTeamspaceEntry) {
return entry.teamspace_description?.trim() || entry.teamspace_project || (entry.is_default ? "Standard" : "Ohne Teamspace-ID");
}
function budgetedProjectTeamspaceEntries(project: ProjectSummary) {
return projectTeamspaceEntries(project).filter((entry) => Number(entry.hour_budget_minutes ?? 0) > 0);
if (projectBudgetExcluded(project)) {
return [];
}
return projectTeamspaceEntries(project).filter((entry) => !entry.teamspace_budget_excluded && Number(entry.hour_budget_minutes ?? 0) > 0);
}
function openRatingFilterGroup(filter: string) {
@@ -1017,9 +1029,9 @@ export function DashboardPage({ currentUser, onNavigate }: DashboardPageProps) {
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);
const overBudget = projects.filter((project) => !projectBudgetExcluded(project) && project.hour_budget_minutes !== null && project.hour_budget_minutes !== undefined && (project.consumption_minutes ?? 0) > project.hour_budget_minutes);
const nearlyFull = projects.filter((project) => {
if (!project.hour_budget_minutes || project.hour_budget_minutes <= 0) {
if (projectBudgetExcluded(project) || !project.hour_budget_minutes || project.hour_budget_minutes <= 0) {
return false;
}
@@ -2349,7 +2361,8 @@ function ProjectBudgetWidget({ widget, editing, projects, stats, onNavigate, onU
<div className="space-y-1">
{visible.map((project) => {
const consumptionMinutes = project.consumption_minutes ?? 0;
const budgetMinutes = project.hour_budget_minutes ?? 0;
const budgetExcluded = projectBudgetExcluded(project);
const budgetMinutes = budgetExcluded ? 0 : project.hour_budget_minutes ?? 0;
const percent = projectConsumptionPercent(project);
const budgetedEntries = budgetedProjectTeamspaceEntries(project);
const showBudgetedTeamspaceEntries = budgetedEntries.length > 1;
@@ -2358,7 +2371,7 @@ function ProjectBudgetWidget({ widget, editing, projects, stats, onNavigate, onU
<button key={project.id} type="button" className="block w-full min-w-0 rounded-md px-2 py-1.5 text-left hover:bg-muted" onClick={() => onNavigate(`/tickets/${project.ticket_id}?month=${currentMonth()}`)}>
<span className="block truncate text-xs font-medium">{project.title || project.ticket_number}</span>
<span className="block truncate text-[11px] text-muted-foreground">
{project.ticket_number} · {showBudgetedTeamspaceEntries ? `${budgetedEntries.length} Budgets · ` : ""}{budgetMinutes > 0 ? `${decimalHours(consumptionMinutes)} / ${decimalHours(budgetMinutes)} · ${percent ?? 0}%` : "ohne Kontingent"}
{project.ticket_number} · {budgetExcluded ? `${decimalHours(consumptionMinutes)} · Budgetprüfung ausgenommen` : `${showBudgetedTeamspaceEntries ? `${budgetedEntries.length} Budgets · ` : ""}${budgetMinutes > 0 ? `${decimalHours(consumptionMinutes)} / ${decimalHours(budgetMinutes)} · ${percent ?? 0}%` : "ohne Kontingent"}`}
</span>
<span className="mt-1 flex flex-wrap gap-1">
<ZammadStateBadge stateId={project.zammad_state_id} />
+105 -22
View File
@@ -23,9 +23,10 @@ import { CopyTicketButton } from "@/components/CopyTicketButton";
import { HelpLink } from "@/components/HelpLink";
import { acknowledgeMissingTicketDayBilling, closePeriod, getStatisticsOverview, reopenPeriod } from "../api";
import { currentMonth, currentWeek, formatDate, formatDateTime, formatMinutes } from "../format";
import type { StatisticsCrmDay, StatisticsOpenSession, StatisticsOverview, StatisticsTeamspaceDay } from "../types";
import type { AuthUser, StatisticsCrmDay, StatisticsOpenSession, StatisticsOverview, StatisticsTeamspaceDay, StatisticsTeamspaceMonthMismatch } from "../types";
type MonthlyClosePageProps = {
currentUser: AuthUser;
onNavigate: (to: string) => void;
};
@@ -56,6 +57,18 @@ function formatTeamspaceDelta(minutes: number) {
return formatSignedMinutes(minutes);
}
function formatMissingTarget(minutes: number, teamspaceTimeTrackingEnabled: boolean) {
if (minutes === 0) {
return "ausgeglichen";
}
if (minutes > 0) {
return `${formatMinutes(minutes)} fehlt ${teamspaceTimeTrackingEnabled ? "in TT" : "in den abgerechneten Zeiten"}`;
}
return `${formatMinutes(Math.abs(minutes))} fehlt in TS`;
}
function StatusCard({ label, value, detail, tone }: { label: string; value: string | number; detail: string; tone?: "ok" | "warn" | "neutral" }) {
const valueClassName = tone === "ok" ? "text-emerald-700 dark:text-emerald-300" : tone === "warn" ? "text-amber-700 dark:text-amber-300" : "";
@@ -64,7 +77,7 @@ function StatusCard({ label, value, detail, tone }: { label: string; value: stri
<CardContent className="p-3">
<p className="text-xs font-medium text-muted-foreground">{label}</p>
<p className={`text-xl font-semibold tracking-normal ${valueClassName}`}>{value}</p>
<p className="text-xs text-muted-foreground">{detail}</p>
<p className="text-xs text-muted-foreground ">{detail}</p>
</CardContent>
</Card>
);
@@ -79,6 +92,10 @@ type OpenTicketGroup = {
totalMinutes: number;
};
type OpenReviewGroup = OpenTicketGroup & {
mismatch: StatisticsTeamspaceMonthMismatch | null;
};
function groupOpenSessions(sessions: StatisticsOpenSession[]) {
const groups = new Map<string, OpenTicketGroup>();
@@ -104,7 +121,44 @@ function groupOpenSessions(sessions: StatisticsOpenSession[]) {
return Array.from(groups.values()).sort((left, right) => left.ticketNumber.localeCompare(right.ticketNumber, "de", { numeric: true, sensitivity: "base" }));
}
function OpenTicketItem({ group, onNavigate }: { group: OpenTicketGroup; onNavigate: (to: string) => void }) {
function buildOpenReviewGroups(openGroups: OpenTicketGroup[], mismatches: StatisticsTeamspaceMonthMismatch[], month: string) {
const groups = new Map<string, OpenReviewGroup>();
for (const group of openGroups) {
groups.set(group.ticketId, { ...group, mismatch: null });
}
for (const mismatch of mismatches) {
const existing = groups.get(mismatch.ticket_id);
if (existing) {
existing.mismatch = mismatch;
continue;
}
groups.set(mismatch.ticket_id, {
ticketId: mismatch.ticket_id,
ticketNumber: mismatch.ticket_number,
organizationName: mismatch.organization_name,
month,
openCount: 0,
totalMinutes: 0,
mismatch
});
}
return Array.from(groups.values()).sort((left, right) => left.ticketNumber.localeCompare(right.ticketNumber, "de", { numeric: true, sensitivity: "base" }));
}
function OpenReviewItem({
group,
teamspaceTimeTrackingEnabled,
onNavigate
}: {
group: OpenReviewGroup;
teamspaceTimeTrackingEnabled: boolean;
onNavigate: (to: string) => void;
}) {
return (
<div className="rounded-md border bg-background px-3 py-2">
<div className="flex items-start justify-between gap-3">
@@ -112,10 +166,13 @@ function OpenTicketItem({ group, onNavigate }: { group: OpenTicketGroup; onNavig
<div className="flex flex-wrap items-center gap-2">
<span className="font-medium">{group.ticketNumber}</span>
<CopyTicketButton ticketNumber={group.ticketNumber} />
<Badge variant="warning">{group.openCount} offen</Badge>
{group.openCount > 0 ? <Badge variant="warning" className="text-xxs">{group.openCount} Bewertung(en)</Badge> : null}
{group.mismatch ? <Badge variant="warning" className="text-xxs">{formatMissingTarget(group.mismatch.teamspace_month_delta_minutes, teamspaceTimeTrackingEnabled)}</Badge> : null}
</div>
<p className="truncate text-sm text-muted-foreground">{group.organizationName}</p>
<p className={`text-xs font-medium ${trackedTextClass}`}>{formatMinutes(group.totalMinutes)} offen zu bewerten</p>
<div className="flex flex-wrap gap-x-3 gap-y-1 text-xs">
{group.openCount > 0 ? <span className={trackedTextClass}>{formatMinutes(group.totalMinutes)} offen zu bewerten</span> : null}
</div>
</div>
<Button className="h-8 shrink-0" variant="secondary" size="sm" onClick={() => onNavigate(`/tickets/${group.ticketId}?month=${group.month}`)}>
Ticket öffnen
@@ -197,7 +254,7 @@ function TeamspaceDayItem({ item, onNavigate }: { item: StatisticsTeamspaceDay;
);
}
export function MonthlyClosePage({ onNavigate }: MonthlyClosePageProps) {
export function MonthlyClosePage({ currentUser, onNavigate }: MonthlyClosePageProps) {
const [viewMode, setViewMode] = useState<MonthlyCloseViewMode>(() => readMonthlyCloseViewMode());
const [month, setMonth] = useState(currentMonth());
const [week, setWeek] = useState(currentWeek());
@@ -214,6 +271,7 @@ export function MonthlyClosePage({ onNavigate }: MonthlyClosePageProps) {
const periodAttentionLabel = viewMode === "month" ? "diesen Monat" : "diese Woche";
const periodInLabel = viewMode === "month" ? "diesem Monat" : "dieser Woche";
const isMonthView = viewMode === "month";
const teamspaceTimeTrackingEnabled = currentUser.teamspace_time_tracking_enabled ?? true;
async function load() {
const requestId = loadRequestId.current + 1;
@@ -342,8 +400,14 @@ export function MonthlyClosePage({ onNavigate }: MonthlyClosePageProps) {
const openSessions = stats?.attention.openSessions ?? [];
const missingCrmDays = stats?.attention.missingCrmDays ?? [];
const acknowledgedMissingCrmDays = stats?.attention.acknowledgedMissingCrmDays ?? [];
const teamspaceMonthMismatches = stats?.attention.teamspaceMonthMismatches ?? [];
const teamspaceDays = stats?.teamspaceDays ?? [];
const billedMinutes = stats?.totals.billedMinutes ?? 0;
const ttTeamspaceMinutes = stats?.totals.crmBilledMinutes ?? 0;
const teamspaceApiBillableMinutes = stats?.totals.teamspaceApiBillableMinutes ?? 0;
const teamspaceApiDeltaMinutes = stats?.totals.teamspaceApiDeltaMinutes ?? 0;
const openTicketGroups = useMemo(() => groupOpenSessions(openSessions), [openSessions]);
const openReviewGroups = useMemo(() => buildOpenReviewGroups(openTicketGroups, teamspaceMonthMismatches, month), [month, openTicketGroups, teamspaceMonthMismatches]);
const visibleMissingCrmDays = useMemo(() => {
const days = showAcknowledgedMissingCrm ? [...missingCrmDays, ...acknowledgedMissingCrmDays] : [...missingCrmDays];
return days.sort((left, right) => left.day.localeCompare(right.day) || left.ticket_number.localeCompare(right.ticket_number, "de", { numeric: true, sensitivity: "base" }));
@@ -352,16 +416,16 @@ export function MonthlyClosePage({ onNavigate }: MonthlyClosePageProps) {
const checklist = useMemo(
() => [
{
label: "Offene Bewertungen",
ok: openSessions.length === 0,
value: openSessions.length,
detail: openSessions.length === 0 ? "Keine offenen Bewertungen." : `${openTicketGroups.length} Ticket(s) bearbeiten`
label: "Offene Prüfungen",
ok: openReviewGroups.length === 0,
value: openReviewGroups.length,
detail: openReviewGroups.length === 0 ? "Keine offenen Prüfungen." : `${openReviewGroups.length} Ticket(s) prüfen`
},
{
label: "Teamspace geprüft",
ok: missingCrmDays.length === 0,
value: missingCrmDays.length,
detail: missingCrmDays.length === 0 ? "Keine offenen Teamspace-Prüfpunkte." : `${acknowledgedMissingCrmDays.length} zur Kenntnis genommen`
ok: missingCrmDays.length === 0 && teamspaceMonthMismatches.length === 0,
value: missingCrmDays.length + teamspaceMonthMismatches.length,
detail: missingCrmDays.length === 0 && teamspaceMonthMismatches.length === 0 ? "Keine offenen Teamspace-Prüfpunkte." : `${teamspaceMonthMismatches.length} Monatsabweichung(en), ${acknowledgedMissingCrmDays.length} zur Kenntnis genommen`
},
{
label: isMonthView ? "Monat abschließbar" : "Wochenansicht",
@@ -370,7 +434,7 @@ export function MonthlyClosePage({ onNavigate }: MonthlyClosePageProps) {
detail: isMonthView ? (stats?.closed ? "Monat ist gesperrt." : canClose ? "Kann abgeschlossen werden." : "Bewertungen prüfen.") : "Abschluss bleibt monatlich."
}
],
[acknowledgedMissingCrmDays.length, canClose, isMonthView, openSessions.length, openTicketGroups.length, missingCrmDays.length, stats?.closed]
[acknowledgedMissingCrmDays.length, canClose, isMonthView, missingCrmDays.length, openReviewGroups.length, stats?.closed, teamspaceMonthMismatches.length]
);
return (
@@ -439,10 +503,29 @@ export function MonthlyClosePage({ onNavigate }: MonthlyClosePageProps) {
) : null}
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-5">
<StatusCard label="Sessions" value={stats?.totals.sessions ?? 0} detail={`${formatMinutes(stats?.totals.minutes ?? 0)} abgerechnet`} />
<StatusCard label="Teamspace" value={formatMinutes(stats?.totals.crmBilledMinutes ?? 0)} detail={`Differenz ${formatTeamspaceDelta(stats?.totals.crmDeltaMinutes ?? 0)}`} />
<StatusCard label="Offene Bewertungen" value={openSessions.length} detail={`${openTicketGroups.length} Ticket(s)`} tone={openSessions.length > 0 ? "warn" : "ok"} />
<StatusCard label="Ohne Teamspace" value={missingCrmDays.length} detail={`${acknowledgedMissingCrmDays.length} zur Kenntnis genommen`} tone={missingCrmDays.length > 0 ? "warn" : "ok"} />
<StatusCard
label="Tickettracker-Werte"
value={stats?.totals.sessions ?? 0}
detail={`${formatMinutes(stats?.totals.minutes ?? 0)} abgerechnet`}
/>
<StatusCard
label="Teamspace-Werte"
value={formatMinutes(ttTeamspaceMinutes)}
detail={`aus TS API ${formatMinutes(teamspaceApiBillableMinutes)}\n${formatMissingTarget(teamspaceApiDeltaMinutes, teamspaceTimeTrackingEnabled)}`}
tone={teamspaceApiDeltaMinutes !== 0 ? "warn" : "ok"}
/>
<StatusCard
label="Offene Prüfungen"
value={openReviewGroups.length}
detail={`${openSessions.length} Bewertung(en)\n${teamspaceMonthMismatches.length}TS-Abweichung(en)`}
tone={openReviewGroups.length > 0 ? "warn" : "ok"}
/>
<StatusCard
label="Ohne Teamspace"
value={missingCrmDays.length}
detail={`${acknowledgedMissingCrmDays.length} zur Kenntnis genommen`}
tone={missingCrmDays.length > 0 ? "warn" : "ok"}
/>
<StatusCard
label="Status"
value={isMonthView ? (stats?.closed ? "geschlossen" : canClose ? "bereit" : "offen") : "Ansicht"}
@@ -498,12 +581,12 @@ export function MonthlyClosePage({ onNavigate }: MonthlyClosePageProps) {
<section className="space-y-2">
<div className="flex min-h-8 items-center gap-2 text-sm font-medium">
<FileWarning className="size-4 text-muted-foreground" />
Offene Bewertungen
Offene Prüfungen
</div>
{openTicketGroups.map((group) => (
<OpenTicketItem key={group.ticketId} group={group} onNavigate={onNavigate} />
{openReviewGroups.map((group) => (
<OpenReviewItem key={group.ticketId} group={group} teamspaceTimeTrackingEnabled={teamspaceTimeTrackingEnabled} onNavigate={onNavigate} />
))}
{openTicketGroups.length === 0 ? <p className="rounded-md border bg-muted/20 p-3 text-sm text-muted-foreground">Keine offenen Bewertungen.</p> : null}
{openReviewGroups.length === 0 ? <p className="rounded-md border bg-muted/20 p-3 text-sm text-muted-foreground">Keine offenen Prüfungen.</p> : null}
</section>
<section className="space-y-2">
+34 -8
View File
@@ -347,7 +347,11 @@ function projectTeamspaceEntryLabel(entry: { teamspace_project: string | null; t
}
function budgetedProjectTeamspaceEntries(project: ProjectSummary) {
return projectTeamspaceEntries(project).filter((entry) => Number(entry.hour_budget_minutes ?? 0) > 0);
if (projectBudgetExcluded(project)) {
return [];
}
return projectTeamspaceEntries(project).filter((entry) => !entry.teamspace_budget_excluded && Number(entry.hour_budget_minutes ?? 0) > 0);
}
function projectEntryConsumption(entry: ProjectTeamspaceEntry) {
@@ -412,7 +416,15 @@ function projectOrganizationKey(project: ProjectSummary) {
return project.organization_id ? `id:${project.organization_id}` : `name:${projectOrganizationLabel(project)}`;
}
function projectBudgetExcluded(project: ProjectSummary) {
return Boolean(project.teamspace_budget_excluded || projectTeamspaceEntries(project).some((entry) => entry.teamspace_budget_excluded));
}
function projectBudget(project: ProjectSummary, _standardTeamspaceOnly = false) {
if (projectBudgetExcluded(project)) {
return null;
}
return project.hour_budget_minutes ?? null;
}
@@ -1656,6 +1668,7 @@ export function ProjectsPage({ currentUser, onNavigate, onStartTimer }: Projects
const usedPercent = projectConsumptionPercent(project, viewSettings.standardTeamspaceOnly);
const budgetExceeded = budgetMinutes !== null && budgetMinutes > 0 && consumptionMinutes > budgetMinutes;
const showBudgetedTeamspaceEntries = !viewSettings.standardTeamspaceOnly && budgetedTeamspaceEntries.length > 1;
const budgetExcluded = projectBudgetExcluded(project);
const canEditProject = project.can_edit === true;
const unassignedTeamspaceEntryCount = Number(project.unassigned_teamspace_session_count ?? 0);
const unassignedTeamspaceSubentryCount = 0;
@@ -1695,17 +1708,28 @@ export function ProjectsPage({ currentUser, onNavigate, onStartTimer }: Projects
</div>
<div className="min-w-0">
<p className="truncate text-sm font-medium xl:hidden">Verbrauch/Gesamt</p>
{budgetExcluded ? (
<>
<p className="text-sm font-semibold">{formatDecimalHours(consumptionMinutes)}</p>
<p className="text-xs text-muted-foreground">Budgetprüfung ausgenommen</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>
{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">
<p className="truncate font-medium xl:hidden">Verbrauch %</p>
<p className={cn("font-semibold", usedPercent !== null && usedPercent >= 100 ? "text-blue-500" : usedPercent !== null && usedPercent >= 90 ? "text-red-500" : undefined)}>
{usedPercent !== null ? `${usedPercent}%` : "-"}
</p>
{budgetExcluded ? (
<p className="text-xs text-muted-foreground">keine Prüfung</p>
) : (
<p className={cn("font-semibold", usedPercent !== null && usedPercent >= 100 ? "text-blue-500" : usedPercent !== null && usedPercent >= 90 ? "text-red-500" : undefined)}>
{usedPercent !== null ? `${usedPercent}%` : "-"}
</p>
)}
</div>
<div className="flex justify-end">
<DropdownMenu>
@@ -1807,7 +1831,9 @@ export function ProjectsPage({ currentUser, onNavigate, onStartTimer }: Projects
</Button>
</div>
) : null}
{showBudgetedTeamspaceEntries ? (
{budgetExcluded ? (
<p className="text-xs text-muted-foreground">Für dieses Teamspace-Projekt werden keine Differenzen und kein Budgetverbrauch ausgewertet. Der Verbrauch zählt weiter in Summen.</p>
) : showBudgetedTeamspaceEntries ? (
<div className="grid gap-1.5 md:grid-cols-2">
{budgetedTeamspaceEntries.map((entry) => {
const entryPercent = projectEntryPercent(entry);
+69 -4
View File
@@ -27,7 +27,7 @@ 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 { AuthUser, StatisticsDailyBucket, StatisticsGroup, StatisticsOverview, StatisticsProjectFollowup, StatisticsProjectFollowupOverview, StatisticsTicket, WorkType } from "../types";
import type { AuthUser, StatisticsDailyBucket, StatisticsGroup, StatisticsOverview, StatisticsProjectFollowup, StatisticsProjectFollowupOverview, StatisticsTicket, TeamspaceMonthActual, WorkType } from "../types";
type StatisticsPageProps = {
currentUser: AuthUser;
@@ -76,6 +76,63 @@ function formatTeamspaceDelta(minutes: number) {
return formatSignedMinutes(minutes);
}
function teamspaceActualLabel(actual: TeamspaceMonthActual) {
return actual.teamspace_description?.trim() || actual.teamspace_project || actual.teamspace_api_project_id || "Teamspace";
}
function TeamspaceMonthActualsLine({
actuals,
tsMinutes,
deltaMinutes
}: {
actuals: TeamspaceMonthActual[] | undefined;
tsMinutes: number | undefined;
deltaMinutes: number | undefined;
}) {
const visibleActuals = actuals ?? [];
if (visibleActuals.length === 0) {
return null;
}
const effectiveTsMinutes = tsMinutes ?? visibleActuals.reduce((sum, actual) => sum + actual.teamspace_billable_minutes, 0);
const effectiveDeltaMinutes = deltaMinutes ?? visibleActuals.reduce((sum, actual) => sum + actual.delta_minutes, 0);
const positions = visibleActuals.flatMap((actual) =>
(actual.positions ?? []).map((position) => ({
...position,
entryLabel: teamspaceActualLabel(actual)
}))
);
return (
<div className="mt-1 space-y-1 text-[11px] text-muted-foreground">
<div className="flex flex-wrap items-center gap-1.5">
<Badge variant={effectiveDeltaMinutes === 0 ? "success" : "warning"} className="h-5 px-1.5 text-[10px]">
TS API {formatMinutes(effectiveTsMinutes)} · Diff {formatTeamspaceDelta(effectiveDeltaMinutes)}
</Badge>
{visibleActuals
.filter((actual) => actual.delta_minutes !== 0)
.slice(0, 3)
.map((actual) => (
<span key={actual.entry_id} className="rounded-sm bg-muted px-1.5 py-0.5">
{teamspaceActualLabel(actual)}: {formatTeamspaceDelta(actual.delta_minutes)}
</span>
))}
</div>
{positions.length > 0 ? (
<div className="flex flex-wrap gap-x-2 gap-y-0.5">
{positions.slice(0, 4).map((position) => (
<span key={`${position.entryLabel}-${position.apiProjectId}`} title={`${position.entryLabel} · API ${position.apiProjectId}`}>
{position.isSubentry ? `Pos. ${position.projectId ?? position.apiProjectId}` : "Hauptprojekt"}: {position.label} · {formatMinutes(position.billableMinutes)}
</span>
))}
{positions.length > 4 ? <span>+{positions.length - 4} weitere</span> : null}
</div>
) : null}
</div>
);
}
function deltaTextClass(minutes: number) {
return minutes < 0 ? "text-red-700 dark:text-red-300" : "text-emerald-700 dark:text-emerald-300";
}
@@ -382,6 +439,11 @@ function TicketHotspot({
<span>{ticket.active_days} Tag(e)</span>
{showDifference ? <span className={deltaTextClass(ticket.crm_delta_minutes)}>{formatTeamspaceDelta(ticket.crm_delta_minutes)}</span> : null}
</div>
<TeamspaceMonthActualsLine
actuals={ticket.teamspace_month_actuals}
tsMinutes={ticket.teamspace_month_billable_minutes}
deltaMinutes={ticket.teamspace_month_delta_minutes}
/>
</div>
<Button variant="secondary" size="sm" onClick={() => onNavigate(`/tickets/${ticket.ticket_id}?month=${month}`)}>
Öffnen
@@ -403,14 +465,17 @@ function daysSince(value: string) {
function ProjectFollowupRow({ project, onNavigate }: { project: StatisticsProjectFollowup; onNavigate: (to: string) => void }) {
const inactiveDays = daysSince(project.latest_tt_update);
const hasBudget = project.hour_budget_minutes !== null;
const budgetExcluded = Boolean(project.teamspace_budget_excluded);
const hasBudget = !budgetExcluded && project.hour_budget_minutes !== null;
return (
<div className="grid gap-3 rounded-md border bg-background p-3 lg:grid-cols-[minmax(0,1fr)_auto] lg:items-center">
<div className="min-w-0 space-y-2">
<div className="flex flex-wrap items-center gap-2">
<span className="font-semibold">{project.ticket_number}</span>
{hasBudget ? (
{budgetExcluded ? (
<Badge variant="outline">Budgetprüfung ausgenommen</Badge>
) : hasBudget ? (
<Badge variant={project.remaining_minutes !== null && project.remaining_minutes > 0 ? "warning" : "secondary"}>
{project.remaining_minutes !== null && project.remaining_minutes > 0 ? `${formatMinutes(project.remaining_minutes)} offen` : "Budget verbraucht"}
</Badge>
@@ -429,7 +494,7 @@ function ProjectFollowupRow({ project, onNavigate }: { project: StatisticsProjec
<div className="flex flex-wrap gap-x-3 gap-y-1 text-xs text-muted-foreground">
<span>Letztes TT-Update {formatDateTime(project.latest_tt_update)}</span>
{inactiveDays !== null ? <span>{inactiveDays} Tag(e) inaktiv</span> : null}
<span>Budget {hasBudget ? formatMinutes(project.hour_budget_minutes!) : "nicht gesetzt"}</span>
<span>Budget {budgetExcluded ? "Prüfung ausgenommen" : hasBudget ? formatMinutes(project.hour_budget_minutes!) : "nicht gesetzt"}</span>
<span>Verbraucht {formatMinutes(project.consumption_minutes)}</span>
<span>{project.open_todos} offene ToDo(s)</span>
</div>
+182 -55
View File
@@ -1,4 +1,4 @@
import { AlertCircle, ArrowLeft, CheckCircle2, ChevronDown, ChevronUp, ClipboardList, Copy, EyeOff, FolderKanban, MailCheck, MailPlus, Pencil, Play, Plus, RefreshCw, RotateCcw, Save, Send, Star, Trash2 } from "lucide-react";
import { AlertCircle, ArrowLeft, CheckCircle2, ChevronDown, ChevronUp, ClipboardList, Copy, FolderKanban, Info, MailCheck, MailPlus, Pencil, Play, Plus, RefreshCw, RotateCcw, Save, Send, Star, Trash2 } from "lucide-react";
import { type ClipboardEvent, FormEvent, useEffect, useMemo, useRef, useState } from "react";
import { toast } from "sonner";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
@@ -123,6 +123,10 @@ function emptyTicketSessionForm(): TicketSessionFormState {
};
}
function hasActivityContent(activity: string, zammadArticleBody: string | null | undefined) {
return activity.trim().length > 0 || Boolean(zammadArticleBody);
}
function notifyZammadArticleResult(result: CreateSessionResult) {
if (result.zammadArticle?.status === "sent") {
toast.success("Zammad-Notiz übertragen");
@@ -140,6 +144,38 @@ function notifyTodosChanged() {
window.dispatchEvent(new Event("tickettracker:todos-changed"));
}
function formatSignedMinutes(minutes: number) {
if (minutes === 0) {
return formatMinutes(0);
}
return `${minutes > 0 ? "+" : "-"}${formatMinutes(Math.abs(minutes))}`;
}
function formatTeamspaceMissingTarget(minutes: number, teamspaceTimeTrackingEnabled: boolean) {
if (minutes === 0) {
return "ausgeglichen";
}
if (minutes > 0) {
return `${formatMinutes(minutes)} fehlt ${teamspaceTimeTrackingEnabled ? "in TT" : "in den abgerechneten Zeiten"}`;
}
return `${formatMinutes(Math.abs(minutes))} fehlt in TS`;
}
function formatTeamspaceDifferenceTarget(minutes: number, teamspaceTimeTrackingEnabled: boolean) {
if (minutes === 0) {
return "0 min";
}
const target = minutes > 0
? teamspaceTimeTrackingEnabled ? "TT" : "abgerechnet"
: "TS";
return `${minutes > 0 ? "+" : "-"}${formatMinutes(Math.abs(minutes))} in ${target}`;
}
function zammadSyncStorageKey(userId: string) {
return `tickettracker.zammad-sync.${userId}`;
}
@@ -271,7 +307,15 @@ function projectTeamspaceEntries(project: ProjectSummary): ProjectTeamspaceEntry
}
function budgetedProjectTeamspaceEntries(project: ProjectSummary) {
return projectTeamspaceEntries(project).filter((entry) => Number(entry.hour_budget_minutes ?? 0) > 0);
if (projectBudgetExcluded(project)) {
return [];
}
return projectTeamspaceEntries(project).filter((entry) => !entry.teamspace_budget_excluded && Number(entry.hour_budget_minutes ?? 0) > 0);
}
function projectBudgetExcluded(project: ProjectSummary) {
return Boolean(project.teamspace_budget_excluded || projectTeamspaceEntries(project).some((entry) => entry.teamspace_budget_excluded));
}
function teamspaceEntryDisplayLabel(entry: { teamspace_project: string | null; teamspace_description?: string | null; is_default: boolean }) {
@@ -1131,11 +1175,13 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onS
const hasProjectTeamspaceApiIds = projectEntries.some((entry) => Boolean(entry.teamspace_api_project_id));
const showTeamspaceSubentryCard = Boolean(project && hasProjectTeamspaceApiIds);
const usesTeamspaceSubentries = false;
const budgetExcluded = project ? projectBudgetExcluded(project) : false;
const budgetConsumptionExcluded = budgetExcluded || isRecurringBillingTicket;
const showBudgetedProjectEntries = budgetedProjectEntries.length > 1;
const requiresProjectTeamspaceEntry = Boolean(project && hasMultipleProjectTeamspaceEntries);
const teamspaceTimeTrackingEnabled = currentUser.teamspace_time_tracking_enabled ?? true;
const projectConsumptionMinutes = project?.consumption_minutes ?? 0;
const projectBudgetMinutes = project?.hour_budget_minutes ?? null;
const projectBudgetMinutes = budgetConsumptionExcluded ? null : project?.hour_budget_minutes ?? null;
const projectConsumptionUsagePercent = project ? projectConsumptionPercent(projectConsumptionMinutes, projectBudgetMinutes) : null;
const projectRemainingMinutes = projectBudgetMinutes && projectBudgetMinutes > 0 ? projectBudgetMinutes - projectConsumptionMinutes : null;
const canEditProject = project?.can_edit === true;
@@ -1154,6 +1200,10 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onS
const isProject = Boolean(project);
const isGlobalTicket = periodType === "all";
const isPeriodClosed = Boolean(data?.closed);
const selectedTeamspaceMonth = isGlobalTicket ? globalMonthFilter : periodType === "month" ? period : period.slice(0, 7);
const teamspaceMonthActuals = data?.teamspaceMonthActuals ?? [];
const teamspaceMonthBillableMinutes = data?.teamspaceMonthBillableMinutes ?? teamspaceMonthActuals.reduce((sum, actual) => sum + actual.teamspace_billable_minutes, 0);
const teamspaceMonthDeltaMinutes = data?.teamspaceMonthDeltaMinutes ?? teamspaceMonthActuals.reduce((sum, actual) => sum + actual.delta_minutes, 0);
const mailDaysBack = useMemo(() => mailDaysBackFromZammadSyncSettings(currentUser.id), [currentUser.id]);
const selectedMailArticleIds = useMemo(
() => (mailPreview?.items ?? []).filter((item) => mailSelection[item.articleId] !== false).map((item) => item.articleId),
@@ -1186,6 +1236,60 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onS
}, [data?.dayBillings, defaultProjectEntryId, requiresProjectTeamspaceEntry]);
const savedDayBillingInput = (key: string) => formatHoursInput(dayBillingMap.get(key));
const isDayBillingDirty = (key: string) => (dayBillingInputs[key] ?? "") !== savedDayBillingInput(key);
const dayBillingTotalByDayMap = useMemo(() => {
const billingMap = new Map<string, number>();
for (const billing of data?.dayBillings ?? []) {
billingMap.set(billing.day, (billingMap.get(billing.day) ?? 0) + Number(billing.billed_minutes ?? 0));
}
return billingMap;
}, [data?.dayBillings]);
const sessionBilledTotalByDayMap = useMemo(() => {
const billingMap = new Map<string, number>();
for (const session of data?.sessions ?? []) {
if (session.billing_status !== "billed") {
continue;
}
const day = sessionDayKey(session.started_at);
billingMap.set(day, (billingMap.get(day) ?? 0) + Number(session.rounded_minutes ?? 0));
}
return billingMap;
}, [data?.sessions]);
const teamspaceApiDayBillingTotalByDayMap = useMemo(() => {
const billingMap = new Map<string, number>();
for (const actual of teamspaceMonthActuals) {
for (const position of actual.positions ?? []) {
for (const day of position.days ?? []) {
billingMap.set(day.day, (billingMap.get(day.day) ?? 0) + Number(day.billableMinutes ?? 0));
}
}
}
return billingMap;
}, [teamspaceMonthActuals]);
const teamspaceDayDeviation = (day: string) => {
if (!project || ticket?.work_type === "support" || budgetExcluded) {
return {
ttMinutes: 0,
tsMinutes: 0,
deltaMinutes: 0
};
}
const ttMinutes = teamspaceTimeTrackingEnabled ? dayBillingTotalByDayMap.get(day) ?? 0 : sessionBilledTotalByDayMap.get(day) ?? 0;
const tsMinutes = teamspaceApiDayBillingTotalByDayMap.get(day) ?? 0;
return {
ttMinutes,
tsMinutes,
deltaMinutes: tsMinutes - ttMinutes
};
};
const unassignedTeamspaceSessions = useMemo(
() => teamspaceTimeTrackingEnabled
? (data?.sessions ?? []).filter((session) =>
@@ -1396,7 +1500,7 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onS
}
async function sendSessionToZammad(session: SessionEntry) {
if (!session.activity.trim() && !session.zammad_article_body) {
if (!hasActivityContent(session.activity, session.zammad_article_body)) {
toast.warning("Keine Zammad-Notiz möglich", {
description: "Die Session hat keine Tätigkeit."
});
@@ -1700,9 +1804,12 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onS
setSyncingTeamspaceSubentries(true);
try {
const { result } = await syncProjectTeamspaceSubentries(project.id);
const syncMonths = selectedTeamspaceMonth === "all"
? availableMonths.map((month) => month.month)
: [selectedTeamspaceMonth];
const { result } = await syncProjectTeamspaceSubentries(project.id, syncMonths);
toast.success("Teamspace-Unterpositionen synchronisiert", {
description: `${result.subentriesSynced} Position(en) abgeglichen, ${result.subentriesRemoved} entfernt.`
description: `${result.subentriesSynced} Position(en), ${result.teamspaceTimeEntriesMapped} Zeiteintrag/Zeiteinträge in ${result.monthsSynced.length} Monat(en).`
});
await load();
} catch (error) {
@@ -1793,7 +1900,7 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onS
return;
}
if (!ticketSession.activity.trim() && !ticketSession.zammadArticleBody) {
if (!hasActivityContent(ticketSession.activity, ticketSession.zammadArticleBody)) {
toast.error("Tätigkeit erfassen");
return;
}
@@ -1838,7 +1945,7 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onS
durationSeconds,
projectTeamspaceEntryId: effectiveWorkType === "consulting" && requiresProjectTeamspaceEntry ? ticketSession.projectTeamspaceEntryId : null,
billingStatus: ticketSession.billingStatus,
createZammadArticle: ticketSession.createZammadArticle && (activityValue.activity.trim().length > 0 || Boolean(activityValue.zammadArticleBody)),
createZammadArticle: ticketSession.createZammadArticle && hasActivityContent(activityValue.activity, activityValue.zammadArticleBody),
zammadArticleTypeId: ticketSession.zammadArticleTypeId
});
toast.success("Session nachgetragen", {
@@ -2298,12 +2405,23 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onS
) : null}
<div className="flex flex-wrap gap-2 text-sm">
<Badge variant="secondary">Zeitraum {project.starts_on || "offen"} - {project.ends_on || "offen"}</Badge>
<Badge variant="secondary">Kontingent {projectBudgetMinutes ? formatMinutes(projectBudgetMinutes) : "offen"}</Badge>
{budgetConsumptionExcluded ? <Badge variant="outline">Budgetprüfung ausgenommen</Badge> : <Badge variant="secondary">Kontingent {projectBudgetMinutes ? formatMinutes(projectBudgetMinutes) : "offen"}</Badge>}
<Badge variant="secondary">Verbrauch {formatMinutes(projectConsumptionMinutes)}</Badge>
<Badge variant={projectRemainingMinutes !== null && projectRemainingMinutes < 0 ? "destructive" : "secondary"}>{projectRemainingLabel(projectRemainingMinutes)}</Badge>
{showBudgetedProjectEntries ? <Badge variant="outline">{budgetedProjectEntries.length} Budgets</Badge> : null}
{budgetConsumptionExcluded ? null : <Badge variant={projectRemainingMinutes !== null && projectRemainingMinutes < 0 ? "destructive" : "secondary"}>{projectRemainingLabel(projectRemainingMinutes)}</Badge>}
{!budgetConsumptionExcluded && showBudgetedProjectEntries ? <Badge variant="outline">{budgetedProjectEntries.length} Budgets</Badge> : null}
</div>
<div className="space-y-2">
{budgetConsumptionExcluded ? (
<Alert>
<Info className="size-4" />
<AlertTitle>Budgetverbrauch nicht auswertbar</AlertTitle>
<AlertDescription>
{budgetExcluded
? "Dieses Teamspace-Projekt ist systemweit von Differenz- und Budgetprüfung ausgenommen. Der Verbrauch zählt weiter in Summen."
: "Dieses Projekt ist eine Fixabrechnung. Der Verbrauch zählt weiter in Summen."}
</AlertDescription>
</Alert>
) : (
<div>
<div className="mb-1 flex items-center justify-between gap-2 text-xs text-muted-foreground">
<span>Budgetverbrauch</span>
@@ -2327,7 +2445,8 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onS
/>
</div>
</div>
{showBudgetedProjectEntries ? (
)}
{!budgetConsumptionExcluded && showBudgetedProjectEntries ? (
<div className="grid gap-1.5 md:grid-cols-2">
{budgetedProjectEntries.map((entry) => {
const consumption = projectEntryConsumptionById.get(entry.id) ?? Number(entry.consumption_minutes ?? 0);
@@ -2515,7 +2634,7 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onS
<FolderKanban className="size-5 text-muted-foreground" />
Teamspace-Unterpositionen
</CardTitle>
<CardDescription>Positionen aus Teamspace für die granulare Zuordnung dieses Projekts.</CardDescription>
<CardDescription>Positionen aus Teamspace als reine Ansicht dieses Projekts.</CardDescription>
</div>
<div className="flex shrink-0 flex-wrap justify-end gap-2">
{canEditProject && projectTeamspaceSubentryCount > 0 ? (
@@ -2563,12 +2682,15 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onS
onValueChange={updateOpenTeamspaceSubentryGroups}
className="overflow-hidden rounded-md border bg-background/50"
>
{projectTeamspaceSubentryGroups.map(({ entry, subentries }) => (
{projectTeamspaceSubentryGroups.map(({ entry, subentries }) => {
return (
<AccordionItem key={entry.id} value={entry.id} className="border-b last:border-b-0">
<AccordionTrigger className="rounded-none border-l-4 border-l-primary/50 bg-muted/40 px-3 py-2 hover:bg-muted/55 hover:no-underline">
<div className="grid min-w-0 flex-1 gap-1 pr-3 text-left sm:grid-cols-[minmax(220px,1fr)_auto] sm:items-center">
<div className="min-w-0">
<p className="truncate text-sm font-semibold">{teamspaceEntryDisplayLabel(entry)}</p>
<p className="flex min-w-0 items-center gap-2 truncate text-sm font-semibold">
<span className="truncate">{teamspaceEntryDisplayLabel(entry)}</span>
</p>
<p className="truncate text-xs font-normal text-muted-foreground">
{entry.teamspace_project || "Ohne Teamspace-ID"}
{entry.teamspace_api_project_id ? ` · API ${entry.teamspace_api_project_id}` : ""}
@@ -2579,14 +2701,13 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onS
</AccordionTrigger>
<AccordionContent className="p-0">
<div className="divide-y border-t bg-background">
{subentries.map((subentry, index) => {
const description = teamspaceFormattedText(subentry.description);
const title = teamspaceFormattedText(subentry.alias) || teamspaceFormattedText(subentry.name) || subentry.teamspace_project_id || "Teamspace-Position";
const positionDisplayId = teamspacePositionDisplayId(subentry.teamspace_project_id);
const fixedPrice = isTeamspaceFixedPricePosition(subentry.billing_type);
const fixedPriceFulfilled = fixedPrice && teamspaceFixedPriceFulfilled(subentry);
{subentries.map((subentry, index) => {
const description = teamspaceFormattedText(subentry.description);
const title = teamspaceFormattedText(subentry.alias) || teamspaceFormattedText(subentry.name) || subentry.teamspace_project_id || "Teamspace-Position";
const positionDisplayId = teamspacePositionDisplayId(subentry.teamspace_project_id);
const fixedPrice = isTeamspaceFixedPricePosition(subentry.billing_type);
const fixedPriceFulfilled = fixedPrice && teamspaceFixedPriceFulfilled(subentry);
const assignedConsumptionMinutes = Number(subentry.sum_minutes_billable ?? subentry.sum_minutes ?? 0);
const fixedPricePlannedMinutes = teamspaceFixedUnitCount(subentry, "planned");
const plannedIncome = teamspaceIncomeValue(subentry.planned_income);
const sumIncome = teamspaceIncomeValue(subentry.sum_income);
const plannedBillableMinutes = Number(subentry.planned_minutes_billable ?? subentry.planned_minutes ?? 0);
@@ -2605,8 +2726,10 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onS
className="grid gap-2 px-3 py-2 text-xs hover:bg-muted/20 lg:grid-cols-[3.5rem_minmax(190px,0.95fr)_minmax(240px,1.45fr)_105px_136px] lg:items-center"
>
<div className="hidden text-muted-foreground lg:block">{positionDisplayId || index + 1}</div>
<div className="min-w-0">
<p className="truncate font-medium text-foreground/90">{title}</p>
<div className="min-w-0">
<p className="flex min-w-0 items-center gap-2 truncate font-medium text-foreground/90">
<span className="truncate">{title}</span>
</p>
<p className="truncate text-muted-foreground">
{positionDisplayId ? `Position ${positionDisplayId}` : "Ohne Positions-ID"}
{subentry.teamspace_api_project_id ? ` · API ${subentry.teamspace_api_project_id}` : ""}
@@ -2653,17 +2776,7 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onS
</>
)}
</span>
{fixedPrice ? (
<span className="block text-[11px] text-muted-foreground/85">
TS {formatMinutes(assignedConsumptionMinutes)}
{fixedPricePlannedMinutes > 0 ? ` / ${formatMinutes(fixedPricePlannedMinutes)}` : ""}
</span>
) : (
<span className="block text-[11px] text-muted-foreground/85">
TS {formatMinutes(assignedConsumptionMinutes)}
</span>
)}
</div>
</div>
{canEditProject && editingTeamspaceSubentries ? (
<div className={cn(
"grid gap-2 rounded-md border border-dashed bg-muted/10 p-2 lg:col-span-5 lg:items-end",
@@ -2706,7 +2819,8 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onS
</div>
</AccordionContent>
</AccordionItem>
))}
);
})}
</Accordion>
) : (
<p className="rounded-md border bg-muted/20 p-3 text-sm text-muted-foreground">
@@ -2823,12 +2937,18 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onS
</div>
) : null}
<div className="space-y-2">
{sessionGroups.map((group) => (
<div className="space-y-2">
{sessionGroups.map((group) => {
const dayDeviation = teamspaceDayDeviation(group.day);
const hasDayDeviation = dayDeviation.deltaMinutes !== 0;
return (
<div key={group.key} className="rounded-md border bg-background">
<div className="grid min-w-0 gap-2 border-b bg-muted/25 px-3 py-2 lg:grid-cols-[220px_minmax(0,150px)_minmax(260px,1fr)_220px] lg:items-center">
<div className="grid min-w-0 gap-2 border-b bg-muted/25 px-3 py-2 lg:grid-cols-[210px_minmax(0,120px)_minmax(240px,1fr)_44px_minmax(130px,0.45fr)_220px] lg:items-center">
<div className="min-w-0 text-left">
<p className="truncate font-medium">{group.label}</p>
<p className="flex min-w-0 items-center gap-2 truncate font-medium">
<span className="truncate">{group.label}</span>
</p>
<p className="text-xs text-muted-foreground">{group.sessions.length} Session(s)</p>
</div>
<Button
@@ -2842,19 +2962,25 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onS
<ClipboardList className="size-3.5" />
TS
</Button>
<div className="grid min-w-0 grid-cols-3 gap-2 lg:gap-3">
<Badge variant="outline" className="flex h-8 min-w-0 justify-center gap-1 whitespace-nowrap px-2" title="Abrechenbar">
<Play className="size-3.5 shrink-0" />
<span className="truncate">{formatMinutes(group.billableMinutes)}</span>
</Badge>
<Badge variant="outline" className="flex h-8 min-w-0 justify-center gap-1 whitespace-nowrap px-2" title="Abgerechnet">
<CheckCircle2 className="size-3.5 shrink-0" />
<span className="truncate">{formatMinutes(group.totalMinutes)}</span>
</Badge>
<Badge variant="secondary" className="flex h-8 min-w-0 justify-center gap-1 whitespace-nowrap px-2" title="Nicht abrechenbar">
<EyeOff className="size-3.5 shrink-0" />
<span className="truncate">{formatMinutes(group.nonBillableMinutes)}</span>
</Badge>
<div className="flex min-w-0 flex-wrap gap-x-3 gap-y-1 text-xs text-muted-foreground">
<span>Abrechb. {formatMinutes(group.billableMinutes)}</span>
<span>Abger. {formatMinutes(group.totalMinutes)}</span>
<span>Nicht {formatMinutes(group.nonBillableMinutes)}</span>
</div>
<div className="flex justify-start lg:justify-center">
{hasDayDeviation ? (
<AlertCircle
className="size-4 shrink-0 text-amber-500"
aria-label="Teamspace-Tagesabweichung"
/>
) : (
<span className="h-4 w-4" aria-hidden="true" />
)}
</div>
<div className="min-w-0 text-xs lg:text-right">
<p className={cn("font-semibold", hasDayDeviation ? "text-amber-600 dark:text-amber-300" : "text-muted-foreground")}>
{formatTeamspaceDifferenceTarget(dayDeviation.deltaMinutes, teamspaceTimeTrackingEnabled)}
</p>
</div>
<div className="flex min-w-0 items-center justify-start gap-2 lg:justify-end">
{teamspaceTimeTrackingEnabled ? (
@@ -2912,7 +3038,7 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onS
const zammadSent = session.zammad_article_status === "sent";
const zammadPending = session.zammad_article_status === "pending";
const zammadMailImported = Boolean(session.zammad_source_article_id);
const canSendZammad = !zammadMailImported && (session.activity.trim().length > 0 || Boolean(session.zammad_article_body));
const canSendZammad = !zammadMailImported && hasActivityContent(session.activity, session.zammad_article_body);
const sendingZammad = sendingZammadSessionId === session.id;
const billedMissingTeamspaceEntry =
teamspaceTimeTrackingEnabled
@@ -3031,7 +3157,8 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onS
})}
</div>
</div>
))}
);
})}
</div>
{data?.sessions.length === 0 ? (
+10 -6
View File
@@ -59,6 +59,10 @@ function emptyManualSession(): ManualSessionFormState {
};
}
function hasActivityContent(activity: string, zammadArticleBody: string | null | undefined) {
return activity.trim().length > 0 || Boolean(zammadArticleBody);
}
type FinishedSession = {
timerId: string;
ticketNumber: string;
@@ -702,7 +706,7 @@ export function TimerPage({ timers, setTimers, selectedTimerId, setSelectedTimer
zammadArticleBody: timerZammadArticleBody,
billingStatus: timer.billingStatus,
promptBillingStatus: currentUser.session_billing_prompt_enabled && timer.billingStatus === null,
createZammadArticle: currentUser.zammad_session_note_default_enabled && timerActivity.trim().length > 0,
createZammadArticle: currentUser.zammad_session_note_default_enabled && hasActivityContent(timerActivity, timerZammadArticleBody),
zammadArticleTypeId: currentUser.zammad_article_type_id,
startedAt: new Date(timer.startedAt).toISOString(),
endedAt: new Date(now).toISOString(),
@@ -771,7 +775,7 @@ export function TimerPage({ timers, setTimers, selectedTimerId, setSelectedTimer
zammadLookup: Boolean(finished.ticket?.zammad_ticket_id),
projectTeamspaceEntryId: finishedRequiresTeamspaceEntry ? projectTeamspaceEntryId : null,
billingStatus: finished.billingStatus,
createZammadArticle: finished.createZammadArticle && (activityValue.activity.trim().length > 0 || Boolean(activityValue.zammadArticleBody)),
createZammadArticle: finished.createZammadArticle && hasActivityContent(activityValue.activity, activityValue.zammadArticleBody),
zammadArticleTypeId: finished.zammadArticleTypeId
});
@@ -870,7 +874,7 @@ export function TimerPage({ timers, setTimers, selectedTimerId, setSelectedTimer
zammadLookup: Boolean(manualLookupTicket?.zammad_ticket_id),
projectTeamspaceEntryId: manualRequiresTeamspaceEntry ? manualSession.projectTeamspaceEntryId : null,
billingStatus: manualSession.billingStatus,
createZammadArticle: manualSession.createZammadArticle && (activityValue.activity.trim().length > 0 || Boolean(activityValue.zammadArticleBody)),
createZammadArticle: manualSession.createZammadArticle && hasActivityContent(activityValue.activity, activityValue.zammadArticleBody),
zammadArticleTypeId: manualSession.zammadArticleTypeId
});
toast.success("Session nachgetragen", {
@@ -1297,7 +1301,7 @@ export function TimerPage({ timers, setTimers, selectedTimerId, setSelectedTimer
...current,
activity,
zammadArticleBody,
createZammadArticle: activity.trim().length > 0 || zammadArticleBody ? current.createZammadArticle : false
createZammadArticle: hasActivityContent(activity, zammadArticleBody) ? current.createZammadArticle : false
} : current);
}}
/>
@@ -1306,7 +1310,7 @@ export function TimerPage({ timers, setTimers, selectedTimerId, setSelectedTimer
<div className="min-w-0 space-y-3 rounded-md border bg-muted/30 p-3">
<div className="flex min-w-0 items-start gap-3">
{(() => {
const hasZammadContent = finished.activity.trim().length > 0 || Boolean(finished.zammadArticleBody);
const hasZammadContent = hasActivityContent(finished.activity, finished.zammadArticleBody);
return (
<>
@@ -1314,7 +1318,7 @@ export function TimerPage({ timers, setTimers, selectedTimerId, setSelectedTimer
id="finished-zammad-article"
checked={finished.createZammadArticle && hasZammadContent}
disabled={!hasZammadContent}
onCheckedChange={(checked) => setFinished((current) => (current ? { ...current, createZammadArticle: checked === true && (current.activity.trim().length > 0 || Boolean(current.zammadArticleBody)) } : current))}
onCheckedChange={(checked) => setFinished((current) => (current ? { ...current, createZammadArticle: checked === true && hasActivityContent(current.activity, current.zammadArticleBody) } : current))}
/>
<div className="min-w-0 space-y-1">
<label className="break-words text-sm font-medium" htmlFor="finished-zammad-article">
File diff suppressed because it is too large. Load diff