diff --git a/backend/src/index.ts b/backend/src/index.ts index 54d8e66..c8544cf 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -2758,6 +2758,21 @@ app.post("/api/sessions", requireUser, async (req, res) => { throw badRequest("endedAt must be after startedAt"); } + const monthClosureResult = await query<{ closed_at: string }>( + ` + SELECT closed_at + FROM month_closures + WHERE month = date_trunc('month', $1::timestamptz AT TIME ZONE 'Europe/Berlin')::date + AND user_id = $2; + `, + [startedAt.toISOString(), user.id] + ); + + if (monthClosureResult.rows.length > 0) { + res.status(409).json({ error: "Monat ist abgeschlossen" }); + return; + } + const roundedMinutes = Math.max(1, Math.round(durationSeconds / 60)); const created = await withTransaction(async (client) => { const existingTicketResult = await client.query<{ id: string; organization_id: string | null; customer_name: string | null; work_type: string | null }>( @@ -3035,6 +3050,21 @@ app.patch("/api/tickets/:ticketId/day-billings/:day", requireUser, async (req, r const billedMinutes = parseOptionalBilledMinutes(req.body.billedMinutes); const userId = currentUser(req).id; + const monthClosureResult = await query<{ closed_at: string }>( + ` + SELECT closed_at + FROM month_closures + WHERE month = date_trunc('month', $1::date)::date + AND user_id = $2; + `, + [day.start, userId] + ); + + if (monthClosureResult.rows.length > 0) { + res.status(409).json({ error: "Monat ist abgeschlossen" }); + return; + } + const sessionResult = await query<{ id: string }>( ` SELECT id diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 44e3c7e..a8edecb 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -100,6 +100,10 @@ function openTicketsStorageKey(userId: string) { return `tickettracker.openTickets.${userId}`; } +function pathWithoutSearch(value: string) { + return value.split("?")[0]; +} + function readOpenTickets(userId: string): OpenTicketNavItem[] { try { const raw = localStorage.getItem(openTicketsStorageKey(userId)); @@ -126,14 +130,16 @@ function readOpenTickets(userId: string): OpenTicketNavItem[] { } } -function routeFromPath(pathname: string) { +function routeFromPath(pathWithSearch: string) { + const [pathname, search = ""] = pathWithSearch.split("?"); + const searchParams = new URLSearchParams(search); const ticketMatch = pathname.match(/^\/tickets\/(\d+)$/); if (ticketMatch) { return { page: "ticket" as const, periodType: "all" as const, - period: "all", + period: searchParams.get("month") ?? "all", ticketId: ticketMatch[1], }; } @@ -141,10 +147,13 @@ function routeFromPath(pathname: string) { const periodTicketMatch = pathname.match(/^\/analysis\/(month|day)\/([^/]+)\/tickets\/(\d+)$/); if (periodTicketMatch) { + const sourcePeriodType = periodTicketMatch[1] as PeriodType; + const sourcePeriod = periodTicketMatch[2]; + return { page: "ticket" as const, - periodType: periodTicketMatch[1] as PeriodType, - period: periodTicketMatch[2], + periodType: "all" as const, + period: sourcePeriodType === "day" ? sourcePeriod.slice(0, 7) : sourcePeriod, ticketId: periodTicketMatch[3], }; } @@ -154,7 +163,7 @@ function routeFromPath(pathname: string) { if (legacyTicketMatch) { return { page: "ticket" as const, - periodType: "month" as const, + periodType: "all" as const, period: legacyTicketMatch[1], ticketId: legacyTicketMatch[2], }; @@ -195,7 +204,7 @@ function useAppRouter() { const [path, setPath] = useState("/timer"); useEffect(() => { - const update = () => setPath(window.location.pathname); + const update = () => setPath(`${window.location.pathname}${window.location.search}`); update(); window.addEventListener("popstate", update); return () => window.removeEventListener("popstate", update); @@ -203,7 +212,7 @@ function useAppRouter() { function navigate(to: string) { window.history.pushState({}, "", to); - setPath(window.location.pathname); + setPath(`${window.location.pathname}${window.location.search}`); } function navHandler(to: string) { @@ -811,7 +820,7 @@ export function App() { setOpenTickets((items) => items.filter((item) => item.key !== ticket.key)); - if (path === ticket.path) { + if (pathWithoutSearch(path) === pathWithoutSearch(ticket.path)) { navigate("/analysis"); } } @@ -958,7 +967,7 @@ export function App() { {openTickets.map((ticket) => { - const isActive = path === ticket.path; + const isActive = pathWithoutSearch(path) === pathWithoutSearch(ticket.path); return ( diff --git a/frontend/src/views/AnalysisPage.tsx b/frontend/src/views/AnalysisPage.tsx index f110dde..9a37cf1 100644 --- a/frontend/src/views/AnalysisPage.tsx +++ b/frontend/src/views/AnalysisPage.tsx @@ -836,7 +836,11 @@ export function AnalysisPage({ onNavigate }: AnalysisPageProps) { - @@ -887,7 +891,12 @@ export function AnalysisPage({ onNavigate }: AnalysisPageProps) {

{ticket.billed_count}

- diff --git a/frontend/src/views/MonthlyClosePage.tsx b/frontend/src/views/MonthlyClosePage.tsx index 7be8512..fbe0e81 100644 --- a/frontend/src/views/MonthlyClosePage.tsx +++ b/frontend/src/views/MonthlyClosePage.tsx @@ -103,7 +103,7 @@ function OpenTicketItem({ group, month, onNavigate }: { group: OpenTicketGroup;

{formatMinutes(group.totalMinutes)} offen zu bewerten

- @@ -145,7 +145,7 @@ function CrmDayItem({ - diff --git a/frontend/src/views/StatisticsPage.tsx b/frontend/src/views/StatisticsPage.tsx index f26169e..12293f2 100644 --- a/frontend/src/views/StatisticsPage.tsx +++ b/frontend/src/views/StatisticsPage.tsx @@ -342,7 +342,7 @@ function TicketHotspot({ ticket, month, onNavigate }: { ticket: StatisticsTicket {formatTeamspaceDelta(ticket.crm_delta_minutes)}
- diff --git a/frontend/src/views/TicketDetailPage.tsx b/frontend/src/views/TicketDetailPage.tsx index 053b693..bdd175b 100644 --- a/frontend/src/views/TicketDetailPage.tsx +++ b/frontend/src/views/TicketDetailPage.tsx @@ -21,7 +21,7 @@ import { updateTicket, updateTicketDayBilling } from "../api"; -import { currentDay, formatDate, formatDateTime, formatMinutes, formatTimeRange } from "../format"; +import { currentDay, currentMonth, formatDate, formatDateTime, formatMinutes, formatTimeRange } from "../format"; import type { BillingStatus, PeriodType, SessionEntry, TicketDetailPeriodType, TicketPeriod, WorkType } from "../types"; import { cn } from "@/lib/utils"; @@ -152,6 +152,7 @@ function isPartialHoursInput(value: string) { } export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onOpenTicketChange }: TicketDetailPageProps) { + const initialGlobalMonthFilter = periodType === "all" && period !== "all" ? period : currentMonth(); const [data, setData] = useState(null); const [loadingId, setLoadingId] = useState(null); const [deletingId, setDeletingId] = useState(null); @@ -173,7 +174,7 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onO const [savingDayBillingKey, setSavingDayBillingKey] = useState(null); const [ticketSession, setTicketSession] = useState(() => emptyTicketSessionForm()); const [savingTicketSession, setSavingTicketSession] = useState(false); - const [globalMonthFilter, setGlobalMonthFilter] = useState("all"); + const [globalMonthFilter, setGlobalMonthFilter] = useState(initialGlobalMonthFilter); const [globalStatusFilter, setGlobalStatusFilter] = useState<"all" | "open" | "billed" | "non_billable">("all"); const [globalSourceFilter, setGlobalSourceFilter] = useState<"all" | "manual" | "recurring">("all"); @@ -199,6 +200,14 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onO void load(); }, [periodType, period, ticketId, globalMonthFilter, globalStatusFilter, globalSourceFilter]); + useEffect(() => { + if (periodType !== "all") { + return; + } + + setGlobalMonthFilter(period !== "all" ? period : currentMonth()); + }, [periodType, period, ticketId]); + useEffect(() => { const nextInputs = Object.fromEntries((data?.dayBillings ?? []).map((billing) => [billing.day, formatHoursInput(billing.billed_minutes)])); setDayBillingInputs(nextInputs); @@ -231,6 +240,8 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onO : `im Monat ${globalMonthFilter}` : `im ${periodLabel.toLowerCase()} ${period}`; const teamspaceLabel = isGlobalTicket || periodType === "month" ? "Teamspace gesamt" : "Teamspace Tag"; + const availableMonths = data?.availableMonths ?? []; + const selectedMonthHasSessions = availableMonths.some((month) => month.month === globalMonthFilter); const dayBillingMap = useMemo(() => new Map((data?.dayBillings ?? []).map((billing) => [billing.day, billing.billed_minutes])), [data?.dayBillings]); const savedDayBillingInput = (day: string) => formatHoursInput(dayBillingMap.get(day)); const isDayBillingDirty = (day: string) => (dayBillingInputs[day] ?? "") !== savedDayBillingInput(day); @@ -282,16 +293,18 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onO return; } + const ticketMonth = isGlobalTicket ? globalMonthFilter : periodType === "day" ? period.slice(0, 7) : period; + onOpenTicketChange?.({ - key: isGlobalTicket ? `ticket:${ticket.id}` : `${periodType}:${period}:${ticket.id}`, + key: `ticket:${ticket.id}`, ticketId: ticket.id, ticketNumber: ticket.ticket_number, - periodType, - period, - path: isGlobalTicket ? `/tickets/${ticket.id}` : `/analysis/${periodType}/${period}/tickets/${ticket.id}`, + periodType: "all", + period: ticketMonth, + path: `/tickets/${ticket.id}?month=${ticketMonth}`, dirty: hasUnsavedChanges }); - }, [ticket, periodType, period, isGlobalTicket, hasUnsavedChanges, onOpenTicketChange]); + }, [ticket, periodType, period, isGlobalTicket, globalMonthFilter, hasUnsavedChanges, onOpenTicketChange]); async function setBilling(sessionId: string, billingStatus: BillingStatus) { if (isPeriodClosed && billingStatus !== null) { @@ -319,6 +332,13 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onO return; } + if (isPeriodClosed) { + toast.warning("Monat ist abgeschlossen", { + description: "Öffne den Monat zuerst wieder, um Teamspace-Zeiten zu ändern." + }); + return; + } + const parsedMinutes = parseHoursInput(dayBillingInputs[day] ?? ""); if (parsedMinutes === undefined) { @@ -542,7 +562,7 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onO

{ticket?.ticket_number ?? "Ticket"}

{ticket?.ticket_number ? : null} - {periodType === "month" && data?.closed ? Monat geschlossen : null} + {(periodType === "month" || (isGlobalTicket && globalMonthFilter !== "all")) && data?.closed ? Monat geschlossen : null}

Sessions {sessionsScopeLabel}

@@ -574,7 +594,8 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onO onChange={(event) => setGlobalMonthFilter(event.currentTarget.value)} > - {(data?.availableMonths ?? []).map((month) => ( + {globalMonthFilter !== "all" && !selectedMonthHasSessions ? : null} + {availableMonths.map((month) => ( @@ -700,7 +721,7 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onO autoComplete="off" inputMode="decimal" value={dayBillingInputs[group.key] ?? ""} - disabled={savingDayBillingKey === group.key} + disabled={isPeriodClosed || savingDayBillingKey === group.key} placeholder="0" onChange={(event) => { const nextValue = event.currentTarget.value; @@ -725,7 +746,7 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onO type="button" size="sm" className={cn("h-7 w-[104px]", !isDayBillingDirty(group.key) && "invisible")} - disabled={!isDayBillingDirty(group.key) || savingDayBillingKey === group.key} + disabled={isPeriodClosed || !isDayBillingDirty(group.key) || savingDayBillingKey === group.key} onMouseDown={(event) => event.preventDefault()} onClick={() => void saveDayBilling(group.key)} >