This commit is contained in:
michael committed 2026-09-15 09:51:30 +00:00
1 parent b7f7d7e793
commit c01d747352
14 files changed
+987 -294

No files matched your search

+13 -2
View File
@@ -48,6 +48,7 @@ export type ZammadSyncExistingFields = {
type: boolean;
projectName: boolean;
budget: boolean;
overrideBudget: boolean;
};
export type ZammadSyncChange = {
@@ -163,6 +164,14 @@ async function requestRestoreEndpoint<T>(path: string, options: ApiRequestInit)
return request<T>(path, options);
}
function backupValidationSql(sql: string) {
const lines = sql.replace(/^\uFEFF/, "").split(/\r?\n/);
const marker = lines.find((line) => line.includes("TicketTracker Datenbankexport")) ?? "-- TicketTracker Datenbankexport";
const structuralLines = lines.filter((line) => /^--\s*(?:[a-z_][a-z0-9_]*:\s*\d+\s+Datensatz|columns\s+[a-z_][a-z0-9_]*:)/i.test(line.trim()));
return [marker, ...structuralLines].join("\n");
}
export function login(payload: { username: string; password: string }) {
return request<{ user: AuthUser }>("/api/auth/login", {
method: "POST",
@@ -242,7 +251,7 @@ export function validateSetupBackup(sql: string) {
method: "POST",
timeoutMs: 60_000,
headers: { "Content-Type": "text/plain; charset=utf-8" },
body: sql
body: backupValidationSql(sql)
});
}
@@ -310,7 +319,7 @@ export function validateDatabaseBackup(sql: string) {
method: "POST",
timeoutMs: 60_000,
headers: { "Content-Type": "text/plain; charset=utf-8" },
body: sql
body: backupValidationSql(sql)
});
}
@@ -531,6 +540,7 @@ export function createProject(payload: {
teamspaceProject?: string | null;
isDefault: boolean;
hourBudgetMinutes?: number | null;
budgetSyncDisabled?: boolean;
manualConsumptionMinutes?: number | null;
}>;
description: string;
@@ -558,6 +568,7 @@ export function updateProject(
teamspaceProject?: string | null;
isDefault: boolean;
hourBudgetMinutes?: number | null;
budgetSyncDisabled?: boolean;
manualConsumptionMinutes?: number | null;
}>;
description: string;
+5
View File
@@ -136,6 +136,8 @@ export type ProjectSummary = {
organization_name: string | null;
customer_name: string | null;
work_type: WorkType | null;
zammad_priority_id?: string | null;
zammad_state_id?: string | null;
teamspace_project: string | null;
description: string;
goals: string;
@@ -169,6 +171,7 @@ export type ProjectTeamspaceEntry = {
teamspace_project: string | null;
is_default: boolean;
hour_budget_minutes: number | null;
budget_sync_disabled?: boolean;
manual_consumption_minutes: number;
teamspace_consumption_minutes?: number;
consumption_minutes?: number;
@@ -313,6 +316,8 @@ export type TicketPeriod = {
organization_name: string | null;
customer_name: string | null;
work_type: WorkType | null;
zammad_priority_id?: string | null;
zammad_state_id?: string | null;
closed_at: string | null;
requires_closure: boolean;
recurring_session_count: number;
+138 -6
View File
@@ -47,6 +47,16 @@ import {
} from "../api";
import { currentDay, currentMonth, formatDate, formatMinutes } from "../format";
import { ticketPattern } from "../timers";
import {
matchesZammadPriorityFilter,
matchesZammadStateFilter,
ZammadPriorityBadge,
zammadPriorityFilterOptions,
zammadPrioritySortRank,
ZammadStateBadge,
zammadStateFilterOptions,
zammadStateSortRank
} from "../zammad-meta";
import type {
DashboardFavorite,
DashboardPreference,
@@ -161,7 +171,27 @@ function widgetAllowedFilters(type: DashboardWidgetType) {
}
if (type === "projectBudgets") {
return ["budgeted", "withoutBudget", "critical90", "overBudget", "open", "closed", "openTodos", "overdueTodos", "fixedBilling", "manualBilling"];
return [
"budgeted",
"withoutBudget",
"critical90",
"overBudget",
"open",
"closed",
"zammadState:2",
"zammadState:3",
"zammadState:4",
"zammadState:7",
"zammadState:none",
"zammadPriority:1",
"zammadPriority:2",
"zammadPriority:3",
"zammadPriority:none",
"openTodos",
"overdueTodos",
"fixedBilling",
"manualBilling"
];
}
if (type === "openRatings") {
@@ -169,7 +199,23 @@ function widgetAllowedFilters(type: DashboardWidgetType) {
}
if (type === "favorites") {
return ["projects", "openTodos", "overdueTodos", "missingMeta", "fixedBilling", "manualBilling"];
return [
"projects",
"openTodos",
"overdueTodos",
"zammadState:2",
"zammadState:3",
"zammadState:4",
"zammadState:7",
"zammadState:none",
"zammadPriority:1",
"zammadPriority:2",
"zammadPriority:3",
"zammadPriority:none",
"missingMeta",
"fixedBilling",
"manualBilling"
];
}
return [];
@@ -498,6 +544,14 @@ function projectFilterGroup(filter: string) {
return "status";
}
if (filter.startsWith("zammadState:")) {
return "zammadState";
}
if (filter.startsWith("zammadPriority:")) {
return "zammadPriority";
}
if (filter === "openTodos" || filter === "overdueTodos") {
return "todos";
}
@@ -547,6 +601,14 @@ function matchesSingleProjectFilter(project: ProjectSummary, filterBy: string, f
return Boolean(project.closed_at);
}
if (filterBy.startsWith("zammadState:")) {
return matchesZammadStateFilter(project.zammad_state_id, filterBy.slice("zammadState:".length) as Parameters<typeof matchesZammadStateFilter>[1]);
}
if (filterBy.startsWith("zammadPriority:")) {
return matchesZammadPriorityFilter(project.zammad_priority_id, filterBy.slice("zammadPriority:".length) as Parameters<typeof matchesZammadPriorityFilter>[1]);
}
if (filterBy === "openTodos") {
return Number(project.open_todos ?? 0) > 0;
}
@@ -608,11 +670,19 @@ function favoriteFilterGroup(filter: string) {
return "todos";
}
if (filter.startsWith("zammadState:")) {
return "zammadState";
}
if (filter.startsWith("zammadPriority:")) {
return "zammadPriority";
}
return filter;
}
function sortedFavorites(favorites: DashboardFavorite[], metas: Record<string, TicketMeta | null>, config: Record<string, unknown>) {
const sortBy = config.sortBy === "ticket" || config.sortBy === "title" || config.sortBy === "recent" ? config.sortBy : "custom";
const sortBy = config.sortBy === "ticket" || config.sortBy === "title" || config.sortBy === "recent" || config.sortBy === "zammadStatus" || config.sortBy === "zammadPriority" ? config.sortBy : "custom";
const direction: DashboardSortDirection = config.sortDirection === "desc" ? "desc" : "asc";
const factor = directionFactor(direction);
@@ -629,6 +699,14 @@ function sortedFavorites(favorites: DashboardFavorite[], metas: Record<string, T
return compareOptionalTimestamp(left.addedAt, right.addedAt, direction) || compareText(left.ticketNumber, right.ticketNumber);
}
if (sortBy === "zammadStatus") {
return (compareNumber(zammadStateSortRank(metas[left.ticketNumber]?.zammad_state_id), zammadStateSortRank(metas[right.ticketNumber]?.zammad_state_id)) * factor) || compareText(left.ticketNumber, right.ticketNumber);
}
if (sortBy === "zammadPriority") {
return (compareNumber(zammadPrioritySortRank(metas[left.ticketNumber]?.zammad_priority_id), zammadPrioritySortRank(metas[right.ticketNumber]?.zammad_priority_id)) * factor) || compareText(left.ticketNumber, right.ticketNumber);
}
return (compareNumber(left.sortOrder, right.sortOrder) * factor) || compareText(left.ticketNumber, right.ticketNumber);
});
}
@@ -1568,12 +1646,34 @@ function WidgetSettings({
function ProjectBudgetWidget({ widget, editing, projects, stats, onNavigate, onUpdateConfig }: WidgetContentProps) {
const filterValues = readFilterValues(
widget.config,
["budgeted", "withoutBudget", "critical90", "overBudget", "open", "closed", "openTodos", "overdueTodos", "fixedBilling", "manualBilling"],
[
"budgeted",
"withoutBudget",
"critical90",
"overBudget",
"open",
"closed",
"zammadState:2",
"zammadState:3",
"zammadState:4",
"zammadState:7",
"zammadState:none",
"zammadPriority:1",
"zammadPriority:2",
"zammadPriority:3",
"zammadPriority:none",
"openTodos",
"overdueTodos",
"fixedBilling",
"manualBilling"
],
["budgeted"]
);
const sortBy =
widget.config?.sortBy === "consumption" ||
widget.config?.sortBy === "consumptionPercent" ||
widget.config?.sortBy === "zammadStatus" ||
widget.config?.sortBy === "zammadPriority" ||
widget.config?.sortBy === "title" ||
widget.config?.sortBy === "ticket" ||
widget.config?.sortBy === "organization" ||
@@ -1598,6 +1698,14 @@ function ProjectBudgetWidget({ widget, editing, projects, stats, onNavigate, onU
return (compareText(left.organization_name || left.customer_name, right.organization_name || right.customer_name) * factor) || compareText(left.ticket_number, right.ticket_number);
}
if (sortBy === "zammadStatus") {
return (compareNumber(zammadStateSortRank(left.zammad_state_id), zammadStateSortRank(right.zammad_state_id)) * factor) || compareText(left.ticket_number, right.ticket_number);
}
if (sortBy === "zammadPriority") {
return (compareNumber(zammadPrioritySortRank(left.zammad_priority_id), zammadPrioritySortRank(right.zammad_priority_id)) * factor) || compareText(left.ticket_number, right.ticket_number);
}
if (sortBy === "consumption") {
return (compareNumber(left.consumption_minutes ?? 0, right.consumption_minutes ?? 0) * factor) || compareText(left.ticket_number, right.ticket_number);
}
@@ -1640,6 +1748,8 @@ function ProjectBudgetWidget({ widget, editing, projects, stats, onNavigate, onU
{ value: "withoutBudget", label: "Ohne Kontingent" },
{ value: "open", label: "Offene Projekte" },
{ value: "closed", label: "Geschlossene Projekte" },
...zammadStateFilterOptions.filter((option) => option.value !== "all").map((option) => ({ value: `zammadState:${option.value}`, label: `Status: ${option.label}` })),
...zammadPriorityFilterOptions.filter((option) => option.value !== "all").map((option) => ({ value: `zammadPriority:${option.value}`, label: `Prio: ${option.label}` })),
{ value: "openTodos", label: "Mit offenen ToDos" },
{ value: "overdueTodos", label: "Mit überfälligen ToDos" },
{ value: "fixedBilling", label: "Fixe Abrechnungen" },
@@ -1655,7 +1765,9 @@ function ProjectBudgetWidget({ widget, editing, projects, stats, onNavigate, onU
{ value: "todos", label: "ToDos" },
{ value: "title", label: "Titel" },
{ value: "ticket", label: "Ticket" },
{ value: "organization", label: "Organisation" }
{ value: "organization", label: "Organisation" },
{ value: "zammadStatus", label: "Zammad-Status" },
{ value: "zammadPriority", label: "Zammad-Prio" }
]}
onChange={onUpdateConfig}
/>
@@ -1677,6 +1789,10 @@ function ProjectBudgetWidget({ widget, editing, projects, stats, onNavigate, onU
<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"}
</span>
<span className="mt-1 flex flex-wrap gap-1">
<ZammadStateBadge stateId={project.zammad_state_id} />
<ZammadPriorityBadge priorityId={project.zammad_priority_id} />
</span>
{percent !== null ? (
<span className="mt-1 block h-1.5 overflow-hidden rounded-full bg-muted" title={`${percent}% Verbrauch`} aria-label={`${percent}% Verbrauch`}>
<span className={cn("block h-full rounded-full", consumptionBarClass(percent))} style={{ width: `${Math.min(100, percent)}%` }} />
@@ -2196,6 +2312,14 @@ function FavoritesWidget({
return todos.some((todo) => todo.ticket_number === favorite.ticketNumber && todo.status === "open" && todo.ends_on && todo.ends_on < today);
}
if (filter.startsWith("zammadState:")) {
return matchesZammadStateFilter(meta?.zammad_state_id, filter.slice("zammadState:".length) as Parameters<typeof matchesZammadStateFilter>[1]);
}
if (filter.startsWith("zammadPriority:")) {
return matchesZammadPriorityFilter(meta?.zammad_priority_id, filter.slice("zammadPriority:".length) as Parameters<typeof matchesZammadPriorityFilter>[1]);
}
if (filter === "missingMeta") {
return favoriteMetas[favorite.ticketNumber] === null;
}
@@ -2233,6 +2357,8 @@ function FavoritesWidget({
{ value: "projects", label: "Nur Projekte" },
{ value: "openTodos", label: "Mit offenen ToDos" },
{ value: "overdueTodos", label: "Mit überfälligen ToDos" },
...zammadStateFilterOptions.filter((option) => option.value !== "all").map((option) => ({ value: `zammadState:${option.value}`, label: `Status: ${option.label}` })),
...zammadPriorityFilterOptions.filter((option) => option.value !== "all").map((option) => ({ value: `zammadPriority:${option.value}`, label: `Prio: ${option.label}` })),
{ value: "missingMeta", label: "Nicht gefunden" },
{ value: "fixedBilling", label: "Fixe Abrechnungen" },
{ value: "manualBilling", label: "Ohne fixe Abrechnung" }
@@ -2243,7 +2369,9 @@ function FavoritesWidget({
{ value: "custom", label: "Frei" },
{ value: "ticket", label: "Ticket" },
{ value: "title", label: "Titel" },
{ value: "recent", label: "Neu" }
{ value: "recent", label: "Neu" },
{ value: "zammadStatus", label: "Zammad-Status" },
{ value: "zammadPriority", label: "Zammad-Prio" }
]}
onChange={onUpdateConfig}
/>
@@ -2261,6 +2389,10 @@ function FavoritesWidget({
<button type="button" className="min-w-0 flex-1 text-left" onClick={() => meta?.id && onNavigate(`/tickets/${meta.id}?month=${currentMonth()}`)}>
<span className="block truncate text-xs font-medium">{favorite.ticketNumber}</span>
<span className="block truncate text-[11px] text-muted-foreground">{meta?.title || meta?.organization_name || "Favorit"}</span>
<span className="mt-1 flex flex-wrap gap-1">
<ZammadStateBadge stateId={meta?.zammad_state_id} />
<ZammadPriorityBadge priorityId={meta?.zammad_priority_id} />
</span>
</button>
{editing && sortBy === "custom" ? (
<>
+1 -1
View File
@@ -181,7 +181,7 @@ export function ProfilePage({ currentUser, onUserUpdated }: ProfilePageProps) {
}}
/>
<p className="text-xs text-muted-foreground">
Die Zammad-User-ID wird nur geprüft, wenn du hier einen neuen Token speicherst.
Die Zammad-ID wird nur geprüft, wenn du hier einen neuen Token speicherst.
</p>
</div>
<div className="space-y-2">
+117 -70
View File
@@ -5,6 +5,7 @@ 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";
@@ -18,6 +19,19 @@ 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,
ZammadStateBadge,
zammadStateFilterOptions,
zammadStateLabel,
zammadStateSortRank
} from "../zammad-meta";
import type { ZammadPriorityFilter, ZammadStateFilter } from "../zammad-meta";
type ProjectsPageProps = {
onNavigate: (to: string) => void;
@@ -39,22 +53,23 @@ type ProjectTeamspaceEntryForm = {
clientId: string;
teamspaceProject: string;
hourBudget: string;
budgetSyncDisabled: boolean;
manualConsumption: string;
isDefault: boolean;
};
type ProjectStatusFilter = "open" | "all" | "closed" | "hidden";
type ProjectBudgetFilter = "all" | "with-budget" | "without-budget" | "remaining" | "over-budget";
type ProjectTodoFilter = "all" | "open" | "overdue" | "none-open";
type ProjectSortBy = "updated" | "ticket" | "title" | "organization" | "status" | "consumption" | "consumptionPercent" | "remaining" | "budget" | "todos";
type ProjectSortBy = "updated" | "ticket" | "title" | "organization" | "status" | "zammadStatus" | "zammadPriority" | "consumption" | "consumptionPercent" | "remaining" | "budget";
type ProjectSortDirection = "asc" | "desc";
type ProjectViewSettings = {
query: string;
status: ProjectStatusFilter;
zammadStatus: ZammadStateFilter;
zammadPriority: ZammadPriorityFilter;
organization: string;
budget: ProjectBudgetFilter;
todos: ProjectTodoFilter;
sortBy: ProjectSortBy;
sortDirection: ProjectSortDirection;
};
@@ -64,9 +79,10 @@ const projectViewSettingsKey = "tickettracker.projects.viewSettings";
const defaultProjectViewSettings: ProjectViewSettings = {
query: "",
status: "open",
zammadStatus: "all",
zammadPriority: "all",
organization: "all",
budget: "all",
todos: "all",
sortBy: "updated",
sortDirection: "desc"
};
@@ -86,14 +102,7 @@ const projectBudgetOptions: Array<{ value: ProjectBudgetFilter; label: string }>
{ value: "over-budget", label: "Überzogen" }
];
const projectTodoOptions: Array<{ value: ProjectTodoFilter; label: string }> = [
{ value: "all", label: "Alle ToDos" },
{ value: "open", label: "Mit offenen ToDos" },
{ value: "overdue", label: "Mit überfälligen ToDos" },
{ value: "none-open", label: "Keine offenen ToDos" }
];
const projectSortValues: ProjectSortBy[] = ["updated", "ticket", "title", "organization", "status", "consumption", "consumptionPercent", "remaining", "budget", "todos"];
const projectSortValues: ProjectSortBy[] = ["updated", "ticket", "title", "organization", "status", "zammadStatus", "zammadPriority", "consumption", "consumptionPercent", "remaining", "budget"];
const projectSortOptions: Array<{ value: ProjectSortBy; label: string }> = [
{ value: "updated", label: "Aktualisierung" },
@@ -101,11 +110,12 @@ 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: "consumption", label: "Verbrauch" },
{ value: "consumptionPercent", label: "Verbrauch %" },
{ value: "remaining", label: "Reststunden" },
{ value: "budget", label: "Kontingent" },
{ value: "todos", label: "ToDos" }
{ value: "budget", label: "Kontingent" }
];
function projectEntryId() {
@@ -121,6 +131,7 @@ function emptyProjectTeamspaceEntry(isDefault = false): ProjectTeamspaceEntryFor
clientId: projectEntryId(),
teamspaceProject: "",
hourBudget: "",
budgetSyncDisabled: false,
manualConsumption: "",
isDefault
};
@@ -143,9 +154,10 @@ function readStoredProjectViewSettings(): ProjectViewSettings {
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,
todos: projectTodoOptions.find((option) => option.value === parsed.todos)?.value ?? defaultProjectViewSettings.todos,
sortBy: projectSortValues.find((value) => value === parsed.sortBy) ?? defaultProjectViewSettings.sortBy,
sortDirection: parsed.sortDirection === "asc" ? "asc" : defaultProjectViewSettings.sortDirection
};
@@ -295,6 +307,8 @@ function projectMatchesQuery(project: ProjectSummary, query: string) {
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
@@ -315,6 +329,10 @@ function compareProjects(left: ProjectSummary, right: ProjectSummary, sortBy: Pr
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) - projectConsumption(right);
} else if (sortBy === "consumptionPercent") {
@@ -330,8 +348,6 @@ function compareProjects(left: ProjectSummary, right: ProjectSummary, sortBy: Pr
comparison = (projectRemaining(left) ?? Number.POSITIVE_INFINITY) - (projectRemaining(right) ?? Number.POSITIVE_INFINITY);
} else if (sortBy === "budget") {
comparison = (left.hour_budget_minutes ?? Number.POSITIVE_INFINITY) - (right.hour_budget_minutes ?? Number.POSITIVE_INFINITY);
} else if (sortBy === "todos") {
comparison = Number(left.open_todos ?? 0) - Number(right.open_todos ?? 0);
} else {
comparison = new Date(left.updated_at).getTime() - new Date(right.updated_at).getTime();
}
@@ -344,6 +360,7 @@ function formFromProject(project: ProjectSummary): ProjectForm {
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
}));
@@ -398,6 +415,7 @@ function parseProjectTeamspaceEntryForms(entries: ProjectTeamspaceEntryForm[]) {
teamspaceProject: entry.teamspaceProject.trim() || null,
isDefault: entry.isDefault,
hourBudgetMinutes,
budgetSyncDisabled: entry.budgetSyncDisabled,
manualConsumptionMinutes: manualConsumptionMinutes ?? 0
};
});
@@ -411,6 +429,7 @@ function parseProjectTeamspaceEntryForms(entries: ProjectTeamspaceEntryForm[]) {
teamspaceProject: string | null;
isDefault: boolean;
hourBudgetMinutes: number | null;
budgetSyncDisabled: boolean;
manualConsumptionMinutes: number;
}>;
}
@@ -451,7 +470,7 @@ function ProjectTeamspaceEntriesFields({
</div>
<div className="space-y-2">
{normalizedEntries.map((entry, index) => (
<div key={entry.clientId} className="grid gap-2 rounded-lg border bg-muted/10 p-3 lg:grid-cols-[minmax(180px,1fr)_130px_150px_120px_36px] lg:items-end">
<div key={entry.clientId} className="grid gap-2 rounded-lg border bg-muted/10 p-3 lg:grid-cols-[minmax(180px,1fr)_130px_150px_130px_120px_36px] lg:items-end">
<div className="space-y-1.5">
<label className="text-xs font-medium text-muted-foreground" htmlFor={`project-teamspace-${entry.clientId}`}>
Teamspace-Projekt {entry.isDefault ? "(Standard)" : ""}
@@ -470,9 +489,13 @@ function ProjectTeamspaceEntriesFields({
id={`project-budget-${entry.clientId}`}
inputMode="decimal"
value={entry.hourBudget}
onChange={(event) => updateEntry(entry.clientId, { hourBudget: event.currentTarget.value })}
onChange={(event) => updateEntry(entry.clientId, { hourBudget: event.currentTarget.value, budgetSyncDisabled: true })}
/>
</div>
<label className="flex min-h-9 items-center gap-2 rounded-md border bg-background px-3 text-xs">
<Checkbox checked={entry.budgetSyncDisabled} onCheckedChange={(checked) => updateEntry(entry.clientId, { budgetSyncDisabled: checked === true })} />
<span>Budget schützen</span>
</label>
<div className="space-y-1.5">
<label className="text-xs font-medium text-muted-foreground" htmlFor={`project-manual-${entry.clientId}`}>
Manueller Verbrauch h
@@ -716,6 +739,14 @@ export function ProjectsPage({ onNavigate, onStartTimer }: ProjectsPageProps) {
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;
}
@@ -742,23 +773,10 @@ export function ProjectsPage({ onNavigate, onStartTimer }: ProjectsPageProps) {
return false;
}
if (viewSettings.todos === "open" && Number(project.open_todos ?? 0) === 0) {
return false;
}
if (viewSettings.todos === "overdue" && Number(project.overdue_todos ?? 0) === 0) {
return false;
}
if (viewSettings.todos === "none-open" && Number(project.open_todos ?? 0) > 0) {
return false;
}
return true;
})
.sort((left, right) => compareProjects(left, right, viewSettings.sortBy, viewSettings.sortDirection));
}, [projects, viewSettings]);
const openTodos = useMemo(() => filteredProjects.reduce((sum, project) => sum + Number(project.open_todos ?? 0), 0), [filteredProjects]);
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),
@@ -767,9 +785,10 @@ export function ProjectsPage({ onNavigate, onStartTimer }: ProjectsPageProps) {
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,
viewSettings.todos !== defaultProjectViewSettings.todos,
viewSettings.sortBy !== defaultProjectViewSettings.sortBy,
viewSettings.sortDirection !== defaultProjectViewSettings.sortDirection
].filter(Boolean).length;
@@ -808,17 +827,28 @@ export function ProjectsPage({ onNavigate, onStartTimer }: ProjectsPageProps) {
setLookupStatus("found");
setUseZammadForCreate(true);
setForm((current) => {
const entries = normalizeProjectTeamspaceEntryForms(current.teamspaceEntries);
const defaultIndex = Math.max(0, entries.findIndex((entry) => entry.isDefault));
const nextEntries = entries.map((entry, index) =>
index === defaultIndex
? {
...entry,
teamspaceProject: entry.teamspaceProject || result.ticket?.project_name || "",
hourBudget: entry.hourBudget || formatHoursInput(result.ticket?.project_hour_budget_minutes ?? null)
}
: entry
);
const zammadEntries = result.ticket?.project_teamspace_entries ?? [];
const nextEntries = zammadEntries.length > 0
? zammadEntries.map((entry, index) => ({
clientId: entry.id || projectEntryId(),
teamspaceProject: entry.teamspace_project ?? "",
hourBudget: formatHoursInput(entry.hour_budget_minutes),
budgetSyncDisabled: entry.budget_sync_disabled ?? false,
manualConsumption: "",
isDefault: entry.is_default || index === 0
}))
: normalizeProjectTeamspaceEntryForms(current.teamspaceEntries).map((entry, index, entries) => {
const defaultIndex = Math.max(0, entries.findIndex((candidate) => candidate.isDefault));
return index === defaultIndex
? {
...entry,
teamspaceProject: entry.teamspaceProject || result.ticket?.project_name || "",
hourBudget: entry.hourBudget || formatHoursInput(result.ticket?.project_hour_budget_minutes ?? null),
budgetSyncDisabled: entry.hourBudget ? entry.budgetSyncDisabled : false
}
: entry;
});
return {
...current,
@@ -1050,10 +1080,6 @@ export function ProjectsPage({ onNavigate, onStartTimer }: ProjectsPageProps) {
<span className="block text-muted-foreground">Rest offen</span>
<span className="font-semibold">{formatMinutes(remainingMinutes)}</span>
</div>
<div className="w-28 shrink-0 rounded-md border bg-muted/20 px-3 py-2">
<span className="block text-muted-foreground">Offene ToDos</span>
<span className="font-semibold">{openTodos}</span>
</div>
</div>
<HelpLink anchor="projekte" label="Hilfe zu Projekten" className="shrink-0" />
</div>
@@ -1076,7 +1102,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-[minmax(220px,1.4fr)_150px_minmax(180px,1fr)_170px_170px_170px_150px]">
<CardContent className="grid gap-3 px-4 pb-4 md:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-4">
<div className="space-y-1.5">
<label className="text-xs font-medium text-muted-foreground" htmlFor="project-search">
Suche
@@ -1105,6 +1131,32 @@ export function ProjectsPage({ onNavigate, onStartTimer }: ProjectsPageProps) {
</SelectContent>
</Select>
</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 }))}>
@@ -1132,19 +1184,6 @@ export function ProjectsPage({ onNavigate, onStartTimer }: ProjectsPageProps) {
</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<label className="text-xs font-medium text-muted-foreground">ToDos</label>
<Select value={viewSettings.todos} onValueChange={(value) => setViewSettings((current) => ({ ...current, todos: value as ProjectTodoFilter }))}>
<SelectTrigger size="sm" className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
{projectTodoOptions.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">Sortierung</label>
<Select value={viewSettings.sortBy} onValueChange={(value) => setViewSettings((current) => ({ ...current, sortBy: value as ProjectSortBy }))}>
@@ -1236,13 +1275,14 @@ export function ProjectsPage({ onNavigate, onStartTimer }: ProjectsPageProps) {
</DialogContent>
</Dialog>
</div>
<div className="hidden grid-cols-[minmax(220px,1.35fr)_minmax(180px,1fr)_100px_170px_95px_110px_44px] gap-3 border-b bg-muted/25 px-4 py-2 text-xs font-medium text-muted-foreground xl:grid">
<div className="hidden grid-cols-[minmax(220px,1.35fr)_minmax(170px,1fr)_105px_115px_105px_165px_90px_44px] gap-3 border-b bg-muted/25 px-4 py-2 text-xs font-medium text-muted-foreground xl:grid">
{sortHeader("Nummer/Titel", "title")}
{sortHeader("Organisation", "organization")}
{sortHeader("Status", "status")}
{sortHeader("Zammad", "zammadStatus")}
{sortHeader("Prio", "zammadPriority")}
{sortHeader("Verbrauch/Gesamt", "consumption")}
{sortHeader("Verbrauch %", "consumptionPercent")}
{sortHeader("ToDos", "todos")}
<span className="text-right">Aktion</span>
</div>
{filteredProjects.map((project) => {
@@ -1256,7 +1296,7 @@ export function ProjectsPage({ onNavigate, onStartTimer }: ProjectsPageProps) {
return (
<div key={project.id} className="border-b last:border-b-0">
<div className="grid gap-3 px-4 py-3 xl:grid-cols-[minmax(220px,1.35fr)_minmax(180px,1fr)_100px_170px_95px_110px_44px] xl:items-center">
<div className="grid gap-3 px-4 py-3 xl:grid-cols-[minmax(220px,1.35fr)_minmax(170px,1fr)_105px_115px_105px_165px_90px_44px] xl:items-center">
<div className="min-w-0">
<button
type="button"
@@ -1272,12 +1312,22 @@ export function ProjectsPage({ onNavigate, onStartTimer }: ProjectsPageProps) {
<p className="truncate">{projectOrganizationLabel(project)}</p>
</div>
<div className="flex flex-wrap gap-1.5">
{project.closed_at ? <Badge variant="secondary">geschlossen</Badge> : <Badge variant="success">offen</Badge>}
<p className="w-full truncate text-sm font-medium xl:hidden">Status</p>
{project.closed_at ? <Badge variant="secondary">Geschlossen</Badge> : <Badge variant="success">Offen</Badge>}
{!canEditProject ? <Badge variant="outline">geteilt</Badge> : null}
{project.close_review_required ? <Badge variant="warning">TS/TT prüfen</Badge> : null}
{(project.unassigned_teamspace_session_count ?? 0) > 0 ? <Badge variant="warning">{project.unassigned_teamspace_session_count} ohne TS</Badge> : null}
</div>
<div className="flex flex-wrap gap-1.5">
<p className="w-full truncate text-sm font-medium xl:hidden">Zammad</p>
<ZammadStateBadge stateId={project.zammad_state_id} />
</div>
<div className="flex flex-wrap gap-1.5">
<p className="w-full truncate text-sm font-medium xl:hidden">Prio</p>
<ZammadPriorityBadge priorityId={project.zammad_priority_id} />
</div>
<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"}
</p>
@@ -1289,10 +1339,6 @@ export function ProjectsPage({ onNavigate, onStartTimer }: ProjectsPageProps) {
{usedPercent !== null ? `${usedPercent}%` : "-"}
</p>
</div>
<div className="flex flex-wrap gap-1.5">
<Badge variant={(project.open_todos ?? 0) > 0 ? "warning" : "success"}>{project.open_todos ?? 0} offen</Badge>
{(project.overdue_todos ?? 0) > 0 ? <Badge variant="destructive">{project.overdue_todos} üf</Badge> : null}
</div>
<div className="flex justify-end">
<DropdownMenu>
<DropdownMenuTrigger asChild>
@@ -1383,6 +1429,7 @@ export function ProjectsPage({ onNavigate, onStartTimer }: ProjectsPageProps) {
<span className="truncate text-muted-foreground">
{label}
{entry.is_default ? " · Standard" : ""}
{entry.budget_sync_disabled ? " · Budget geschützt" : ""}
</span>
<span className="shrink-0 font-medium">
{formatMinutes(entryConsumption)} / {entry.hour_budget_minutes ? formatMinutes(entry.hour_budget_minutes) : "offen"}
+21 -4
View File
@@ -5,6 +5,7 @@ import { Alert } from "@/components/ui/alert";
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 } from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
@@ -32,6 +33,7 @@ import {
import { currentDay, currentMonth, formatDate, formatDateTime, formatMinutes, formatTimeRange } from "../format";
import { ticketHasRecurringBilling } from "../recurring-billings";
import type { BillingStatus, PeriodType, ProjectSummary, ProjectTeamspaceEntry, SessionEntry, TicketDetailPeriodType, TicketPeriod, WorkType } from "../types";
import { ZammadPriorityBadge, ZammadStateBadge } from "../zammad-meta";
import { cn } from "@/lib/utils";
type TicketDetailPageProps = {
@@ -77,6 +79,7 @@ type ProjectTeamspaceEntryForm = {
clientId: string;
teamspaceProject: string;
hourBudget: string;
budgetSyncDisabled: boolean;
manualConsumption: string;
isDefault: boolean;
};
@@ -126,6 +129,7 @@ function projectEditForm(project: ProjectSummary): ProjectEditFormState {
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
}));
@@ -262,6 +266,7 @@ function normalizeProjectTeamspaceEntryForms(entries: ProjectTeamspaceEntryForm[
clientId: projectEntryId(),
teamspaceProject: "",
hourBudget: "",
budgetSyncDisabled: false,
manualConsumption: "",
isDefault: true
}
@@ -289,6 +294,7 @@ function parseProjectTeamspaceEntryForms(entries: ProjectTeamspaceEntryForm[]) {
teamspaceProject: entry.teamspaceProject.trim() || null,
isDefault: entry.isDefault,
hourBudgetMinutes,
budgetSyncDisabled: entry.budgetSyncDisabled,
manualConsumptionMinutes: manualConsumptionMinutes ?? 0
};
});
@@ -302,6 +308,7 @@ function parseProjectTeamspaceEntryForms(entries: ProjectTeamspaceEntryForm[]) {
teamspaceProject: string | null;
isDefault: boolean;
hourBudgetMinutes: number | null;
budgetSyncDisabled: boolean;
manualConsumptionMinutes: number;
}>;
}
@@ -327,13 +334,13 @@ function ProjectTeamspaceEntriesEditor({
<div className="space-y-2">
<div className="flex items-center justify-between gap-2">
<label className="text-sm font-medium">Teamspace-Einträge</label>
<Button type="button" size="sm" variant="secondary" onClick={() => onChange([...normalizedEntries, { clientId: projectEntryId(), teamspaceProject: "", hourBudget: "", manualConsumption: "", isDefault: false }])}>
<Button type="button" size="sm" variant="secondary" onClick={() => onChange([...normalizedEntries, { clientId: projectEntryId(), teamspaceProject: "", hourBudget: "", budgetSyncDisabled: false, manualConsumption: "", isDefault: false }])}>
<Plus className="size-3.5" />
Eintrag
</Button>
</div>
{normalizedEntries.map((entry, index) => (
<div key={entry.clientId} className="grid gap-2 rounded-lg border bg-muted/10 p-3 lg:grid-cols-[minmax(180px,1fr)_120px_150px_120px_36px] lg:items-end">
<div key={entry.clientId} className="grid gap-2 rounded-lg border bg-muted/10 p-3 lg:grid-cols-[minmax(180px,1fr)_120px_150px_130px_120px_36px] lg:items-end">
<div className="space-y-1.5">
<label className="text-xs font-medium text-muted-foreground" htmlFor={`project-ticket-teamspace-${entry.clientId}`}>
Teamspace-Projekt {entry.isDefault ? "(Standard)" : ""}
@@ -346,8 +353,12 @@ function ProjectTeamspaceEntriesEditor({
</div>
<div className="space-y-1.5">
<label className="text-xs font-medium text-muted-foreground" htmlFor={`project-ticket-budget-${entry.clientId}`}>Budget h</label>
<Input id={`project-ticket-budget-${entry.clientId}`} inputMode="decimal" value={entry.hourBudget} onChange={(event) => updateEntry(entry.clientId, { hourBudget: event.currentTarget.value })} />
<Input id={`project-ticket-budget-${entry.clientId}`} inputMode="decimal" value={entry.hourBudget} onChange={(event) => updateEntry(entry.clientId, { hourBudget: event.currentTarget.value, budgetSyncDisabled: true })} />
</div>
<label className="flex min-h-9 items-center gap-2 rounded-md border bg-background px-3 text-xs">
<Checkbox checked={entry.budgetSyncDisabled} onCheckedChange={(checked) => updateEntry(entry.clientId, { budgetSyncDisabled: checked === true })} />
<span>Budget schützen</span>
</label>
<div className="space-y-1.5">
<label className="text-xs font-medium text-muted-foreground" htmlFor={`project-ticket-manual-${entry.clientId}`}>Manueller Verbrauch h</label>
<Input id={`project-ticket-manual-${entry.clientId}`} inputMode="decimal" value={entry.manualConsumption} onChange={(event) => updateEntry(entry.clientId, { manualConsumption: event.currentTarget.value })} />
@@ -418,6 +429,7 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onO
clientId: projectEntryId(),
teamspaceProject: "",
hourBudget: "",
budgetSyncDisabled: false,
manualConsumption: "",
isDefault: true
}
@@ -1108,6 +1120,8 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onO
<h2 className="text-xl font-semibold tracking-normal sm:text-2xl">{ticket?.title || ticket?.ticket_number || "Ticket"}</h2>
{ticket?.ticket_number ? <CopyTicketButton ticketNumber={ticket.ticket_number} /> : null}
{isProject ? <Badge variant="outline">Projekt</Badge> : null}
<ZammadStateBadge stateId={ticket?.zammad_state_id} />
<ZammadPriorityBadge priorityId={ticket?.zammad_priority_id} />
{(periodType === "month" || (isGlobalTicket && globalMonthFilter !== "all")) && data?.closed ? <Badge variant="outline">Monat geschlossen</Badge> : null}
</div>
<p className="text-sm text-muted-foreground">
@@ -1260,7 +1274,9 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onO
<CardTitle className="flex items-center gap-2">
<FolderKanban className="size-5 text-muted-foreground" />
Projekt
{project.closed_at ? <Badge variant="secondary">geschlossen</Badge> : <Badge variant="success">offen</Badge>}
{project.closed_at ? <Badge variant="secondary">Geschlossen</Badge> : <Badge variant="success">Offen</Badge>}
<ZammadStateBadge stateId={project.zammad_state_id} />
<ZammadPriorityBadge priorityId={project.zammad_priority_id} />
{!canEditProject ? <Badge variant="outline">geteilt</Badge> : null}
{project.close_review_required ? <Badge variant="warning">TS/TT prüfen</Badge> : null}
{(project.unassigned_teamspace_session_count ?? 0) > 0 ? <Badge variant="warning">{project.unassigned_teamspace_session_count} ohne TS</Badge> : null}
@@ -1341,6 +1357,7 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onO
<span className="truncate text-muted-foreground">
{label}
{entry.is_default ? " · Standard" : ""}
{entry.budget_sync_disabled ? " · Budget geschützt" : ""}
</span>
<span className="shrink-0 font-medium">
{formatMinutes(consumption)} / {entry.hour_budget_minutes ? formatMinutes(entry.hour_budget_minutes) : "offen"}
+61 -33
View File
@@ -20,6 +20,7 @@ import {
type ZammadSyncResult
} from "../api";
import type { AuthUser } from "../types";
import { zammadPriorityLabel, zammadStateLabel } from "../zammad-meta";
type ZammadSyncPageProps = {
currentUser: AuthUser;
@@ -32,10 +33,11 @@ const defaultFields: ZammadSyncExistingFields = {
organization: true,
type: true,
projectName: true,
budget: true
budget: true,
overrideBudget: false
};
const fieldOptions: Array<{ key: keyof ZammadSyncExistingFields; label: string; description: string }> = [
const fieldOptions: Array<{ key: Exclude<keyof ZammadSyncExistingFields, "overrideBudget">; label: string; description: string }> = [
{ key: "title", label: "Titel", description: "Ticket-Titel aus Zammad übernehmen." },
{ key: "organization", label: "Organisation", description: "Lokale Organisation anhand der Zammad-ID setzen." },
{ key: "type", label: "Typ", description: "Support/Consulting übernehmen, Projekte anlegen oder entfernen." },
@@ -164,16 +166,8 @@ function formatZammadGroup(value: unknown) {
function formatZammadPriority(value: unknown) {
const priorityId = numericValue(value);
if (priorityId === 1) {
return "1 (Niedrig)";
}
if (priorityId === 2) {
return "2 (Normal)";
}
if (priorityId === 3) {
return "3 (Hoch)";
if (priorityId !== null) {
return `${priorityId} (${zammadPriorityLabel(priorityId)})`;
}
return formatValue(value);
@@ -182,20 +176,8 @@ function formatZammadPriority(value: unknown) {
function formatZammadState(value: unknown) {
const stateId = numericValue(value);
if (stateId === 2) {
return "2 (offen)";
}
if (stateId === 3) {
return "3 (warten auf Erinnerung)";
}
if (stateId === 4) {
return "4 (geschlossen)";
}
if (stateId === 7) {
return "7 (warten auf schließen)";
if (stateId !== null) {
return `${stateId} (${zammadStateLabel(stateId)})`;
}
return formatValue(value);
@@ -222,6 +204,10 @@ function formatChangeValue(field: string, value: unknown) {
return formatMinutes(value);
}
if (field === "Teamspace-Projekte") {
return formatTeamspaceProjectList(value) || "-";
}
if (field === "Typ") {
return formatWorkType(value);
}
@@ -242,7 +228,7 @@ function formatChangeValue(field: string, value: unknown) {
}
function visibleTicketTrackerChanges(item: ZammadSyncItem) {
return item.changes.filter((change) => !change.field.startsWith("Zammad-"));
return item.changes.filter((change) => !change.field.startsWith("Zammad-") || change.field === "Zammad-Status" || change.field === "Zammad-Priorität");
}
function changeAllowedByFields(item: ZammadSyncItem, change: ZammadSyncChange, fields: ZammadSyncExistingFields) {
@@ -270,6 +256,10 @@ function changeAllowedByFields(item: ZammadSyncItem, change: ZammadSyncChange, f
return fields.projectName && consultingProjectCanBeTouched;
}
if (change.field === "Teamspace-Projekte") {
return fields.projectName && consultingProjectCanBeTouched;
}
if (change.field === "Teamspace-Budget") {
return fields.budget && consultingProjectCanBeTouched;
}
@@ -290,6 +280,23 @@ function cachedObject(source: Record<string, unknown> | null | undefined, key: s
return value && typeof value === "object" && !Array.isArray(value) ? (value as Record<string, unknown>) : null;
}
function cachedStringList(source: Record<string, unknown> | null | undefined, key: string) {
const value = cachedValue(source, key);
return Array.isArray(value) ? value.map((entry) => (typeof entry === "string" ? entry.trim() : "")).filter(Boolean) : [];
}
function formatTeamspaceProjectList(value: unknown) {
if (Array.isArray(value)) {
return value.map((entry) => (typeof entry === "string" ? entry.trim() : "")).filter(Boolean).join(" + ");
}
return typeof value === "string" ? value.trim() : "";
}
function splitTeamspaceProjects(value: unknown) {
return typeof value === "string" ? value.split("+").map((entry) => entry.trim()).filter(Boolean) : [];
}
function comparablePreviewValue(value: unknown) {
if (typeof value === "string" && value.trim().length === 0) {
return null;
@@ -317,10 +324,14 @@ function comparablePreviewIdValue(value: unknown) {
}
function comparablePreviewValueForField(field: string, value: unknown) {
if (field === "Organisation") {
if (field === "Organisation" || field === "Zammad-Priorität" || field === "Zammad-Status") {
return comparablePreviewIdValue(value);
}
if (field === "Teamspace-Projekte" && Array.isArray(value)) {
return value.map((entry) => comparablePreviewValue(entry));
}
return comparablePreviewValue(value);
}
@@ -349,8 +360,16 @@ function changesFromExistingPreviewCache(item: ZammadSyncItem, fields: ZammadSyn
const localWorkType = cachedValue(local, "workType");
const zammadWorkType = cachedValue(zammad, "workType");
const zammadStateId = numericValue(cachedValue(zammad, "stateId"));
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");
const zammadTeamspaceProjectList = zammadTeamspaceProjects.length ? zammadTeamspaceProjects : splitTeamspaceProjects(cachedValue(zammad, "teamspaceProject"));
const canSyncBudget = fields.budget && (cachedValue(project, "defaultBudgetSyncDisabled") !== true || fields.overrideBudget);
const changes: ZammadSyncChange[] = [];
addCachedChange(changes, "Zammad-Priorität", cachedValue(local, "zammadPriorityId"), cachedValue(zammad, "priorityId"));
addCachedChange(changes, "Zammad-Status", cachedValue(local, "zammadStateId"), cachedValue(zammad, "stateId"));
if (zammadWorkType === "support" && zammadStateId === 4) {
return changes;
}
@@ -379,11 +398,11 @@ function changesFromExistingPreviewCache(item: ZammadSyncItem, fields: ZammadSyn
}
if (fields.projectName) {
addCachedChange(changes, "Teamspace-Projekt", cachedValue(project, "teamspaceProject"), cachedValue(zammad, "teamspaceProject"));
addCachedChange(changes, "Teamspace-Projekte", localTeamspaceProjectList, zammadTeamspaceProjectList);
}
if (fields.budget) {
addCachedChange(changes, "Teamspace-Budget", cachedValue(project, "hourBudgetMinutes"), cachedValue(zammad, "teamspaceBudgetMinutes"));
if (canSyncBudget) {
addCachedChange(changes, "Teamspace-Budget", cachedValue(project, "defaultHourBudgetMinutes") ?? cachedValue(project, "hourBudgetMinutes"), cachedValue(zammad, "teamspaceBudgetMinutes"));
}
if (zammadStateId === 4 && cachedValue(project, "closedAt") === null && cachedValue(project, "closeReviewRequired") !== true) {
@@ -516,7 +535,7 @@ function zammadDetails(item: ZammadSyncItem) {
`Owner ${formatValue(zammad.ownerId)}`,
`Org ${formatValue(zammad.organizationId)}`,
`Titel ${formatValue(zammad.title)}`,
`Projekt ${formatValue(zammad.teamspaceProject)}`,
`Projekt ${formatValue(formatTeamspaceProjectList(zammad.teamspaceProjects) || zammad.teamspaceProject)}`,
`Budget ${formatMinutes(zammad.teamspaceBudgetMinutes)}`
];
}
@@ -681,7 +700,7 @@ export function ZammadSyncPage({ currentUser }: ZammadSyncPageProps) {
function setField(key: keyof ZammadSyncExistingFields, checked: boolean) {
setZammadSyncPageState((current) => ({
fields: { ...current.fields, [key]: checked }
fields: { ...current.fields, [key]: checked, ...(key === "budget" && !checked ? { overrideBudget: false } : {}) }
}));
}
@@ -894,6 +913,15 @@ export function ZammadSyncPage({ currentUser }: ZammadSyncPageProps) {
</label>
))}
</div>
{fields.budget ? (
<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>
</label>
) : null}
<div className="flex flex-wrap justify-end gap-2">
{syncingExisting ? (
<Button type="button" variant="outline" onClick={resetRunningState}>
+149
View File
@@ -0,0 +1,149 @@
import { AlertTriangle, ArrowDown, ArrowUp, CheckCircle2, CircleDot, CirclePause, Flag, Minus } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { cn } from "@/lib/utils";
export type ZammadStateFilter = "all" | "none" | "2" | "3" | "4" | "7";
export type ZammadPriorityFilter = "all" | "none" | "1" | "2" | "3";
export const zammadStateFilterOptions: Array<{ value: ZammadStateFilter; label: string }> = [
{ value: "all", label: "Alle Zammad-Status" },
{ value: "2", label: "Offen" },
{ value: "3", label: "Wartend" },
{ value: "4", label: "Geschlossen" },
{ value: "7", label: "Abschluss" },
{ value: "none", label: "Ohne Zammad-Status" }
];
export const zammadPriorityFilterOptions: Array<{ value: ZammadPriorityFilter; label: string }> = [
{ value: "all", label: "Alle Prioritäten" },
{ value: "1", label: "Niedrig" },
{ value: "2", label: "Normal" },
{ value: "3", label: "Hoch" },
{ value: "none", label: "Ohne Priorität" }
];
const zammadStateMeta = {
"2": {
label: "Offen",
order: 10,
className: "border-emerald-500/30 bg-emerald-500/10 text-emerald-700 dark:text-emerald-300",
Icon: CircleDot
},
"3": {
label: "Wartend",
order: 20,
className: "border-blue-500/30 bg-blue-500/10 text-blue-700 dark:text-blue-300",
Icon: CirclePause
},
"7": {
label: "Abschluss",
order: 30,
className: "border-amber-500/30 bg-amber-500/10 text-amber-700 dark:text-amber-300",
Icon: Flag
},
"4": {
label: "Geschlossen",
order: 40,
className: "border-slate-500/30 bg-slate-500/10 text-slate-700 dark:text-slate-300",
Icon: CheckCircle2
}
};
const zammadPriorityMeta = {
"1": {
label: "Niedrig",
order: 10,
className: "border-blue-500/30 bg-blue-500/10 text-blue-700 dark:text-blue-300",
Icon: ArrowDown
},
"2": {
label: "Normal",
order: 20,
className: "border-emerald-500/30 bg-emerald-500/10 text-emerald-700 dark:text-emerald-300",
Icon: Minus
},
"3": {
label: "Hoch",
order: 30,
className: "border-red-500/30 bg-red-500/10 text-red-700 dark:text-red-300",
Icon: ArrowUp
}
};
export function zammadId(value: unknown) {
if (value === null || value === undefined || value === "") {
return null;
}
const normalized = String(value).trim();
return /^\d+$/.test(normalized) ? normalized.replace(/^0+(?=\d)/, "") : normalized || null;
}
export function zammadStateLabel(value: unknown) {
const id = zammadId(value);
return id ? zammadStateMeta[id as keyof typeof zammadStateMeta]?.label ?? `Status ${id}` : "-";
}
export function zammadPriorityLabel(value: unknown) {
const id = zammadId(value);
return id ? zammadPriorityMeta[id as keyof typeof zammadPriorityMeta]?.label ?? `Priorität ${id}` : "-";
}
export function zammadStateSortRank(value: unknown) {
const id = zammadId(value);
const fallback = id ? Number(id) : Number.NaN;
return id ? zammadStateMeta[id as keyof typeof zammadStateMeta]?.order ?? (Number.isFinite(fallback) ? 900 + fallback : 999) : 999;
}
export function zammadPrioritySortRank(value: unknown) {
const id = zammadId(value);
const fallback = id ? Number(id) : Number.NaN;
return id ? zammadPriorityMeta[id as keyof typeof zammadPriorityMeta]?.order ?? (Number.isFinite(fallback) ? 900 + fallback : 999) : 999;
}
export function matchesZammadStateFilter(value: unknown, filter: ZammadStateFilter) {
const id = zammadId(value);
return filter === "all" || (filter === "none" ? !id : id === filter);
}
export function matchesZammadPriorityFilter(value: unknown, filter: ZammadPriorityFilter) {
const id = zammadId(value);
return filter === "all" || (filter === "none" ? !id : id === filter);
}
export function ZammadStateBadge({ stateId, className }: { stateId: unknown; className?: string }) {
const id = zammadId(stateId);
if (!id) {
return null;
}
const meta = zammadStateMeta[id as keyof typeof zammadStateMeta];
const Icon = meta?.Icon ?? AlertTriangle;
return (
<Badge variant="outline" className={cn(meta?.className ?? "border-border bg-muted/40 text-muted-foreground", className)}>
<Icon className="size-3" />
{meta?.label ?? `Status ${id}`}
</Badge>
);
}
export function ZammadPriorityBadge({ priorityId, className }: { priorityId: unknown; className?: string }) {
const id = zammadId(priorityId);
if (!id) {
return null;
}
const meta = zammadPriorityMeta[id as keyof typeof zammadPriorityMeta];
const Icon = meta?.Icon ?? AlertTriangle;
return (
<Badge variant="outline" className={cn(meta?.className ?? "border-border bg-muted/40 text-muted-foreground", className)}>
<Icon className="size-3" />
{meta?.label ?? `Priorität ${id}`}
</Badge>
);
}