This commit is contained in:
michael committed 2026-09-23 07:52:15 +00:00
1 parent 040f1903e6
commit d6c8877ed7
16 files changed
+1541 -178

No files matched your search

+55 -1
View File
@@ -22,7 +22,7 @@ import { CopyTicketButton } from "@/components/CopyTicketButton";
import { HelpLink } from "@/components/HelpLink";
import { acknowledgeMissingTicketDayBilling, closePeriod, getStatisticsOverview, reopenPeriod } from "../api";
import { currentMonth, formatDate, formatDateTime, formatMinutes } from "../format";
import type { StatisticsCrmDay, StatisticsOpenSession, StatisticsOverview } from "../types";
import type { StatisticsCrmDay, StatisticsOpenSession, StatisticsOverview, StatisticsTeamspaceDay } from "../types";
type MonthlyClosePageProps = {
onNavigate: (to: string) => void;
@@ -156,6 +156,36 @@ function CrmDayItem({
);
}
function TeamspaceDayItem({ item, onNavigate }: { item: StatisticsTeamspaceDay; onNavigate: (to: string) => void }) {
const deltaVariant = item.delta_minutes === 0 ? "outline" : item.delta_minutes > 0 ? "success" : "destructive";
return (
<div className="grid gap-3 rounded-md border bg-background px-3 py-2 lg:grid-cols-[minmax(8rem,0.85fr)_minmax(0,1.45fr)_minmax(15rem,1fr)_auto] lg:items-center">
<div className="flex flex-wrap items-center gap-2">
<Badge variant="outline">{formatDate(`${item.day}T00:00:00`)}</Badge>
<Badge variant="info">{item.session_count} Session(s)</Badge>
</div>
<div className="min-w-0 space-y-1">
<div className="flex min-w-0 flex-wrap items-center gap-2">
<span className="font-medium">{item.ticket_number}</span>
<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="flex flex-wrap gap-x-3 gap-y-1 text-sm">
<span className={`font-medium ${trackedTextClass}`}>Abgerechnet {formatMinutes(item.tracked_minutes)}</span>
<span className={`font-medium ${teamspaceTextClass}`}>Teamspace {formatMinutes(item.crm_billed_minutes)}</span>
<Badge variant={deltaVariant}>Diff. {formatTeamspaceDelta(item.delta_minutes)}</Badge>
</div>
<Button className="h-8 justify-self-start lg:justify-self-end" variant="secondary" size="sm" onClick={() => onNavigate(`/tickets/${item.ticket_id}?month=${item.day.slice(0, 7)}`)}>
Tag öffnen
<ExternalLink className="size-4" />
</Button>
</div>
);
}
export function MonthlyClosePage({ onNavigate }: MonthlyClosePageProps) {
const [month, setMonth] = useState(currentMonth());
const [stats, setStats] = useState<StatisticsOverview | null>(null);
@@ -274,6 +304,7 @@ export function MonthlyClosePage({ onNavigate }: MonthlyClosePageProps) {
const openSessions = stats?.attention.openSessions ?? [];
const missingCrmDays = stats?.attention.missingCrmDays ?? [];
const acknowledgedMissingCrmDays = stats?.attention.acknowledgedMissingCrmDays ?? [];
const teamspaceDays = stats?.teamspaceDays ?? [];
const openTicketGroups = useMemo(() => groupOpenSessions(openSessions), [openSessions]);
const visibleMissingCrmDays = useMemo(() => {
const days = showAcknowledgedMissingCrm ? [...missingCrmDays, ...acknowledgedMissingCrmDays] : [...missingCrmDays];
@@ -379,6 +410,29 @@ export function MonthlyClosePage({ onNavigate }: MonthlyClosePageProps) {
</CardContent>
</Card>
<Card>
<CardHeader className="p-4">
<div className="flex items-start justify-between gap-3">
<div>
<CardTitle>Vergleich abgerechnete Zeiten Teamspace</CardTitle>
<CardDescription>Teamspace-Tageswerte je Ticket mit Datum der abgerechneten Session(s) und Projekt, sofern verfügbar.</CardDescription>
</div>
<Badge variant="outline">{teamspaceDays.length} Eintrag(e)</Badge>
</div>
</CardHeader>
<CardContent className="space-y-2 px-4 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 diesen Monat sind noch keine Teamspace-Werte zu abgerechneten Ticket-Tagen vorhanden.</p>
)}
</CardContent>
</Card>
<Card>
<CardHeader className="p-4">
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
+22 -2
View File
@@ -1,9 +1,10 @@
import { FormEvent, useEffect, useState } from "react";
import { KeyRound, Save, Unplug, UserCircle, X } from "lucide-react";
import { KeyRound, MailPlus, Save, Unplug, UserCircle, X } from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Switch } from "@/components/ui/switch";
import { HelpLink } from "@/components/HelpLink";
import { updateCurrentUser } from "../api";
import type { AuthUser } from "../types";
@@ -21,6 +22,7 @@ export function ProfilePage({ currentUser, onUserUpdated }: ProfilePageProps) {
const [confirmPassword, setConfirmPassword] = useState("");
const [zammadApiToken, setZammadApiToken] = useState("");
const [zammadTokenDirty, setZammadTokenDirty] = useState(false);
const [zammadEmailSessionWebhookEnabled, setZammadEmailSessionWebhookEnabled] = useState(currentUser.zammad_email_session_webhook_enabled);
const [saving, setSaving] = useState(false);
useEffect(() => {
@@ -28,6 +30,7 @@ export function ProfilePage({ currentUser, onUserUpdated }: ProfilePageProps) {
setDisplayName(currentUser.display_name);
setZammadApiToken("");
setZammadTokenDirty(false);
setZammadEmailSessionWebhookEnabled(currentUser.zammad_email_session_webhook_enabled);
}, [currentUser]);
async function submit(event: FormEvent) {
@@ -54,7 +57,8 @@ export function ProfilePage({ currentUser, onUserUpdated }: ProfilePageProps) {
displayName,
currentPassword: wantsPasswordChange ? currentPassword : undefined,
newPassword: wantsPasswordChange ? newPassword : undefined,
...(currentUser.role === "user" && zammadTokenDirty ? { zammadApiToken } : {})
...(currentUser.role === "user" && zammadTokenDirty ? { zammadApiToken } : {}),
...(currentUser.role === "user" ? { zammadEmailSessionWebhookEnabled } : {})
};
try {
@@ -206,6 +210,22 @@ export function ProfilePage({ currentUser, onUserUpdated }: ProfilePageProps) {
) : null}
</div>
</div>
<div className="mt-4 flex items-center justify-between gap-3 rounded-md border bg-background p-3">
<div className="flex items-center gap-2">
<MailPlus className="size-4 text-muted-foreground" />
<div>
<label className="text-sm font-medium" htmlFor="profile-zammad-email-session-webhook">
eMail-Sessions aus Zammad
</label>
<p className="text-xs text-muted-foreground">Ausgehende Zammad-eMails automatisch als Session übernehmen.</p>
</div>
</div>
<Switch
id="profile-zammad-email-session-webhook"
checked={zammadEmailSessionWebhookEnabled}
onCheckedChange={setZammadEmailSessionWebhookEnabled}
/>
</div>
</div>
) : null}
+8 -2
View File
@@ -1,6 +1,6 @@
import { FormEvent, useEffect, useMemo, useState } from "react";
import type { Dispatch, SetStateAction } from "react";
import { Pause, Play, Plus, RotateCcw, Search, Square, Upload } from "lucide-react";
import { FolderGit, Pause, Play, Plus, RotateCcw, Search, Square, Upload } from "lucide-react";
import { toast } from "sonner";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
@@ -610,7 +610,13 @@ export function TimerPage({ timers, setTimers, selectedTimerId, setSelectedTimer
<CardDescription>
{selectedTimer
? selectedTimer.organizationName
? `${selectedTimer.isProject ? "Projekt · " : ""}${selectedTimer.projectName || selectedTimer.organizationName}${selectedTimer.workType ? ` · ${selectedTimer.workType === "support" ? "Support" : "Consulting"}` : ""}`
? (
<span className="inline-flex min-w-0 items-center gap-1.5">
{selectedTimer.isProject ? <FolderGit className="size-3.5 shrink-0" aria-label="Projekt" /> : null}
<span>{selectedTimer.organizationName}</span>
{selectedTimer.workType ? <span>· {selectedTimer.workType === "support" ? "Support" : "Consulting"}</span> : null}
</span>
)
: "Beim Umschalten wird dieser Timer aktiviert und alle anderen werden pausiert."
: "Starte einen Timer, um eine Session zu erfassen."}
</CardDescription>