import { ArrowDown, ArrowUp, CheckCircle2, Eye, EyeOff, MoreHorizontal, Pencil, Play, Plus, RotateCcw, Save, Search, SlidersHorizontal, Star, Trash2 } from "lucide-react"; import { FormEvent, useEffect, useMemo, useState } from "react"; import { toast } from "sonner"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; import { Checkbox } from "@/components/ui/checkbox"; import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog"; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu"; import { Input } from "@/components/ui/input"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Textarea } from "@/components/ui/textarea"; import { HelpLink } from "@/components/HelpLink"; import { MultiFilterDropdown, type MultiFilterOption } from "@/components/MultiFilterDropdown"; import { OrganizationSelect } from "@/components/OrganizationSelect"; import { cn } from "@/lib/utils"; import { addFavoriteTicket, createProject, getProjects, hideProject, lookupTicket, unhideProject, updateProject, updateProjectStatus } from "../api"; import { currentMonth, formatMinutes } from "../format"; import { projectHasRecurringBilling } from "../recurring-billings"; import { ticketPattern } from "../timers"; import type { ProjectSummary, ProjectTeamspaceEntry, TicketMeta } from "../types"; import { matchesZammadPriorityFilter, matchesZammadStateFilter, ZammadPriorityBadge, zammadPriorityFilterOptions, zammadPriorityLabel, zammadPrioritySortRank, zammadId, zammadStateId, ZammadStateBadge, zammadStateFilterOptions, zammadStateLabel, zammadStateSortRank } from "../zammad-meta"; type ProjectsPageProps = { onNavigate: (to: string) => void; onStartTimer: (ticketNumber: string) => Promise; }; type ProjectForm = { ticketNumber: string; title: string; organizationId: string; organizationName: string | null; description: string; startsOn: string; endsOn: string; teamspaceEntries: ProjectTeamspaceEntryForm[]; }; type ProjectTeamspaceEntryForm = { clientId: string; teamspaceProject: string; hourBudget: string; budgetSyncDisabled: boolean; manualConsumption: string; isDefault: boolean; }; type ProjectSortBy = "updated" | "ticket" | "title" | "organization" | "status" | "zammadStatus" | "zammadPriority" | "consumption" | "consumptionPercent" | "remaining" | "budget"; type ProjectSortDirection = "asc" | "desc"; type ProjectGroupBy = "none" | "organization" | "status" | "zammadStatus" | "zammadPriority" | "budgetState" | "teamspace"; type ProjectFilterOption = MultiFilterOption; type ProjectViewSettings = { query: string; filterValues: string[]; organizationValues: string[]; standardTeamspaceOnly: boolean; groupBy: ProjectGroupBy; groupDirection: ProjectSortDirection; sortBy: ProjectSortBy; sortDirection: ProjectSortDirection; }; const projectViewSettingsKey = "tickettracker.projects.viewSettings"; const defaultProjectViewSettings: ProjectViewSettings = { query: "", filterValues: ["open"], organizationValues: [], standardTeamspaceOnly: false, groupBy: "none", groupDirection: "asc", sortBy: "updated", sortDirection: "desc" }; const projectStaticFilterOptions: ProjectFilterOption[] = [ { value: "open", label: "TT Offen" }, { value: "closed", label: "TT Geschl." }, { value: "hidden", label: "Ausgebl." }, { value: "budgeted", label: "Mit Kontingent" }, { value: "withoutBudget", label: "Ohne Kontingent" }, { value: "remaining", label: "Mit Rest" }, { value: "overBudget", label: "Überzogen" }, ...zammadStateFilterOptions.filter((option) => option.value !== "all").map((option) => ({ value: `zammadState:${option.value}`, label: `ZA ${option.label}` })), ...zammadPriorityFilterOptions.filter((option) => option.value !== "all").map((option) => ({ value: `zammadPriority:${option.value}`, label: `ZA Prio ${option.label}` })) ]; const projectStaticFilterValues = new Set(projectStaticFilterOptions.map((option) => option.value)); const projectSortValues: ProjectSortBy[] = ["updated", "ticket", "title", "organization", "status", "zammadStatus", "zammadPriority", "consumption", "consumptionPercent", "remaining", "budget"]; const projectGroupValues: ProjectGroupBy[] = ["none", "organization", "status", "zammadStatus", "zammadPriority", "budgetState", "teamspace"]; const projectSortOptions: Array<{ value: ProjectSortBy; label: string }> = [ { value: "updated", label: "Aktualisierung" }, { value: "ticket", label: "Ticket" }, { value: "title", label: "Titel" }, { value: "organization", label: "Organisation" }, { value: "status", label: "Status" }, { value: "zammadStatus", label: "ZA Status" }, { value: "zammadPriority", label: "ZA Prio" }, { value: "consumption", label: "Verbrauch" }, { value: "consumptionPercent", label: "Verbrauch %" }, { value: "remaining", label: "Reststunden" }, { value: "budget", label: "Kontingent" } ]; const projectGroupOptions: Array<{ value: ProjectGroupBy; label: string }> = [ { value: "none", label: "Keine Gruppierung" }, { value: "organization", label: "Organisation" }, { value: "status", label: "TT-Status" }, { value: "zammadStatus", label: "Zammad-Status" }, { value: "zammadPriority", label: "Zammad-Prio" }, { value: "budgetState", label: "Kontingentstatus" }, { value: "teamspace", label: "Standard-Teamspace" } ]; function projectEntryId() { if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") { return crypto.randomUUID(); } return `entry-${Date.now()}-${Math.random().toString(16).slice(2)}`; } function emptyProjectTeamspaceEntry(isDefault = false): ProjectTeamspaceEntryForm { return { clientId: projectEntryId(), teamspaceProject: "", hourBudget: "", budgetSyncDisabled: false, manualConsumption: "", isDefault }; } function isKnownProjectFilterValue(value: unknown): value is string { if (typeof value !== "string") { return false; } return projectStaticFilterValues.has(value); } function normalizedProjectFilterValues(values: unknown, fallback = defaultProjectViewSettings.filterValues) { if (!Array.isArray(values)) { return fallback; } return Array.from(new Set(values.filter(isKnownProjectFilterValue))); } function legacyProjectBudgetFilter(value: unknown) { if (value === "with-budget") { return "budgeted"; } if (value === "without-budget") { return "withoutBudget"; } if (value === "remaining") { return "remaining"; } if (value === "over-budget") { return "overBudget"; } return null; } function legacyProjectFilters(parsed: Partial & Record) { const filters: string[] = []; const hasLegacyFilter = ["status", "zammadStatus", "zammadPriority", "organization", "budget"].some((key) => Object.prototype.hasOwnProperty.call(parsed, key)); if (parsed.status === "open" || parsed.status === "closed" || parsed.status === "hidden") { filters.push(parsed.status); } if (typeof parsed.zammadStatus === "string" && parsed.zammadStatus !== "all" && zammadStateFilterOptions.some((option) => option.value === parsed.zammadStatus)) { filters.push(`zammadState:${parsed.zammadStatus}`); } if (typeof parsed.zammadPriority === "string" && parsed.zammadPriority !== "all" && zammadPriorityFilterOptions.some((option) => option.value === parsed.zammadPriority)) { filters.push(`zammadPriority:${parsed.zammadPriority}`); } const budgetFilter = legacyProjectBudgetFilter(parsed.budget); if (budgetFilter) { filters.push(budgetFilter); } if (hasLegacyFilter) { return Array.from(new Set(filters)); } return filters.length > 0 ? Array.from(new Set(filters)) : defaultProjectViewSettings.filterValues; } function normalizedProjectOrganizationValues(values: unknown) { if (!Array.isArray(values)) { return []; } return Array.from( new Set( values .map((value) => { if (typeof value !== "string") { return ""; } return value.startsWith("organization:") ? value.slice("organization:".length).trim() : value.trim(); }) .filter(Boolean) ) ); } function legacyProjectOrganizationValues(parsed: Partial & Record) { if (Array.isArray(parsed.organizationValues)) { return normalizedProjectOrganizationValues(parsed.organizationValues); } if (Array.isArray(parsed.filterValues)) { return normalizedProjectOrganizationValues(parsed.filterValues.filter((value) => typeof value === "string" && value.startsWith("organization:"))); } if (typeof parsed.organization === "string" && parsed.organization !== "all") { return [parsed.organization]; } return defaultProjectViewSettings.organizationValues; } function legacyStandardTeamspaceOnly(parsed: Partial & Record) { if (typeof parsed.standardTeamspaceOnly === "boolean") { return parsed.standardTeamspaceOnly; } return Array.isArray(parsed.filterValues) && parsed.filterValues.includes("standardBudgetOnly"); } function readStoredProjectViewSettings(): ProjectViewSettings { if (typeof window === "undefined") { return defaultProjectViewSettings; } try { const raw = window.localStorage.getItem(projectViewSettingsKey); if (!raw) { return defaultProjectViewSettings; } const parsed = JSON.parse(raw) as Partial; const filterValues = Array.isArray(parsed.filterValues) ? normalizedProjectFilterValues(parsed.filterValues) : legacyProjectFilters(parsed as Partial & Record); return { query: typeof parsed.query === "string" ? parsed.query : defaultProjectViewSettings.query, filterValues, organizationValues: legacyProjectOrganizationValues(parsed as Partial & Record), standardTeamspaceOnly: legacyStandardTeamspaceOnly(parsed as Partial & Record), groupBy: projectGroupValues.find((value) => value === parsed.groupBy) ?? defaultProjectViewSettings.groupBy, groupDirection: parsed.groupDirection === "desc" ? "desc" : defaultProjectViewSettings.groupDirection, sortBy: projectSortValues.find((value) => value === parsed.sortBy) ?? defaultProjectViewSettings.sortBy, sortDirection: parsed.sortDirection === "asc" ? "asc" : defaultProjectViewSettings.sortDirection }; } catch { return defaultProjectViewSettings; } } function emptyProjectForm(): ProjectForm { return { ticketNumber: "", title: "", organizationId: "", organizationName: null, description: "", startsOn: "", endsOn: "", teamspaceEntries: [emptyProjectTeamspaceEntry(true)] }; } function combinedProjectDescription(project: ProjectSummary) { return [project.description, project.goals].map((part) => part.trim()).filter(Boolean).join("\n\n"); } function formatHoursInput(minutes?: number | null) { if (minutes === null || minutes === undefined) { return ""; } return new Intl.NumberFormat("de-DE", { maximumFractionDigits: 2 }).format(minutes / 60); } function formatDecimalHours(minutes: number) { return `${new Intl.NumberFormat("de-DE", { maximumFractionDigits: 2 }).format(minutes / 60)} h`; } function projectTeamspaceEntries(project: ProjectSummary): ProjectTeamspaceEntry[] { if (project.teamspace_entries?.length) { return project.teamspace_entries; } return [ { id: `${project.id}-default`, teamspace_project: project.teamspace_project, is_default: true, hour_budget_minutes: project.hour_budget_minutes, manual_consumption_minutes: project.manual_consumption_minutes ?? 0, teamspace_consumption_minutes: project.teamspace_consumption_minutes ?? 0, consumption_minutes: project.consumption_minutes ?? project.teamspace_consumption_minutes ?? 0, display_order: 0 } ]; } function budgetedProjectTeamspaceEntries(project: ProjectSummary) { return projectTeamspaceEntries(project).filter((entry) => Number(entry.hour_budget_minutes ?? 0) > 0); } function projectEntryConsumption(entry: ProjectTeamspaceEntry) { return Number(entry.consumption_minutes ?? 0); } function defaultProjectTeamspaceEntry(project: ProjectSummary) { const entries = projectTeamspaceEntries(project); return entries.find((entry) => entry.is_default) ?? entries[0] ?? null; } function projectEntryPercent(entry: ProjectTeamspaceEntry) { if (!entry.hour_budget_minutes || entry.hour_budget_minutes <= 0) { return null; } return Math.round((projectEntryConsumption(entry) / entry.hour_budget_minutes) * 100); } function projectConsumptionBarClass(percent: number) { if (percent >= 100) { return "bg-blue-500"; } if (percent >= 90) { return "bg-red-500"; } return "bg-emerald-500"; } function SortDirectionIcon({ direction }: { direction: ProjectSortDirection }) { return direction === "asc" ? : ; } function parseHoursInput(value: string) { const trimmed = value.trim(); if (!trimmed) { return null; } const hours = Number(trimmed.replace(",", ".")); if (!Number.isFinite(hours) || hours < 0) { return undefined; } return Math.round(hours * 60); } function compareText(left: string | null | undefined, right: string | null | undefined) { return (left ?? "").localeCompare(right ?? "", "de", { numeric: true, sensitivity: "base" }); } function projectOrganizationLabel(project: ProjectSummary) { return project.organization_name ?? project.customer_name ?? "Keine Organisation"; } function projectOrganizationKey(project: ProjectSummary) { return project.organization_id ? `id:${project.organization_id}` : `name:${projectOrganizationLabel(project)}`; } function projectBudget(project: ProjectSummary, standardTeamspaceOnly = false) { if (standardTeamspaceOnly) { return defaultProjectTeamspaceEntry(project)?.hour_budget_minutes ?? null; } return project.hour_budget_minutes ?? null; } function projectConsumption(project: ProjectSummary, standardTeamspaceOnly = false) { if (standardTeamspaceOnly) { const defaultEntry = defaultProjectTeamspaceEntry(project); return defaultEntry ? projectEntryConsumption(defaultEntry) : 0; } return Number(project.consumption_minutes ?? 0); } function projectRemaining(project: ProjectSummary, standardTeamspaceOnly = false) { const budgetMinutes = projectBudget(project, standardTeamspaceOnly); if (budgetMinutes === null || budgetMinutes === undefined) { return null; } return budgetMinutes - projectConsumption(project, standardTeamspaceOnly); } function projectConsumptionPercent(project: ProjectSummary, standardTeamspaceOnly = false) { const budgetMinutes = projectBudget(project, standardTeamspaceOnly); if (!budgetMinutes || budgetMinutes <= 0) { return null; } return Math.round((projectConsumption(project, standardTeamspaceOnly) / budgetMinutes) * 100); } function projectMatchesQuery(project: ProjectSummary, query: string) { const normalized = query.trim().toLocaleLowerCase("de-DE"); if (!normalized) { return true; } const values = [ project.ticket_number, project.ticket_number.replace(/^Ticket#/i, ""), project.title ?? "", projectOrganizationLabel(project), project.teamspace_project ?? "", zammadStateLabel(project.zammad_state_id), zammadPriorityLabel(project.zammad_priority_id), ...projectTeamspaceEntries(project).map((entry) => entry.teamspace_project ?? ""), project.description, project.goals ]; return values.some((value) => value.toLocaleLowerCase("de-DE").includes(normalized)); } function groupedProjectFilterMatch(filters: string[], groupForFilter: (filter: string) => string, matchesFilter: (filter: string) => boolean) { if (filters.length === 0) { return true; } const groups = new Map(); for (const filter of filters) { const group = groupForFilter(filter); groups.set(group, [...(groups.get(group) ?? []), filter]); } return Array.from(groups.values()).every((groupFilters) => groupFilters.some((filter) => matchesFilter(filter))); } function projectFilterGroup(filter: string) { if (filter === "open" || filter === "closed") { return "status"; } if (filter === "hidden") { return "visibility"; } if (filter.startsWith("zammadState:")) { return "zammadState"; } if (filter.startsWith("zammadPriority:")) { return "zammadPriority"; } return "budget"; } function matchesSingleProjectFilter(project: ProjectSummary, filter: string, standardTeamspaceOnly = false) { if (filter === "open") { return !project.closed_at; } if (filter === "closed") { return Boolean(project.closed_at); } if (filter === "hidden") { return Boolean(project.hidden_for_user); } if (filter.startsWith("zammadState:")) { return matchesZammadStateFilter(project.zammad_state_id, filter.slice("zammadState:".length) as Parameters[1]); } if (filter.startsWith("zammadPriority:")) { return matchesZammadPriorityFilter(project.zammad_priority_id, filter.slice("zammadPriority:".length) as Parameters[1]); } const remaining = projectRemaining(project, standardTeamspaceOnly); const budgetMinutes = projectBudget(project, standardTeamspaceOnly) ?? 0; if (filter === "budgeted") { return budgetMinutes > 0; } if (filter === "withoutBudget") { return budgetMinutes <= 0; } if (filter === "remaining") { return budgetMinutes > 0 && remaining !== null && remaining > 0; } if (filter === "overBudget") { return budgetMinutes > 0 && remaining !== null && remaining < 0; } return true; } function matchesProjectFilters(project: ProjectSummary, filters: string[], organizationValues: string[], standardTeamspaceOnly = false) { if (organizationValues.length > 0 && !organizationValues.includes(projectOrganizationKey(project))) { return false; } return groupedProjectFilterMatch(filters, projectFilterGroup, (filter) => matchesSingleProjectFilter(project, filter, standardTeamspaceOnly)); } function projectShouldLoadClosed(filters: string[]) { const statusFilters = filters.filter((filter) => filter === "open" || filter === "closed"); return statusFilters.length === 0 || statusFilters.includes("closed"); } function projectShouldLoadHidden(filters: string[]) { return filters.includes("hidden"); } function compareProjects(left: ProjectSummary, right: ProjectSummary, sortBy: ProjectSortBy, direction: ProjectSortDirection, standardTeamspaceOnly = false) { const factor = direction === "asc" ? 1 : -1; let comparison = 0; if (sortBy === "ticket") { comparison = compareText(left.ticket_number, right.ticket_number); } else if (sortBy === "title") { comparison = compareText(left.title || left.ticket_number, right.title || right.ticket_number); } else if (sortBy === "organization") { comparison = compareText(projectOrganizationLabel(left), projectOrganizationLabel(right)); } else if (sortBy === "status") { comparison = Number(Boolean(left.closed_at)) - Number(Boolean(right.closed_at)); } else if (sortBy === "zammadStatus") { comparison = zammadStateSortRank(left.zammad_state_id) - zammadStateSortRank(right.zammad_state_id); } else if (sortBy === "zammadPriority") { comparison = zammadPrioritySortRank(left.zammad_priority_id) - zammadPrioritySortRank(right.zammad_priority_id); } else if (sortBy === "consumption") { comparison = projectConsumption(left, standardTeamspaceOnly) - projectConsumption(right, standardTeamspaceOnly); } else if (sortBy === "consumptionPercent") { const leftPercent = projectConsumptionPercent(left, standardTeamspaceOnly); const rightPercent = projectConsumptionPercent(right, standardTeamspaceOnly); if (leftPercent === null || rightPercent === null) { return leftPercent === rightPercent ? compareText(left.ticket_number, right.ticket_number) : leftPercent === null ? 1 : -1; } comparison = leftPercent - rightPercent; } else if (sortBy === "remaining") { comparison = (projectRemaining(left, standardTeamspaceOnly) ?? Number.POSITIVE_INFINITY) - (projectRemaining(right, standardTeamspaceOnly) ?? Number.POSITIVE_INFINITY); } else if (sortBy === "budget") { comparison = (projectBudget(left, standardTeamspaceOnly) ?? Number.POSITIVE_INFINITY) - (projectBudget(right, standardTeamspaceOnly) ?? Number.POSITIVE_INFINITY); } else { comparison = new Date(left.updated_at).getTime() - new Date(right.updated_at).getTime(); } return comparison * factor || compareText(left.ticket_number, right.ticket_number); } type ProjectGroupMeta = { key: string; label: string; sortLabel: string; rank: number; }; type ProjectGroup = ProjectGroupMeta & { projects: ProjectSummary[]; consumptionMinutes: number; remainingMinutes: number; }; function projectBudgetStateGroup(project: ProjectSummary, standardTeamspaceOnly: boolean): ProjectGroupMeta { const budgetMinutes = projectBudget(project, standardTeamspaceOnly) ?? 0; const remaining = projectRemaining(project, standardTeamspaceOnly); if (budgetMinutes <= 0) { return { key: "budgetState:none", label: "Ohne Kontingent", sortLabel: "Ohne Kontingent", rank: 40 }; } if (remaining !== null && remaining < 0) { return { key: "budgetState:over", label: "Überzogen", sortLabel: "Überzogen", rank: 10 }; } if (remaining === 0) { return { key: "budgetState:used", label: "Aufgebraucht", sortLabel: "Aufgebraucht", rank: 30 }; } return { key: "budgetState:remaining", label: "Mit Rest", sortLabel: "Mit Rest", rank: 20 }; } function projectGroupMeta(project: ProjectSummary, groupBy: ProjectGroupBy, standardTeamspaceOnly: boolean): ProjectGroupMeta { if (groupBy === "organization") { const label = projectOrganizationLabel(project); return { key: `organization:${projectOrganizationKey(project)}`, label, sortLabel: label, rank: 0 }; } if (groupBy === "status") { return project.closed_at ? { key: "status:closed", label: "Geschlossen", sortLabel: "Geschlossen", rank: 20 } : { key: "status:open", label: "Offen", sortLabel: "Offen", rank: 10 }; } if (groupBy === "zammadStatus") { const id = zammadStateId(project.zammad_state_id); const label = id ? zammadStateLabel(id) : "Ohne Zammad-Status"; return { key: `zammadStatus:${id ?? "none"}`, label, sortLabel: label, rank: zammadStateSortRank(id) }; } if (groupBy === "zammadPriority") { const id = zammadId(project.zammad_priority_id); const label = id ? zammadPriorityLabel(id) : "Ohne Zammad-Prio"; return { key: `zammadPriority:${id ?? "none"}`, label, sortLabel: label, rank: zammadPrioritySortRank(id) }; } if (groupBy === "budgetState") { return projectBudgetStateGroup(project, standardTeamspaceOnly); } if (groupBy === "teamspace") { const label = defaultProjectTeamspaceEntry(project)?.teamspace_project || "Ohne Teamspace-ID"; return { key: `teamspace:${label.toLocaleLowerCase("de-DE")}`, label, sortLabel: label, rank: 0 }; } return { key: "all", label: "Alle Projekte", sortLabel: "Alle Projekte", rank: 0 }; } function groupProjects(projects: ProjectSummary[], groupBy: ProjectGroupBy, groupDirection: ProjectSortDirection, standardTeamspaceOnly: boolean): ProjectGroup[] { if (groupBy === "none") { return [ { key: "all", label: "Alle Projekte", sortLabel: "Alle Projekte", rank: 0, projects, consumptionMinutes: projects.reduce((sum, project) => sum + projectConsumption(project, standardTeamspaceOnly), 0), remainingMinutes: projects.reduce((sum, project) => sum + Math.max(projectRemaining(project, standardTeamspaceOnly) ?? 0, 0), 0) } ]; } const factor = groupDirection === "asc" ? 1 : -1; const groups = new Map(); for (const project of projects) { const meta = projectGroupMeta(project, groupBy, standardTeamspaceOnly); const group = groups.get(meta.key); if (group) { group.projects.push(project); group.consumptionMinutes += projectConsumption(project, standardTeamspaceOnly); group.remainingMinutes += Math.max(projectRemaining(project, standardTeamspaceOnly) ?? 0, 0); } else { groups.set(meta.key, { ...meta, projects: [project], consumptionMinutes: projectConsumption(project, standardTeamspaceOnly), remainingMinutes: Math.max(projectRemaining(project, standardTeamspaceOnly) ?? 0, 0) }); } } return Array.from(groups.values()).sort((left, right) => ((left.rank - right.rank) || compareText(left.sortLabel, right.sortLabel)) * factor); } function formFromProject(project: ProjectSummary): ProjectForm { const entries = projectTeamspaceEntries(project).map((entry, index) => ({ clientId: entry.id || projectEntryId(), teamspaceProject: entry.teamspace_project ?? "", hourBudget: formatHoursInput(entry.hour_budget_minutes), budgetSyncDisabled: entry.budget_sync_disabled ?? false, manualConsumption: formatHoursInput(entry.manual_consumption_minutes), isDefault: entry.is_default || index === 0 })); const hasDefault = entries.some((entry) => entry.isDefault); return { ticketNumber: project.ticket_number, title: project.title ?? "", organizationId: project.organization_id ?? "", organizationName: project.organization_name ?? project.customer_name ?? null, description: combinedProjectDescription(project), startsOn: project.starts_on ?? "", endsOn: project.ends_on ?? "", teamspaceEntries: hasDefault ? entries : entries.map((entry, index) => ({ ...entry, isDefault: index === 0 })) }; } type ProjectFormFieldsProps = { form: ProjectForm; setForm: (form: ProjectForm) => void; organizationLocked: boolean; lookupStatus: "idle" | "loading" | "found" | "not-found"; zammadLookupBusy?: boolean; saving: boolean; submitLabel: string; onZammadLookup?: () => void; onSubmit: (event: FormEvent) => void; }; function normalizeProjectTeamspaceEntryForms(entries: ProjectTeamspaceEntryForm[]) { const source = entries.length ? entries : [emptyProjectTeamspaceEntry(true)]; const defaultIndex = Math.max(0, source.findIndex((entry) => entry.isDefault)); return source.map((entry, index) => ({ ...entry, isDefault: index === defaultIndex })); } function parseProjectTeamspaceEntryForms(entries: ProjectTeamspaceEntryForm[]) { const normalizedEntries = normalizeProjectTeamspaceEntryForms(entries); const parsedEntries = normalizedEntries.map((entry) => { const hourBudgetMinutes = parseHoursInput(entry.hourBudget); const manualConsumptionMinutes = parseHoursInput(entry.manualConsumption); if (hourBudgetMinutes === undefined || manualConsumptionMinutes === undefined) { return undefined; } return { id: /^\d+$/.test(entry.clientId) ? entry.clientId : null, teamspaceProject: entry.teamspaceProject.trim() || null, isDefault: entry.isDefault, hourBudgetMinutes, budgetSyncDisabled: entry.budgetSyncDisabled, manualConsumptionMinutes: manualConsumptionMinutes ?? 0 }; }); if (parsedEntries.some((entry) => entry === undefined)) { return undefined; } return parsedEntries as Array<{ id?: string | null; teamspaceProject: string | null; isDefault: boolean; hourBudgetMinutes: number | null; budgetSyncDisabled: boolean; manualConsumptionMinutes: number; }>; } function defaultProjectTeamspacePayload(entries: ReturnType) { return entries?.find((entry) => entry.isDefault) ?? entries?.[0] ?? null; } function ProjectTeamspaceEntriesFields({ entries, onChange }: { entries: ProjectTeamspaceEntryForm[]; onChange: (entries: ProjectTeamspaceEntryForm[]) => void; }) { const normalizedEntries = normalizeProjectTeamspaceEntryForms(entries); function updateEntry(clientId: string, changes: Partial) { onChange(normalizeProjectTeamspaceEntryForms(normalizedEntries.map((entry) => (entry.clientId === clientId ? { ...entry, ...changes } : entry)))); } function setDefault(clientId: string) { onChange(normalizedEntries.map((entry) => ({ ...entry, isDefault: entry.clientId === clientId }))); } function removeEntry(clientId: string) { onChange(normalizeProjectTeamspaceEntryForms(normalizedEntries.filter((entry) => entry.clientId !== clientId))); } return (
{normalizedEntries.map((entry, index) => (
updateEntry(entry.clientId, { teamspaceProject: event.currentTarget.value })} />
updateEntry(entry.clientId, { hourBudget: event.currentTarget.value, budgetSyncDisabled: true })} />
updateEntry(entry.clientId, { manualConsumption: event.currentTarget.value })} />
))}

