Refine monthly close cleanup
This commit is contained in:
+69
-1
@@ -701,12 +701,17 @@ async function getStatisticsOverview(period: ParsedPeriod, userId: string) {
|
|||||||
td.day,
|
td.day,
|
||||||
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
|
||||||
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
|
||||||
AND tdb.user_id = $3
|
AND tdb.user_id = $3
|
||||||
AND tdb.day = td.day
|
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 (
|
session_group AS (
|
||||||
SELECT
|
SELECT
|
||||||
@@ -916,6 +921,7 @@ async function getStatisticsOverview(period: ParsedPeriod, userId: string) {
|
|||||||
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
|
||||||
|
AND NOT tdb.missing_crm_acknowledged
|
||||||
GROUP BY tdb.ticket_id, tdb.ticket_number, tdb.day, tdb.tracked_minutes, tdb.crm_billed_minutes
|
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;
|
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]
|
[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] });
|
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) => {
|
app.post("/api/periods/:periodType/:period/tickets/:ticketId/close", async (req, res) => {
|
||||||
parsePeriod(req.params.periodType, req.params.period);
|
parsePeriod(req.params.periodType, req.params.period);
|
||||||
parsePositiveInteger(req.params.ticketId, "ticketId");
|
parsePositiveInteger(req.params.ticketId, "ticketId");
|
||||||
|
|||||||
@@ -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("ticket_month_closures", "month", ["ticket_id", "month"], adminId);
|
||||||
await ensureClosureOwnership("month_closures", "month", ["month"], adminId);
|
await ensureClosureOwnership("month_closures", "month", ["month"], adminId);
|
||||||
await ensureClosureOwnership("ticket_day_closures", "day", ["ticket_id", "day"], 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_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_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_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("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("UPDATE recurring_billings SET active = true WHERE active = false;");
|
||||||
await query(`
|
await query(`
|
||||||
|
|||||||
@@ -688,7 +688,7 @@ export function App() {
|
|||||||
<QuickTimerStarter inputId="quick-ticket-number-mobile" onStartTimer={startQuickTimer} />
|
<QuickTimerStarter inputId="quick-ticket-number-mobile" onStartTimer={startQuickTimer} />
|
||||||
</div>
|
</div>
|
||||||
<main className="min-h-0 min-w-0 flex-1 overflow-x-hidden p-3 sm:p-4 md:p-5">
|
<main className="min-h-0 min-w-0 flex-1 overflow-x-hidden p-3 sm:p-4 md:p-5">
|
||||||
<div className="mx-auto w-full max-w-[1180px]">
|
<div className="mx-auto w-full max-w-screen-2xl">
|
||||||
{route.page === "timer" ? (
|
{route.page === "timer" ? (
|
||||||
<TimerPage
|
<TimerPage
|
||||||
timers={timers}
|
timers={timers}
|
||||||
|
|||||||
@@ -284,6 +284,13 @@ export function updateTicketDayBilling(ticketId: string, day: string, billedMinu
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function acknowledgeMissingTicketDayBilling(ticketId: string, day: string, acknowledged = true) {
|
||||||
|
return request(`/api/tickets/${ticketId}/day-billings/${day}/acknowledgement`, {
|
||||||
|
method: "PATCH",
|
||||||
|
body: JSON.stringify({ acknowledged })
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export function getMonthOverview(month: string) {
|
export function getMonthOverview(month: string) {
|
||||||
return getPeriodOverview("month", month);
|
return getPeriodOverview("month", month);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import {
|
import {
|
||||||
AlertTriangle,
|
|
||||||
CheckCircle2,
|
CheckCircle2,
|
||||||
ChevronLeft,
|
ChevronLeft,
|
||||||
ChevronRight,
|
ChevronRight,
|
||||||
@@ -9,7 +8,6 @@ import {
|
|||||||
Lock,
|
Lock,
|
||||||
LockOpen,
|
LockOpen,
|
||||||
Sparkles,
|
Sparkles,
|
||||||
TrendingUp,
|
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { useEffect, useMemo, useRef, useState } from "react";
|
import { useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
@@ -18,8 +16,10 @@ import { Alert } from "@/components/ui/alert";
|
|||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { closePeriod, getStatisticsOverview, reopenPeriod } from "../api";
|
import { CopyTicketButton } from "@/components/CopyTicketButton";
|
||||||
|
import { acknowledgeMissingTicketDayBilling, closePeriod, getStatisticsOverview, reopenPeriod } from "../api";
|
||||||
import { currentMonth, formatDate, formatDateTime, formatMinutes } from "../format";
|
import { currentMonth, formatDate, formatDateTime, formatMinutes } from "../format";
|
||||||
import type { StatisticsCrmDay, StatisticsOpenSession, StatisticsOverview } from "../types";
|
import type { StatisticsCrmDay, StatisticsOpenSession, StatisticsOverview } from "../types";
|
||||||
|
|
||||||
@@ -60,32 +60,87 @@ function StatusCard({ label, value, detail, tone }: { label: string; value: stri
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function OpenSessionItem({ session, month, onNavigate }: { session: StatisticsOpenSession; month: string; onNavigate: (to: string) => void }) {
|
type OpenTicketGroup = {
|
||||||
|
ticketId: string;
|
||||||
|
ticketNumber: string;
|
||||||
|
organizationName: string;
|
||||||
|
openCount: number;
|
||||||
|
totalMinutes: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
function groupOpenSessions(sessions: StatisticsOpenSession[]) {
|
||||||
|
const groups = new Map<string, OpenTicketGroup>();
|
||||||
|
|
||||||
|
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 (
|
return (
|
||||||
<div className="grid gap-2 rounded-md border bg-background p-3 sm:grid-cols-[1fr_auto] sm:items-center">
|
<div className="flex min-h-32 flex-col justify-between gap-3 rounded-md border bg-background p-3">
|
||||||
<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">
|
||||||
<span className="font-medium">{session.ticket_number}</span>
|
<span className="font-medium">{group.ticketNumber}</span>
|
||||||
<Badge variant="outline">{formatMinutes(session.rounded_minutes)}</Badge>
|
<CopyTicketButton ticketNumber={group.ticketNumber} />
|
||||||
<Badge variant="warning">offen</Badge>
|
<Badge variant="warning">{group.openCount} offen</Badge>
|
||||||
</div>
|
</div>
|
||||||
<p className="truncate text-sm text-muted-foreground">{session.organization_name}</p>
|
<p className="truncate text-sm text-muted-foreground">{group.organizationName}</p>
|
||||||
<p className="whitespace-pre-wrap text-sm">{session.activity}</p>
|
<p className={`text-sm font-medium ${trackedTextClass}`}>{formatMinutes(group.totalMinutes)} offen zu bewerten</p>
|
||||||
</div>
|
</div>
|
||||||
<Button variant="secondary" size="sm" onClick={() => onNavigate(`/analysis/month/${month}/tickets/${session.ticket_id}`)}>
|
<div className="flex justify-end">
|
||||||
Bewerten
|
<Button variant="secondary" size="sm" onClick={() => onNavigate(`/analysis/month/${month}/tickets/${group.ticketId}`)}>
|
||||||
|
Ticket öffnen
|
||||||
<ExternalLink className="size-4" />
|
<ExternalLink className="size-4" />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
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 (
|
return (
|
||||||
<div className="grid gap-2 rounded-md border bg-background p-3 sm:grid-cols-[1fr_auto] sm:items-center">
|
<div className="flex min-h-32 flex-col justify-between gap-3 rounded-md border bg-background p-3">
|
||||||
<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
|
||||||
|
checked={acknowledging}
|
||||||
|
disabled={acknowledging}
|
||||||
|
aria-label="Zur Kenntnis genommen"
|
||||||
|
onCheckedChange={(checked) => {
|
||||||
|
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} />
|
||||||
<Badge variant="outline">{formatDate(`${item.day}T00:00:00`)}</Badge>
|
<Badge variant="outline">{formatDate(`${item.day}T00:00:00`)}</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>
|
||||||
@@ -95,11 +150,16 @@ function CrmDayItem({ item, onNavigate }: { item: StatisticsCrmDay; onNavigate:
|
|||||||
{typeof item.delta_minutes === "number" ? <span className="text-muted-foreground">Differenz {formatTeamspaceDelta(item.delta_minutes)}</span> : null}
|
{typeof item.delta_minutes === "number" ? <span className="text-muted-foreground">Differenz {formatTeamspaceDelta(item.delta_minutes)}</span> : null}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Button variant="ghost" size="sm" onClick={() => onNavigate(`/analysis/day/${item.day}/tickets/${item.ticket_id}`)}>
|
<div className="flex flex-wrap justify-end gap-2">
|
||||||
|
<Button variant="ghost" size="sm" disabled={acknowledging} onClick={() => onAcknowledge(item)}>
|
||||||
|
Zur Kenntnis
|
||||||
|
</Button>
|
||||||
|
<Button variant="secondary" size="sm" onClick={() => onNavigate(`/analysis/day/${item.day}/tickets/${item.ticket_id}`)}>
|
||||||
Tag öffnen
|
Tag öffnen
|
||||||
<ExternalLink className="size-4" />
|
<ExternalLink className="size-4" />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -109,6 +169,7 @@ export function MonthlyClosePage({ onNavigate }: MonthlyClosePageProps) {
|
|||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
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 loadRequestId = useRef(0);
|
const loadRequestId = useRef(0);
|
||||||
|
|
||||||
async function load() {
|
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 openSessions = stats?.attention.openSessions ?? [];
|
||||||
const missingCrmDays = stats?.attention.missingCrmDays ?? [];
|
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 canClose = Boolean(stats && !stats.closed && stats.totals.sessions > 0 && openSessions.length === 0);
|
||||||
const checklist = useMemo(
|
const checklist = useMemo(
|
||||||
() => [
|
() => [
|
||||||
{ label: "Offene Bewertungen", count: openSessions.length, blocker: true },
|
{ label: "Offene Bewertungen", count: openSessions.length, detail: `${openTicketGroups.length} Ticket(s)`, blocker: true },
|
||||||
{ label: "Tage ohne Teamspace-Wert", count: missingCrmDays.length, blocker: false },
|
{ label: "Tage ohne Teamspace-Wert", count: missingCrmDays.length, detail: "zur Kenntnisnahme", blocker: false }
|
||||||
{ label: "Teamspace-Differenzen", count: crmMismatches.length, blocker: false }
|
|
||||||
],
|
],
|
||||||
[openSessions.length, missingCrmDays.length, crmMismatches.length]
|
[openSessions.length, openTicketGroups.length, missingCrmDays.length]
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -236,22 +319,13 @@ export function MonthlyClosePage({ onNavigate }: MonthlyClosePageProps) {
|
|||||||
<CheckCircle2 className="size-4 shrink-0" />
|
<CheckCircle2 className="size-4 shrink-0" />
|
||||||
<span>Dieser Monat wurde am {formatDateTime(stats.closedAt!)} abgeschlossen.</span>
|
<span>Dieser Monat wurde am {formatDateTime(stats.closedAt!)} abgeschlossen.</span>
|
||||||
</Alert>
|
</Alert>
|
||||||
) : openSessions.length > 0 ? (
|
|
||||||
<Alert variant="warning" className="flex items-center gap-2 py-3">
|
|
||||||
<AlertTriangle className="size-4 shrink-0" />
|
|
||||||
<span>{openSessions.length} offene Bewertung(en) blockieren den Monatsabschluss.</span>
|
|
||||||
</Alert>
|
|
||||||
) : missingCrmDays.length > 0 || crmMismatches.length > 0 ? (
|
|
||||||
<Alert variant="warning" className="flex items-center gap-2 py-3">
|
|
||||||
<AlertTriangle className="size-4 shrink-0" />
|
|
||||||
<span>Der Monat ist abschließbar, hat aber noch Teamspace-Prüfpunkte.</span>
|
|
||||||
</Alert>
|
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
|
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-5">
|
||||||
<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="Offen" value={openSessions.length} detail="blockiert den Abschluss" 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="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>
|
||||||
|
|
||||||
@@ -260,14 +334,14 @@ export function MonthlyClosePage({ onNavigate }: MonthlyClosePageProps) {
|
|||||||
<CardTitle>Checkliste</CardTitle>
|
<CardTitle>Checkliste</CardTitle>
|
||||||
<CardDescription>Bewertungen sind Pflicht. Teamspace-Punkte helfen beim sauberen CRM-Abgleich.</CardDescription>
|
<CardDescription>Bewertungen sind Pflicht. Teamspace-Punkte helfen beim sauberen CRM-Abgleich.</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="grid gap-2 px-4 pb-4 md:grid-cols-3">
|
<CardContent className="grid gap-2 px-4 pb-4 md:grid-cols-2">
|
||||||
{checklist.map((item) => (
|
{checklist.map((item) => (
|
||||||
<div key={item.label} className="rounded-md border bg-background p-3">
|
<div key={item.label} className="rounded-md border bg-background p-3">
|
||||||
<div className="mb-2 flex items-center justify-between gap-2">
|
<div className="mb-2 flex items-center justify-between gap-2">
|
||||||
<span className="text-sm font-medium">{item.label}</span>
|
<span className="text-sm font-medium">{item.label}</span>
|
||||||
<Badge variant={item.count > 0 ? "warning" : "success"}>{item.count}</Badge>
|
<Badge variant={item.count > 0 ? "warning" : "success"}>{item.count}</Badge>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-muted-foreground">{item.blocker ? "Muss erledigt sein." : "Vor Abschluss prüfen."}</p>
|
<p className="text-xs text-muted-foreground">{item.blocker ? "Muss erledigt sein." : item.detail}</p>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
@@ -278,16 +352,16 @@ export function MonthlyClosePage({ onNavigate }: MonthlyClosePageProps) {
|
|||||||
<CardTitle>Aufräumen</CardTitle>
|
<CardTitle>Aufräumen</CardTitle>
|
||||||
<CardDescription>Alles, was für diesen Monat noch Aufmerksamkeit braucht.</CardDescription>
|
<CardDescription>Alles, was für diesen Monat noch Aufmerksamkeit braucht.</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-5 px-4 pb-4">
|
<CardContent className="grid gap-4 px-4 pb-4 xl:grid-cols-2">
|
||||||
<section className="space-y-2">
|
<section className="space-y-2">
|
||||||
<div className="flex items-center gap-2 text-sm font-medium">
|
<div className="flex items-center gap-2 text-sm font-medium">
|
||||||
<FileWarning className="size-4 text-muted-foreground" />
|
<FileWarning className="size-4 text-muted-foreground" />
|
||||||
Offene Bewertungen
|
Offene Bewertungen
|
||||||
</div>
|
</div>
|
||||||
{openSessions.map((session) => (
|
{openTicketGroups.map((group) => (
|
||||||
<OpenSessionItem key={session.id} session={session} month={month} onNavigate={onNavigate} />
|
<OpenTicketItem key={group.ticketId} group={group} month={month} onNavigate={onNavigate} />
|
||||||
))}
|
))}
|
||||||
{openSessions.length === 0 ? <p className="rounded-md border bg-muted/20 p-3 text-sm text-muted-foreground">Keine offenen Bewertungen.</p> : null}
|
{openTicketGroups.length === 0 ? <p className="rounded-md border bg-muted/20 p-3 text-sm text-muted-foreground">Keine offenen Bewertungen.</p> : null}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="space-y-2">
|
<section className="space-y-2">
|
||||||
@@ -296,21 +370,16 @@ export function MonthlyClosePage({ onNavigate }: MonthlyClosePageProps) {
|
|||||||
Tage ohne Teamspace-Wert
|
Tage ohne Teamspace-Wert
|
||||||
</div>
|
</div>
|
||||||
{missingCrmDays.map((item) => (
|
{missingCrmDays.map((item) => (
|
||||||
<CrmDayItem key={`${item.ticket_id}-${item.day}-missing`} item={item} onNavigate={onNavigate} />
|
<CrmDayItem
|
||||||
|
key={`${item.ticket_id}-${item.day}-missing`}
|
||||||
|
item={item}
|
||||||
|
acknowledging={acknowledgingKeys.has(`${item.ticket_id}:${item.day}`)}
|
||||||
|
onAcknowledge={acknowledgeMissingCrmDay}
|
||||||
|
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}
|
{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}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="space-y-2">
|
|
||||||
<div className="flex items-center gap-2 text-sm font-medium">
|
|
||||||
<TrendingUp className="size-4 text-muted-foreground" />
|
|
||||||
Teamspace-Differenzen
|
|
||||||
</div>
|
|
||||||
{crmMismatches.map((item) => (
|
|
||||||
<CrmDayItem key={`${item.ticket_id}-${item.day}-mismatch`} item={item} onNavigate={onNavigate} />
|
|
||||||
))}
|
|
||||||
{crmMismatches.length === 0 ? <p className="rounded-md border bg-muted/20 p-3 text-sm text-muted-foreground">Keine Abweichungen zwischen Sessions und Teamspace.</p> : null}
|
|
||||||
</section>
|
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user