Use global ticket detail entrypoint
This commit is contained in:
1 parent
b67dbad0b1
commit
018c06dc3e
6 files changed
+94
-25
No files matched your search
@@ -2758,6 +2758,21 @@ app.post("/api/sessions", requireUser, async (req, res) => {
|
|||||||
throw badRequest("endedAt must be after startedAt");
|
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 roundedMinutes = Math.max(1, Math.round(durationSeconds / 60));
|
||||||
const created = await withTransaction(async (client) => {
|
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 }>(
|
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 billedMinutes = parseOptionalBilledMinutes(req.body.billedMinutes);
|
||||||
const userId = currentUser(req).id;
|
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 }>(
|
const sessionResult = await query<{ id: string }>(
|
||||||
`
|
`
|
||||||
SELECT id
|
SELECT id
|
||||||
|
|||||||
+18
-9
@@ -100,6 +100,10 @@ function openTicketsStorageKey(userId: string) {
|
|||||||
return `tickettracker.openTickets.${userId}`;
|
return `tickettracker.openTickets.${userId}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function pathWithoutSearch(value: string) {
|
||||||
|
return value.split("?")[0];
|
||||||
|
}
|
||||||
|
|
||||||
function readOpenTickets(userId: string): OpenTicketNavItem[] {
|
function readOpenTickets(userId: string): OpenTicketNavItem[] {
|
||||||
try {
|
try {
|
||||||
const raw = localStorage.getItem(openTicketsStorageKey(userId));
|
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+)$/);
|
const ticketMatch = pathname.match(/^\/tickets\/(\d+)$/);
|
||||||
|
|
||||||
if (ticketMatch) {
|
if (ticketMatch) {
|
||||||
return {
|
return {
|
||||||
page: "ticket" as const,
|
page: "ticket" as const,
|
||||||
periodType: "all" as const,
|
periodType: "all" as const,
|
||||||
period: "all",
|
period: searchParams.get("month") ?? "all",
|
||||||
ticketId: ticketMatch[1],
|
ticketId: ticketMatch[1],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -141,10 +147,13 @@ function routeFromPath(pathname: string) {
|
|||||||
const periodTicketMatch = pathname.match(/^\/analysis\/(month|day)\/([^/]+)\/tickets\/(\d+)$/);
|
const periodTicketMatch = pathname.match(/^\/analysis\/(month|day)\/([^/]+)\/tickets\/(\d+)$/);
|
||||||
|
|
||||||
if (periodTicketMatch) {
|
if (periodTicketMatch) {
|
||||||
|
const sourcePeriodType = periodTicketMatch[1] as PeriodType;
|
||||||
|
const sourcePeriod = periodTicketMatch[2];
|
||||||
|
|
||||||
return {
|
return {
|
||||||
page: "ticket" as const,
|
page: "ticket" as const,
|
||||||
periodType: periodTicketMatch[1] as PeriodType,
|
periodType: "all" as const,
|
||||||
period: periodTicketMatch[2],
|
period: sourcePeriodType === "day" ? sourcePeriod.slice(0, 7) : sourcePeriod,
|
||||||
ticketId: periodTicketMatch[3],
|
ticketId: periodTicketMatch[3],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -154,7 +163,7 @@ function routeFromPath(pathname: string) {
|
|||||||
if (legacyTicketMatch) {
|
if (legacyTicketMatch) {
|
||||||
return {
|
return {
|
||||||
page: "ticket" as const,
|
page: "ticket" as const,
|
||||||
periodType: "month" as const,
|
periodType: "all" as const,
|
||||||
period: legacyTicketMatch[1],
|
period: legacyTicketMatch[1],
|
||||||
ticketId: legacyTicketMatch[2],
|
ticketId: legacyTicketMatch[2],
|
||||||
};
|
};
|
||||||
@@ -195,7 +204,7 @@ function useAppRouter() {
|
|||||||
const [path, setPath] = useState("/timer");
|
const [path, setPath] = useState("/timer");
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const update = () => setPath(window.location.pathname);
|
const update = () => setPath(`${window.location.pathname}${window.location.search}`);
|
||||||
update();
|
update();
|
||||||
window.addEventListener("popstate", update);
|
window.addEventListener("popstate", update);
|
||||||
return () => window.removeEventListener("popstate", update);
|
return () => window.removeEventListener("popstate", update);
|
||||||
@@ -203,7 +212,7 @@ function useAppRouter() {
|
|||||||
|
|
||||||
function navigate(to: string) {
|
function navigate(to: string) {
|
||||||
window.history.pushState({}, "", to);
|
window.history.pushState({}, "", to);
|
||||||
setPath(window.location.pathname);
|
setPath(`${window.location.pathname}${window.location.search}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
function navHandler(to: string) {
|
function navHandler(to: string) {
|
||||||
@@ -811,7 +820,7 @@ export function App() {
|
|||||||
|
|
||||||
setOpenTickets((items) => items.filter((item) => item.key !== ticket.key));
|
setOpenTickets((items) => items.filter((item) => item.key !== ticket.key));
|
||||||
|
|
||||||
if (path === ticket.path) {
|
if (pathWithoutSearch(path) === pathWithoutSearch(ticket.path)) {
|
||||||
navigate("/analysis");
|
navigate("/analysis");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -958,7 +967,7 @@ export function App() {
|
|||||||
<SidebarGroupContent>
|
<SidebarGroupContent>
|
||||||
<SidebarMenu>
|
<SidebarMenu>
|
||||||
{openTickets.map((ticket) => {
|
{openTickets.map((ticket) => {
|
||||||
const isActive = path === ticket.path;
|
const isActive = pathWithoutSearch(path) === pathWithoutSearch(ticket.path);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SidebarMenuItem key={ticket.key}>
|
<SidebarMenuItem key={ticket.key}>
|
||||||
|
|||||||
@@ -836,7 +836,11 @@ export function AnalysisPage({ onNavigate }: AnalysisPageProps) {
|
|||||||
<TicketStatus ticket={ticket} />
|
<TicketStatus ticket={ticket} />
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="text-right">
|
<TableCell className="text-right">
|
||||||
<Button variant="ghost" size="sm" onClick={() => onNavigate(`/analysis/${periodType}/${period}/tickets/${ticket.id}`)}>
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => onNavigate(`/tickets/${ticket.id}?month=${periodType === "day" ? period.slice(0, 7) : period}`)}
|
||||||
|
>
|
||||||
Öffnen
|
Öffnen
|
||||||
<ExternalLink className="size-4" />
|
<ExternalLink className="size-4" />
|
||||||
</Button>
|
</Button>
|
||||||
@@ -887,7 +891,12 @@ export function AnalysisPage({ onNavigate }: AnalysisPageProps) {
|
|||||||
<p className="font-medium">{ticket.billed_count}</p>
|
<p className="font-medium">{ticket.billed_count}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Button className="w-full" size="sm" variant="secondary" onClick={() => onNavigate(`/analysis/${periodType}/${period}/tickets/${ticket.id}`)}>
|
<Button
|
||||||
|
className="w-full"
|
||||||
|
size="sm"
|
||||||
|
variant="secondary"
|
||||||
|
onClick={() => onNavigate(`/tickets/${ticket.id}?month=${periodType === "day" ? period.slice(0, 7) : period}`)}
|
||||||
|
>
|
||||||
Öffnen
|
Öffnen
|
||||||
<ExternalLink className="size-4" />
|
<ExternalLink className="size-4" />
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -103,7 +103,7 @@ function OpenTicketItem({ group, month, onNavigate }: { group: OpenTicketGroup;
|
|||||||
<p className={`text-sm font-medium ${trackedTextClass}`}>{formatMinutes(group.totalMinutes)} offen zu bewerten</p>
|
<p className={`text-sm font-medium ${trackedTextClass}`}>{formatMinutes(group.totalMinutes)} offen zu bewerten</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-end">
|
<div className="flex justify-end">
|
||||||
<Button variant="secondary" size="sm" onClick={() => onNavigate(`/analysis/month/${month}/tickets/${group.ticketId}`)}>
|
<Button variant="secondary" size="sm" onClick={() => onNavigate(`/tickets/${group.ticketId}?month=${month}`)}>
|
||||||
Ticket öffnen
|
Ticket öffnen
|
||||||
<ExternalLink className="size-4" />
|
<ExternalLink className="size-4" />
|
||||||
</Button>
|
</Button>
|
||||||
@@ -145,7 +145,7 @@ function CrmDayItem({
|
|||||||
<Button variant={isAcknowledged ? "secondary" : "ghost"} size="sm" disabled={busy} onClick={() => onSetAcknowledgement(item, !isAcknowledged)}>
|
<Button variant={isAcknowledged ? "secondary" : "ghost"} size="sm" disabled={busy} onClick={() => onSetAcknowledgement(item, !isAcknowledged)}>
|
||||||
{isAcknowledged ? "Zurücknehmen" : "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(`/tickets/${item.ticket_id}?month=${item.day.slice(0, 7)}`)}>
|
||||||
Tag öffnen
|
Tag öffnen
|
||||||
<ExternalLink className="size-4" />
|
<ExternalLink className="size-4" />
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -342,7 +342,7 @@ function TicketHotspot({ ticket, month, onNavigate }: { ticket: StatisticsTicket
|
|||||||
<span className={deltaTextClass(ticket.crm_delta_minutes)}>{formatTeamspaceDelta(ticket.crm_delta_minutes)}</span>
|
<span className={deltaTextClass(ticket.crm_delta_minutes)}>{formatTeamspaceDelta(ticket.crm_delta_minutes)}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Button variant="secondary" size="sm" onClick={() => onNavigate(`/analysis/month/${month}/tickets/${ticket.ticket_id}`)}>
|
<Button variant="secondary" size="sm" onClick={() => onNavigate(`/tickets/${ticket.ticket_id}?month=${month}`)}>
|
||||||
Öffnen
|
Öffnen
|
||||||
<ExternalLink className="size-4" />
|
<ExternalLink className="size-4" />
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ import {
|
|||||||
updateTicket,
|
updateTicket,
|
||||||
updateTicketDayBilling
|
updateTicketDayBilling
|
||||||
} from "../api";
|
} 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 type { BillingStatus, PeriodType, SessionEntry, TicketDetailPeriodType, TicketPeriod, WorkType } from "../types";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
@@ -152,6 +152,7 @@ function isPartialHoursInput(value: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onOpenTicketChange }: TicketDetailPageProps) {
|
export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onOpenTicketChange }: TicketDetailPageProps) {
|
||||||
|
const initialGlobalMonthFilter = periodType === "all" && period !== "all" ? period : currentMonth();
|
||||||
const [data, setData] = useState<TicketPeriod | null>(null);
|
const [data, setData] = useState<TicketPeriod | null>(null);
|
||||||
const [loadingId, setLoadingId] = useState<string | null>(null);
|
const [loadingId, setLoadingId] = useState<string | null>(null);
|
||||||
const [deletingId, setDeletingId] = useState<string | null>(null);
|
const [deletingId, setDeletingId] = useState<string | null>(null);
|
||||||
@@ -173,7 +174,7 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onO
|
|||||||
const [savingDayBillingKey, setSavingDayBillingKey] = useState<string | null>(null);
|
const [savingDayBillingKey, setSavingDayBillingKey] = useState<string | null>(null);
|
||||||
const [ticketSession, setTicketSession] = useState<TicketSessionFormState>(() => emptyTicketSessionForm());
|
const [ticketSession, setTicketSession] = useState<TicketSessionFormState>(() => emptyTicketSessionForm());
|
||||||
const [savingTicketSession, setSavingTicketSession] = useState(false);
|
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 [globalStatusFilter, setGlobalStatusFilter] = useState<"all" | "open" | "billed" | "non_billable">("all");
|
||||||
const [globalSourceFilter, setGlobalSourceFilter] = useState<"all" | "manual" | "recurring">("all");
|
const [globalSourceFilter, setGlobalSourceFilter] = useState<"all" | "manual" | "recurring">("all");
|
||||||
|
|
||||||
@@ -199,6 +200,14 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onO
|
|||||||
void load();
|
void load();
|
||||||
}, [periodType, period, ticketId, globalMonthFilter, globalStatusFilter, globalSourceFilter]);
|
}, [periodType, period, ticketId, globalMonthFilter, globalStatusFilter, globalSourceFilter]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (periodType !== "all") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setGlobalMonthFilter(period !== "all" ? period : currentMonth());
|
||||||
|
}, [periodType, period, ticketId]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const nextInputs = Object.fromEntries((data?.dayBillings ?? []).map((billing) => [billing.day, formatHoursInput(billing.billed_minutes)]));
|
const nextInputs = Object.fromEntries((data?.dayBillings ?? []).map((billing) => [billing.day, formatHoursInput(billing.billed_minutes)]));
|
||||||
setDayBillingInputs(nextInputs);
|
setDayBillingInputs(nextInputs);
|
||||||
@@ -231,6 +240,8 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onO
|
|||||||
: `im Monat ${globalMonthFilter}`
|
: `im Monat ${globalMonthFilter}`
|
||||||
: `im ${periodLabel.toLowerCase()} ${period}`;
|
: `im ${periodLabel.toLowerCase()} ${period}`;
|
||||||
const teamspaceLabel = isGlobalTicket || periodType === "month" ? "Teamspace gesamt" : "Teamspace Tag";
|
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 dayBillingMap = useMemo(() => new Map((data?.dayBillings ?? []).map((billing) => [billing.day, billing.billed_minutes])), [data?.dayBillings]);
|
||||||
const savedDayBillingInput = (day: string) => formatHoursInput(dayBillingMap.get(day));
|
const savedDayBillingInput = (day: string) => formatHoursInput(dayBillingMap.get(day));
|
||||||
const isDayBillingDirty = (day: string) => (dayBillingInputs[day] ?? "") !== savedDayBillingInput(day);
|
const isDayBillingDirty = (day: string) => (dayBillingInputs[day] ?? "") !== savedDayBillingInput(day);
|
||||||
@@ -282,16 +293,18 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onO
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const ticketMonth = isGlobalTicket ? globalMonthFilter : periodType === "day" ? period.slice(0, 7) : period;
|
||||||
|
|
||||||
onOpenTicketChange?.({
|
onOpenTicketChange?.({
|
||||||
key: isGlobalTicket ? `ticket:${ticket.id}` : `${periodType}:${period}:${ticket.id}`,
|
key: `ticket:${ticket.id}`,
|
||||||
ticketId: ticket.id,
|
ticketId: ticket.id,
|
||||||
ticketNumber: ticket.ticket_number,
|
ticketNumber: ticket.ticket_number,
|
||||||
periodType,
|
periodType: "all",
|
||||||
period,
|
period: ticketMonth,
|
||||||
path: isGlobalTicket ? `/tickets/${ticket.id}` : `/analysis/${periodType}/${period}/tickets/${ticket.id}`,
|
path: `/tickets/${ticket.id}?month=${ticketMonth}`,
|
||||||
dirty: hasUnsavedChanges
|
dirty: hasUnsavedChanges
|
||||||
});
|
});
|
||||||
}, [ticket, periodType, period, isGlobalTicket, hasUnsavedChanges, onOpenTicketChange]);
|
}, [ticket, periodType, period, isGlobalTicket, globalMonthFilter, hasUnsavedChanges, onOpenTicketChange]);
|
||||||
|
|
||||||
async function setBilling(sessionId: string, billingStatus: BillingStatus) {
|
async function setBilling(sessionId: string, billingStatus: BillingStatus) {
|
||||||
if (isPeriodClosed && billingStatus !== null) {
|
if (isPeriodClosed && billingStatus !== null) {
|
||||||
@@ -319,6 +332,13 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onO
|
|||||||
return;
|
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] ?? "");
|
const parsedMinutes = parseHoursInput(dayBillingInputs[day] ?? "");
|
||||||
|
|
||||||
if (parsedMinutes === undefined) {
|
if (parsedMinutes === undefined) {
|
||||||
@@ -542,7 +562,7 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onO
|
|||||||
<div className="flex flex-wrap items-center gap-2">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
<h2 className="text-xl font-semibold tracking-normal sm:text-2xl">{ticket?.ticket_number ?? "Ticket"}</h2>
|
<h2 className="text-xl font-semibold tracking-normal sm:text-2xl">{ticket?.ticket_number ?? "Ticket"}</h2>
|
||||||
{ticket?.ticket_number ? <CopyTicketButton ticketNumber={ticket.ticket_number} /> : null}
|
{ticket?.ticket_number ? <CopyTicketButton ticketNumber={ticket.ticket_number} /> : null}
|
||||||
{periodType === "month" && data?.closed ? <Badge variant="outline">Monat geschlossen</Badge> : null}
|
{(periodType === "month" || (isGlobalTicket && globalMonthFilter !== "all")) && data?.closed ? <Badge variant="outline">Monat geschlossen</Badge> : null}
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm text-muted-foreground">Sessions {sessionsScopeLabel}</p>
|
<p className="text-sm text-muted-foreground">Sessions {sessionsScopeLabel}</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -574,7 +594,8 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onO
|
|||||||
onChange={(event) => setGlobalMonthFilter(event.currentTarget.value)}
|
onChange={(event) => setGlobalMonthFilter(event.currentTarget.value)}
|
||||||
>
|
>
|
||||||
<option value="all">Alle Monate</option>
|
<option value="all">Alle Monate</option>
|
||||||
{(data?.availableMonths ?? []).map((month) => (
|
{globalMonthFilter !== "all" && !selectedMonthHasSessions ? <option value={globalMonthFilter}>{globalMonthFilter} (0)</option> : null}
|
||||||
|
{availableMonths.map((month) => (
|
||||||
<option key={month.month} value={month.month}>
|
<option key={month.month} value={month.month}>
|
||||||
{month.month} ({month.session_count})
|
{month.month} ({month.session_count})
|
||||||
</option>
|
</option>
|
||||||
@@ -700,7 +721,7 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onO
|
|||||||
autoComplete="off"
|
autoComplete="off"
|
||||||
inputMode="decimal"
|
inputMode="decimal"
|
||||||
value={dayBillingInputs[group.key] ?? ""}
|
value={dayBillingInputs[group.key] ?? ""}
|
||||||
disabled={savingDayBillingKey === group.key}
|
disabled={isPeriodClosed || savingDayBillingKey === group.key}
|
||||||
placeholder="0"
|
placeholder="0"
|
||||||
onChange={(event) => {
|
onChange={(event) => {
|
||||||
const nextValue = event.currentTarget.value;
|
const nextValue = event.currentTarget.value;
|
||||||
@@ -725,7 +746,7 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onO
|
|||||||
type="button"
|
type="button"
|
||||||
size="sm"
|
size="sm"
|
||||||
className={cn("h-7 w-[104px]", !isDayBillingDirty(group.key) && "invisible")}
|
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()}
|
onMouseDown={(event) => event.preventDefault()}
|
||||||
onClick={() => void saveDayBilling(group.key)}
|
onClick={() => void saveDayBilling(group.key)}
|
||||||
>
|
>
|
||||||
|
|||||||
Reference in new issue
Block a user