Update
This commit is contained in:
1 parent
9be6c6e36a
commit
fa6db04370
16 files changed
+1123
-118
No files matched your search
@@ -59,6 +59,7 @@ type TicketSessionFormState = {
|
||||
organizationName: string | null;
|
||||
projectTeamspaceEntryId: string;
|
||||
activity: string;
|
||||
billingStatus: BillingStatus;
|
||||
workType: WorkType;
|
||||
day: string;
|
||||
startTime: string;
|
||||
@@ -90,6 +91,7 @@ function emptyTicketSessionForm(): TicketSessionFormState {
|
||||
organizationName: null,
|
||||
projectTeamspaceEntryId: "",
|
||||
activity: "",
|
||||
billingStatus: null,
|
||||
workType: "support",
|
||||
day: currentDay(),
|
||||
startTime: "09:00",
|
||||
@@ -174,7 +176,17 @@ function BillingButtons({
|
||||
const status = session.billing_status;
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-2 gap-1.5 sm:inline-grid">
|
||||
<div className="grid grid-cols-3 gap-1.5 sm:inline-grid">
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant={status === "billable" ? "default" : "secondary"}
|
||||
disabled={loading || (disabled && status !== "billable")}
|
||||
onClick={() => onChange(status === "billable" ? null : "billable")}
|
||||
className={cn("h-8", status === "billable" ? "" : "bg-secondary/70")}
|
||||
>
|
||||
Abrechenbar
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
@@ -199,6 +211,29 @@ function BillingButtons({
|
||||
);
|
||||
}
|
||||
|
||||
function BillingCaptureSelect({
|
||||
id,
|
||||
value,
|
||||
onChange
|
||||
}: {
|
||||
id: string;
|
||||
value: BillingStatus;
|
||||
onChange: (status: BillingStatus) => void;
|
||||
}) {
|
||||
return (
|
||||
<select
|
||||
id={id}
|
||||
className="h-9 w-full rounded-lg border border-input bg-background px-2 text-sm focus-visible:outline-none focus-visible:ring-3 focus-visible:ring-ring/50"
|
||||
value={value ?? ""}
|
||||
onChange={(event) => onChange(event.currentTarget.value === "" ? null : event.currentTarget.value as Exclude<BillingStatus, null>)}
|
||||
>
|
||||
<option value="">---</option>
|
||||
<option value="billable">Abrechenbar</option>
|
||||
<option value="non_billable">Nicht abrechenbar</option>
|
||||
</select>
|
||||
);
|
||||
}
|
||||
|
||||
function sessionDayKey(value: string) {
|
||||
const date = new Date(value);
|
||||
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}-${String(date.getDate()).padStart(2, "0")}`;
|
||||
@@ -474,7 +509,7 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onO
|
||||
const [ticketSession, setTicketSession] = useState<TicketSessionFormState>(() => emptyTicketSessionForm());
|
||||
const [savingTicketSession, setSavingTicketSession] = useState(false);
|
||||
const [globalMonthFilter, setGlobalMonthFilter] = useState(initialGlobalMonthFilter);
|
||||
const [globalStatusFilter, setGlobalStatusFilter] = useState<"all" | "open" | "billed" | "non_billable">("all");
|
||||
const [globalStatusFilter, setGlobalStatusFilter] = useState<"all" | "open" | "billable" | "billed" | "non_billable">("all");
|
||||
const [globalSourceFilter, setGlobalSourceFilter] = useState<"all" | "manual" | "recurring">("all");
|
||||
const [editingProject, setEditingProject] = useState(false);
|
||||
const [savingProject, setSavingProject] = useState(false);
|
||||
@@ -595,19 +630,21 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onO
|
||||
const sessionGroups = useMemo(() => {
|
||||
const groups = new Map<
|
||||
string,
|
||||
{ key: string; label: string; totalMinutes: number; nonBillableMinutes: number; openMinutes: number; sessions: SessionEntry[] }
|
||||
{ key: string; label: string; totalMinutes: number; billableMinutes: number; nonBillableMinutes: number; openMinutes: number; sessions: SessionEntry[] }
|
||||
>();
|
||||
|
||||
for (const session of data?.sessions ?? []) {
|
||||
const key = sessionDayKey(session.started_at);
|
||||
const group = groups.get(key);
|
||||
const billedMinutes = session.billing_status === "billed" ? session.rounded_minutes : 0;
|
||||
const billableMinutes = session.billing_status === "billable" ? session.rounded_minutes : 0;
|
||||
const nonBillableMinutes = session.billing_status === "non_billable" ? session.rounded_minutes : 0;
|
||||
const openMinutes = session.billing_status === null ? session.rounded_minutes : 0;
|
||||
|
||||
if (group) {
|
||||
group.sessions.push(session);
|
||||
group.totalMinutes += billedMinutes;
|
||||
group.billableMinutes += billableMinutes;
|
||||
group.nonBillableMinutes += nonBillableMinutes;
|
||||
group.openMinutes += openMinutes;
|
||||
continue;
|
||||
@@ -617,6 +654,7 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onO
|
||||
key,
|
||||
label: formatDate(session.started_at),
|
||||
totalMinutes: billedMinutes,
|
||||
billableMinutes,
|
||||
nonBillableMinutes,
|
||||
openMinutes,
|
||||
sessions: [session]
|
||||
@@ -984,7 +1022,8 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onO
|
||||
startedAt: startedAt.toISOString(),
|
||||
endedAt: endedAt.toISOString(),
|
||||
durationSeconds,
|
||||
projectTeamspaceEntryId: effectiveWorkType === "consulting" && requiresProjectTeamspaceEntry ? ticketSession.projectTeamspaceEntryId : null
|
||||
projectTeamspaceEntryId: effectiveWorkType === "consulting" && requiresProjectTeamspaceEntry ? ticketSession.projectTeamspaceEntryId : null,
|
||||
billingStatus: ticketSession.billingStatus
|
||||
});
|
||||
toast.success("Session nachgetragen", {
|
||||
description: "Der Eintrag wurde diesem Ticket zugeordnet. Betroffene Abschlüsse wurden wieder geöffnet."
|
||||
@@ -995,7 +1034,8 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onO
|
||||
organizationName: ticket.organization_name ?? null,
|
||||
projectTeamspaceEntryId: "",
|
||||
workType: ticket.work_type ?? "support",
|
||||
day: ticketSession.day
|
||||
day: ticketSession.day,
|
||||
billingStatus: null
|
||||
});
|
||||
await load();
|
||||
} catch (error) {
|
||||
@@ -1277,7 +1317,7 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onO
|
||||
>
|
||||
<option value="all">Alle Bewertungen</option>
|
||||
<option value="open">Offen</option>
|
||||
<option value="billed">Abgerechnet</option>
|
||||
<option value="billable">Abrechenbar</option>
|
||||
<option value="non_billable">Nicht abrechenbar</option>
|
||||
</select>
|
||||
</div>
|
||||
@@ -1657,7 +1697,7 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onO
|
||||
<p className="truncate font-medium">{group.label}</p>
|
||||
<p className="text-xs text-muted-foreground">{group.sessions.length} Session(s)</p>
|
||||
</div>
|
||||
<div className="grid w-full gap-2 sm:grid-cols-[13rem_10rem_8rem] xl:w-auto xl:grid-cols-[13rem_10rem_8rem_auto] xl:items-center xl:justify-end">
|
||||
<div className="grid w-full gap-2 sm:grid-cols-[13rem_10rem_10rem_8rem] xl:w-auto xl:grid-cols-[13rem_10rem_10rem_8rem_auto] xl:items-center xl:justify-end">
|
||||
<Button type="button" size="sm" variant="secondary" className="h-7 w-full justify-center" onClick={() => openTeamspacePreparation(group)}>
|
||||
<ClipboardList className="size-3.5" />
|
||||
TS Aufbereitung
|
||||
@@ -1665,10 +1705,13 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onO
|
||||
<Badge variant="outline" className="flex h-7 w-full justify-center whitespace-nowrap">
|
||||
Abgerechnet {formatMinutes(group.totalMinutes)}
|
||||
</Badge>
|
||||
<Badge variant="outline" className="flex h-7 w-full justify-center whitespace-nowrap">
|
||||
Abrechenbar {formatMinutes(group.billableMinutes)}
|
||||
</Badge>
|
||||
<Badge variant={group.openMinutes > 0 ? "warning" : "success"} className="flex h-7 w-full justify-center whitespace-nowrap">
|
||||
Offen {formatMinutes(group.openMinutes)}
|
||||
</Badge>
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground sm:col-span-3 xl:col-span-1">
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground sm:col-span-4 xl:col-span-1">
|
||||
<label htmlFor={`crm-billing-${group.key}`}>Teamspace</label>
|
||||
<Input
|
||||
id={`crm-billing-${group.key}`}
|
||||
@@ -1872,7 +1915,7 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onO
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="grid gap-3 lg:grid-cols-[150px_120px_120px_minmax(220px,1fr)_auto] lg:items-end">
|
||||
<div className="grid gap-3 lg:grid-cols-[150px_120px_120px_160px_minmax(220px,1fr)_auto] lg:items-end">
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium" htmlFor="ticket-session-day">
|
||||
Datum
|
||||
@@ -1909,6 +1952,16 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onO
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium" htmlFor="ticket-session-billing-status">
|
||||
Bewertung
|
||||
</label>
|
||||
<BillingCaptureSelect
|
||||
id="ticket-session-billing-status"
|
||||
value={ticketSession.billingStatus}
|
||||
onChange={(billingStatus) => setTicketSession({ ...ticketSession, billingStatus })}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium" htmlFor="ticket-session-activity">
|
||||
Tätigkeit
|
||||
|
||||
Reference in new issue
Block a user