Der Standard-Eintrag bekommt die aus Teamspace gebuchten Tageswerte. Manueller Verbrauch zählt nur im Projektverbrauch.

); } function ProjectFormFields({ form, setForm, organizationLocked, lookupStatus, zammadLookupBusy = false, saving, submitLabel, onZammadLookup, onSubmit }: ProjectFormFieldsProps) { return (
setForm({ ...form, ticketNumber: event.currentTarget.value, organizationId: "", organizationName: null }) } required /> {form.ticketNumber && !ticketPattern.test(form.ticketNumber.trim()) ?

Format: Ticket#XXXXXX

: null} {lookupStatus === "loading" ?

Suche Ticket...

: null} {lookupStatus === "found" ?

Ticket gefunden. Daten werden übernommen.

: null} {lookupStatus === "not-found" ? (

Neues Ticket. Organisation bitte wählen oder aus Zammad übernehmen.

{onZammadLookup ? ( ) : null}
) : null}
setForm({ ...form, title: event.currentTarget.value })} />
Consulting
setForm({ ...form, organizationId: organization.id, organizationName: organization.name })} required />
setForm({ ...form, startsOn: event.currentTarget.value })} />
setForm({ ...form, endsOn: event.currentTarget.value })} />
setForm({ ...form, teamspaceEntries })} />