Allow undoing Teamspace acknowledgements
This commit is contained in:
+28
-2
@@ -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
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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[];
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user