Allow undoing Teamspace acknowledgements

This commit is contained in:
2026-08-05 15:27:45 +02:00
parent 20bc04778d
commit 84057771fe
3 changed files with 79 additions and 30 deletions
+28 -2
View File
@@ -702,7 +702,8 @@ async function getStatisticsOverview(period: ParsedPeriod, userId: string) {
td.tracked_minutes, td.tracked_minutes,
COALESCE(tdb.billed_minutes, 0)::int AS crm_billed_minutes, COALESCE(tdb.billed_minutes, 0)::int AS crm_billed_minutes,
tdb.billed_minutes IS NOT NULL AS has_crm_value, tdb.billed_minutes IS NOT NULL AS has_crm_value,
ack.acknowledged_at IS NOT NULL AS missing_crm_acknowledged ack.acknowledged_at IS NOT NULL AS missing_crm_acknowledged,
ack.acknowledged_at AS missing_crm_acknowledged_at
FROM ticket_day td FROM ticket_day td
LEFT JOIN ticket_day_billings tdb LEFT JOIN ticket_day_billings tdb
ON tdb.ticket_id = td.ticket_id ON tdb.ticket_id = td.ticket_id
@@ -753,6 +754,7 @@ async function getStatisticsOverview(period: ParsedPeriod, userId: string) {
ticketResult, ticketResult,
openResult, openResult,
missingCrmResult, missingCrmResult,
acknowledgedMissingCrmResult,
mismatchResult, mismatchResult,
closureResult closureResult
] = await Promise.all([ ] = await Promise.all([
@@ -917,7 +919,9 @@ async function getStatisticsOverview(period: ParsedPeriod, userId: string) {
MIN(sb.organization_name) AS organization_name, MIN(sb.organization_name) AS organization_name,
tdb.day::text AS day, tdb.day::text AS day,
tdb.tracked_minutes, tdb.tracked_minutes,
tdb.crm_billed_minutes tdb.crm_billed_minutes,
false AS acknowledged,
NULL::text AS acknowledged_at
FROM ticket_day_billing tdb FROM ticket_day_billing tdb
JOIN session_base sb ON sb.ticket_id = tdb.ticket_id AND sb.day = tdb.day JOIN session_base sb ON sb.ticket_id = tdb.ticket_id AND sb.day = tdb.day
WHERE NOT tdb.has_crm_value WHERE NOT tdb.has_crm_value
@@ -927,6 +931,27 @@ async function getStatisticsOverview(period: ParsedPeriod, userId: string) {
`, `,
[period.startIso, period.endIso, userId] [period.startIso, period.endIso, userId]
), ),
query(
`
${baseCte}
SELECT
tdb.ticket_id::text AS ticket_id,
tdb.ticket_number,
MIN(sb.organization_name) AS organization_name,
tdb.day::text AS day,
tdb.tracked_minutes,
tdb.crm_billed_minutes,
true AS acknowledged,
MAX(tdb.missing_crm_acknowledged_at)::text AS acknowledged_at
FROM ticket_day_billing tdb
JOIN session_base sb ON sb.ticket_id = tdb.ticket_id AND sb.day = tdb.day
WHERE NOT tdb.has_crm_value
AND tdb.missing_crm_acknowledged
GROUP BY tdb.ticket_id, tdb.ticket_number, tdb.day, tdb.tracked_minutes, tdb.crm_billed_minutes
ORDER BY tdb.day ASC, tdb.ticket_number ASC;
`,
[period.startIso, period.endIso, userId]
),
query( query(
` `
${baseCte} ${baseCte}
@@ -1009,6 +1034,7 @@ async function getStatisticsOverview(period: ParsedPeriod, userId: string) {
attention: { attention: {
openSessions: openResult.rows, openSessions: openResult.rows,
missingCrmDays: missingCrmResult.rows, missingCrmDays: missingCrmResult.rows,
acknowledgedMissingCrmDays: acknowledgedMissingCrmResult.rows,
crmMismatches: mismatchResult.rows crmMismatches: mismatchResult.rows
} }
}; };
+3
View File
@@ -258,6 +258,8 @@ export type StatisticsCrmDay = {
tracked_minutes: number; tracked_minutes: number;
crm_billed_minutes: number; crm_billed_minutes: number;
delta_minutes?: number; delta_minutes?: number;
acknowledged?: boolean;
acknowledged_at?: string | null;
}; };
export type StatisticsOverview = { export type StatisticsOverview = {
@@ -291,6 +293,7 @@ export type StatisticsOverview = {
attention: { attention: {
openSessions: StatisticsOpenSession[]; openSessions: StatisticsOpenSession[];
missingCrmDays: StatisticsCrmDay[]; missingCrmDays: StatisticsCrmDay[];
acknowledgedMissingCrmDays: StatisticsCrmDay[];
crmMismatches: StatisticsCrmDay[]; crmMismatches: StatisticsCrmDay[];
}; };
}; };
+45 -25
View File
@@ -116,32 +116,31 @@ function OpenTicketItem({ group, month, onNavigate }: { group: OpenTicketGroup;
function CrmDayItem({ function CrmDayItem({
item, item,
acknowledging, busy,
onAcknowledge, onSetAcknowledgement,
onNavigate onNavigate
}: { }: {
item: StatisticsCrmDay; item: StatisticsCrmDay;
acknowledging: boolean; busy: boolean;
onAcknowledge: (item: StatisticsCrmDay) => void; onSetAcknowledgement: (item: StatisticsCrmDay, acknowledged: boolean) => void;
onNavigate: (to: string) => void; onNavigate: (to: string) => void;
}) { }) {
const isAcknowledged = Boolean(item.acknowledged);
return ( return (
<div className="flex min-h-32 flex-col justify-between gap-3 rounded-md border bg-background p-3"> <div className={`flex min-h-32 flex-col justify-between gap-3 rounded-md border bg-background p-3 ${isAcknowledged ? "opacity-75" : ""}`}>
<div className="min-w-0"> <div className="min-w-0">
<div className="flex flex-wrap items-center gap-2"> <div className="flex flex-wrap items-center gap-2">
<Checkbox <Checkbox
checked={acknowledging} checked={isAcknowledged}
disabled={acknowledging} disabled={busy}
aria-label="Zur Kenntnis genommen" aria-label="Zur Kenntnis genommen"
onCheckedChange={(checked) => { onCheckedChange={(checked) => onSetAcknowledgement(item, checked === true)}
if (checked) {
onAcknowledge(item);
}
}}
/> />
<span className="font-medium">{item.ticket_number}</span> <span className="font-medium">{item.ticket_number}</span>
<CopyTicketButton ticketNumber={item.ticket_number} /> <CopyTicketButton ticketNumber={item.ticket_number} />
<Badge variant="outline">{formatDate(`${item.day}T00:00:00`)}</Badge> <Badge variant="outline">{formatDate(`${item.day}T00:00:00`)}</Badge>
<Badge variant={isAcknowledged ? "success" : "warning"}>{isAcknowledged ? "zur Kenntnis" : "offen"}</Badge>
</div> </div>
<p className="truncate text-sm text-muted-foreground">{item.organization_name}</p> <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"> <p className="flex flex-wrap gap-x-3 gap-y-1 text-xs">
@@ -151,8 +150,8 @@ function CrmDayItem({
</p> </p>
</div> </div>
<div className="flex flex-wrap justify-end gap-2"> <div className="flex flex-wrap justify-end gap-2">
<Button variant="ghost" size="sm" disabled={acknowledging} onClick={() => onAcknowledge(item)}> <Button variant={isAcknowledged ? "secondary" : "ghost"} size="sm" disabled={busy} onClick={() => onSetAcknowledgement(item, !isAcknowledged)}>
Zur Kenntnis {isAcknowledged ? "Zurücknehmen" : "Zur Kenntnis"}
</Button> </Button>
<Button variant="secondary" size="sm" onClick={() => onNavigate(`/analysis/day/${item.day}/tickets/${item.ticket_id}`)}> <Button variant="secondary" size="sm" onClick={() => onNavigate(`/analysis/day/${item.day}/tickets/${item.ticket_id}`)}>
Tag öffnen Tag öffnen
@@ -170,6 +169,7 @@ export function MonthlyClosePage({ onNavigate }: MonthlyClosePageProps) {
const [closing, setClosing] = useState(false); const [closing, setClosing] = useState(false);
const [reopening, setReopening] = useState(false); const [reopening, setReopening] = useState(false);
const [acknowledgingKeys, setAcknowledgingKeys] = useState<Set<string>>(() => new Set()); const [acknowledgingKeys, setAcknowledgingKeys] = useState<Set<string>>(() => new Set());
const [showAcknowledgedMissingCrm, setShowAcknowledgedMissingCrm] = useState(false);
const loadRequestId = useRef(0); const loadRequestId = useRef(0);
async function load() { async function load() {
@@ -242,18 +242,18 @@ export function MonthlyClosePage({ onNavigate }: MonthlyClosePageProps) {
} }
} }
async function acknowledgeMissingCrmDay(item: StatisticsCrmDay) { async function setMissingCrmAcknowledgement(item: StatisticsCrmDay, acknowledged: boolean) {
const key = `${item.ticket_id}:${item.day}`; const key = `${item.ticket_id}:${item.day}`;
setAcknowledgingKeys((current) => new Set(current).add(key)); setAcknowledgingKeys((current) => new Set(current).add(key));
try { try {
await acknowledgeMissingTicketDayBilling(item.ticket_id, item.day, true); await acknowledgeMissingTicketDayBilling(item.ticket_id, item.day, acknowledged);
toast.success("Teamspace-Prüfung abgehakt", { toast.success(acknowledged ? "Teamspace-Prüfung abgehakt" : "Kenntnisnahme zurückgenommen", {
description: `${item.ticket_number} am ${formatDate(`${item.day}T00:00:00`)}` description: `${item.ticket_number} am ${formatDate(`${item.day}T00:00:00`)}`
}); });
await load(); await load();
} catch (error) { } catch (error) {
toast.error("Konnte nicht abgehakt werden", { toast.error(acknowledged ? "Konnte nicht abgehakt werden" : "Konnte nicht zurückgenommen werden", {
description: error instanceof Error ? error.message : "Unbekannter Fehler" description: error instanceof Error ? error.message : "Unbekannter Fehler"
}); });
} finally { } finally {
@@ -267,14 +267,19 @@ export function MonthlyClosePage({ onNavigate }: MonthlyClosePageProps) {
const openSessions = stats?.attention.openSessions ?? []; const openSessions = stats?.attention.openSessions ?? [];
const missingCrmDays = stats?.attention.missingCrmDays ?? []; const missingCrmDays = stats?.attention.missingCrmDays ?? [];
const acknowledgedMissingCrmDays = stats?.attention.acknowledgedMissingCrmDays ?? [];
const openTicketGroups = useMemo(() => groupOpenSessions(openSessions), [openSessions]); const openTicketGroups = useMemo(() => groupOpenSessions(openSessions), [openSessions]);
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 canClose = Boolean(stats && !stats.closed && stats.totals.sessions > 0 && openSessions.length === 0); const canClose = Boolean(stats && !stats.closed && stats.totals.sessions > 0 && openSessions.length === 0);
const checklist = useMemo( const checklist = useMemo(
() => [ () => [
{ label: "Offene Bewertungen", count: openSessions.length, detail: `${openTicketGroups.length} Ticket(s)`, blocker: true }, { label: "Offene Bewertungen", count: openSessions.length, detail: `${openTicketGroups.length} Ticket(s)`, blocker: true },
{ label: "Tage ohne Teamspace-Wert", count: missingCrmDays.length, detail: "zur Kenntnisnahme", blocker: false } { label: "Tage ohne Teamspace-Wert", count: missingCrmDays.length, detail: `${acknowledgedMissingCrmDays.length} zur Kenntnis genommen`, blocker: false }
], ],
[openSessions.length, openTicketGroups.length, missingCrmDays.length] [acknowledgedMissingCrmDays.length, openSessions.length, openTicketGroups.length, missingCrmDays.length]
); );
return ( return (
@@ -325,7 +330,7 @@ export function MonthlyClosePage({ onNavigate }: MonthlyClosePageProps) {
<StatusCard label="Sessions" value={stats?.totals.sessions ?? 0} detail={`${formatMinutes(stats?.totals.minutes ?? 0)} getrackt`} /> <StatusCard label="Sessions" value={stats?.totals.sessions ?? 0} detail={`${formatMinutes(stats?.totals.minutes ?? 0)} getrackt`} />
<StatusCard label="Teamspace" value={formatMinutes(stats?.totals.crmBilledMinutes ?? 0)} detail={`Differenz ${formatTeamspaceDelta(stats?.totals.crmDeltaMinutes ?? 0)}`} /> <StatusCard label="Teamspace" value={formatMinutes(stats?.totals.crmBilledMinutes ?? 0)} detail={`Differenz ${formatTeamspaceDelta(stats?.totals.crmDeltaMinutes ?? 0)}`} />
<StatusCard label="Offene Bewertungen" value={openSessions.length} detail={`${openTicketGroups.length} Ticket(s)`} tone={openSessions.length > 0 ? "warn" : "ok"} /> <StatusCard label="Offene Bewertungen" value={openSessions.length} detail={`${openTicketGroups.length} Ticket(s)`} tone={openSessions.length > 0 ? "warn" : "ok"} />
<StatusCard label="Ohne Teamspace" value={missingCrmDays.length} detail="nicht zur Kenntnis genommen" tone={missingCrmDays.length > 0 ? "warn" : "ok"} /> <StatusCard label="Ohne Teamspace" value={missingCrmDays.length} detail={`${acknowledgedMissingCrmDays.length} zur Kenntnis genommen`} tone={missingCrmDays.length > 0 ? "warn" : "ok"} />
<StatusCard label="Status" value={stats?.closed ? "geschlossen" : canClose ? "bereit" : "offen"} detail={stats?.closed ? "Monat ist gesperrt" : canClose ? "kann abgeschlossen werden" : "Prüfpunkte bearbeiten"} tone={stats?.closed || canClose ? "ok" : "warn"} /> <StatusCard label="Status" value={stats?.closed ? "geschlossen" : canClose ? "bereit" : "offen"} detail={stats?.closed ? "Monat ist gesperrt" : canClose ? "kann abgeschlossen werden" : "Prüfpunkte bearbeiten"} tone={stats?.closed || canClose ? "ok" : "warn"} />
</div> </div>
@@ -365,20 +370,35 @@ export function MonthlyClosePage({ onNavigate }: MonthlyClosePageProps) {
</section> </section>
<section className="space-y-2"> <section className="space-y-2">
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<div className="flex items-center gap-2 text-sm font-medium"> <div className="flex items-center gap-2 text-sm font-medium">
<Sparkles className="size-4 text-muted-foreground" /> <Sparkles className="size-4 text-muted-foreground" />
Tage ohne Teamspace-Wert Tage ohne Teamspace-Wert
</div> </div>
{missingCrmDays.map((item) => ( <Button
type="button"
variant="secondary"
size="sm"
disabled={acknowledgedMissingCrmDays.length === 0}
onClick={() => setShowAcknowledgedMissingCrm((current) => !current)}
>
{showAcknowledgedMissingCrm ? "Zur Kenntnis ausblenden" : `Zur Kenntnis anzeigen (${acknowledgedMissingCrmDays.length})`}
</Button>
</div>
{visibleMissingCrmDays.map((item) => (
<CrmDayItem <CrmDayItem
key={`${item.ticket_id}-${item.day}-missing`} key={`${item.ticket_id}-${item.day}-missing`}
item={item} item={item}
acknowledging={acknowledgingKeys.has(`${item.ticket_id}:${item.day}`)} busy={acknowledgingKeys.has(`${item.ticket_id}:${item.day}`)}
onAcknowledge={acknowledgeMissingCrmDay} onSetAcknowledgement={setMissingCrmAcknowledgement}
onNavigate={onNavigate} onNavigate={onNavigate}
/> />
))} ))}
{missingCrmDays.length === 0 ? <p className="rounded-md border bg-muted/20 p-3 text-sm text-muted-foreground">Alle getrackten Tage haben einen Teamspace-Wert.</p> : null} {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 getrackten Tage haben einen Teamspace-Wert."}
</p>
) : null}
</section> </section>
</CardContent> </CardContent>
</Card> </Card>