1.2.0-rc1

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

No files matched your search

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