diff --git a/backend/src/index.ts b/backend/src/index.ts index 7c21904..733f4d9 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -701,12 +701,17 @@ async function getStatisticsOverview(period: ParsedPeriod, userId: string) { td.day, td.tracked_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 FROM ticket_day td LEFT JOIN ticket_day_billings tdb ON tdb.ticket_id = td.ticket_id AND tdb.user_id = $3 AND tdb.day = td.day + LEFT JOIN ticket_day_billing_acknowledgements ack + ON ack.ticket_id = td.ticket_id + AND ack.user_id = $3 + AND ack.day = td.day ), session_group AS ( SELECT @@ -916,6 +921,7 @@ async function getStatisticsOverview(period: ParsedPeriod, userId: string) { 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 NOT 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; `, @@ -2560,9 +2566,71 @@ app.patch("/api/tickets/:ticketId/day-billings/:day", async (req, res) => { [ticketId, userId, day.start, billedMinutes] ); + await query( + ` + DELETE FROM ticket_day_billing_acknowledgements + WHERE ticket_id = $1 + AND user_id = $2 + AND day = $3::date; + `, + [ticketId, userId, day.start] + ); + res.json({ dayBilling: result.rows[0] }); }); +app.patch("/api/tickets/:ticketId/day-billings/:day/acknowledgement", async (req, res) => { + const ticketId = parsePositiveInteger(req.params.ticketId, "ticketId"); + const day = parseDay(req.params.day); + const acknowledged = req.body.acknowledged !== false; + const userId = currentUser(req).id; + + const sessionResult = await query<{ id: string }>( + ` + SELECT id + FROM sessions + WHERE ticket_id = $1 + AND user_id = $2 + AND (started_at AT TIME ZONE 'Europe/Berlin')::date = $3::date + LIMIT 1; + `, + [ticketId, userId, day.start] + ); + + if (sessionResult.rowCount === 0) { + res.status(404).json({ error: "No sessions found for this ticket and day" }); + return; + } + + if (!acknowledged) { + await query( + ` + DELETE FROM ticket_day_billing_acknowledgements + WHERE ticket_id = $1 + AND user_id = $2 + AND day = $3::date; + `, + [ticketId, userId, day.start] + ); + + res.json({ acknowledged: false }); + return; + } + + const result = await query( + ` + INSERT INTO ticket_day_billing_acknowledgements (ticket_id, user_id, day) + VALUES ($1, $2, $3::date) + ON CONFLICT (ticket_id, user_id, day) + DO UPDATE SET acknowledged_at = now() + RETURNING day::text AS day, acknowledged_at; + `, + [ticketId, userId, day.start] + ); + + res.json({ acknowledged: true, acknowledgement: result.rows[0] }); +}); + app.post("/api/periods/:periodType/:period/tickets/:ticketId/close", async (req, res) => { parsePeriod(req.params.periodType, req.params.period); parsePositiveInteger(req.params.ticketId, "ticketId"); diff --git a/backend/src/migrations.ts b/backend/src/migrations.ts index 2cba532..d79ec81 100644 --- a/backend/src/migrations.ts +++ b/backend/src/migrations.ts @@ -295,6 +295,16 @@ export async function migrate() { ); `); + await query(` + CREATE TABLE IF NOT EXISTS ticket_day_billing_acknowledgements ( + ticket_id BIGINT NOT NULL REFERENCES tickets(id) ON DELETE CASCADE, + user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + day DATE NOT NULL, + acknowledged_at TIMESTAMPTZ NOT NULL DEFAULT now(), + PRIMARY KEY (ticket_id, user_id, day) + ); + `); + await ensureClosureOwnership("ticket_month_closures", "month", ["ticket_id", "month"], adminId); await ensureClosureOwnership("month_closures", "month", ["month"], adminId); await ensureClosureOwnership("ticket_day_closures", "day", ["ticket_id", "day"], adminId); @@ -309,6 +319,7 @@ export async function migrate() { await query("CREATE INDEX IF NOT EXISTS idx_sessions_billing_status ON sessions(billing_status);"); await query("CREATE INDEX IF NOT EXISTS idx_ticket_day_closures_day ON ticket_day_closures(day);"); await query("CREATE INDEX IF NOT EXISTS idx_ticket_day_billings_user_day ON ticket_day_billings(user_id, day);"); + await query("CREATE INDEX IF NOT EXISTS idx_ticket_day_billing_ack_user_day ON ticket_day_billing_acknowledgements(user_id, day);"); await query("CREATE INDEX IF NOT EXISTS idx_recurring_billings_user_active ON recurring_billings(user_id, active);"); await query("UPDATE recurring_billings SET active = true WHERE active = false;"); await query(` diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 43faf96..4471ba8 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -688,7 +688,7 @@ export function App() {
-
+
{route.page === "timer" ? ( void }) { +type OpenTicketGroup = { + ticketId: string; + ticketNumber: string; + organizationName: string; + openCount: number; + totalMinutes: number; +}; + +function groupOpenSessions(sessions: StatisticsOpenSession[]) { + const groups = new Map(); + + for (const session of sessions) { + const existing = groups.get(session.ticket_id); + + if (existing) { + existing.openCount += 1; + existing.totalMinutes += session.rounded_minutes; + continue; + } + + groups.set(session.ticket_id, { + ticketId: session.ticket_id, + ticketNumber: session.ticket_number, + organizationName: session.organization_name, + 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 OpenTicketItem({ group, month, onNavigate }: { group: OpenTicketGroup; month: string; onNavigate: (to: string) => void }) { return ( -
+
- {session.ticket_number} - {formatMinutes(session.rounded_minutes)} - offen + {group.ticketNumber} + + {group.openCount} offen
-

{session.organization_name}

-

{session.activity}

+

{group.organizationName}

+

{formatMinutes(group.totalMinutes)} offen zu bewerten

+
+
+
-
); } -function CrmDayItem({ item, onNavigate }: { item: StatisticsCrmDay; onNavigate: (to: string) => void }) { +function CrmDayItem({ + item, + acknowledging, + onAcknowledge, + onNavigate +}: { + item: StatisticsCrmDay; + acknowledging: boolean; + onAcknowledge: (item: StatisticsCrmDay) => void; + onNavigate: (to: string) => void; +}) { return ( -
+
+ { + if (checked) { + onAcknowledge(item); + } + }} + /> {item.ticket_number} + {formatDate(`${item.day}T00:00:00`)}

{item.organization_name}

@@ -95,10 +150,15 @@ function CrmDayItem({ item, onNavigate }: { item: StatisticsCrmDay; onNavigate: {typeof item.delta_minutes === "number" ? Differenz {formatTeamspaceDelta(item.delta_minutes)} : null}

- +
+ + +
); } @@ -109,6 +169,7 @@ export function MonthlyClosePage({ onNavigate }: MonthlyClosePageProps) { const [loading, setLoading] = useState(false); const [closing, setClosing] = useState(false); const [reopening, setReopening] = useState(false); + const [acknowledgingKeys, setAcknowledgingKeys] = useState>(() => new Set()); const loadRequestId = useRef(0); async function load() { @@ -181,17 +242,39 @@ export function MonthlyClosePage({ onNavigate }: MonthlyClosePageProps) { } } + async function acknowledgeMissingCrmDay(item: StatisticsCrmDay) { + const key = `${item.ticket_id}:${item.day}`; + setAcknowledgingKeys((current) => new Set(current).add(key)); + + try { + await acknowledgeMissingTicketDayBilling(item.ticket_id, item.day, true); + toast.success("Teamspace-Prüfung abgehakt", { + description: `${item.ticket_number} am ${formatDate(`${item.day}T00:00:00`)}` + }); + await load(); + } catch (error) { + toast.error("Konnte nicht abgehakt 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 crmMismatches = stats?.attention.crmMismatches ?? []; + const openTicketGroups = useMemo(() => groupOpenSessions(openSessions), [openSessions]); const canClose = Boolean(stats && !stats.closed && stats.totals.sessions > 0 && openSessions.length === 0); const checklist = useMemo( () => [ - { label: "Offene Bewertungen", count: openSessions.length, blocker: true }, - { label: "Tage ohne Teamspace-Wert", count: missingCrmDays.length, blocker: false }, - { label: "Teamspace-Differenzen", count: crmMismatches.length, blocker: false } + { 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 } ], - [openSessions.length, missingCrmDays.length, crmMismatches.length] + [openSessions.length, openTicketGroups.length, missingCrmDays.length] ); return ( @@ -236,22 +319,13 @@ export function MonthlyClosePage({ onNavigate }: MonthlyClosePageProps) { Dieser Monat wurde am {formatDateTime(stats.closedAt!)} abgeschlossen. - ) : openSessions.length > 0 ? ( - - - {openSessions.length} offene Bewertung(en) blockieren den Monatsabschluss. - - ) : missingCrmDays.length > 0 || crmMismatches.length > 0 ? ( - - - Der Monat ist abschließbar, hat aber noch Teamspace-Prüfpunkte. - ) : null} -
+
- 0 ? "warn" : "ok"} /> + 0 ? "warn" : "ok"} /> + 0 ? "warn" : "ok"} />
@@ -260,14 +334,14 @@ export function MonthlyClosePage({ onNavigate }: MonthlyClosePageProps) { Checkliste Bewertungen sind Pflicht. Teamspace-Punkte helfen beim sauberen CRM-Abgleich. - + {checklist.map((item) => (
{item.label} 0 ? "warning" : "success"}>{item.count}
-

{item.blocker ? "Muss erledigt sein." : "Vor Abschluss prüfen."}

+

{item.blocker ? "Muss erledigt sein." : item.detail}

))}
@@ -278,16 +352,16 @@ export function MonthlyClosePage({ onNavigate }: MonthlyClosePageProps) { Aufräumen Alles, was für diesen Monat noch Aufmerksamkeit braucht. - +
Offene Bewertungen
- {openSessions.map((session) => ( - + {openTicketGroups.map((group) => ( + ))} - {openSessions.length === 0 ?

Keine offenen Bewertungen.

: null} + {openTicketGroups.length === 0 ?

Keine offenen Bewertungen.

: null}
@@ -296,21 +370,16 @@ export function MonthlyClosePage({ onNavigate }: MonthlyClosePageProps) { Tage ohne Teamspace-Wert
{missingCrmDays.map((item) => ( - + ))} {missingCrmDays.length === 0 ?

Alle getrackten Tage haben einen Teamspace-Wert.

: null} - -
-
- - Teamspace-Differenzen -
- {crmMismatches.map((item) => ( - - ))} - {crmMismatches.length === 0 ?

Keine Abweichungen zwischen Sessions und Teamspace.

: null} -