Update
This commit is contained in:
1 parent
235c19e51b
commit
227644cfeb
8 files changed
+1041
-309
No files matched your search
@@ -585,6 +585,12 @@ function matchesSingleProjectFilter(project: ProjectSummary, filterBy: string, f
|
||||
return budgetMinutes <= 0;
|
||||
}
|
||||
|
||||
if (filterBy === "standardBudgetOnly") {
|
||||
const budgetedEntries = budgetedProjectTeamspaceEntries(project);
|
||||
|
||||
return budgetedEntries.length > 0 && budgetedEntries.every((entry) => entry.is_default);
|
||||
}
|
||||
|
||||
if (filterBy === "critical90") {
|
||||
return budgetMinutes > 0 && percent >= 0.9;
|
||||
}
|
||||
@@ -653,6 +659,10 @@ function projectTeamspaceEntries(project: ProjectSummary): ProjectTeamspaceEntry
|
||||
];
|
||||
}
|
||||
|
||||
function budgetedProjectTeamspaceEntries(project: ProjectSummary) {
|
||||
return projectTeamspaceEntries(project).filter((entry) => Number(entry.hour_budget_minutes ?? 0) > 0);
|
||||
}
|
||||
|
||||
function openRatingFilterGroup(filter: string) {
|
||||
if (filter === "fixedBilling" || filter === "manualBilling") {
|
||||
return "source";
|
||||
@@ -1649,6 +1659,7 @@ function ProjectBudgetWidget({ widget, editing, projects, stats, onNavigate, onU
|
||||
[
|
||||
"budgeted",
|
||||
"withoutBudget",
|
||||
"standardBudgetOnly",
|
||||
"critical90",
|
||||
"overBudget",
|
||||
"open",
|
||||
@@ -1746,6 +1757,7 @@ function ProjectBudgetWidget({ widget, editing, projects, stats, onNavigate, onU
|
||||
{ value: "critical90", label: "Ab 90%" },
|
||||
{ value: "overBudget", label: "Ab 100%" },
|
||||
{ value: "withoutBudget", label: "Ohne Kontingent" },
|
||||
{ value: "standardBudgetOnly", label: "Nur Std.-Budget" },
|
||||
{ value: "open", label: "TT Offen" },
|
||||
{ value: "closed", label: "TT Geschl." },
|
||||
...zammadStateFilterOptions.filter((option) => option.value !== "all").map((option) => ({ value: `zammadState:${option.value}`, label: `ZA ${option.label}` })),
|
||||
@@ -1780,14 +1792,14 @@ function ProjectBudgetWidget({ widget, editing, projects, stats, onNavigate, onU
|
||||
const consumptionMinutes = project.consumption_minutes ?? 0;
|
||||
const budgetMinutes = project.hour_budget_minutes ?? 0;
|
||||
const percent = projectConsumptionPercent(project);
|
||||
const entries = projectTeamspaceEntries(project);
|
||||
const hasMultipleTeamspaceEntries = entries.length > 1;
|
||||
const budgetedEntries = budgetedProjectTeamspaceEntries(project);
|
||||
const showBudgetedTeamspaceEntries = budgetedEntries.length > 1;
|
||||
|
||||
return (
|
||||
<button key={project.id} type="button" className="block w-full min-w-0 rounded-md px-2 py-1.5 text-left hover:bg-muted" onClick={() => onNavigate(`/tickets/${project.ticket_id}?month=${currentMonth()}`)}>
|
||||
<span className="block truncate text-xs font-medium">{project.title || project.ticket_number}</span>
|
||||
<span className="block truncate text-[11px] text-muted-foreground">
|
||||
{project.ticket_number} · {hasMultipleTeamspaceEntries ? `${entries.length} Teamspace · ` : ""}{budgetMinutes > 0 ? `${decimalHours(consumptionMinutes)} / ${decimalHours(budgetMinutes)} · ${percent ?? 0}%` : "ohne Kontingent"}
|
||||
{project.ticket_number} · {showBudgetedTeamspaceEntries ? `${budgetedEntries.length} Budgets · ` : ""}{budgetMinutes > 0 ? `${decimalHours(consumptionMinutes)} / ${decimalHours(budgetMinutes)} · ${percent ?? 0}%` : "ohne Kontingent"}
|
||||
</span>
|
||||
<span className="mt-1 flex flex-wrap gap-1">
|
||||
<ZammadStateBadge stateId={project.zammad_state_id} />
|
||||
@@ -1798,9 +1810,9 @@ function ProjectBudgetWidget({ widget, editing, projects, stats, onNavigate, onU
|
||||
<span className={cn("block h-full rounded-full", consumptionBarClass(percent))} style={{ width: `${Math.min(100, percent)}%` }} />
|
||||
</span>
|
||||
) : null}
|
||||
{hasMultipleTeamspaceEntries ? (
|
||||
{showBudgetedTeamspaceEntries ? (
|
||||
<span className="mt-1 grid gap-1">
|
||||
{entries.map((entry) => {
|
||||
{budgetedEntries.map((entry) => {
|
||||
const entryBudgetMinutes = entry.hour_budget_minutes ?? 0;
|
||||
const entryConsumptionMinutes = entry.consumption_minutes ?? 0;
|
||||
const entryPercent = entryBudgetMinutes > 0 ? Math.round((entryConsumptionMinutes / entryBudgetMinutes) * 100) : null;
|
||||
|
||||
+406
-178
@@ -1,4 +1,4 @@
|
||||
import { ArrowDown, ArrowUp, CheckCircle2, Eye, EyeOff, MoreHorizontal, Pencil, Play, Plus, RotateCcw, Save, Search, SlidersHorizontal, Star, Trash2 } from "lucide-react";
|
||||
import { ArrowDown, ArrowUp, CheckCircle2, ChevronsUpDown, 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";
|
||||
|
||||
@@ -9,6 +9,7 @@ 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 { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { HelpLink } from "@/components/HelpLink";
|
||||
@@ -31,7 +32,6 @@ import {
|
||||
zammadStateLabel,
|
||||
zammadStateSortRank
|
||||
} from "../zammad-meta";
|
||||
import type { ZammadPriorityFilter, ZammadStateFilter } from "../zammad-meta";
|
||||
|
||||
type ProjectsPageProps = {
|
||||
onNavigate: (to: string) => void;
|
||||
@@ -58,18 +58,15 @@ type ProjectTeamspaceEntryForm = {
|
||||
isDefault: boolean;
|
||||
};
|
||||
|
||||
type ProjectStatusFilter = "open" | "all" | "closed" | "hidden";
|
||||
type ProjectBudgetFilter = "all" | "with-budget" | "without-budget" | "remaining" | "over-budget";
|
||||
type ProjectSortBy = "updated" | "ticket" | "title" | "organization" | "status" | "zammadStatus" | "zammadPriority" | "consumption" | "consumptionPercent" | "remaining" | "budget";
|
||||
type ProjectSortDirection = "asc" | "desc";
|
||||
type ProjectFilterOption = { value: string; label: string };
|
||||
|
||||
type ProjectViewSettings = {
|
||||
query: string;
|
||||
status: ProjectStatusFilter;
|
||||
zammadStatus: ZammadStateFilter;
|
||||
zammadPriority: ZammadPriorityFilter;
|
||||
organization: string;
|
||||
budget: ProjectBudgetFilter;
|
||||
filterValues: string[];
|
||||
organizationValues: string[];
|
||||
standardTeamspaceOnly: boolean;
|
||||
sortBy: ProjectSortBy;
|
||||
sortDirection: ProjectSortDirection;
|
||||
};
|
||||
@@ -78,29 +75,26 @@ const projectViewSettingsKey = "tickettracker.projects.viewSettings";
|
||||
|
||||
const defaultProjectViewSettings: ProjectViewSettings = {
|
||||
query: "",
|
||||
status: "open",
|
||||
zammadStatus: "all",
|
||||
zammadPriority: "all",
|
||||
organization: "all",
|
||||
budget: "all",
|
||||
filterValues: ["open"],
|
||||
organizationValues: [],
|
||||
standardTeamspaceOnly: false,
|
||||
sortBy: "updated",
|
||||
sortDirection: "desc"
|
||||
};
|
||||
|
||||
const projectStatusOptions: Array<{ value: ProjectStatusFilter; label: string }> = [
|
||||
{ value: "open", label: "Offen" },
|
||||
{ value: "all", label: "Alle" },
|
||||
{ value: "closed", label: "Geschlossen" },
|
||||
{ value: "hidden", label: "Ausgeblendet" }
|
||||
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 projectBudgetOptions: Array<{ value: ProjectBudgetFilter; label: string }> = [
|
||||
{ value: "all", label: "Alle Kontingente" },
|
||||
{ value: "with-budget", label: "Mit Kontingent" },
|
||||
{ value: "without-budget", label: "Ohne Kontingent" },
|
||||
{ value: "remaining", label: "Mit Reststunden" },
|
||||
{ value: "over-budget", label: "Überzogen" }
|
||||
];
|
||||
const projectStaticFilterValues = new Set(projectStaticFilterOptions.map((option) => option.value));
|
||||
|
||||
const projectSortValues: ProjectSortBy[] = ["updated", "ticket", "title", "organization", "status", "zammadStatus", "zammadPriority", "consumption", "consumptionPercent", "remaining", "budget"];
|
||||
|
||||
@@ -110,8 +104,8 @@ const projectSortOptions: Array<{ value: ProjectSortBy; label: string }> = [
|
||||
{ value: "title", label: "Titel" },
|
||||
{ value: "organization", label: "Organisation" },
|
||||
{ value: "status", label: "Status" },
|
||||
{ value: "zammadStatus", label: "Zammad-Status" },
|
||||
{ value: "zammadPriority", label: "Zammad-Priorität" },
|
||||
{ value: "zammadStatus", label: "ZA Status" },
|
||||
{ value: "zammadPriority", label: "ZA Prio" },
|
||||
{ value: "consumption", label: "Verbrauch" },
|
||||
{ value: "consumptionPercent", label: "Verbrauch %" },
|
||||
{ value: "remaining", label: "Reststunden" },
|
||||
@@ -137,6 +131,115 @@ function emptyProjectTeamspaceEntry(isDefault = false): ProjectTeamspaceEntryFor
|
||||
};
|
||||
}
|
||||
|
||||
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<ProjectViewSettings> & Record<string, unknown>) {
|
||||
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<ProjectViewSettings> & Record<string, unknown>) {
|
||||
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<ProjectViewSettings> & Record<string, unknown>) {
|
||||
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;
|
||||
@@ -150,14 +253,15 @@ function readStoredProjectViewSettings(): ProjectViewSettings {
|
||||
}
|
||||
|
||||
const parsed = JSON.parse(raw) as Partial<ProjectViewSettings>;
|
||||
const filterValues = Array.isArray(parsed.filterValues)
|
||||
? normalizedProjectFilterValues(parsed.filterValues)
|
||||
: legacyProjectFilters(parsed as Partial<ProjectViewSettings> & Record<string, unknown>);
|
||||
|
||||
return {
|
||||
query: typeof parsed.query === "string" ? parsed.query : defaultProjectViewSettings.query,
|
||||
status: projectStatusOptions.find((option) => option.value === parsed.status)?.value ?? defaultProjectViewSettings.status,
|
||||
zammadStatus: zammadStateFilterOptions.find((option) => option.value === parsed.zammadStatus)?.value ?? defaultProjectViewSettings.zammadStatus,
|
||||
zammadPriority: zammadPriorityFilterOptions.find((option) => option.value === parsed.zammadPriority)?.value ?? defaultProjectViewSettings.zammadPriority,
|
||||
organization: typeof parsed.organization === "string" ? parsed.organization : defaultProjectViewSettings.organization,
|
||||
budget: projectBudgetOptions.find((option) => option.value === parsed.budget)?.value ?? defaultProjectViewSettings.budget,
|
||||
filterValues,
|
||||
organizationValues: legacyProjectOrganizationValues(parsed as Partial<ProjectViewSettings> & Record<string, unknown>),
|
||||
standardTeamspaceOnly: legacyStandardTeamspaceOnly(parsed as Partial<ProjectViewSettings> & Record<string, unknown>),
|
||||
sortBy: projectSortValues.find((value) => value === parsed.sortBy) ?? defaultProjectViewSettings.sortBy,
|
||||
sortDirection: parsed.sortDirection === "asc" ? "asc" : defaultProjectViewSettings.sortDirection
|
||||
};
|
||||
@@ -214,14 +318,20 @@ function projectTeamspaceEntries(project: ProjectSummary): ProjectTeamspaceEntry
|
||||
];
|
||||
}
|
||||
|
||||
function projectTeamspaceEntryCount(project: ProjectSummary) {
|
||||
return Number(project.teamspace_entry_count ?? projectTeamspaceEntries(project).length);
|
||||
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;
|
||||
@@ -246,6 +356,86 @@ function SortDirectionIcon({ direction }: { direction: ProjectSortDirection }) {
|
||||
return direction === "asc" ? <ArrowUp className="size-3.5" /> : <ArrowDown className="size-3.5" />;
|
||||
}
|
||||
|
||||
function ProjectFilterDropdown({
|
||||
values,
|
||||
options,
|
||||
onChange,
|
||||
placeholder = "Alle Filter",
|
||||
countLabel = "Filter",
|
||||
emptyLabel = "Keine Filter."
|
||||
}: {
|
||||
values: string[];
|
||||
options: ProjectFilterOption[];
|
||||
onChange: (values: string[]) => void;
|
||||
placeholder?: string;
|
||||
countLabel?: string;
|
||||
emptyLabel?: string;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [search, setSearch] = useState("");
|
||||
const normalizedSearch = search.trim().toLocaleLowerCase("de-DE");
|
||||
const visibleOptions = normalizedSearch
|
||||
? options.filter((option) => option.label.toLocaleLowerCase("de-DE").includes(normalizedSearch) || option.value.toLocaleLowerCase("de-DE").includes(normalizedSearch))
|
||||
: options;
|
||||
const activeLabels = values.map((value) => options.find((option) => option.value === value)?.label).filter(Boolean);
|
||||
|
||||
function toggle(value: string) {
|
||||
onChange(values.includes(value) ? values.filter((filter) => filter !== value) : [...values, value]);
|
||||
}
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button type="button" variant="outline" role="combobox" aria-expanded={open} className="h-8 w-full justify-between px-2 text-xs font-normal">
|
||||
<span className={cn("truncate", values.length === 0 && "text-muted-foreground")}>
|
||||
{activeLabels.length === 0 ? placeholder : activeLabels.length === 1 ? activeLabels[0] : `${activeLabels.length} ${countLabel}`}
|
||||
</span>
|
||||
<ChevronsUpDown className="size-3.5 opacity-60" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align="start" className="w-[--radix-popover-trigger-width] gap-1 p-1.5">
|
||||
<Input className="h-7 text-xs" value={search} placeholder="Suchen..." onChange={(event) => setSearch(event.currentTarget.value)} />
|
||||
<div className="max-h-64 overflow-y-auto pr-1">
|
||||
{visibleOptions.length === 0 ? (
|
||||
<p className="px-2 py-3 text-center text-xs text-muted-foreground">{emptyLabel}</p>
|
||||
) : (
|
||||
<div className="space-y-0.5">
|
||||
{visibleOptions.map((option) => {
|
||||
const checked = values.includes(option.value);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={option.value}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-pressed={checked}
|
||||
className="flex h-7 w-full min-w-0 cursor-default items-center gap-2 rounded-md px-2 text-left text-xs outline-none hover:bg-muted focus-visible:bg-muted"
|
||||
onClick={() => toggle(option.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter" || event.key === " ") {
|
||||
event.preventDefault();
|
||||
toggle(option.value);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Checkbox checked={checked} tabIndex={-1} className="pointer-events-none" />
|
||||
<span className="truncate">{option.label}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{values.length > 0 ? (
|
||||
<Button type="button" size="sm" variant="ghost" className="h-6 justify-start px-2 text-[11px]" onClick={() => onChange([])}>
|
||||
Zurücksetzen
|
||||
</Button>
|
||||
) : null}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
||||
function parseHoursInput(value: string) {
|
||||
const trimmed = value.trim();
|
||||
|
||||
@@ -274,24 +464,42 @@ function projectOrganizationKey(project: ProjectSummary) {
|
||||
return project.organization_id ? `id:${project.organization_id}` : `name:${projectOrganizationLabel(project)}`;
|
||||
}
|
||||
|
||||
function projectConsumption(project: ProjectSummary) {
|
||||
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) {
|
||||
if (project.hour_budget_minutes === null || project.hour_budget_minutes === undefined) {
|
||||
function projectRemaining(project: ProjectSummary, standardTeamspaceOnly = false) {
|
||||
const budgetMinutes = projectBudget(project, standardTeamspaceOnly);
|
||||
|
||||
if (budgetMinutes === null || budgetMinutes === undefined) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return project.hour_budget_minutes - projectConsumption(project);
|
||||
return budgetMinutes - projectConsumption(project, standardTeamspaceOnly);
|
||||
}
|
||||
|
||||
function projectConsumptionPercent(project: ProjectSummary) {
|
||||
if (!project.hour_budget_minutes || project.hour_budget_minutes <= 0) {
|
||||
function projectConsumptionPercent(project: ProjectSummary, standardTeamspaceOnly = false) {
|
||||
const budgetMinutes = projectBudget(project, standardTeamspaceOnly);
|
||||
|
||||
if (!budgetMinutes || budgetMinutes <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return Math.round((projectConsumption(project) / project.hour_budget_minutes) * 100);
|
||||
return Math.round((projectConsumption(project, standardTeamspaceOnly) / budgetMinutes) * 100);
|
||||
}
|
||||
|
||||
function projectMatchesQuery(project: ProjectSummary, query: string) {
|
||||
@@ -317,7 +525,103 @@ function projectMatchesQuery(project: ProjectSummary, query: string) {
|
||||
return values.some((value) => value.toLocaleLowerCase("de-DE").includes(normalized));
|
||||
}
|
||||
|
||||
function compareProjects(left: ProjectSummary, right: ProjectSummary, sortBy: ProjectSortBy, direction: ProjectSortDirection) {
|
||||
function groupedProjectFilterMatch(filters: string[], groupForFilter: (filter: string) => string, matchesFilter: (filter: string) => boolean) {
|
||||
if (filters.length === 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const groups = new Map<string, string[]>();
|
||||
|
||||
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<typeof matchesZammadStateFilter>[1]);
|
||||
}
|
||||
|
||||
if (filter.startsWith("zammadPriority:")) {
|
||||
return matchesZammadPriorityFilter(project.zammad_priority_id, filter.slice("zammadPriority:".length) as Parameters<typeof matchesZammadPriorityFilter>[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;
|
||||
|
||||
@@ -334,10 +638,10 @@ function compareProjects(left: ProjectSummary, right: ProjectSummary, sortBy: Pr
|
||||
} else if (sortBy === "zammadPriority") {
|
||||
comparison = zammadPrioritySortRank(left.zammad_priority_id) - zammadPrioritySortRank(right.zammad_priority_id);
|
||||
} else if (sortBy === "consumption") {
|
||||
comparison = projectConsumption(left) - projectConsumption(right);
|
||||
comparison = projectConsumption(left, standardTeamspaceOnly) - projectConsumption(right, standardTeamspaceOnly);
|
||||
} else if (sortBy === "consumptionPercent") {
|
||||
const leftPercent = projectConsumptionPercent(left);
|
||||
const rightPercent = projectConsumptionPercent(right);
|
||||
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;
|
||||
@@ -345,9 +649,9 @@ function compareProjects(left: ProjectSummary, right: ProjectSummary, sortBy: Pr
|
||||
|
||||
comparison = leftPercent - rightPercent;
|
||||
} else if (sortBy === "remaining") {
|
||||
comparison = (projectRemaining(left) ?? Number.POSITIVE_INFINITY) - (projectRemaining(right) ?? Number.POSITIVE_INFINITY);
|
||||
comparison = (projectRemaining(left, standardTeamspaceOnly) ?? Number.POSITIVE_INFINITY) - (projectRemaining(right, standardTeamspaceOnly) ?? Number.POSITIVE_INFINITY);
|
||||
} else if (sortBy === "budget") {
|
||||
comparison = (left.hour_budget_minutes ?? Number.POSITIVE_INFINITY) - (right.hour_budget_minutes ?? Number.POSITIVE_INFINITY);
|
||||
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();
|
||||
}
|
||||
@@ -638,7 +942,7 @@ export function ProjectsPage({ onNavigate, onStartTimer }: ProjectsPageProps) {
|
||||
async function load() {
|
||||
setLoading(true);
|
||||
try {
|
||||
const result = await getProjects(viewSettings.status !== "open", viewSettings.status === "hidden");
|
||||
const result = await getProjects(projectShouldLoadClosed(viewSettings.filterValues), projectShouldLoadHidden(viewSettings.filterValues));
|
||||
setProjects(result.projects);
|
||||
setHiddenProjectCount(result.hiddenCount ?? 0);
|
||||
setTotalProjectCount(result.totalCount ?? result.projects.length);
|
||||
@@ -653,7 +957,7 @@ export function ProjectsPage({ onNavigate, onStartTimer }: ProjectsPageProps) {
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
}, [viewSettings.status]);
|
||||
}, [viewSettings.filterValues]);
|
||||
|
||||
useEffect(() => {
|
||||
window.localStorage.setItem(projectViewSettingsKey, JSON.stringify(viewSettings));
|
||||
@@ -727,68 +1031,28 @@ export function ProjectsPage({ onNavigate, onStartTimer }: ProjectsPageProps) {
|
||||
const filteredProjects = useMemo(() => {
|
||||
return projects
|
||||
.filter((project) => {
|
||||
if (viewSettings.status === "hidden" && !project.hidden_for_user) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (viewSettings.status === "open" && project.closed_at) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (viewSettings.status === "closed" && !project.closed_at) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!matchesZammadStateFilter(project.zammad_state_id, viewSettings.zammadStatus)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!matchesZammadPriorityFilter(project.zammad_priority_id, viewSettings.zammadPriority)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (viewSettings.organization !== "all" && projectOrganizationKey(project) !== viewSettings.organization) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!projectMatchesQuery(project, viewSettings.query)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const remaining = projectRemaining(project);
|
||||
|
||||
if (viewSettings.budget === "with-budget" && remaining === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (viewSettings.budget === "without-budget" && remaining !== null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (viewSettings.budget === "remaining" && (remaining === null || remaining <= 0)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (viewSettings.budget === "over-budget" && (remaining === null || remaining >= 0)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
return matchesProjectFilters(project, viewSettings.filterValues, viewSettings.organizationValues, viewSettings.standardTeamspaceOnly);
|
||||
})
|
||||
.sort((left, right) => compareProjects(left, right, viewSettings.sortBy, viewSettings.sortDirection));
|
||||
.sort((left, right) => compareProjects(left, right, viewSettings.sortBy, viewSettings.sortDirection, viewSettings.standardTeamspaceOnly));
|
||||
}, [projects, viewSettings]);
|
||||
const projectConsumptionMinutes = useMemo(() => filteredProjects.reduce((sum, project) => sum + projectConsumption(project), 0), [filteredProjects]);
|
||||
const remainingMinutes = useMemo(
|
||||
() => filteredProjects.reduce((sum, project) => sum + Math.max(projectRemaining(project) ?? 0, 0), 0),
|
||||
[filteredProjects]
|
||||
const projectConsumptionMinutes = useMemo(
|
||||
() => filteredProjects.reduce((sum, project) => sum + projectConsumption(project, viewSettings.standardTeamspaceOnly), 0),
|
||||
[filteredProjects, viewSettings.standardTeamspaceOnly]
|
||||
);
|
||||
const remainingMinutes = useMemo(
|
||||
() => filteredProjects.reduce((sum, project) => sum + Math.max(projectRemaining(project, viewSettings.standardTeamspaceOnly) ?? 0, 0), 0),
|
||||
[filteredProjects, viewSettings.standardTeamspaceOnly]
|
||||
);
|
||||
const closedProjectsLoaded = projectShouldLoadClosed(viewSettings.filterValues);
|
||||
const activeFilterCount = [
|
||||
viewSettings.query.trim() !== "",
|
||||
viewSettings.status !== defaultProjectViewSettings.status,
|
||||
viewSettings.zammadStatus !== defaultProjectViewSettings.zammadStatus,
|
||||
viewSettings.zammadPriority !== defaultProjectViewSettings.zammadPriority,
|
||||
viewSettings.organization !== defaultProjectViewSettings.organization,
|
||||
viewSettings.budget !== defaultProjectViewSettings.budget,
|
||||
JSON.stringify(viewSettings.filterValues) !== JSON.stringify(defaultProjectViewSettings.filterValues),
|
||||
viewSettings.organizationValues.length > 0,
|
||||
viewSettings.standardTeamspaceOnly,
|
||||
viewSettings.sortBy !== defaultProjectViewSettings.sortBy,
|
||||
viewSettings.sortDirection !== defaultProjectViewSettings.sortDirection
|
||||
].filter(Boolean).length;
|
||||
@@ -1070,7 +1334,7 @@ export function ProjectsPage({ onNavigate, onStartTimer }: ProjectsPageProps) {
|
||||
</div>
|
||||
<div className="w-28 shrink-0 rounded-md border bg-muted/20 px-3 py-2">
|
||||
<span className="block text-muted-foreground">Geschlossen</span>
|
||||
<span className="font-semibold">{viewSettings.status !== "open" ? closedProjects.length : "-"}</span>
|
||||
<span className="font-semibold">{closedProjectsLoaded ? closedProjects.length : "-"}</span>
|
||||
</div>
|
||||
<div className="w-32 shrink-0 rounded-md border bg-muted/20 px-3 py-2">
|
||||
<span className="block text-muted-foreground">Verbrauch</span>
|
||||
@@ -1102,7 +1366,7 @@ export function ProjectsPage({ onNavigate, onStartTimer }: ProjectsPageProps) {
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="grid gap-3 px-4 pb-4 md:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-4">
|
||||
<CardContent className="grid gap-3 px-4 pb-4 md:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-5">
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs font-medium text-muted-foreground" htmlFor="project-search">
|
||||
Suche
|
||||
@@ -1119,71 +1383,34 @@ export function ProjectsPage({ onNavigate, onStartTimer }: ProjectsPageProps) {
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs font-medium text-muted-foreground">Status</label>
|
||||
<Select value={viewSettings.status} onValueChange={(value) => setViewSettings((current) => ({ ...current, status: value as ProjectStatusFilter }))}>
|
||||
<SelectTrigger size="sm" className="w-full">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{projectStatusOptions.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>{option.label}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<label className="text-xs font-medium text-muted-foreground">Filter</label>
|
||||
<ProjectFilterDropdown
|
||||
values={viewSettings.filterValues}
|
||||
options={projectStaticFilterOptions}
|
||||
onChange={(filterValues) => setViewSettings((current) => ({ ...current, filterValues }))}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs font-medium text-muted-foreground">Zammad-Status</label>
|
||||
<Select value={viewSettings.zammadStatus} onValueChange={(value) => setViewSettings((current) => ({ ...current, zammadStatus: value as ZammadStateFilter }))}>
|
||||
<SelectTrigger size="sm" className="w-full">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{zammadStateFilterOptions.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>{option.label}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs font-medium text-muted-foreground">Zammad-Priorität</label>
|
||||
<Select value={viewSettings.zammadPriority} onValueChange={(value) => setViewSettings((current) => ({ ...current, zammadPriority: value as ZammadPriorityFilter }))}>
|
||||
<SelectTrigger size="sm" className="w-full">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{zammadPriorityFilterOptions.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>{option.label}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs font-medium text-muted-foreground">Organisation</label>
|
||||
<Select value={viewSettings.organization} onValueChange={(value) => setViewSettings((current) => ({ ...current, organization: value }))}>
|
||||
<SelectTrigger size="sm" className="w-full">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">Alle Organisationen</SelectItem>
|
||||
{organizationOptions.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>{option.label}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs font-medium text-muted-foreground">Kontingent</label>
|
||||
<Select value={viewSettings.budget} onValueChange={(value) => setViewSettings((current) => ({ ...current, budget: value as ProjectBudgetFilter }))}>
|
||||
<SelectTrigger size="sm" className="w-full">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{projectBudgetOptions.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>{option.label}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<label className="text-xs font-medium text-muted-foreground">Organisationen</label>
|
||||
<ProjectFilterDropdown
|
||||
values={viewSettings.organizationValues}
|
||||
options={organizationOptions}
|
||||
onChange={(organizationValues) => setViewSettings((current) => ({ ...current, organizationValues }))}
|
||||
placeholder="Alle Org."
|
||||
countLabel="Org."
|
||||
emptyLabel="Keine Org."
|
||||
/>
|
||||
</div>
|
||||
<label className={cn("flex min-w-0 items-center gap-3 rounded-md border px-3 py-2", viewSettings.standardTeamspaceOnly ? "bg-muted/30" : "bg-background")}>
|
||||
<Checkbox
|
||||
checked={viewSettings.standardTeamspaceOnly}
|
||||
onCheckedChange={(checked) => setViewSettings((current) => ({ ...current, standardTeamspaceOnly: checked === true }))}
|
||||
/>
|
||||
<span className="min-w-0">
|
||||
<span className="block truncate text-xs font-medium text-muted-foreground">Nur Standard-TS</span>
|
||||
<span className="block truncate text-xs">Hauptbalken</span>
|
||||
</span>
|
||||
</label>
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs font-medium text-muted-foreground">Sortierung</label>
|
||||
<Select value={viewSettings.sortBy} onValueChange={(value) => setViewSettings((current) => ({ ...current, sortBy: value as ProjectSortBy }))}>
|
||||
@@ -1224,7 +1451,7 @@ export function ProjectsPage({ onNavigate, onStartTimer }: ProjectsPageProps) {
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
{hiddenProjectCount > 0 && viewSettings.status !== "hidden" ? (
|
||||
{hiddenProjectCount > 0 && !projectShouldLoadHidden(viewSettings.filterValues) ? (
|
||||
<div className="flex flex-col gap-2 rounded-md border border-amber-500/30 bg-amber-500/10 px-4 py-3 text-sm text-amber-800 dark:text-amber-200 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<EyeOff className="size-4 shrink-0" />
|
||||
@@ -1237,7 +1464,7 @@ export function ProjectsPage({ onNavigate, onStartTimer }: ProjectsPageProps) {
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
className="shrink-0"
|
||||
onClick={() => setViewSettings((current) => ({ ...current, status: "hidden" }))}
|
||||
onClick={() => setViewSettings((current) => ({ ...current, filterValues: ["hidden"] }))}
|
||||
>
|
||||
Anzeigen
|
||||
</Button>
|
||||
@@ -1287,11 +1514,12 @@ export function ProjectsPage({ onNavigate, onStartTimer }: ProjectsPageProps) {
|
||||
</div>
|
||||
{filteredProjects.map((project) => {
|
||||
const isEditing = editingProjectId === project.id;
|
||||
const teamspaceEntries = projectTeamspaceEntries(project);
|
||||
const consumptionMinutes = project.consumption_minutes ?? 0;
|
||||
const remaining = projectRemaining(project);
|
||||
const usedPercent = projectConsumptionPercent(project);
|
||||
const hasMultipleTeamspaceEntries = teamspaceEntries.length > 1;
|
||||
const budgetedTeamspaceEntries = budgetedProjectTeamspaceEntries(project);
|
||||
const consumptionMinutes = projectConsumption(project, viewSettings.standardTeamspaceOnly);
|
||||
const budgetMinutes = projectBudget(project, viewSettings.standardTeamspaceOnly);
|
||||
const remaining = projectRemaining(project, viewSettings.standardTeamspaceOnly);
|
||||
const usedPercent = projectConsumptionPercent(project, viewSettings.standardTeamspaceOnly);
|
||||
const showBudgetedTeamspaceEntries = !viewSettings.standardTeamspaceOnly && budgetedTeamspaceEntries.length > 1;
|
||||
const canEditProject = project.can_edit !== false;
|
||||
|
||||
return (
|
||||
@@ -1329,9 +1557,9 @@ export function ProjectsPage({ onNavigate, onStartTimer }: ProjectsPageProps) {
|
||||
<div className="min-w-0">
|
||||
<p className="truncate text-sm font-medium xl:hidden">Verbrauch/Gesamt</p>
|
||||
<p className={cn("text-sm font-semibold", remaining !== null && remaining < 0 ? "text-destructive" : undefined)}>
|
||||
{formatDecimalHours(consumptionMinutes)} / {project.hour_budget_minutes ? formatDecimalHours(project.hour_budget_minutes) : "offen"}
|
||||
{formatDecimalHours(consumptionMinutes)} / {budgetMinutes ? formatDecimalHours(budgetMinutes) : "offen"}
|
||||
</p>
|
||||
{hasMultipleTeamspaceEntries ? <p className="text-xs text-muted-foreground">{projectTeamspaceEntryCount(project)} Teamspace</p> : null}
|
||||
{showBudgetedTeamspaceEntries ? <p className="text-xs text-muted-foreground">{budgetedTeamspaceEntries.length} Budgets</p> : null}
|
||||
</div>
|
||||
<div className="min-w-0 text-sm">
|
||||
<p className="truncate font-medium xl:hidden">Verbrauch %</p>
|
||||
@@ -1416,9 +1644,9 @@ export function ProjectsPage({ onNavigate, onStartTimer }: ProjectsPageProps) {
|
||||
Zammad meldet das Consulting-Ticket als geschlossen. Bitte Zeiten in Teamspace und TicketTracker prüfen und das Projekt danach schließen.
|
||||
</p>
|
||||
) : null}
|
||||
{hasMultipleTeamspaceEntries ? (
|
||||
{showBudgetedTeamspaceEntries ? (
|
||||
<div className="grid gap-1.5 md:grid-cols-2">
|
||||
{teamspaceEntries.map((entry) => {
|
||||
{budgetedTeamspaceEntries.map((entry) => {
|
||||
const entryPercent = projectEntryPercent(entry);
|
||||
const entryConsumption = projectEntryConsumption(entry);
|
||||
const label = entry.teamspace_project || (entry.is_default ? "Standard" : "Ohne Teamspace-ID");
|
||||
|
||||
@@ -124,6 +124,16 @@ function projectTeamspaceEntries(project: ProjectSummary): ProjectTeamspaceEntry
|
||||
];
|
||||
}
|
||||
|
||||
function budgetedProjectTeamspaceEntries(project: ProjectSummary) {
|
||||
return projectTeamspaceEntries(project).filter((entry) => Number(entry.hour_budget_minutes ?? 0) > 0);
|
||||
}
|
||||
|
||||
function teamspaceEntryOptionLabel(entry: { teamspace_project: string | null; is_default: boolean }) {
|
||||
const label = entry.teamspace_project || "Ohne Teamspace-ID";
|
||||
|
||||
return entry.is_default ? `${label} (Standard)` : label;
|
||||
}
|
||||
|
||||
function projectEditForm(project: ProjectSummary): ProjectEditFormState {
|
||||
const entries = projectTeamspaceEntries(project).map((entry, index) => ({
|
||||
clientId: entry.id || projectEntryId(),
|
||||
@@ -503,7 +513,9 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onO
|
||||
const isRecurringBillingTicket = ticketHasRecurringBilling(ticket);
|
||||
const projectDescription = project ? combinedProjectDescription(project) : "";
|
||||
const projectEntries = useMemo(() => (project ? projectTeamspaceEntries(project) : []), [project]);
|
||||
const budgetedProjectEntries = useMemo(() => (project ? budgetedProjectTeamspaceEntries(project) : []), [project]);
|
||||
const hasMultipleProjectTeamspaceEntries = projectEntries.length > 1;
|
||||
const showBudgetedProjectEntries = budgetedProjectEntries.length > 1;
|
||||
const requiresProjectTeamspaceEntry = Boolean(project && hasMultipleProjectTeamspaceEntries);
|
||||
const projectConsumptionMinutes = project?.consumption_minutes ?? 0;
|
||||
const projectBudgetMinutes = project?.hour_budget_minutes ?? null;
|
||||
@@ -1284,9 +1296,9 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onO
|
||||
<CardDescription>
|
||||
{!canEditProject && project.owner_display_name
|
||||
? `Eigentümer: ${project.owner_display_name}`
|
||||
: hasMultipleProjectTeamspaceEntries
|
||||
? `${projectEntries.length} Teamspace-Einträge`
|
||||
: project.teamspace_project || "Kein Teamspace-Projekt hinterlegt"}
|
||||
: showBudgetedProjectEntries
|
||||
? `${budgetedProjectEntries.length} budgetierte Teamspace-Einträge`
|
||||
: budgetedProjectEntries[0]?.teamspace_project || project.teamspace_project || "Kein Teamspace-Projekt hinterlegt"}
|
||||
</CardDescription>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
@@ -1329,9 +1341,9 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onO
|
||||
<Badge variant="secondary">Zeitraum {project.starts_on || "offen"} - {project.ends_on || "offen"}</Badge>
|
||||
<Badge variant="secondary">Kontingent {projectBudgetMinutes ? formatMinutes(projectBudgetMinutes) : "offen"}</Badge>
|
||||
<Badge variant="secondary">Verbrauch {formatMinutes(projectConsumptionMinutes)}</Badge>
|
||||
{hasMultipleProjectTeamspaceEntries ? <Badge variant="outline">{projectEntries.length} Teamspace</Badge> : null}
|
||||
{showBudgetedProjectEntries ? <Badge variant="outline">{budgetedProjectEntries.length} Budgets</Badge> : null}
|
||||
</div>
|
||||
{hasMultipleProjectTeamspaceEntries ? (
|
||||
{showBudgetedProjectEntries ? (
|
||||
<div className="space-y-2">
|
||||
<div>
|
||||
<div className="mb-1 flex items-center justify-between gap-2 text-xs text-muted-foreground">
|
||||
@@ -1346,7 +1358,7 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onO
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid gap-1.5 md:grid-cols-2">
|
||||
{projectEntries.map((entry) => {
|
||||
{budgetedProjectEntries.map((entry) => {
|
||||
const consumption = Number(entry.consumption_minutes ?? 0);
|
||||
const percent = projectConsumptionPercent(consumption, entry.hour_budget_minutes);
|
||||
const label = entry.teamspace_project || (entry.is_default ? "Standard" : "Ohne Teamspace-ID");
|
||||
@@ -1762,7 +1774,7 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onO
|
||||
<option value="">Bitte wählen</option>
|
||||
{projectEntries.map((entry) => (
|
||||
<option key={entry.id} value={entry.id}>
|
||||
{entry.teamspace_project || (entry.is_default ? "Standard" : "Ohne Teamspace-ID")}
|
||||
{teamspaceEntryOptionLabel(entry)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
@@ -1943,7 +1955,7 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onO
|
||||
<option value="">Bitte wählen</option>
|
||||
{projectEntries.map((entry) => (
|
||||
<option key={entry.id} value={entry.id}>
|
||||
{entry.teamspace_project || (entry.is_default ? "Standard" : "Ohne Teamspace-ID")}
|
||||
{teamspaceEntryOptionLabel(entry)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
@@ -61,6 +61,12 @@ function ticketTeamspaceEntries(ticket: TicketMeta | null | undefined) {
|
||||
return ticket?.project_teamspace_entries ?? [];
|
||||
}
|
||||
|
||||
function teamspaceEntryOptionLabel(entry: { teamspace_project: string | null; is_default: boolean }) {
|
||||
const label = entry.teamspace_project || "Ohne Teamspace-ID";
|
||||
|
||||
return entry.is_default ? `${label} (Standard)` : label;
|
||||
}
|
||||
|
||||
function requiresTeamspaceEntry(ticket: TicketMeta | null | undefined) {
|
||||
return Boolean(ticket?.is_project && ticketTeamspaceEntries(ticket).length > 1);
|
||||
}
|
||||
@@ -741,7 +747,7 @@ export function TimerPage({ timers, setTimers, selectedTimerId, setSelectedTimer
|
||||
<option value="">Bitte wählen</option>
|
||||
{ticketTeamspaceEntries(manualLookupTicket).map((entry) => (
|
||||
<option key={entry.id} value={entry.id}>
|
||||
{entry.teamspace_project || (entry.is_default ? "Standard" : "Ohne Teamspace-ID")}
|
||||
{teamspaceEntryOptionLabel(entry)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
@@ -880,7 +886,7 @@ export function TimerPage({ timers, setTimers, selectedTimerId, setSelectedTimer
|
||||
<option value="">Bitte wählen</option>
|
||||
{ticketTeamspaceEntries(finished?.ticket).map((entry) => (
|
||||
<option key={entry.id} value={entry.id}>
|
||||
{entry.teamspace_project || (entry.is_default ? "Standard" : "Ohne Teamspace-ID")}
|
||||
{teamspaceEntryOptionLabel(entry)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useMemo, useSyncExternalStore } from "react";
|
||||
import { useEffect, useMemo, useState, useSyncExternalStore } from "react";
|
||||
import { AlertCircle, CheckCircle2, DatabaseZap, DownloadCloud, RefreshCw } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
@@ -14,11 +14,14 @@ import {
|
||||
applyOwnZammadConsultingTicketsFromPreview,
|
||||
previewExistingZammadTickets,
|
||||
previewOwnZammadConsultingTickets,
|
||||
waitForZammadSyncJob,
|
||||
type ZammadSyncExistingFields,
|
||||
type ZammadSyncChange,
|
||||
type ZammadSyncJob,
|
||||
type ZammadSyncItem,
|
||||
type ZammadSyncResult
|
||||
} from "../api";
|
||||
import { getLocalStorageValue, setLocalStorageValue } from "@/lib/local-storage.client";
|
||||
import type { AuthUser } from "../types";
|
||||
import { zammadPriorityLabel, zammadStateLabel } from "../zammad-meta";
|
||||
|
||||
@@ -27,6 +30,7 @@ type ZammadSyncPageProps = {
|
||||
};
|
||||
|
||||
type SelectionMap = Record<string, boolean>;
|
||||
type RunningJobKind = "preview" | "apply";
|
||||
|
||||
const defaultFields: ZammadSyncExistingFields = {
|
||||
title: true,
|
||||
@@ -51,25 +55,39 @@ type ZammadSyncPageState = {
|
||||
fields: ZammadSyncExistingFields;
|
||||
existingResult: ZammadSyncResult | null;
|
||||
existingPreviewJobId: string | null;
|
||||
existingRunningJobId: string | null;
|
||||
existingRunningJobKind: RunningJobKind | null;
|
||||
importResult: ZammadSyncResult | null;
|
||||
importPreviewJobId: string | null;
|
||||
importRunningJobId: string | null;
|
||||
importRunningJobKind: RunningJobKind | null;
|
||||
existingSelection: SelectionMap;
|
||||
importSelection: SelectionMap;
|
||||
syncingExisting: boolean;
|
||||
importingConsulting: boolean;
|
||||
};
|
||||
|
||||
let zammadSyncPageState: ZammadSyncPageState = {
|
||||
fields: defaultFields,
|
||||
existingResult: null,
|
||||
existingPreviewJobId: null,
|
||||
importResult: null,
|
||||
importPreviewJobId: null,
|
||||
existingSelection: {},
|
||||
importSelection: {},
|
||||
syncingExisting: false,
|
||||
importingConsulting: false
|
||||
};
|
||||
function emptyZammadSyncPageState(): ZammadSyncPageState {
|
||||
return {
|
||||
fields: defaultFields,
|
||||
existingResult: null,
|
||||
existingPreviewJobId: null,
|
||||
existingRunningJobId: null,
|
||||
existingRunningJobKind: null,
|
||||
importResult: null,
|
||||
importPreviewJobId: null,
|
||||
importRunningJobId: null,
|
||||
importRunningJobKind: null,
|
||||
existingSelection: {},
|
||||
importSelection: {},
|
||||
syncingExisting: false,
|
||||
importingConsulting: false
|
||||
};
|
||||
}
|
||||
|
||||
let zammadSyncPageState: ZammadSyncPageState = emptyZammadSyncPageState();
|
||||
let hydratedZammadSyncUserId: string | null = null;
|
||||
let activeZammadSyncStorageUserId: string | null = null;
|
||||
|
||||
const zammadSyncPageListeners = new Set<() => void>();
|
||||
|
||||
@@ -89,9 +107,104 @@ function setZammadSyncPageState(update: Partial<ZammadSyncPageState> | ((current
|
||||
...zammadSyncPageState,
|
||||
...(typeof update === "function" ? update(zammadSyncPageState) : update)
|
||||
};
|
||||
|
||||
if (activeZammadSyncStorageUserId) {
|
||||
saveZammadSyncPageState(activeZammadSyncStorageUserId, zammadSyncPageState);
|
||||
}
|
||||
|
||||
zammadSyncPageListeners.forEach((listener) => listener());
|
||||
}
|
||||
|
||||
function zammadSyncStorageKey(userId: string) {
|
||||
return `tickettracker.zammad-sync.${userId}`;
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function selectionMap(value: unknown): SelectionMap {
|
||||
if (!isRecord(value)) {
|
||||
return {};
|
||||
}
|
||||
|
||||
return Object.fromEntries(Object.entries(value).filter(([, selected]) => typeof selected === "boolean")) as SelectionMap;
|
||||
}
|
||||
|
||||
function syncResult(value: unknown): ZammadSyncResult | null {
|
||||
if (!isRecord(value) || (value.mode !== "preview" && value.mode !== "applied") || !Array.isArray(value.items)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return value as ZammadSyncResult;
|
||||
}
|
||||
|
||||
function syncFields(value: unknown): ZammadSyncExistingFields {
|
||||
const source = isRecord(value) ? value : {};
|
||||
|
||||
return {
|
||||
...defaultFields,
|
||||
title: typeof source.title === "boolean" ? source.title : defaultFields.title,
|
||||
organization: typeof source.organization === "boolean" ? source.organization : defaultFields.organization,
|
||||
type: typeof source.type === "boolean" ? source.type : defaultFields.type,
|
||||
projectName: typeof source.projectName === "boolean" ? source.projectName : defaultFields.projectName,
|
||||
budget: typeof source.budget === "boolean" ? source.budget : defaultFields.budget,
|
||||
overrideBudget: typeof source.overrideBudget === "boolean" ? source.overrideBudget : defaultFields.overrideBudget,
|
||||
pendingTodo: typeof source.pendingTodo === "boolean" ? source.pendingTodo : defaultFields.pendingTodo
|
||||
};
|
||||
}
|
||||
|
||||
function nullableString(value: unknown) {
|
||||
return typeof value === "string" && value.trim() ? value : null;
|
||||
}
|
||||
|
||||
function runningJobKind(value: unknown): RunningJobKind | null {
|
||||
return value === "preview" || value === "apply" ? value : null;
|
||||
}
|
||||
|
||||
function loadZammadSyncPageState(userId: string): ZammadSyncPageState | null {
|
||||
const raw = getLocalStorageValue(zammadSyncStorageKey(userId));
|
||||
|
||||
if (!raw) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(raw) as unknown;
|
||||
|
||||
if (!isRecord(parsed)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const existingRunningJobId = nullableString(parsed.existingRunningJobId);
|
||||
const existingRunningJobKind = runningJobKind(parsed.existingRunningJobKind);
|
||||
const importRunningJobId = nullableString(parsed.importRunningJobId);
|
||||
const importRunningJobKind = runningJobKind(parsed.importRunningJobKind);
|
||||
|
||||
return {
|
||||
fields: syncFields(parsed.fields),
|
||||
existingResult: syncResult(parsed.existingResult),
|
||||
existingPreviewJobId: nullableString(parsed.existingPreviewJobId),
|
||||
existingRunningJobId,
|
||||
existingRunningJobKind,
|
||||
importResult: syncResult(parsed.importResult),
|
||||
importPreviewJobId: nullableString(parsed.importPreviewJobId),
|
||||
importRunningJobId,
|
||||
importRunningJobKind,
|
||||
existingSelection: selectionMap(parsed.existingSelection),
|
||||
importSelection: selectionMap(parsed.importSelection),
|
||||
syncingExisting: Boolean(existingRunningJobId && existingRunningJobKind),
|
||||
importingConsulting: Boolean(importRunningJobId && importRunningJobKind)
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function saveZammadSyncPageState(userId: string, state: ZammadSyncPageState) {
|
||||
setLocalStorageValue(zammadSyncStorageKey(userId), JSON.stringify(state));
|
||||
}
|
||||
|
||||
function useZammadSyncPageState() {
|
||||
return useSyncExternalStore(subscribeZammadSyncPageState, getZammadSyncPageState, getZammadSyncPageState);
|
||||
}
|
||||
@@ -206,7 +319,7 @@ function formatChangeValue(field: string, value: unknown) {
|
||||
return formatMinutes(value);
|
||||
}
|
||||
|
||||
if (field === "Teamspace-Projekte") {
|
||||
if (field === "Teamspace-Projekte" || field === "Teamspace-Prüfung") {
|
||||
return formatTeamspaceProjectList(value) || "-";
|
||||
}
|
||||
|
||||
@@ -262,6 +375,10 @@ function changeAllowedByFields(item: ZammadSyncItem, change: ZammadSyncChange, f
|
||||
return fields.projectName && consultingProjectCanBeTouched;
|
||||
}
|
||||
|
||||
if (change.field === "Teamspace-Prüfung") {
|
||||
return fields.projectName && consultingProjectCanBeTouched;
|
||||
}
|
||||
|
||||
if (change.field === "Teamspace-Budget") {
|
||||
return fields.budget && consultingProjectCanBeTouched;
|
||||
}
|
||||
@@ -293,7 +410,7 @@ function cachedStringList(source: Record<string, unknown> | null | undefined, ke
|
||||
|
||||
function formatTeamspaceProjectList(value: unknown) {
|
||||
if (Array.isArray(value)) {
|
||||
return value.map((entry) => (typeof entry === "string" ? entry.trim() : "")).filter(Boolean).join(" + ");
|
||||
return normalizedTeamspaceProjectList(value).join(" + ");
|
||||
}
|
||||
|
||||
return typeof value === "string" ? value.trim() : "";
|
||||
@@ -303,6 +420,50 @@ function splitTeamspaceProjects(value: unknown) {
|
||||
return typeof value === "string" ? value.match(/[^\s+]+/g) ?? [] : [];
|
||||
}
|
||||
|
||||
function normalizedTeamspaceProjectList(value: unknown) {
|
||||
const entries = Array.isArray(value) ? value : splitTeamspaceProjects(value);
|
||||
const seen = new Set<string>();
|
||||
const normalized: string[] = [];
|
||||
|
||||
for (const entry of entries) {
|
||||
const teamspaceProject = typeof entry === "string" ? entry.trim() : "";
|
||||
|
||||
if (!teamspaceProject || seen.has(teamspaceProject)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
seen.add(teamspaceProject);
|
||||
normalized.push(teamspaceProject);
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function teamspaceProjectDiff(localProjects: unknown, zammadProjects: unknown) {
|
||||
const local = normalizedTeamspaceProjectList(localProjects);
|
||||
const zammad = normalizedTeamspaceProjectList(zammadProjects);
|
||||
const localSet = new Set(local);
|
||||
const zammadSet = new Set(zammad);
|
||||
|
||||
return {
|
||||
local,
|
||||
additions: zammad.filter((project) => !localSet.has(project)),
|
||||
missingInZammad: local.filter((project) => !zammadSet.has(project))
|
||||
};
|
||||
}
|
||||
|
||||
function addCachedTeamspaceProjectChanges(changes: ZammadSyncChange[], localProjects: unknown, zammadProjects: unknown) {
|
||||
const diff = teamspaceProjectDiff(localProjects, zammadProjects);
|
||||
|
||||
if (diff.additions.length > 0) {
|
||||
changes.push({ field: "Teamspace-Projekte", from: diff.local, to: [...diff.local, ...diff.additions] });
|
||||
}
|
||||
|
||||
if (diff.missingInZammad.length > 0) {
|
||||
changes.push({ field: "Teamspace-Prüfung", from: diff.missingInZammad, to: "nicht mehr aus Zammad; bitte manuell prüfen" });
|
||||
}
|
||||
}
|
||||
|
||||
function zammadPendingTodoPreview(zammad: Record<string, unknown>) {
|
||||
const stateId = numericValue(cachedValue(zammad, "stateId"));
|
||||
const pendingTime = cachedValue(zammad, "pendingTime");
|
||||
@@ -355,8 +516,8 @@ function comparablePreviewValueForField(field: string, value: unknown) {
|
||||
return comparablePreviewIdValue(value);
|
||||
}
|
||||
|
||||
if (field === "Teamspace-Projekte" && Array.isArray(value)) {
|
||||
return value.map((entry) => comparablePreviewValue(entry));
|
||||
if (field === "Teamspace-Projekte") {
|
||||
return normalizedTeamspaceProjectList(value).sort((left, right) => left.localeCompare(right, "de", { numeric: true, sensitivity: "base" }));
|
||||
}
|
||||
|
||||
return comparablePreviewValue(value);
|
||||
@@ -368,7 +529,7 @@ function cachedValueChanged(field: string, left: unknown, right: unknown) {
|
||||
|
||||
function addCachedChange(changes: ZammadSyncChange[], field: string, from: unknown, to: unknown) {
|
||||
if (cachedValueChanged(field, from, to)) {
|
||||
changes.push({ field, from: comparablePreviewValue(from), to: comparablePreviewValue(to) });
|
||||
changes.push({ field, from: comparablePreviewValueForField(field, from), to: comparablePreviewValueForField(field, to) });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -387,6 +548,7 @@ function changesFromExistingPreviewCache(item: ZammadSyncItem, fields: ZammadSyn
|
||||
const localWorkType = cachedValue(local, "workType");
|
||||
const zammadWorkType = cachedValue(zammad, "workType");
|
||||
const zammadStateId = numericValue(cachedValue(zammad, "stateId"));
|
||||
const pendingTodoExists = cachedValue(local, "zammadPendingTodoExists");
|
||||
const localTeamspaceProjects = cachedStringList(project, "teamspaceProjects");
|
||||
const zammadTeamspaceProjects = cachedStringList(zammad, "teamspaceProjects");
|
||||
const localTeamspaceProjectList = localTeamspaceProjects.length ? localTeamspaceProjects : [cachedValue(project, "teamspaceProject")].filter((entry): entry is string => typeof entry === "string");
|
||||
@@ -398,7 +560,7 @@ function changesFromExistingPreviewCache(item: ZammadSyncItem, fields: ZammadSyn
|
||||
addCachedChange(changes, "Zammad-Status", cachedValue(local, "zammadStateId"), cachedValue(zammad, "stateId"));
|
||||
|
||||
const pendingTodo = zammadPendingTodoPreview(zammad);
|
||||
if (fields.pendingTodo && pendingTodo) {
|
||||
if (fields.pendingTodo && pendingTodo && pendingTodoExists === false) {
|
||||
changes.push({ field: "ToDo", from: null, to: pendingTodo });
|
||||
}
|
||||
|
||||
@@ -430,7 +592,7 @@ function changesFromExistingPreviewCache(item: ZammadSyncItem, fields: ZammadSyn
|
||||
}
|
||||
|
||||
if (fields.projectName) {
|
||||
addCachedChange(changes, "Teamspace-Projekte", localTeamspaceProjectList, zammadTeamspaceProjectList);
|
||||
addCachedTeamspaceProjectChanges(changes, localTeamspaceProjectList, zammadTeamspaceProjectList);
|
||||
}
|
||||
|
||||
if (canSyncBudget) {
|
||||
@@ -587,6 +749,7 @@ function ResultSummary({
|
||||
const selectable = selectableItems(result);
|
||||
const selectedCount = selection ? selectable.filter((item) => itemIsSelected(item, selection)).length : 0;
|
||||
const allSelected = selectable.length > 0 && selectedCount === selectable.length;
|
||||
const listedItems = result.items.filter((item) => item.action !== "skip" || visibleTicketTrackerChanges(item).length > 0);
|
||||
const metrics = [
|
||||
["Geprüft", result.checked],
|
||||
[result.mode === "preview" ? "Würde ändern" : "Aktualisiert", result.updated],
|
||||
@@ -642,14 +805,14 @@ function ResultSummary({
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{result.items.length === 0 ? (
|
||||
{listedItems.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={selection ? 5 : 4} className="py-8 text-center text-sm text-muted-foreground">
|
||||
Keine Datensätze im Lauf.
|
||||
Keine Änderungen für die aktuelle Auswahl.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
result.items.map((item) => {
|
||||
listedItems.map((item) => {
|
||||
const action = formatAction(item);
|
||||
const isSelectable = item.action === "update" || item.action === "create";
|
||||
const visibleChanges = visibleTicketTrackerChanges(item);
|
||||
@@ -717,20 +880,155 @@ function ResultSummary({
|
||||
}
|
||||
|
||||
export function ZammadSyncPage({ currentUser }: ZammadSyncPageProps) {
|
||||
const pageState = useZammadSyncPageState();
|
||||
const {
|
||||
fields,
|
||||
existingResult,
|
||||
existingRunningJobId,
|
||||
existingRunningJobKind,
|
||||
importResult,
|
||||
importRunningJobId,
|
||||
importRunningJobKind,
|
||||
existingSelection,
|
||||
importSelection,
|
||||
syncingExisting,
|
||||
importingConsulting
|
||||
} = useZammadSyncPageState();
|
||||
} = pageState;
|
||||
const [storageReady, setStorageReady] = useState(false);
|
||||
const displayedExistingResult = useMemo(() => applyFieldsToExistingPreview(existingResult, fields), [existingResult, fields]);
|
||||
const selectedExistingCount = useMemo(() => selectedTicketNumbers(displayedExistingResult, existingSelection).length, [displayedExistingResult, existingSelection]);
|
||||
const selectedImportCount = useMemo(() => selectedTicketNumbers(importResult, importSelection).length, [importResult, importSelection]);
|
||||
const zammadReady = currentUser.has_zammad_api_token && Boolean(currentUser.zammad_user_id);
|
||||
|
||||
useEffect(() => {
|
||||
setStorageReady(false);
|
||||
activeZammadSyncStorageUserId = currentUser.id;
|
||||
const restored = loadZammadSyncPageState(currentUser.id);
|
||||
|
||||
if (restored) {
|
||||
setZammadSyncPageState(restored);
|
||||
} else if (hydratedZammadSyncUserId !== currentUser.id) {
|
||||
setZammadSyncPageState(emptyZammadSyncPageState());
|
||||
}
|
||||
|
||||
hydratedZammadSyncUserId = currentUser.id;
|
||||
setStorageReady(true);
|
||||
}, [currentUser.id]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!storageReady) {
|
||||
return;
|
||||
}
|
||||
|
||||
saveZammadSyncPageState(currentUser.id, pageState);
|
||||
}, [currentUser.id, pageState, storageReady]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!storageReady || !existingRunningJobId || !existingRunningJobKind) {
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
|
||||
void waitForZammadSyncJob(existingRunningJobId)
|
||||
.then((result) => {
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (existingRunningJobKind === "preview") {
|
||||
setZammadSyncPageState((current) => ({
|
||||
existingResult: result,
|
||||
existingPreviewJobId: existingRunningJobId,
|
||||
existingSelection: Object.keys(current.existingSelection).length ? current.existingSelection : selectionFromResult(result),
|
||||
existingRunningJobId: null,
|
||||
existingRunningJobKind: null,
|
||||
syncingExisting: false
|
||||
}));
|
||||
} else {
|
||||
setZammadSyncPageState({
|
||||
existingResult: result,
|
||||
existingPreviewJobId: null,
|
||||
existingSelection: {},
|
||||
existingRunningJobId: null,
|
||||
existingRunningJobKind: null,
|
||||
syncingExisting: false
|
||||
});
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
|
||||
setZammadSyncPageState({
|
||||
existingRunningJobId: null,
|
||||
existingRunningJobKind: null,
|
||||
syncingExisting: false
|
||||
});
|
||||
toast.error("Zammad-Lauf konnte nicht fortgesetzt werden", {
|
||||
description: error instanceof Error ? error.message : "Unbekannter Fehler"
|
||||
});
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [existingRunningJobId, existingRunningJobKind, storageReady]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!storageReady || !importRunningJobId || !importRunningJobKind) {
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
|
||||
void waitForZammadSyncJob(importRunningJobId)
|
||||
.then((result) => {
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (importRunningJobKind === "preview") {
|
||||
setZammadSyncPageState((current) => ({
|
||||
importResult: result,
|
||||
importPreviewJobId: importRunningJobId,
|
||||
importSelection: Object.keys(current.importSelection).length ? current.importSelection : selectionFromResult(result),
|
||||
importRunningJobId: null,
|
||||
importRunningJobKind: null,
|
||||
importingConsulting: false
|
||||
}));
|
||||
} else {
|
||||
setZammadSyncPageState({
|
||||
importResult: result,
|
||||
importPreviewJobId: null,
|
||||
importSelection: {},
|
||||
importRunningJobId: null,
|
||||
importRunningJobKind: null,
|
||||
importingConsulting: false
|
||||
});
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
|
||||
setZammadSyncPageState({
|
||||
importRunningJobId: null,
|
||||
importRunningJobKind: null,
|
||||
importingConsulting: false
|
||||
});
|
||||
toast.error("Zammad-Importlauf konnte nicht fortgesetzt werden", {
|
||||
description: error instanceof Error ? error.message : "Unbekannter Fehler"
|
||||
});
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [importRunningJobId, importRunningJobKind, storageReady]);
|
||||
|
||||
function setField(key: keyof ZammadSyncExistingFields, checked: boolean) {
|
||||
setZammadSyncPageState((current) => ({
|
||||
fields: { ...current.fields, [key]: checked, ...(key === "budget" && !checked ? { overrideBudget: false } : {}) }
|
||||
@@ -740,7 +1038,11 @@ export function ZammadSyncPage({ currentUser }: ZammadSyncPageProps) {
|
||||
function resetRunningState() {
|
||||
setZammadSyncPageState({
|
||||
syncingExisting: false,
|
||||
importingConsulting: false
|
||||
importingConsulting: false,
|
||||
existingRunningJobId: null,
|
||||
existingRunningJobKind: null,
|
||||
importRunningJobId: null,
|
||||
importRunningJobKind: null
|
||||
});
|
||||
toast.info("Laufstatus zurückgesetzt");
|
||||
}
|
||||
@@ -751,13 +1053,17 @@ export function ZammadSyncPage({ currentUser }: ZammadSyncPageProps) {
|
||||
}
|
||||
|
||||
const syncFields = zammadSyncPageState.fields;
|
||||
setZammadSyncPageState({ syncingExisting: true });
|
||||
setZammadSyncPageState({ syncingExisting: true, existingRunningJobId: null, existingRunningJobKind: null });
|
||||
try {
|
||||
const { jobId, result } = await previewExistingZammadTickets(syncFields);
|
||||
const { jobId, result } = await previewExistingZammadTickets(syncFields, (job: ZammadSyncJob) => {
|
||||
setZammadSyncPageState({ syncingExisting: true, existingRunningJobId: job.id, existingRunningJobKind: "preview" });
|
||||
});
|
||||
setZammadSyncPageState({
|
||||
existingResult: result,
|
||||
existingPreviewJobId: jobId,
|
||||
existingSelection: selectionFromResult(result)
|
||||
existingSelection: selectionFromResult(result),
|
||||
existingRunningJobId: null,
|
||||
existingRunningJobKind: null
|
||||
});
|
||||
toast.success("Vorschau erstellt", {
|
||||
description: `${result.updated + result.created} Datensatz/Datensätze können übernommen werden.`
|
||||
@@ -767,7 +1073,7 @@ export function ZammadSyncPage({ currentUser }: ZammadSyncPageProps) {
|
||||
description: error instanceof Error ? error.message : "Unbekannter Fehler"
|
||||
});
|
||||
} finally {
|
||||
setZammadSyncPageState({ syncingExisting: false });
|
||||
setZammadSyncPageState({ syncingExisting: false, existingRunningJobId: null, existingRunningJobKind: null });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -790,13 +1096,17 @@ export function ZammadSyncPage({ currentUser }: ZammadSyncPageProps) {
|
||||
}
|
||||
|
||||
const syncFields = zammadSyncPageState.fields;
|
||||
setZammadSyncPageState({ syncingExisting: true });
|
||||
setZammadSyncPageState({ syncingExisting: true, existingRunningJobId: null, existingRunningJobKind: null });
|
||||
try {
|
||||
const { result } = await applyExistingZammadTicketsFromPreview(zammadSyncPageState.existingPreviewJobId, syncFields, ticketNumbers);
|
||||
const { result } = await applyExistingZammadTicketsFromPreview(zammadSyncPageState.existingPreviewJobId, syncFields, ticketNumbers, (job: ZammadSyncJob) => {
|
||||
setZammadSyncPageState({ syncingExisting: true, existingRunningJobId: job.id, existingRunningJobKind: "apply" });
|
||||
});
|
||||
setZammadSyncPageState({
|
||||
existingResult: result,
|
||||
existingPreviewJobId: null,
|
||||
existingSelection: {}
|
||||
existingSelection: {},
|
||||
existingRunningJobId: null,
|
||||
existingRunningJobKind: null
|
||||
});
|
||||
toast.success("Auswahl synchronisiert", {
|
||||
description: `${result.updated} aktualisiert, ${result.skipped} ohne Transfer.`
|
||||
@@ -806,7 +1116,7 @@ export function ZammadSyncPage({ currentUser }: ZammadSyncPageProps) {
|
||||
description: error instanceof Error ? error.message : "Unbekannter Fehler"
|
||||
});
|
||||
} finally {
|
||||
setZammadSyncPageState({ syncingExisting: false });
|
||||
setZammadSyncPageState({ syncingExisting: false, existingRunningJobId: null, existingRunningJobKind: null });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -815,13 +1125,17 @@ export function ZammadSyncPage({ currentUser }: ZammadSyncPageProps) {
|
||||
return;
|
||||
}
|
||||
|
||||
setZammadSyncPageState({ importingConsulting: true });
|
||||
setZammadSyncPageState({ importingConsulting: true, importRunningJobId: null, importRunningJobKind: null });
|
||||
try {
|
||||
const { jobId, result } = await previewOwnZammadConsultingTickets();
|
||||
const { jobId, result } = await previewOwnZammadConsultingTickets((job: ZammadSyncJob) => {
|
||||
setZammadSyncPageState({ importingConsulting: true, importRunningJobId: job.id, importRunningJobKind: "preview" });
|
||||
});
|
||||
setZammadSyncPageState({
|
||||
importResult: result,
|
||||
importPreviewJobId: jobId,
|
||||
importSelection: selectionFromResult(result)
|
||||
importSelection: selectionFromResult(result),
|
||||
importRunningJobId: null,
|
||||
importRunningJobKind: null
|
||||
});
|
||||
toast.success("Import-Vorschau erstellt", {
|
||||
description: `${result.created} Consulting-Ticket(s) können angelegt werden.`
|
||||
@@ -831,7 +1145,7 @@ export function ZammadSyncPage({ currentUser }: ZammadSyncPageProps) {
|
||||
description: error instanceof Error ? error.message : "Unbekannter Fehler"
|
||||
});
|
||||
} finally {
|
||||
setZammadSyncPageState({ importingConsulting: false });
|
||||
setZammadSyncPageState({ importingConsulting: false, importRunningJobId: null, importRunningJobKind: null });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -852,13 +1166,17 @@ export function ZammadSyncPage({ currentUser }: ZammadSyncPageProps) {
|
||||
return;
|
||||
}
|
||||
|
||||
setZammadSyncPageState({ importingConsulting: true });
|
||||
setZammadSyncPageState({ importingConsulting: true, importRunningJobId: null, importRunningJobKind: null });
|
||||
try {
|
||||
const { result } = await applyOwnZammadConsultingTicketsFromPreview(zammadSyncPageState.importPreviewJobId, ticketNumbers);
|
||||
const { result } = await applyOwnZammadConsultingTicketsFromPreview(zammadSyncPageState.importPreviewJobId, ticketNumbers, (job: ZammadSyncJob) => {
|
||||
setZammadSyncPageState({ importingConsulting: true, importRunningJobId: job.id, importRunningJobKind: "apply" });
|
||||
});
|
||||
setZammadSyncPageState({
|
||||
importResult: result,
|
||||
importPreviewJobId: null,
|
||||
importSelection: {}
|
||||
importSelection: {},
|
||||
importRunningJobId: null,
|
||||
importRunningJobKind: null
|
||||
});
|
||||
toast.success("Auswahl importiert", {
|
||||
description: `${result.created} angelegt, ${result.skipped} ohne Transfer.`
|
||||
@@ -868,7 +1186,7 @@ export function ZammadSyncPage({ currentUser }: ZammadSyncPageProps) {
|
||||
description: error instanceof Error ? error.message : "Unbekannter Fehler"
|
||||
});
|
||||
} finally {
|
||||
setZammadSyncPageState({ importingConsulting: false });
|
||||
setZammadSyncPageState({ importingConsulting: false, importRunningJobId: null, importRunningJobKind: null });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -950,8 +1268,8 @@ export function ZammadSyncPage({ currentUser }: ZammadSyncPageProps) {
|
||||
<label className={cn("flex min-w-0 gap-3 rounded-md border p-3", fields.overrideBudget ? "bg-muted/30" : "bg-background")}>
|
||||
<Checkbox checked={fields.overrideBudget} onCheckedChange={(checked) => setField("overrideBudget", checked === true)} />
|
||||
<span className="min-w-0">
|
||||
<span className="block text-sm font-medium">Manuelle Budget-Änderungen überschreiben</span>
|
||||
<span className="block text-xs text-muted-foreground">Ausgeschaltet: geschützte Budgets bleiben in TicketTracker unverändert.</span>
|
||||
<span className="block text-sm font-medium">Budget-Schutz im Sync ignorieren</span>
|
||||
<span className="block text-xs text-muted-foreground">Aus: nur ungeschützte Budgets aktualisieren. An: Zammad überschreibt auch geschützte Budgets.</span>
|
||||
</span>
|
||||
</label>
|
||||
) : null}
|
||||
|
||||
Reference in new issue
Block a user