This commit is contained in:
michael committed 2026-09-15 11:49:47 +00:00
1 parent 235c19e51b
commit 227644cfeb
8 files changed
+1033 -301

No files matched your search

+195 -61
View File
@@ -1097,6 +1097,32 @@ function parseProjectTeamspaceEntries(
return entries; return entries;
} }
type ParsedProjectTeamspaceEntry = ReturnType<typeof parseProjectTeamspaceEntries>[number];
function budgetMinutesChanged(left: number | null | undefined, right: number | null | undefined) {
return (left ?? null) !== (right ?? null);
}
function protectNewManualBudgetEntries(entries: ParsedProjectTeamspaceEntry[]) {
return entries.map((entry) => entry.hourBudgetMinutes !== null ? { ...entry, budgetSyncDisabled: true } : entry);
}
function protectChangedManualBudgetEntries(
entries: ParsedProjectTeamspaceEntry[],
currentEntries: Array<{ id: string; hour_budget_minutes: number | null }>
) {
const currentById = new Map(currentEntries.map((entry) => [entry.id, entry.hour_budget_minutes]));
return entries.map((entry) => {
const currentBudget = entry.id ? currentById.get(String(entry.id)) : undefined;
const manuallyChanged = entry.id
? currentBudget !== undefined && budgetMinutesChanged(currentBudget, entry.hourBudgetMinutes)
: entry.hourBudgetMinutes !== null;
return manuallyChanged ? { ...entry, budgetSyncDisabled: true } : entry;
});
}
function parseTodoPriority(value: unknown) { function parseTodoPriority(value: unknown) {
if (value !== "low" && value !== "medium" && value !== "high" && value !== "urgent") { if (value !== "low" && value !== "medium" && value !== "high" && value !== "urgent") {
throw badRequest("priority must be low, medium, high or urgent"); throw badRequest("priority must be low, medium, high or urgent");
@@ -1994,8 +2020,11 @@ function syncComparableValueForField(field: string, value: unknown) {
return syncIdComparableValue(value); return syncIdComparableValue(value);
} }
if (field === "Teamspace-Projekte" && Array.isArray(value)) { if (field === "Teamspace-Projekte") {
return value.map((entry) => syncComparableValue(entry)); const entries = Array.isArray(value)
? value.map((entry) => (typeof entry === "string" ? entry : null))
: parseZammadTeamspaceProjects(value);
return normalizedTeamspaceProjectList(entries).sort((left, right) => left.localeCompare(right, "de", { numeric: true, sensitivity: "base" }));
} }
return syncComparableValue(value); return syncComparableValue(value);
@@ -2007,7 +2036,7 @@ function syncValueChanged(field: string, left: unknown, right: unknown) {
function addSyncChange(changes: ZammadSyncChange[], field: string, from: unknown, to: unknown) { function addSyncChange(changes: ZammadSyncChange[], field: string, from: unknown, to: unknown) {
if (syncValueChanged(field, from, to)) { if (syncValueChanged(field, from, to)) {
changes.push({ field, from: syncComparableValue(from), to: syncComparableValue(to) }); changes.push({ field, from: syncComparableValueForField(field, from), to: syncComparableValueForField(field, to) });
} }
} }
@@ -2068,7 +2097,11 @@ function zammadTicketFromSyncSnapshot(snapshot: Record<string, unknown> | null):
}; };
} }
function localSyncSnapshot(localTicket: LocalZammadSyncTicket, project: SyncProjectSnapshot | null = null) { function localSyncSnapshot(
localTicket: LocalZammadSyncTicket,
project: SyncProjectSnapshot | null = null,
options: { zammadPendingTodoExists?: boolean | null } = {}
) {
return { return {
ticketId: localTicket.id, ticketId: localTicket.id,
ticketNumber: localTicket.ticket_number, ticketNumber: localTicket.ticket_number,
@@ -2081,6 +2114,7 @@ function localSyncSnapshot(localTicket: LocalZammadSyncTicket, project: SyncProj
zammadPriorityId: localTicket.zammad_priority_id, zammadPriorityId: localTicket.zammad_priority_id,
zammadStateId: localTicket.zammad_state_id, zammadStateId: localTicket.zammad_state_id,
zammadOwnerId: localTicket.zammad_owner_id, zammadOwnerId: localTicket.zammad_owner_id,
zammadPendingTodoExists: options.zammadPendingTodoExists ?? null,
project: project project: project
? { ? {
id: project.id, id: project.id,
@@ -2169,8 +2203,60 @@ function zammadTeamspaceProjectList(zammadTicket: ZammadTicketImport) {
return zammadTicket.teamspaceProjects.length ? zammadTicket.teamspaceProjects : zammadTicket.teamspaceProject ? [zammadTicket.teamspaceProject] : []; return zammadTicket.teamspaceProjects.length ? zammadTicket.teamspaceProjects : zammadTicket.teamspaceProject ? [zammadTicket.teamspaceProject] : [];
} }
function normalizedTeamspaceProjectList(projects: Array<string | null | undefined>) {
const seen = new Set<string>();
const normalized: string[] = [];
for (const project of projects) {
const value = project?.trim();
if (!value || seen.has(value)) {
continue;
}
seen.add(value);
normalized.push(value);
}
return normalized;
}
function formatTeamspaceProjectList(projects: Array<string | null | undefined>) { function formatTeamspaceProjectList(projects: Array<string | null | undefined>) {
return projects.map((project) => project?.trim()).filter(Boolean).join(" + "); return normalizedTeamspaceProjectList(projects).join(" + ");
}
function teamspaceProjectDiff(localProjects: Array<string | null | undefined>, zammadProjects: Array<string | null | undefined>) {
const local = normalizedTeamspaceProjectList(localProjects);
const zammad = normalizedTeamspaceProjectList(zammadProjects);
const localSet = new Set(local);
const zammadSet = new Set(zammad);
return {
local,
zammad,
additions: zammad.filter((project) => !localSet.has(project)),
missingInZammad: local.filter((project) => !zammadSet.has(project))
};
}
function addTeamspaceProjectSyncChanges(changes: ZammadSyncChange[], localProjects: Array<string | null | undefined>, zammadProjects: Array<string | null | undefined>) {
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 zammadPendingTodoPayload(zammadTicket: ZammadTicketImport) { function zammadPendingTodoPayload(zammadTicket: ZammadTicketImport) {
@@ -2224,7 +2310,16 @@ async function ensureZammadPendingTodo(client: QueryClient, ticketId: string, us
await client.query( await client.query(
` `
INSERT INTO ticket_todos (ticket_id, user_id, title, description, priority, starts_on, ends_on, sort_order) INSERT INTO ticket_todos (ticket_id, user_id, title, description, priority, starts_on, ends_on, sort_order)
VALUES ($1, $2, $3, $4, 'high', $5::date, $5::date, (SELECT COALESCE(MAX(sort_order), 0) + 1024 FROM ticket_todos WHERE user_id = $2)); SELECT $1, $2, $3, $4, 'high', $5::date, $5::date, (SELECT COALESCE(MAX(sort_order), 0) + 1024 FROM ticket_todos WHERE user_id = $2)
WHERE NOT EXISTS (
SELECT 1
FROM ticket_todos
WHERE ticket_id = $1
AND user_id = $2
AND title = $3
AND starts_on = $5::date
AND ends_on = $5::date
);
`, `,
[ticketId, userId, payload.title, payload.description, payload.day] [ticketId, userId, payload.title, payload.description, payload.day]
); );
@@ -2237,13 +2332,15 @@ async function syncProjectTeamspaceEntriesFromZammad(
budgetMinutes: number | null, budgetMinutes: number | null,
syncBudget: boolean syncBudget: boolean
) { ) {
if (teamspaceProjects.length === 0) { const zammadProjects = normalizedTeamspaceProjectList(teamspaceProjects);
if (zammadProjects.length === 0) {
return; return;
} }
const existingResult = await client.query<{ id: string }>( const existingResult = await client.query<{ id: string; teamspace_project: string | null; is_default: boolean; display_order: number }>(
` `
SELECT id::text AS id SELECT id::text AS id, teamspace_project, is_default, display_order
FROM project_teamspace_entries FROM project_teamspace_entries
WHERE project_id = $1 WHERE project_id = $1
ORDER BY is_default DESC, display_order ASC, id ASC ORDER BY is_default DESC, display_order ASC, id ASC
@@ -2251,51 +2348,17 @@ async function syncProjectTeamspaceEntriesFromZammad(
`, `,
[projectId] [projectId]
); );
const savedIds: string[] = [];
await client.query("UPDATE project_teamspace_entries SET is_default = false, updated_at = now() WHERE project_id = $1;", [projectId]); if (existingResult.rows.length === 0) {
for (const [index, teamspaceProject] of zammadProjects.entries()) {
for (const [index, teamspaceProject] of teamspaceProjects.entries()) {
const existing = existingResult.rows[index];
const isDefault = index === 0;
if (existing) {
await client.query( await client.query(
`
UPDATE project_teamspace_entries
SET teamspace_project = $1,
is_default = $2,
hour_budget_minutes = CASE WHEN $3::boolean THEN $4 ELSE hour_budget_minutes END,
budget_sync_disabled = CASE WHEN $3::boolean THEN false ELSE budget_sync_disabled END,
display_order = $5,
updated_at = now()
WHERE id = $6
AND project_id = $7;
`,
[teamspaceProject, isDefault, syncBudget && isDefault, budgetMinutes, index * 1024, existing.id, projectId]
);
savedIds.push(existing.id);
} else {
const inserted = await client.query<{ id: string }>(
` `
INSERT INTO project_teamspace_entries (project_id, teamspace_project, is_default, hour_budget_minutes, budget_sync_disabled, manual_consumption_minutes, display_order) INSERT INTO project_teamspace_entries (project_id, teamspace_project, is_default, hour_budget_minutes, budget_sync_disabled, manual_consumption_minutes, display_order)
VALUES ($1, $2, $3, $4, false, 0, $5) VALUES ($1, $2, $3, $4, false, 0, $5);
RETURNING id::text AS id;
`, `,
[projectId, teamspaceProject, isDefault, syncBudget && isDefault ? budgetMinutes : null, index * 1024] [projectId, teamspaceProject, index === 0, syncBudget && index === 0 ? budgetMinutes : null, index * 1024]
); );
savedIds.push(inserted.rows[0].id);
} }
}
await client.query(
`
DELETE FROM project_teamspace_entries
WHERE project_id = $1
AND NOT (id = ANY($2::bigint[]));
`,
[projectId, savedIds]
);
await client.query( await client.query(
` `
@@ -2305,7 +2368,59 @@ async function syncProjectTeamspaceEntriesFromZammad(
updated_at = now() updated_at = now()
WHERE id = $1; WHERE id = $1;
`, `,
[projectId, teamspaceProjects[0] ?? null, syncBudget, budgetMinutes] [projectId, zammadProjects[0] ?? null, syncBudget, budgetMinutes]
);
return;
}
let defaultEntry = existingResult.rows.find((entry) => entry.is_default) ?? existingResult.rows[0];
if (!defaultEntry.is_default) {
await client.query("UPDATE project_teamspace_entries SET is_default = (id = $2), updated_at = now() WHERE project_id = $1;", [projectId, defaultEntry.id]);
defaultEntry = { ...defaultEntry, is_default: true };
}
const existingTeamspaceProjects = new Set(normalizedTeamspaceProjectList(existingResult.rows.map((entry) => entry.teamspace_project)));
let nextDisplayOrder = Math.max(...existingResult.rows.map((entry) => Number(entry.display_order ?? 0)), 0) + 1024;
for (const teamspaceProject of zammadProjects) {
if (existingTeamspaceProjects.has(teamspaceProject)) {
continue;
}
await client.query(
`
INSERT INTO project_teamspace_entries (project_id, teamspace_project, is_default, hour_budget_minutes, budget_sync_disabled, manual_consumption_minutes, display_order)
VALUES ($1, $2, false, NULL, false, 0, $3);
`,
[projectId, teamspaceProject, nextDisplayOrder]
);
nextDisplayOrder += 1024;
}
if (syncBudget) {
await client.query(
`
UPDATE project_teamspace_entries
SET hour_budget_minutes = $1,
budget_sync_disabled = false,
updated_at = now()
WHERE id = $2
AND project_id = $3;
`,
[budgetMinutes, defaultEntry.id, projectId]
);
}
await client.query(
`
UPDATE projects
SET teamspace_project = $2,
hour_budget_minutes = CASE WHEN $3::boolean THEN $4 ELSE hour_budget_minutes END,
updated_at = now()
WHERE id = $1;
`,
[projectId, defaultEntry.teamspace_project, syncBudget, budgetMinutes]
); );
} }
@@ -2317,12 +2432,13 @@ async function planExistingZammadTicketSync(
fields: ZammadSyncExistingFields fields: ZammadSyncExistingFields
): Promise<ZammadSyncItem | null> { ): Promise<ZammadSyncItem | null> {
const changes: ZammadSyncChange[] = []; const changes: ZammadSyncChange[] = [];
const pendingTodo = zammadPendingTodoPayload(zammadTicket);
const pendingTodoExists = pendingTodo ? await zammadPendingTodoExists(client, localTicket.id, userId, zammadTicket) : null;
addSyncChange(changes, "Zammad-Priorität", localTicket.zammad_priority_id, zammadTicket.zammadPriorityId); addSyncChange(changes, "Zammad-Priorität", localTicket.zammad_priority_id, zammadTicket.zammadPriorityId);
addSyncChange(changes, "Zammad-Status", localTicket.zammad_state_id, zammadTicket.zammadStateId); addSyncChange(changes, "Zammad-Status", localTicket.zammad_state_id, zammadTicket.zammadStateId);
const pendingTodo = zammadPendingTodoPayload(zammadTicket); if (fields.pendingTodo && pendingTodo && !pendingTodoExists) {
if (fields.pendingTodo && pendingTodo && !(await zammadPendingTodoExists(client, localTicket.id, userId, zammadTicket))) {
changes.push({ field: "ToDo", from: null, to: `${pendingTodo.title} (${pendingTodo.day})` }); changes.push({ field: "ToDo", from: null, to: `${pendingTodo.title} (${pendingTodo.day})` });
} }
@@ -2332,7 +2448,7 @@ async function planExistingZammadTicketSync(
ticketNumber: localTicket.ticket_number, ticketNumber: localTicket.ticket_number,
action: "update", action: "update",
reason: "Zammad-Metadaten werden aktualisiert.", reason: "Zammad-Metadaten werden aktualisiert.",
local: localSyncSnapshot(localTicket), local: localSyncSnapshot(localTicket, null, { zammadPendingTodoExists: pendingTodoExists }),
zammad: zammadSyncSnapshot(zammadTicket), zammad: zammadSyncSnapshot(zammadTicket),
changes changes
} }
@@ -2346,7 +2462,7 @@ async function planExistingZammadTicketSync(
ticketNumber: localTicket.ticket_number, ticketNumber: localTicket.ticket_number,
action: "skip", action: "skip",
reason: "Zammad-Organisation ist lokal nicht synchronisiert.", reason: "Zammad-Organisation ist lokal nicht synchronisiert.",
local: localSyncSnapshot(localTicket), local: localSyncSnapshot(localTicket, null, { zammadPendingTodoExists: pendingTodoExists }),
zammad: zammadSyncSnapshot(zammadTicket), zammad: zammadSyncSnapshot(zammadTicket),
changes: [] changes: []
}; };
@@ -2374,7 +2490,7 @@ async function planExistingZammadTicketSync(
ticketNumber: localTicket.ticket_number, ticketNumber: localTicket.ticket_number,
action: changes.length > 0 ? "update" : "skip", action: changes.length > 0 ? "update" : "skip",
reason: changes.length > 0 ? "Ticket wird als Support synchronisiert." : "Keine Änderung nach aktueller Auswahl.", reason: changes.length > 0 ? "Ticket wird als Support synchronisiert." : "Keine Änderung nach aktueller Auswahl.",
local: localSyncSnapshot(localTicket), local: localSyncSnapshot(localTicket, null, { zammadPendingTodoExists: pendingTodoExists }),
zammad: zammadSyncSnapshot(zammadTicket), zammad: zammadSyncSnapshot(zammadTicket),
changes changes
}; };
@@ -2390,7 +2506,7 @@ async function planExistingZammadTicketSync(
const zammadTeamspaceProjects = zammadTeamspaceProjectList(zammadTicket); const zammadTeamspaceProjects = zammadTeamspaceProjectList(zammadTicket);
const localTeamspaceProjects = project?.teamspace_entries?.length const localTeamspaceProjects = project?.teamspace_entries?.length
? project.teamspace_entries.map((entry) => entry.teamspace_project) ? project.teamspace_entries.map((entry) => entry.teamspace_project)
: [project?.teamspace_project ?? null]; : project?.teamspace_project ? [project.teamspace_project] : [];
const canSyncBudget = fields.budget && (!project?.default_budget_sync_disabled || fields.overrideBudget); const canSyncBudget = fields.budget && (!project?.default_budget_sync_disabled || fields.overrideBudget);
if (zammadTicket.zammadStateId === 4 && project?.closed_at) { if (zammadTicket.zammadStateId === 4 && project?.closed_at) {
@@ -2402,7 +2518,7 @@ async function planExistingZammadTicketSync(
} }
if (fields.projectName) { if (fields.projectName) {
addSyncChange(changes, "Teamspace-Projekte", localTeamspaceProjects, zammadTeamspaceProjects); addTeamspaceProjectSyncChanges(changes, localTeamspaceProjects, zammadTeamspaceProjects);
} }
if (canSyncBudget) { if (canSyncBudget) {
@@ -2424,7 +2540,7 @@ async function planExistingZammadTicketSync(
ticketNumber: localTicket.ticket_number, ticketNumber: localTicket.ticket_number,
action: changes.length > 0 ? "update" : "skip", action: changes.length > 0 ? "update" : "skip",
reason: changes.length > 0 ? "Ticket wird aktualisiert." : "Keine Änderung nach aktueller Auswahl.", reason: changes.length > 0 ? "Ticket wird aktualisiert." : "Keine Änderung nach aktueller Auswahl.",
local: localSyncSnapshot(localTicket, project), local: localSyncSnapshot(localTicket, project, { zammadPendingTodoExists: pendingTodoExists }),
zammad: zammadSyncSnapshot(zammadTicket), zammad: zammadSyncSnapshot(zammadTicket),
changes changes
}; };
@@ -6230,6 +6346,8 @@ app.post("/api/projects", requireUser, async (req, res) => {
if (zammadTicket) { if (zammadTicket) {
const zammadTeamspaceProjects = zammadTeamspaceProjectList(zammadTicket); const zammadTeamspaceProjects = zammadTeamspaceProjectList(zammadTicket);
const currentDefaultTeamspaceEntry = teamspaceEntries.find((entry) => entry.isDefault) ?? teamspaceEntries[0]; const currentDefaultTeamspaceEntry = teamspaceEntries.find((entry) => entry.isDefault) ?? teamspaceEntries[0];
const defaultBudgetManuallyChanged = currentDefaultTeamspaceEntry.hourBudgetMinutes !== null
&& budgetMinutesChanged(currentDefaultTeamspaceEntry.hourBudgetMinutes, zammadTicket.teamspaceBudgetMinutes);
teamspaceEntries = zammadTeamspaceProjects.length > 0 teamspaceEntries = zammadTeamspaceProjects.length > 0
? zammadTeamspaceProjects.map((teamspaceProjectName, index) => ({ ? zammadTeamspaceProjects.map((teamspaceProjectName, index) => ({
@@ -6237,7 +6355,7 @@ app.post("/api/projects", requireUser, async (req, res) => {
teamspaceProject: teamspaceProjectName, teamspaceProject: teamspaceProjectName,
isDefault: index === 0, isDefault: index === 0,
hourBudgetMinutes: index === 0 ? currentDefaultTeamspaceEntry.hourBudgetMinutes ?? zammadTicket.teamspaceBudgetMinutes : null, hourBudgetMinutes: index === 0 ? currentDefaultTeamspaceEntry.hourBudgetMinutes ?? zammadTicket.teamspaceBudgetMinutes : null,
budgetSyncDisabled: false, budgetSyncDisabled: index === 0 && (currentDefaultTeamspaceEntry.budgetSyncDisabled || defaultBudgetManuallyChanged),
manualConsumptionMinutes: index === 0 ? currentDefaultTeamspaceEntry.manualConsumptionMinutes : 0, manualConsumptionMinutes: index === 0 ? currentDefaultTeamspaceEntry.manualConsumptionMinutes : 0,
displayOrder: index * 1024 displayOrder: index * 1024
})) }))
@@ -6247,10 +6365,14 @@ app.post("/api/projects", requireUser, async (req, res) => {
...entry, ...entry,
teamspaceProject: entry.teamspaceProject ?? zammadTicket.teamspaceProject, teamspaceProject: entry.teamspaceProject ?? zammadTicket.teamspaceProject,
hourBudgetMinutes: entry.hourBudgetMinutes ?? zammadTicket.teamspaceBudgetMinutes, hourBudgetMinutes: entry.hourBudgetMinutes ?? zammadTicket.teamspaceBudgetMinutes,
budgetSyncDisabled: false budgetSyncDisabled: entry.budgetSyncDisabled || (entry.hourBudgetMinutes !== null && budgetMinutesChanged(entry.hourBudgetMinutes, zammadTicket.teamspaceBudgetMinutes))
} }
: entry : entry
); )
.map((entry) => !entry.isDefault && entry.hourBudgetMinutes !== null ? { ...entry, budgetSyncDisabled: true } : entry);
defaultTeamspaceEntry = teamspaceEntries.find((entry) => entry.isDefault) ?? teamspaceEntries[0];
} else {
teamspaceEntries = protectNewManualBudgetEntries(teamspaceEntries);
defaultTeamspaceEntry = teamspaceEntries.find((entry) => entry.isDefault) ?? teamspaceEntries[0]; defaultTeamspaceEntry = teamspaceEntries.find((entry) => entry.isDefault) ?? teamspaceEntries[0];
} }
@@ -6474,8 +6596,8 @@ app.patch("/api/projects/:projectId", requireUser, async (req, res) => {
const endsOn = parseOptionalDateOnly(req.body.endsOn, "endsOn"); const endsOn = parseOptionalDateOnly(req.body.endsOn, "endsOn");
const hourBudgetMinutes = parseOptionalNonNegativeInteger(req.body.hourBudgetMinutes, "hourBudgetMinutes"); const hourBudgetMinutes = parseOptionalNonNegativeInteger(req.body.hourBudgetMinutes, "hourBudgetMinutes");
const manualConsumptionMinutes = parseOptionalNonNegativeInteger(req.body.manualConsumptionMinutes, "manualConsumptionMinutes") ?? 0; const manualConsumptionMinutes = parseOptionalNonNegativeInteger(req.body.manualConsumptionMinutes, "manualConsumptionMinutes") ?? 0;
const teamspaceEntries = parseProjectTeamspaceEntries(req.body.teamspaceEntries, { teamspaceProject, hourBudgetMinutes, manualConsumptionMinutes }); let teamspaceEntries = parseProjectTeamspaceEntries(req.body.teamspaceEntries, { teamspaceProject, hourBudgetMinutes, manualConsumptionMinutes });
const defaultTeamspaceEntry = teamspaceEntries.find((entry) => entry.isDefault) ?? teamspaceEntries[0]; let defaultTeamspaceEntry = teamspaceEntries.find((entry) => entry.isDefault) ?? teamspaceEntries[0];
if (startsOn && endsOn && endsOn < startsOn) { if (startsOn && endsOn && endsOn < startsOn) {
throw badRequest("endsOn must be after startsOn"); throw badRequest("endsOn must be after startsOn");
@@ -6498,6 +6620,18 @@ app.patch("/api/projects/:projectId", requireUser, async (req, res) => {
return null; return null;
} }
const currentTeamspaceEntries = await client.query<{ id: string; hour_budget_minutes: number | null }>(
`
SELECT id::text AS id, hour_budget_minutes
FROM project_teamspace_entries
WHERE project_id = $1
FOR UPDATE;
`,
[projectId]
);
teamspaceEntries = protectChangedManualBudgetEntries(teamspaceEntries, currentTeamspaceEntries.rows);
defaultTeamspaceEntry = teamspaceEntries.find((entry) => entry.isDefault) ?? teamspaceEntries[0];
const organization = await getOrganizationByZammadId(client, organizationId); const organization = await getOrganizationByZammadId(client, organizationId);
if (!organization) { if (!organization) {
+11 -10
View File
@@ -375,7 +375,7 @@ function wait(milliseconds: number) {
return new Promise((resolve) => window.setTimeout(resolve, milliseconds)); return new Promise((resolve) => window.setTimeout(resolve, milliseconds));
} }
async function waitForZammadSyncJob(jobId: string) { export async function waitForZammadSyncJob(jobId: string) {
const startedAt = Date.now(); const startedAt = Date.now();
const timeoutMs = 10 * 60 * 1000; const timeoutMs = 10 * 60 * 1000;
@@ -398,12 +398,13 @@ async function waitForZammadSyncJob(jobId: string) {
} }
} }
async function runZammadSyncJob(url: string, body: Record<string, unknown>) { async function runZammadSyncJob(url: string, body: Record<string, unknown>, onJobStarted?: (job: ZammadSyncJob) => void) {
const { job } = await request<{ job: ZammadSyncJob }>(url, { const { job } = await request<{ job: ZammadSyncJob }>(url, {
method: "POST", method: "POST",
timeoutMs: 20_000, timeoutMs: 20_000,
body: JSON.stringify(body) body: JSON.stringify(body)
}); });
onJobStarted?.(job);
const result = await waitForZammadSyncJob(job.id); const result = await waitForZammadSyncJob(job.id);
return { jobId: job.id, result }; return { jobId: job.id, result };
@@ -428,30 +429,30 @@ export async function importOwnZammadConsultingTickets(options: { dryRun?: boole
return result; return result;
} }
export function previewExistingZammadTickets(fields: ZammadSyncExistingFields) { export function previewExistingZammadTickets(fields: ZammadSyncExistingFields, onJobStarted?: (job: ZammadSyncJob) => void) {
return runZammadSyncJob("/api/zammad-sync/jobs/existing", { return runZammadSyncJob("/api/zammad-sync/jobs/existing", {
fields, fields,
dryRun: true dryRun: true
}); }, onJobStarted);
} }
export function applyExistingZammadTicketsFromPreview(previewJobId: string, fields: ZammadSyncExistingFields, ticketNumbers: string[]) { export function applyExistingZammadTicketsFromPreview(previewJobId: string, fields: ZammadSyncExistingFields, ticketNumbers: string[], onJobStarted?: (job: ZammadSyncJob) => void) {
return runZammadSyncJob(`/api/zammad-sync/jobs/${encodeURIComponent(previewJobId)}/apply-existing`, { return runZammadSyncJob(`/api/zammad-sync/jobs/${encodeURIComponent(previewJobId)}/apply-existing`, {
fields, fields,
ticketNumbers ticketNumbers
}); }, onJobStarted);
} }
export function previewOwnZammadConsultingTickets() { export function previewOwnZammadConsultingTickets(onJobStarted?: (job: ZammadSyncJob) => void) {
return runZammadSyncJob("/api/zammad-sync/jobs/consulting-import", { return runZammadSyncJob("/api/zammad-sync/jobs/consulting-import", {
dryRun: true dryRun: true
}); }, onJobStarted);
} }
export function applyOwnZammadConsultingTicketsFromPreview(previewJobId: string, ticketNumbers: string[]) { export function applyOwnZammadConsultingTicketsFromPreview(previewJobId: string, ticketNumbers: string[], onJobStarted?: (job: ZammadSyncJob) => void) {
return runZammadSyncJob(`/api/zammad-sync/jobs/${encodeURIComponent(previewJobId)}/apply-consulting-import`, { return runZammadSyncJob(`/api/zammad-sync/jobs/${encodeURIComponent(previewJobId)}/apply-consulting-import`, {
ticketNumbers ticketNumbers
}); }, onJobStarted);
} }
export function getRecurringBillings() { export function getRecurringBillings() {
+26 -5
View File
@@ -16,6 +16,7 @@ export const helpSections: HelpSection[] = [
quickItems: [ quickItems: [
"Sessions sind die getrackten oder nachgetragenen Arbeitszeiten.", "Sessions sind die getrackten oder nachgetragenen Arbeitszeiten.",
"Teamspace-Werte sind Tageswerte pro Ticket, nicht einzelne Sessions.", "Teamspace-Werte sind Tageswerte pro Ticket, nicht einzelne Sessions.",
"Projekt-Sessions können einem Teamspace-Eintrag zugeordnet sein.",
"Abgeschlossen wird nur der Monat." "Abgeschlossen wird nur der Monat."
], ],
details: [ details: [
@@ -23,6 +24,7 @@ export const helpSections: HelpSection[] = [
"TicketTracker ersetzt auch nicht das CRM. Die CRM- beziehungsweise Teamspace-Zeit wird als Kontrollwert gepflegt, damit abgerechnete Zeiten und eingetragene Abrechnung gegenübergestellt werden können.", "TicketTracker ersetzt auch nicht das CRM. Die CRM- beziehungsweise Teamspace-Zeit wird als Kontrollwert gepflegt, damit abgerechnete Zeiten und eingetragene Abrechnung gegenübergestellt werden können.",
"Eine Session ist immer einem Benutzer zugeordnet. In den normalen Auswertungen sieht jeder Benutzer nur seine eigenen Sessions.", "Eine Session ist immer einem Benutzer zugeordnet. In den normalen Auswertungen sieht jeder Benutzer nur seine eigenen Sessions.",
"Ticketdaten wie Organisation und Art können aus bestehenden Tickets übernommen werden, auch wenn ein anderer Benutzer das Ticket schon bearbeitet hat.", "Ticketdaten wie Organisation und Art können aus bestehenden Tickets übernommen werden, auch wenn ein anderer Benutzer das Ticket schon bearbeitet hat.",
"Consulting-Projekte können einen oder mehrere Teamspace-Einträge haben. Einer davon ist immer der Standard-Eintrag.",
"Tickets werden nicht abgeschlossen. Sie gruppieren Sessions. Relevant für den Abschluss ist nur, ob alle Sessions eines Monats bewertet sind.", "Tickets werden nicht abgeschlossen. Sie gruppieren Sessions. Relevant für den Abschluss ist nur, ob alle Sessions eines Monats bewertet sind.",
"Der Monatsabschluss sperrt den Monat gegen versehentliche Änderungen. Muss doch noch etwas korrigiert werden, kann der Monat wieder geöffnet werden." "Der Monatsabschluss sperrt den Monat gegen versehentliche Änderungen. Muss doch noch etwas korrigiert werden, kann der Monat wieder geöffnet werden."
] ]
@@ -41,6 +43,7 @@ export const helpSections: HelpSection[] = [
"Während der Arbeit läuft genau ein Timer aktiv. Wechselst du zu einem anderen Timer, pausiert TicketTracker die übrigen Timer automatisch.", "Während der Arbeit läuft genau ein Timer aktiv. Wechselst du zu einem anderen Timer, pausiert TicketTracker die übrigen Timer automatisch.",
"Wenn die Arbeit erledigt ist, beendest du den Timer. Die Zeit stoppt sofort beim Klick auf Beenden; das anschließende Ausfüllen der Tätigkeit zählt nicht mehr zur Sessionzeit.", "Wenn die Arbeit erledigt ist, beendest du den Timer. Die Zeit stoppt sofort beim Klick auf Beenden; das anschließende Ausfüllen der Tätigkeit zählt nicht mehr zur Sessionzeit.",
"Im Beenden-Dialog trägst du die Tätigkeit ein. Wenn das Ticket bereits bekannt ist, übernimmt TicketTracker Organisation und Art automatisch. Bei neuen Tickets wählst du Organisation und Art zusätzlich aus.", "Im Beenden-Dialog trägst du die Tätigkeit ein. Wenn das Ticket bereits bekannt ist, übernimmt TicketTracker Organisation und Art automatisch. Bei neuen Tickets wählst du Organisation und Art zusätzlich aus.",
"Hat ein Consulting-Projekt mehrere Teamspace-Einträge, wählst du beim Speichern der Session den passenden Eintrag aus. Bei genau einem Teamspace-Eintrag ordnet TicketTracker automatisch zu.",
"Vergessene Sessions trägst du auf der Seite Session starten im Bereich Session nachtragen nach. Wenn das Ticket bereits in der Ticketliste geöffnet ist, nutzt du in der Ticketdetailansicht ebenfalls Session nachtragen; dann ist die Zuordnung zum Ticket bereits vorbelegt.", "Vergessene Sessions trägst du auf der Seite Session starten im Bereich Session nachtragen nach. Wenn das Ticket bereits in der Ticketliste geöffnet ist, nutzt du in der Ticketdetailansicht ebenfalls Session nachtragen; dann ist die Zuordnung zum Ticket bereits vorbelegt.",
"Unter Tickets wählst du Monat oder Tag, öffnest ein Ticket über Öffnen und siehst dort alle Sessions nach Tag gruppiert.", "Unter Tickets wählst du Monat oder Tag, öffnest ein Ticket über Öffnen und siehst dort alle Sessions nach Tag gruppiert.",
"Im Ticket bewertest du jede Session mit Abgerechnet oder Nicht abrechenbar. Ein erneuter Klick auf den aktiven Status nimmt die Bewertung zurück.", "Im Ticket bewertest du jede Session mit Abgerechnet oder Nicht abrechenbar. Ein erneuter Klick auf den aktiven Status nimmt die Bewertung zurück.",
@@ -116,6 +119,8 @@ export const helpSections: HelpSection[] = [
"Wenn ein anderer Timer aktiviert wird, pausiert TicketTracker die übrigen Timer automatisch. Dadurch kann nie versehentlich auf zwei Tickets gleichzeitig Zeit laufen.", "Wenn ein anderer Timer aktiviert wird, pausiert TicketTracker die übrigen Timer automatisch. Dadurch kann nie versehentlich auf zwei Tickets gleichzeitig Zeit laufen.",
"Beim Klick auf Beenden wird die Stoppuhr sofort angehalten. Das Speichern der Tätigkeit findet danach statt und verlängert die gemessene Zeit nicht.", "Beim Klick auf Beenden wird die Stoppuhr sofort angehalten. Das Speichern der Tätigkeit findet danach statt und verlängert die gemessene Zeit nicht.",
"Existiert das Ticket bereits, übernimmt TicketTracker Organisation und Art. Dann muss beim Beenden nur noch die Tätigkeit eingetragen werden.", "Existiert das Ticket bereits, übernimmt TicketTracker Organisation und Art. Dann muss beim Beenden nur noch die Tätigkeit eingetragen werden.",
"Bei Projekt-Tickets mit mehreren Teamspace-Einträgen zeigt der Beenden-Dialog eine Auswahl. Der Standard-Eintrag ist dort mit Standard gekennzeichnet.",
"Bei Projekt-Tickets mit genau einem Teamspace-Eintrag wird die Session automatisch diesem Eintrag zugeordnet.",
"Existiert das Ticket noch nicht, müssen Organisation, Tätigkeit und Art angegeben werden.", "Existiert das Ticket noch nicht, müssen Organisation, Tätigkeit und Art angegeben werden.",
"Pausen werden von der Gesamtdauer abgezogen. Die gespeicherte Zeit wird auf volle Minuten gerundet.", "Pausen werden von der Gesamtdauer abgezogen. Die gespeicherte Zeit wird auf volle Minuten gerundet.",
"Der Schnellstart im Kopfbereich funktioniert auf allen Unterseiten und nutzt dieselbe Timerlogik." "Der Schnellstart im Kopfbereich funktioniert auf allen Unterseiten und nutzt dieselbe Timerlogik."
@@ -136,7 +141,8 @@ export const helpSections: HelpSection[] = [
"Die Organisation muss aus der synchronisierten Organisationsliste gewählt werden. Freitext ist absichtlich gesperrt.", "Die Organisation muss aus der synchronisierten Organisationsliste gewählt werden. Freitext ist absichtlich gesperrt.",
"Die Tätigkeit ist eine Textarea. Zeilenumbrüche bleiben erhalten und werden in der Ticketdetailansicht wieder ausgegeben.", "Die Tätigkeit ist eine Textarea. Zeilenumbrüche bleiben erhalten und werden in der Ticketdetailansicht wieder ausgegeben.",
"Nachgetragene Sessions starten immer offen. Sie müssen später als Abgerechnet oder Nicht abrechenbar bewertet werden.", "Nachgetragene Sessions starten immer offen. Sie müssen später als Abgerechnet oder Nicht abrechenbar bewertet werden.",
"In der Ticketdetailansicht kann eine Session direkt zum geöffneten Ticket nachgetragen werden. Ticketnummer und Stammdaten sind dort schon vorbelegt beziehungsweise aus dem Ticket abgeleitet." "In der Ticketdetailansicht kann eine Session direkt zum geöffneten Ticket nachgetragen werden. Ticketnummer und Stammdaten sind dort schon vorbelegt beziehungsweise aus dem Ticket abgeleitet.",
"Bei Consulting-Projekten mit mehreren Teamspace-Einträgen ist der passende Teamspace-Eintrag Pflicht. Bei nur einem Eintrag wird er automatisch gesetzt."
] ]
}, },
{ {
@@ -209,10 +215,12 @@ export const helpSections: HelpSection[] = [
"Oben steht die Ticketnummer mit Copy-Icon, damit sie schnell in andere Systeme übernommen werden kann.", "Oben steht die Ticketnummer mit Copy-Icon, damit sie schnell in andere Systeme übernommen werden kann.",
"Die Session-Einträge sind nach Kalendertagen gruppiert. Pro Tagesgruppe steht die Summe der abgerechneten Zeit.", "Die Session-Einträge sind nach Kalendertagen gruppiert. Pro Tagesgruppe steht die Summe der abgerechneten Zeit.",
"Nicht abrechenbare und offene Zeiten werden neben der abgerechneten Tageszeit separat angezeigt.", "Nicht abrechenbare und offene Zeiten werden neben der abgerechneten Tageszeit separat angezeigt.",
"Bei Projekt-Tickets mit mehreren Teamspace-Einträgen zeigt jede zugeordnete Session den gewählten Teamspace-Eintrag. Nicht zugeordnete Sessions werden hervorgehoben.",
"Das Teamspace-Feld wird als Stundenwert gepflegt, zum Beispiel 1, 1,5 oder 2.25.", "Das Teamspace-Feld wird als Stundenwert gepflegt, zum Beispiel 1, 1,5 oder 2.25.",
"Während der Eingabe wird der Teamspace-Wert nicht sofort gespeichert. Sobald eine Änderung vorliegt, erscheint ein Speichern-Button.", "Während der Eingabe wird der Teamspace-Wert nicht sofort gespeichert. Sobald eine Änderung vorliegt, erscheint ein Speichern-Button.",
"Ein leeres Teamspace-Feld entfernt den gespeicherten Teamspace-Wert für diesen Ticket-Tag.", "Ein leeres Teamspace-Feld entfernt den gespeicherten Teamspace-Wert für diesen Ticket-Tag.",
"Der Teamspace-Wert hängt am Ticket, Benutzer und Tag. Deshalb ist er in Monats- und Tagesansicht identisch.", "Der Teamspace-Wert hängt am Ticket, Benutzer und Tag. Deshalb ist er in Monats- und Tagesansicht identisch.",
"Im Projektbereich der Ticketdetailansicht werden einzelne Teamspace-Budgetzeilen nur angezeigt, wenn mehrere Teamspace-Einträge tatsächlich ein Budget haben. Ein einzelnes Budget bleibt im Hauptbalken.",
"Geöffnete Tickets werden in der Seitenleiste unter Geöffnete Tickets abgelegt und bleiben nach Refresh oder Browserneustart erhalten.", "Geöffnete Tickets werden in der Seitenleiste unter Geöffnete Tickets abgelegt und bleiben nach Refresh oder Browserneustart erhalten.",
"Die Reihenfolge bleibt beim Anklicken stabil und kann nur per Drag and Drop geändert werden.", "Die Reihenfolge bleibt beim Anklicken stabil und kann nur per Drag and Drop geändert werden.",
"Der Schließen-Button in der Seitenleiste entfernt ein Ticket nur, wenn im aktuell geöffneten Ticket keine ungespeicherten Werte mehr vorhanden sind.", "Der Schließen-Button in der Seitenleiste entfernt ein Ticket nur, wenn im aktuell geöffneten Ticket keine ungespeicherten Werte mehr vorhanden sind.",
@@ -244,6 +252,7 @@ export const helpSections: HelpSection[] = [
quickItems: [ quickItems: [
"Projekte werden über eine Ticketnummer angelegt.", "Projekte werden über eine Ticketnummer angelegt.",
"Vorhandene Ticketdaten werden automatisch übernommen.", "Vorhandene Ticketdaten werden automatisch übernommen.",
"Ein Projekt kann mehrere Teamspace-Einträge haben.",
"Projekt-Sessions sind immer Consulting." "Projekt-Sessions sind immer Consulting."
], ],
details: [ details: [
@@ -251,11 +260,18 @@ export const helpSections: HelpSection[] = [
"Beim Anlegen gibst du die Ticketnummer ein. Existiert das Ticket bereits, übernimmt TicketTracker Organisation und Stammdaten automatisch.", "Beim Anlegen gibst du die Ticketnummer ein. Existiert das Ticket bereits, übernimmt TicketTracker Organisation und Stammdaten automatisch.",
"Existiert das Ticket noch nicht, muss eine Organisation aus der synchronisierten Organisationsliste gewählt werden.", "Existiert das Ticket noch nicht, muss eine Organisation aus der synchronisierten Organisationsliste gewählt werden.",
"Ein Projekt-Ticket bleibt ein normales Ticket. Es kann über Suche, Tickets, geöffnete Tickets und ToDos geöffnet werden.", "Ein Projekt-Ticket bleibt ein normales Ticket. Es kann über Suche, Tickets, geöffnete Tickets und ToDos geöffnet werden.",
"Zusätzlich speicherst du Teamspace-Projekt, Beschreibung/Ziele, ungefähren Zeitraum und gesamtes Stundenkontingent.", "Zusätzlich speicherst du einen oder mehrere Teamspace-Einträge, Beschreibung/Ziele, ungefähren Zeitraum und Budgets.",
"Der Projektverbrauch wird nur aus gepflegten Teamspace-Tageswerten und einem optionalen manuellen Projektverbrauch berechnet. Getrackte Sessionzeiten zählen nicht in den Projektverbrauch.", "Der erste Teamspace-Eintrag ist der Standard. Er bekommt die aus Teamspace gebuchten Tageswerte und das Budget aus Zammad, solange kein Budgetschutz greift.",
"Hat ein Projekt genau einen Teamspace-Eintrag, ordnet TicketTracker Consulting-Sessions automatisch diesem Eintrag zu. Bei mehreren Einträgen muss der passende Eintrag beim Speichern oder Bearbeiten der Session gewählt werden.",
"Die Auswahl zeigt den Standard-Eintrag ausdrücklich mit Standard an, auch wenn der Eintrag eine normale Teamspace-ID hat.",
"Der Projektverbrauch wird aus gepflegten Teamspace-Tageswerten und optionalem manuellem Verbrauch je Teamspace-Eintrag berechnet. Getrackte Sessionzeiten zählen nicht in den Projektverbrauch.",
"Der manuelle Projektverbrauch ist für Altbestände oder einmalige Vorbuchungen gedacht und wirkt ausschließlich im Projektverbrauch. Er erscheint nicht in Auswertungen, Statistiken, CSV-Export oder Monatsabschluss.", "Der manuelle Projektverbrauch ist für Altbestände oder einmalige Vorbuchungen gedacht und wirkt ausschließlich im Projektverbrauch. Er erscheint nicht in Auswertungen, Statistiken, CSV-Export oder Monatsabschluss.",
"Manuelle Änderungen am Budget setzen den Budgetschutz des betroffenen Teamspace-Eintrags automatisch. Der Haken kann im Projekt wieder entfernt werden, wenn das Budget künftig wieder aus Zammad synchronisiert werden soll.",
"Projekte können offen oder geschlossen sein. Standardmäßig zeigt die Projektseite offene Projekte; geschlossene können zusätzlich eingeblendet werden.", "Projekte können offen oder geschlossen sein. Standardmäßig zeigt die Projektseite offene Projekte; geschlossene können zusätzlich eingeblendet werden.",
"Auf der Projektseite kannst du Projekte nach Freitext, Status, Organisation, Kontingentstatus und ToDos filtern sowie nach Ticket, Titel, Organisation, Teamspace-ID, Verbrauch, Reststunden, Kontingent oder ToDos sortieren. Diese Einstellungen bleiben im Browser gespeichert.", "Auf der Projektseite gibt es ein gemeinsames Filter-Dropdown. Filter derselben Gruppe wirken als Oder, unterschiedliche Gruppen werden kombiniert.",
"Filterbar sind unter anderem TicketTracker-Status, Zammad-Status, Zammad-Priorität, Organisation, Kontingentstatus und Nur Std.-Budget.",
"Nur Std.-Budget zeigt Projekte, bei denen ein Budget vorhanden ist und dieses ausschließlich auf dem Standard-Teamspace-Eintrag liegt.",
"In der Projektliste und im Budget-Widget werden einzelne Teamspace-Budgetzeilen nur angezeigt, wenn mehrere Teamspace-Einträge ein Budget haben. Nicht budgetierte Einträge werden dort ausgeblendet.",
"Reststunden werden aus dem Stundenkontingent minus Projektverbrauch berechnet. Gibt es kein Kontingent, kann TicketTracker keinen offenen Rest berechnen.", "Reststunden werden aus dem Stundenkontingent minus Projektverbrauch berechnet. Gibt es kein Kontingent, kann TicketTracker keinen offenen Rest berechnen.",
"Offene Projekte stehen immer vor geschlossenen Projekten. Der Projektstatus ändert nicht die Anzeige der Sessions im Ticket.", "Offene Projekte stehen immer vor geschlossenen Projekten. Der Projektstatus ändert nicht die Anzeige der Sessions im Ticket.",
"Projekt-Tickets sind immer Consulting. Auch wenn Sessions später über den normalen Timer oder das Nachtragen erfasst werden, erkennt TicketTracker das Projekt und übernimmt Consulting.", "Projekt-Tickets sind immer Consulting. Auch wenn Sessions später über den normalen Timer oder das Nachtragen erfasst werden, erkennt TicketTracker das Projekt und übernimmt Consulting.",
@@ -282,6 +298,7 @@ export const helpSections: HelpSection[] = [
"In der linken Navigation zeigt die Kachel Termine die festen heutigen ToDos, laufende ToDos als Summe und überfällige Aufgaben.", "In der linken Navigation zeigt die Kachel Termine die festen heutigen ToDos, laufende ToDos als Summe und überfällige Aufgaben.",
"Unter der Aufgabenliste zeigt der ToDo-Kalender offene ToDos im Monatsüberblick. Erledigte ToDos werden dort ausgeblendet; Wochenenden und bayerische Feiertage bleiben ohne ToDo-Einträge.", "Unter der Aufgabenliste zeigt der ToDo-Kalender offene ToDos im Monatsüberblick. Erledigte ToDos werden dort ausgeblendet; Wochenenden und bayerische Feiertage bleiben ohne ToDo-Einträge.",
"Ein-Tages-ToDos werden einzeln und farblich nach Priorität angezeigt. Zeiträume, offene Von-Daten und Bis-Daten werden pro Tag nur als Summe angezeigt.", "Ein-Tages-ToDos werden einzeln und farblich nach Priorität angezeigt. Zeiträume, offene Von-Daten und Bis-Daten werden pro Tag nur als Summe angezeigt.",
"Wenn der Zammad-Sync entsprechend aktiviert ist, kann aus Zammad-Wartedaten automatisch ein ToDo als Erinnerung oder Abschlussprüfung erzeugt werden.",
"Bei Nur-Von läuft die Summe ab diesem Datum weiter, bis das ToDo erledigt, gelöscht oder mit einem Bis-Datum begrenzt wird. Bei Nur-Bis zählt der Kalender ab heute bis zum Bis-Datum.", "Bei Nur-Von läuft die Summe ab diesem Datum weiter, bis das ToDo erledigt, gelöscht oder mit einem Bis-Datum begrenzt wird. Bei Nur-Bis zählt der Kalender ab heute bis zum Bis-Datum.",
"Ein offenes ToDo ist überfällig, wenn Umsetzung bis vor heute liegt. Im Kalender bleibt es ab dem Tag nach der Deadline an jedem Arbeitstag als roter +X üf-Badge sichtbar, bis es erledigt oder gelöscht wird.", "Ein offenes ToDo ist überfällig, wenn Umsetzung bis vor heute liegt. Im Kalender bleibt es ab dem Tag nach der Deadline an jedem Arbeitstag als roter +X üf-Badge sichtbar, bis es erledigt oder gelöscht wird.",
"Ein Klick auf ein einzelnes ToDo öffnet die Bearbeitung. Eine Tagessumme öffnet eine kompakte Tagesliste; daraus kannst du ein ToDo separat öffnen, erledigen, löschen oder das Ticket öffnen.", "Ein Klick auf ein einzelnes ToDo öffnet die Bearbeitung. Eine Tagessumme öffnet eine kompakte Tagesliste; daraus kannst du ein ToDo separat öffnen, erledigen, löschen oder das Ticket öffnen.",
@@ -512,7 +529,7 @@ export const helpSections: HelpSection[] = [
"Beim Speichern eines neuen Tokens fragt TicketTracker Zammad über /api/v1/users/me ab und speichert die zurückgelieferte Zammad User-ID.", "Beim Speichern eines neuen Tokens fragt TicketTracker Zammad über /api/v1/users/me ab und speichert die zurückgelieferte Zammad User-ID.",
"Die gespeicherte Zammad User-ID wird danach read-only im Profil angezeigt.", "Die gespeicherte Zammad User-ID wird danach read-only im Profil angezeigt.",
"Die Token-Prüfung läuft nur, wenn der Token im Profil geändert oder entfernt wird. Normale Profiländerungen prüfen Zammad nicht erneut.", "Die Token-Prüfung läuft nur, wenn der Token im Profil geändert oder entfernt wird. Normale Profiländerungen prüfen Zammad nicht erneut.",
"Der persönliche Zammad Token ist vom Admin-Token für den Organisations-Sync getrennt. Es kann zufällig derselbe Token sein, wird aber separat gespeichert und verwendet.", "Der persönliche Zammad Token ist vom Admin-Token für den Organisations-Sync getrennt. Er wird für ticketbezogene Zammad-Abgleiche, Consulting-Importe und die Zuordnung zum Zammad-Owner verwendet.",
"Im Admin-Profil wird kein persönlicher Zammad-Token angezeigt.", "Im Admin-Profil wird kein persönlicher Zammad-Token angezeigt.",
"Admins können Benutzer ebenfalls verwalten, aber jeder Benutzer kann die eigenen Daten jederzeit selbst pflegen." "Admins können Benutzer ebenfalls verwalten, aber jeder Benutzer kann die eigenen Daten jederzeit selbst pflegen."
] ]
@@ -531,6 +548,10 @@ export const helpSections: HelpSection[] = [
"Admins können bestehende Benutzer bearbeiten, Passwörter setzen und Benutzer deaktivieren.", "Admins können bestehende Benutzer bearbeiten, Passwörter setzen und Benutzer deaktivieren.",
"Der feste Admin-Zugang kann nicht deaktiviert oder zur normalen User-Rolle herabgestuft werden.", "Der feste Admin-Zugang kann nicht deaktiviert oder zur normalen User-Rolle herabgestuft werden.",
"Der Data-Sync speichert Zammad-Zugangsdaten und synchronisiert Organisationen in die lokale Datenbank.", "Der Data-Sync speichert Zammad-Zugangsdaten und synchronisiert Organisationen in die lokale Datenbank.",
"Der Zammad-Ticket-Sync aktualisiert Titel, Organisation, Typ, Zammad-Status, Zammad-Priorität sowie Teamspace-Projekte und Budgets je nach gewählter Option.",
"Zammad-Projektwerte können mehrere Teamspace-IDs enthalten. TicketTracker erkennt zusammenhängende IDs und legt entsprechend mehrere Teamspace-Einträge an.",
"Beim Budget-Sync bleiben geschützte TicketTracker-Budgets unverändert. Die Option Budget-Schutz im Sync ignorieren überschreibt auch geschützte Budgets mit Zammad-Werten.",
"Optional kann der Ticket-Sync bei Zammad-Status Wartend oder Abschluss und vorhandenem Wartedatum ein hoch priorisiertes ToDo für dieses Datum erzeugen.",
"Der Datenbankexport erzeugt eine SQL-Datei mit Benutzern, Einstellungen, Organisationen, Tickets, Sessions, Teamspace-Werten, fixen Abrechnungen und Abschlüssen.", "Der Datenbankexport erzeugt eine SQL-Datei mit Benutzern, Einstellungen, Organisationen, Tickets, Sessions, Teamspace-Werten, fixen Abrechnungen und Abschlüssen.",
"Der Restore im Adminbereich prüft zuerst, ob die Dump-Struktur zur aktuellen Datenbank passt.", "Der Restore im Adminbereich prüft zuerst, ob die Dump-Struktur zur aktuellen Datenbank passt.",
"Der Restore-Button wird erst nach erfolgreicher Prüfung aktiv.", "Der Restore-Button wird erst nach erfolgreicher Prüfung aktiv.",
+17 -5
View File
@@ -585,6 +585,12 @@ function matchesSingleProjectFilter(project: ProjectSummary, filterBy: string, f
return budgetMinutes <= 0; return budgetMinutes <= 0;
} }
if (filterBy === "standardBudgetOnly") {
const budgetedEntries = budgetedProjectTeamspaceEntries(project);
return budgetedEntries.length > 0 && budgetedEntries.every((entry) => entry.is_default);
}
if (filterBy === "critical90") { if (filterBy === "critical90") {
return budgetMinutes > 0 && percent >= 0.9; 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) { function openRatingFilterGroup(filter: string) {
if (filter === "fixedBilling" || filter === "manualBilling") { if (filter === "fixedBilling" || filter === "manualBilling") {
return "source"; return "source";
@@ -1649,6 +1659,7 @@ function ProjectBudgetWidget({ widget, editing, projects, stats, onNavigate, onU
[ [
"budgeted", "budgeted",
"withoutBudget", "withoutBudget",
"standardBudgetOnly",
"critical90", "critical90",
"overBudget", "overBudget",
"open", "open",
@@ -1746,6 +1757,7 @@ function ProjectBudgetWidget({ widget, editing, projects, stats, onNavigate, onU
{ value: "critical90", label: "Ab 90%" }, { value: "critical90", label: "Ab 90%" },
{ value: "overBudget", label: "Ab 100%" }, { value: "overBudget", label: "Ab 100%" },
{ value: "withoutBudget", label: "Ohne Kontingent" }, { value: "withoutBudget", label: "Ohne Kontingent" },
{ value: "standardBudgetOnly", label: "Nur Std.-Budget" },
{ value: "open", label: "TT Offen" }, { value: "open", label: "TT Offen" },
{ value: "closed", label: "TT Geschl." }, { value: "closed", label: "TT Geschl." },
...zammadStateFilterOptions.filter((option) => option.value !== "all").map((option) => ({ value: `zammadState:${option.value}`, label: `ZA ${option.label}` })), ...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 consumptionMinutes = project.consumption_minutes ?? 0;
const budgetMinutes = project.hour_budget_minutes ?? 0; const budgetMinutes = project.hour_budget_minutes ?? 0;
const percent = projectConsumptionPercent(project); const percent = projectConsumptionPercent(project);
const entries = projectTeamspaceEntries(project); const budgetedEntries = budgetedProjectTeamspaceEntries(project);
const hasMultipleTeamspaceEntries = entries.length > 1; const showBudgetedTeamspaceEntries = budgetedEntries.length > 1;
return ( 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()}`)}> <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-xs font-medium">{project.title || project.ticket_number}</span>
<span className="block truncate text-[11px] text-muted-foreground"> <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>
<span className="mt-1 flex flex-wrap gap-1"> <span className="mt-1 flex flex-wrap gap-1">
<ZammadStateBadge stateId={project.zammad_state_id} /> <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 className={cn("block h-full rounded-full", consumptionBarClass(percent))} style={{ width: `${Math.min(100, percent)}%` }} />
</span> </span>
) : null} ) : null}
{hasMultipleTeamspaceEntries ? ( {showBudgetedTeamspaceEntries ? (
<span className="mt-1 grid gap-1"> <span className="mt-1 grid gap-1">
{entries.map((entry) => { {budgetedEntries.map((entry) => {
const entryBudgetMinutes = entry.hour_budget_minutes ?? 0; const entryBudgetMinutes = entry.hour_budget_minutes ?? 0;
const entryConsumptionMinutes = entry.consumption_minutes ?? 0; const entryConsumptionMinutes = entry.consumption_minutes ?? 0;
const entryPercent = entryBudgetMinutes > 0 ? Math.round((entryConsumptionMinutes / entryBudgetMinutes) * 100) : null; const entryPercent = entryBudgetMinutes > 0 ? Math.round((entryConsumptionMinutes / entryBudgetMinutes) * 100) : null;
+406 -178
View File
@@ -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 { FormEvent, useEffect, useMemo, useState } from "react";
import { toast } from "sonner"; 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 { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu"; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu";
import { Input } from "@/components/ui/input"; 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 { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Textarea } from "@/components/ui/textarea"; import { Textarea } from "@/components/ui/textarea";
import { HelpLink } from "@/components/HelpLink"; import { HelpLink } from "@/components/HelpLink";
@@ -31,7 +32,6 @@ import {
zammadStateLabel, zammadStateLabel,
zammadStateSortRank zammadStateSortRank
} from "../zammad-meta"; } from "../zammad-meta";
import type { ZammadPriorityFilter, ZammadStateFilter } from "../zammad-meta";
type ProjectsPageProps = { type ProjectsPageProps = {
onNavigate: (to: string) => void; onNavigate: (to: string) => void;
@@ -58,18 +58,15 @@ type ProjectTeamspaceEntryForm = {
isDefault: boolean; 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 ProjectSortBy = "updated" | "ticket" | "title" | "organization" | "status" | "zammadStatus" | "zammadPriority" | "consumption" | "consumptionPercent" | "remaining" | "budget";
type ProjectSortDirection = "asc" | "desc"; type ProjectSortDirection = "asc" | "desc";
type ProjectFilterOption = { value: string; label: string };
type ProjectViewSettings = { type ProjectViewSettings = {
query: string; query: string;
status: ProjectStatusFilter; filterValues: string[];
zammadStatus: ZammadStateFilter; organizationValues: string[];
zammadPriority: ZammadPriorityFilter; standardTeamspaceOnly: boolean;
organization: string;
budget: ProjectBudgetFilter;
sortBy: ProjectSortBy; sortBy: ProjectSortBy;
sortDirection: ProjectSortDirection; sortDirection: ProjectSortDirection;
}; };
@@ -78,29 +75,26 @@ const projectViewSettingsKey = "tickettracker.projects.viewSettings";
const defaultProjectViewSettings: ProjectViewSettings = { const defaultProjectViewSettings: ProjectViewSettings = {
query: "", query: "",
status: "open", filterValues: ["open"],
zammadStatus: "all", organizationValues: [],
zammadPriority: "all", standardTeamspaceOnly: false,
organization: "all",
budget: "all",
sortBy: "updated", sortBy: "updated",
sortDirection: "desc" sortDirection: "desc"
}; };
const projectStatusOptions: Array<{ value: ProjectStatusFilter; label: string }> = [ const projectStaticFilterOptions: ProjectFilterOption[] = [
{ value: "open", label: "Offen" }, { value: "open", label: "TT Offen" },
{ value: "all", label: "Alle" }, { value: "closed", label: "TT Geschl." },
{ value: "closed", label: "Geschlossen" }, { value: "hidden", label: "Ausgebl." },
{ value: "hidden", label: "Ausgeblendet" } { 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 }> = [ const projectStaticFilterValues = new Set(projectStaticFilterOptions.map((option) => option.value));
{ 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 projectSortValues: ProjectSortBy[] = ["updated", "ticket", "title", "organization", "status", "zammadStatus", "zammadPriority", "consumption", "consumptionPercent", "remaining", "budget"]; 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: "title", label: "Titel" },
{ value: "organization", label: "Organisation" }, { value: "organization", label: "Organisation" },
{ value: "status", label: "Status" }, { value: "status", label: "Status" },
{ value: "zammadStatus", label: "Zammad-Status" }, { value: "zammadStatus", label: "ZA Status" },
{ value: "zammadPriority", label: "Zammad-Priorität" }, { value: "zammadPriority", label: "ZA Prio" },
{ value: "consumption", label: "Verbrauch" }, { value: "consumption", label: "Verbrauch" },
{ value: "consumptionPercent", label: "Verbrauch %" }, { value: "consumptionPercent", label: "Verbrauch %" },
{ value: "remaining", label: "Reststunden" }, { 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 { function readStoredProjectViewSettings(): ProjectViewSettings {
if (typeof window === "undefined") { if (typeof window === "undefined") {
return defaultProjectViewSettings; return defaultProjectViewSettings;
@@ -150,14 +253,15 @@ function readStoredProjectViewSettings(): ProjectViewSettings {
} }
const parsed = JSON.parse(raw) as Partial<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 { return {
query: typeof parsed.query === "string" ? parsed.query : defaultProjectViewSettings.query, query: typeof parsed.query === "string" ? parsed.query : defaultProjectViewSettings.query,
status: projectStatusOptions.find((option) => option.value === parsed.status)?.value ?? defaultProjectViewSettings.status, filterValues,
zammadStatus: zammadStateFilterOptions.find((option) => option.value === parsed.zammadStatus)?.value ?? defaultProjectViewSettings.zammadStatus, organizationValues: legacyProjectOrganizationValues(parsed as Partial<ProjectViewSettings> & Record<string, unknown>),
zammadPriority: zammadPriorityFilterOptions.find((option) => option.value === parsed.zammadPriority)?.value ?? defaultProjectViewSettings.zammadPriority, standardTeamspaceOnly: legacyStandardTeamspaceOnly(parsed as Partial<ProjectViewSettings> & Record<string, unknown>),
organization: typeof parsed.organization === "string" ? parsed.organization : defaultProjectViewSettings.organization,
budget: projectBudgetOptions.find((option) => option.value === parsed.budget)?.value ?? defaultProjectViewSettings.budget,
sortBy: projectSortValues.find((value) => value === parsed.sortBy) ?? defaultProjectViewSettings.sortBy, sortBy: projectSortValues.find((value) => value === parsed.sortBy) ?? defaultProjectViewSettings.sortBy,
sortDirection: parsed.sortDirection === "asc" ? "asc" : defaultProjectViewSettings.sortDirection sortDirection: parsed.sortDirection === "asc" ? "asc" : defaultProjectViewSettings.sortDirection
}; };
@@ -214,14 +318,20 @@ function projectTeamspaceEntries(project: ProjectSummary): ProjectTeamspaceEntry
]; ];
} }
function projectTeamspaceEntryCount(project: ProjectSummary) { function budgetedProjectTeamspaceEntries(project: ProjectSummary) {
return Number(project.teamspace_entry_count ?? projectTeamspaceEntries(project).length); return projectTeamspaceEntries(project).filter((entry) => Number(entry.hour_budget_minutes ?? 0) > 0);
} }
function projectEntryConsumption(entry: ProjectTeamspaceEntry) { function projectEntryConsumption(entry: ProjectTeamspaceEntry) {
return Number(entry.consumption_minutes ?? 0); 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) { function projectEntryPercent(entry: ProjectTeamspaceEntry) {
if (!entry.hour_budget_minutes || entry.hour_budget_minutes <= 0) { if (!entry.hour_budget_minutes || entry.hour_budget_minutes <= 0) {
return null; return null;
@@ -246,6 +356,86 @@ function SortDirectionIcon({ direction }: { direction: ProjectSortDirection }) {
return direction === "asc" ? <ArrowUp className="size-3.5" /> : <ArrowDown className="size-3.5" />; 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) { function parseHoursInput(value: string) {
const trimmed = value.trim(); const trimmed = value.trim();
@@ -274,24 +464,42 @@ function projectOrganizationKey(project: ProjectSummary) {
return project.organization_id ? `id:${project.organization_id}` : `name:${projectOrganizationLabel(project)}`; 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); return Number(project.consumption_minutes ?? 0);
} }
function projectRemaining(project: ProjectSummary) { function projectRemaining(project: ProjectSummary, standardTeamspaceOnly = false) {
if (project.hour_budget_minutes === null || project.hour_budget_minutes === undefined) { const budgetMinutes = projectBudget(project, standardTeamspaceOnly);
if (budgetMinutes === null || budgetMinutes === undefined) {
return null; return null;
} }
return project.hour_budget_minutes - projectConsumption(project); return budgetMinutes - projectConsumption(project, standardTeamspaceOnly);
} }
function projectConsumptionPercent(project: ProjectSummary) { function projectConsumptionPercent(project: ProjectSummary, standardTeamspaceOnly = false) {
if (!project.hour_budget_minutes || project.hour_budget_minutes <= 0) { const budgetMinutes = projectBudget(project, standardTeamspaceOnly);
if (!budgetMinutes || budgetMinutes <= 0) {
return null; 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) { 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)); 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; const factor = direction === "asc" ? 1 : -1;
let comparison = 0; let comparison = 0;
@@ -334,10 +638,10 @@ function compareProjects(left: ProjectSummary, right: ProjectSummary, sortBy: Pr
} else if (sortBy === "zammadPriority") { } else if (sortBy === "zammadPriority") {
comparison = zammadPrioritySortRank(left.zammad_priority_id) - zammadPrioritySortRank(right.zammad_priority_id); comparison = zammadPrioritySortRank(left.zammad_priority_id) - zammadPrioritySortRank(right.zammad_priority_id);
} else if (sortBy === "consumption") { } else if (sortBy === "consumption") {
comparison = projectConsumption(left) - projectConsumption(right); comparison = projectConsumption(left, standardTeamspaceOnly) - projectConsumption(right, standardTeamspaceOnly);
} else if (sortBy === "consumptionPercent") { } else if (sortBy === "consumptionPercent") {
const leftPercent = projectConsumptionPercent(left); const leftPercent = projectConsumptionPercent(left, standardTeamspaceOnly);
const rightPercent = projectConsumptionPercent(right); const rightPercent = projectConsumptionPercent(right, standardTeamspaceOnly);
if (leftPercent === null || rightPercent === null) { if (leftPercent === null || rightPercent === null) {
return leftPercent === rightPercent ? compareText(left.ticket_number, right.ticket_number) : leftPercent === null ? 1 : -1; 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; comparison = leftPercent - rightPercent;
} else if (sortBy === "remaining") { } 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") { } 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 { } else {
comparison = new Date(left.updated_at).getTime() - new Date(right.updated_at).getTime(); 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() { async function load() {
setLoading(true); setLoading(true);
try { try {
const result = await getProjects(viewSettings.status !== "open", viewSettings.status === "hidden"); const result = await getProjects(projectShouldLoadClosed(viewSettings.filterValues), projectShouldLoadHidden(viewSettings.filterValues));
setProjects(result.projects); setProjects(result.projects);
setHiddenProjectCount(result.hiddenCount ?? 0); setHiddenProjectCount(result.hiddenCount ?? 0);
setTotalProjectCount(result.totalCount ?? result.projects.length); setTotalProjectCount(result.totalCount ?? result.projects.length);
@@ -653,7 +957,7 @@ export function ProjectsPage({ onNavigate, onStartTimer }: ProjectsPageProps) {
useEffect(() => { useEffect(() => {
void load(); void load();
}, [viewSettings.status]); }, [viewSettings.filterValues]);
useEffect(() => { useEffect(() => {
window.localStorage.setItem(projectViewSettingsKey, JSON.stringify(viewSettings)); window.localStorage.setItem(projectViewSettingsKey, JSON.stringify(viewSettings));
@@ -727,68 +1031,28 @@ export function ProjectsPage({ onNavigate, onStartTimer }: ProjectsPageProps) {
const filteredProjects = useMemo(() => { const filteredProjects = useMemo(() => {
return projects return projects
.filter((project) => { .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)) { if (!projectMatchesQuery(project, viewSettings.query)) {
return false; return false;
} }
const remaining = projectRemaining(project); return matchesProjectFilters(project, viewSettings.filterValues, viewSettings.organizationValues, viewSettings.standardTeamspaceOnly);
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;
}) })
.sort((left, right) => compareProjects(left, right, viewSettings.sortBy, viewSettings.sortDirection)); .sort((left, right) => compareProjects(left, right, viewSettings.sortBy, viewSettings.sortDirection, viewSettings.standardTeamspaceOnly));
}, [projects, viewSettings]); }, [projects, viewSettings]);
const projectConsumptionMinutes = useMemo(() => filteredProjects.reduce((sum, project) => sum + projectConsumption(project), 0), [filteredProjects]); const projectConsumptionMinutes = useMemo(
const remainingMinutes = useMemo( () => filteredProjects.reduce((sum, project) => sum + projectConsumption(project, viewSettings.standardTeamspaceOnly), 0),
() => filteredProjects.reduce((sum, project) => sum + Math.max(projectRemaining(project) ?? 0, 0), 0), [filteredProjects, viewSettings.standardTeamspaceOnly]
[filteredProjects]
); );
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 = [ const activeFilterCount = [
viewSettings.query.trim() !== "", viewSettings.query.trim() !== "",
viewSettings.status !== defaultProjectViewSettings.status, JSON.stringify(viewSettings.filterValues) !== JSON.stringify(defaultProjectViewSettings.filterValues),
viewSettings.zammadStatus !== defaultProjectViewSettings.zammadStatus, viewSettings.organizationValues.length > 0,
viewSettings.zammadPriority !== defaultProjectViewSettings.zammadPriority, viewSettings.standardTeamspaceOnly,
viewSettings.organization !== defaultProjectViewSettings.organization,
viewSettings.budget !== defaultProjectViewSettings.budget,
viewSettings.sortBy !== defaultProjectViewSettings.sortBy, viewSettings.sortBy !== defaultProjectViewSettings.sortBy,
viewSettings.sortDirection !== defaultProjectViewSettings.sortDirection viewSettings.sortDirection !== defaultProjectViewSettings.sortDirection
].filter(Boolean).length; ].filter(Boolean).length;
@@ -1070,7 +1334,7 @@ export function ProjectsPage({ onNavigate, onStartTimer }: ProjectsPageProps) {
</div> </div>
<div className="w-28 shrink-0 rounded-md border bg-muted/20 px-3 py-2"> <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="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>
<div className="w-32 shrink-0 rounded-md border bg-muted/20 px-3 py-2"> <div className="w-32 shrink-0 rounded-md border bg-muted/20 px-3 py-2">
<span className="block text-muted-foreground">Verbrauch</span> <span className="block text-muted-foreground">Verbrauch</span>
@@ -1102,7 +1366,7 @@ export function ProjectsPage({ onNavigate, onStartTimer }: ProjectsPageProps) {
</Button> </Button>
</div> </div>
</CardHeader> </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"> <div className="space-y-1.5">
<label className="text-xs font-medium text-muted-foreground" htmlFor="project-search"> <label className="text-xs font-medium text-muted-foreground" htmlFor="project-search">
Suche Suche
@@ -1119,71 +1383,34 @@ export function ProjectsPage({ onNavigate, onStartTimer }: ProjectsPageProps) {
/> />
</div> </div>
<div className="space-y-1.5"> <div className="space-y-1.5">
<label className="text-xs font-medium text-muted-foreground">Status</label> <label className="text-xs font-medium text-muted-foreground">Filter</label>
<Select value={viewSettings.status} onValueChange={(value) => setViewSettings((current) => ({ ...current, status: value as ProjectStatusFilter }))}> <ProjectFilterDropdown
<SelectTrigger size="sm" className="w-full"> values={viewSettings.filterValues}
<SelectValue /> options={projectStaticFilterOptions}
</SelectTrigger> onChange={(filterValues) => setViewSettings((current) => ({ ...current, filterValues }))}
<SelectContent> />
{projectStatusOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>{option.label}</SelectItem>
))}
</SelectContent>
</Select>
</div> </div>
<div className="space-y-1.5"> <div className="space-y-1.5">
<label className="text-xs font-medium text-muted-foreground">Zammad-Status</label> <label className="text-xs font-medium text-muted-foreground">Organisationen</label>
<Select value={viewSettings.zammadStatus} onValueChange={(value) => setViewSettings((current) => ({ ...current, zammadStatus: value as ZammadStateFilter }))}> <ProjectFilterDropdown
<SelectTrigger size="sm" className="w-full"> values={viewSettings.organizationValues}
<SelectValue /> options={organizationOptions}
</SelectTrigger> onChange={(organizationValues) => setViewSettings((current) => ({ ...current, organizationValues }))}
<SelectContent> placeholder="Alle Org."
{zammadStateFilterOptions.map((option) => ( countLabel="Org."
<SelectItem key={option.value} value={option.value}>{option.label}</SelectItem> emptyLabel="Keine Org."
))} />
</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>
</div> </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"> <div className="space-y-1.5">
<label className="text-xs font-medium text-muted-foreground">Sortierung</label> <label className="text-xs font-medium text-muted-foreground">Sortierung</label>
<Select value={viewSettings.sortBy} onValueChange={(value) => setViewSettings((current) => ({ ...current, sortBy: value as ProjectSortBy }))}> <Select value={viewSettings.sortBy} onValueChange={(value) => setViewSettings((current) => ({ ...current, sortBy: value as ProjectSortBy }))}>
@@ -1224,7 +1451,7 @@ export function ProjectsPage({ onNavigate, onStartTimer }: ProjectsPageProps) {
</div> </div>
</CardContent> </CardContent>
</Card> </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 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"> <div className="flex min-w-0 items-center gap-2">
<EyeOff className="size-4 shrink-0" /> <EyeOff className="size-4 shrink-0" />
@@ -1237,7 +1464,7 @@ export function ProjectsPage({ onNavigate, onStartTimer }: ProjectsPageProps) {
size="sm" size="sm"
variant="secondary" variant="secondary"
className="shrink-0" className="shrink-0"
onClick={() => setViewSettings((current) => ({ ...current, status: "hidden" }))} onClick={() => setViewSettings((current) => ({ ...current, filterValues: ["hidden"] }))}
> >
Anzeigen Anzeigen
</Button> </Button>
@@ -1287,11 +1514,12 @@ export function ProjectsPage({ onNavigate, onStartTimer }: ProjectsPageProps) {
</div> </div>
{filteredProjects.map((project) => { {filteredProjects.map((project) => {
const isEditing = editingProjectId === project.id; const isEditing = editingProjectId === project.id;
const teamspaceEntries = projectTeamspaceEntries(project); const budgetedTeamspaceEntries = budgetedProjectTeamspaceEntries(project);
const consumptionMinutes = project.consumption_minutes ?? 0; const consumptionMinutes = projectConsumption(project, viewSettings.standardTeamspaceOnly);
const remaining = projectRemaining(project); const budgetMinutes = projectBudget(project, viewSettings.standardTeamspaceOnly);
const usedPercent = projectConsumptionPercent(project); const remaining = projectRemaining(project, viewSettings.standardTeamspaceOnly);
const hasMultipleTeamspaceEntries = teamspaceEntries.length > 1; const usedPercent = projectConsumptionPercent(project, viewSettings.standardTeamspaceOnly);
const showBudgetedTeamspaceEntries = !viewSettings.standardTeamspaceOnly && budgetedTeamspaceEntries.length > 1;
const canEditProject = project.can_edit !== false; const canEditProject = project.can_edit !== false;
return ( return (
@@ -1329,9 +1557,9 @@ export function ProjectsPage({ onNavigate, onStartTimer }: ProjectsPageProps) {
<div className="min-w-0"> <div className="min-w-0">
<p className="truncate text-sm font-medium xl:hidden">Verbrauch/Gesamt</p> <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)}> <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> </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>
<div className="min-w-0 text-sm"> <div className="min-w-0 text-sm">
<p className="truncate font-medium xl:hidden">Verbrauch %</p> <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. Zammad meldet das Consulting-Ticket als geschlossen. Bitte Zeiten in Teamspace und TicketTracker prüfen und das Projekt danach schließen.
</p> </p>
) : null} ) : null}
{hasMultipleTeamspaceEntries ? ( {showBudgetedTeamspaceEntries ? (
<div className="grid gap-1.5 md:grid-cols-2"> <div className="grid gap-1.5 md:grid-cols-2">
{teamspaceEntries.map((entry) => { {budgetedTeamspaceEntries.map((entry) => {
const entryPercent = projectEntryPercent(entry); const entryPercent = projectEntryPercent(entry);
const entryConsumption = projectEntryConsumption(entry); const entryConsumption = projectEntryConsumption(entry);
const label = entry.teamspace_project || (entry.is_default ? "Standard" : "Ohne Teamspace-ID"); const label = entry.teamspace_project || (entry.is_default ? "Standard" : "Ohne Teamspace-ID");
+20 -8
View File
@@ -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 { function projectEditForm(project: ProjectSummary): ProjectEditFormState {
const entries = projectTeamspaceEntries(project).map((entry, index) => ({ const entries = projectTeamspaceEntries(project).map((entry, index) => ({
clientId: entry.id || projectEntryId(), clientId: entry.id || projectEntryId(),
@@ -503,7 +513,9 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onO
const isRecurringBillingTicket = ticketHasRecurringBilling(ticket); const isRecurringBillingTicket = ticketHasRecurringBilling(ticket);
const projectDescription = project ? combinedProjectDescription(project) : ""; const projectDescription = project ? combinedProjectDescription(project) : "";
const projectEntries = useMemo(() => (project ? projectTeamspaceEntries(project) : []), [project]); const projectEntries = useMemo(() => (project ? projectTeamspaceEntries(project) : []), [project]);
const budgetedProjectEntries = useMemo(() => (project ? budgetedProjectTeamspaceEntries(project) : []), [project]);
const hasMultipleProjectTeamspaceEntries = projectEntries.length > 1; const hasMultipleProjectTeamspaceEntries = projectEntries.length > 1;
const showBudgetedProjectEntries = budgetedProjectEntries.length > 1;
const requiresProjectTeamspaceEntry = Boolean(project && hasMultipleProjectTeamspaceEntries); const requiresProjectTeamspaceEntry = Boolean(project && hasMultipleProjectTeamspaceEntries);
const projectConsumptionMinutes = project?.consumption_minutes ?? 0; const projectConsumptionMinutes = project?.consumption_minutes ?? 0;
const projectBudgetMinutes = project?.hour_budget_minutes ?? null; const projectBudgetMinutes = project?.hour_budget_minutes ?? null;
@@ -1284,9 +1296,9 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onO
<CardDescription> <CardDescription>
{!canEditProject && project.owner_display_name {!canEditProject && project.owner_display_name
? `Eigentümer: ${project.owner_display_name}` ? `Eigentümer: ${project.owner_display_name}`
: hasMultipleProjectTeamspaceEntries : showBudgetedProjectEntries
? `${projectEntries.length} Teamspace-Einträge` ? `${budgetedProjectEntries.length} budgetierte Teamspace-Einträge`
: project.teamspace_project || "Kein Teamspace-Projekt hinterlegt"} : budgetedProjectEntries[0]?.teamspace_project || project.teamspace_project || "Kein Teamspace-Projekt hinterlegt"}
</CardDescription> </CardDescription>
</div> </div>
<div className="flex shrink-0 items-center gap-1"> <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">Zeitraum {project.starts_on || "offen"} - {project.ends_on || "offen"}</Badge>
<Badge variant="secondary">Kontingent {projectBudgetMinutes ? formatMinutes(projectBudgetMinutes) : "offen"}</Badge> <Badge variant="secondary">Kontingent {projectBudgetMinutes ? formatMinutes(projectBudgetMinutes) : "offen"}</Badge>
<Badge variant="secondary">Verbrauch {formatMinutes(projectConsumptionMinutes)}</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> </div>
{hasMultipleProjectTeamspaceEntries ? ( {showBudgetedProjectEntries ? (
<div className="space-y-2"> <div className="space-y-2">
<div> <div>
<div className="mb-1 flex items-center justify-between gap-2 text-xs text-muted-foreground"> <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> </div>
<div className="grid gap-1.5 md:grid-cols-2"> <div className="grid gap-1.5 md:grid-cols-2">
{projectEntries.map((entry) => { {budgetedProjectEntries.map((entry) => {
const consumption = Number(entry.consumption_minutes ?? 0); const consumption = Number(entry.consumption_minutes ?? 0);
const percent = projectConsumptionPercent(consumption, entry.hour_budget_minutes); const percent = projectConsumptionPercent(consumption, entry.hour_budget_minutes);
const label = entry.teamspace_project || (entry.is_default ? "Standard" : "Ohne Teamspace-ID"); 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> <option value="">Bitte wählen</option>
{projectEntries.map((entry) => ( {projectEntries.map((entry) => (
<option key={entry.id} value={entry.id}> <option key={entry.id} value={entry.id}>
{entry.teamspace_project || (entry.is_default ? "Standard" : "Ohne Teamspace-ID")} {teamspaceEntryOptionLabel(entry)}
</option> </option>
))} ))}
</select> </select>
@@ -1943,7 +1955,7 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onO
<option value="">Bitte wählen</option> <option value="">Bitte wählen</option>
{projectEntries.map((entry) => ( {projectEntries.map((entry) => (
<option key={entry.id} value={entry.id}> <option key={entry.id} value={entry.id}>
{entry.teamspace_project || (entry.is_default ? "Standard" : "Ohne Teamspace-ID")} {teamspaceEntryOptionLabel(entry)}
</option> </option>
))} ))}
</select> </select>
+8 -2
View File
@@ -61,6 +61,12 @@ function ticketTeamspaceEntries(ticket: TicketMeta | null | undefined) {
return ticket?.project_teamspace_entries ?? []; 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) { function requiresTeamspaceEntry(ticket: TicketMeta | null | undefined) {
return Boolean(ticket?.is_project && ticketTeamspaceEntries(ticket).length > 1); 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> <option value="">Bitte wählen</option>
{ticketTeamspaceEntries(manualLookupTicket).map((entry) => ( {ticketTeamspaceEntries(manualLookupTicket).map((entry) => (
<option key={entry.id} value={entry.id}> <option key={entry.id} value={entry.id}>
{entry.teamspace_project || (entry.is_default ? "Standard" : "Ohne Teamspace-ID")} {teamspaceEntryOptionLabel(entry)}
</option> </option>
))} ))}
</select> </select>
@@ -880,7 +886,7 @@ export function TimerPage({ timers, setTimers, selectedTimerId, setSelectedTimer
<option value="">Bitte wählen</option> <option value="">Bitte wählen</option>
{ticketTeamspaceEntries(finished?.ticket).map((entry) => ( {ticketTeamspaceEntries(finished?.ticket).map((entry) => (
<option key={entry.id} value={entry.id}> <option key={entry.id} value={entry.id}>
{entry.teamspace_project || (entry.is_default ? "Standard" : "Ohne Teamspace-ID")} {teamspaceEntryOptionLabel(entry)}
</option> </option>
))} ))}
</select> </select>
+350 -32
View File
@@ -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 { AlertCircle, CheckCircle2, DatabaseZap, DownloadCloud, RefreshCw } from "lucide-react";
import { toast } from "sonner"; import { toast } from "sonner";
@@ -14,11 +14,14 @@ import {
applyOwnZammadConsultingTicketsFromPreview, applyOwnZammadConsultingTicketsFromPreview,
previewExistingZammadTickets, previewExistingZammadTickets,
previewOwnZammadConsultingTickets, previewOwnZammadConsultingTickets,
waitForZammadSyncJob,
type ZammadSyncExistingFields, type ZammadSyncExistingFields,
type ZammadSyncChange, type ZammadSyncChange,
type ZammadSyncJob,
type ZammadSyncItem, type ZammadSyncItem,
type ZammadSyncResult type ZammadSyncResult
} from "../api"; } from "../api";
import { getLocalStorageValue, setLocalStorageValue } from "@/lib/local-storage.client";
import type { AuthUser } from "../types"; import type { AuthUser } from "../types";
import { zammadPriorityLabel, zammadStateLabel } from "../zammad-meta"; import { zammadPriorityLabel, zammadStateLabel } from "../zammad-meta";
@@ -27,6 +30,7 @@ type ZammadSyncPageProps = {
}; };
type SelectionMap = Record<string, boolean>; type SelectionMap = Record<string, boolean>;
type RunningJobKind = "preview" | "apply";
const defaultFields: ZammadSyncExistingFields = { const defaultFields: ZammadSyncExistingFields = {
title: true, title: true,
@@ -51,25 +55,39 @@ type ZammadSyncPageState = {
fields: ZammadSyncExistingFields; fields: ZammadSyncExistingFields;
existingResult: ZammadSyncResult | null; existingResult: ZammadSyncResult | null;
existingPreviewJobId: string | null; existingPreviewJobId: string | null;
existingRunningJobId: string | null;
existingRunningJobKind: RunningJobKind | null;
importResult: ZammadSyncResult | null; importResult: ZammadSyncResult | null;
importPreviewJobId: string | null; importPreviewJobId: string | null;
importRunningJobId: string | null;
importRunningJobKind: RunningJobKind | null;
existingSelection: SelectionMap; existingSelection: SelectionMap;
importSelection: SelectionMap; importSelection: SelectionMap;
syncingExisting: boolean; syncingExisting: boolean;
importingConsulting: boolean; importingConsulting: boolean;
}; };
let zammadSyncPageState: ZammadSyncPageState = { function emptyZammadSyncPageState(): ZammadSyncPageState {
return {
fields: defaultFields, fields: defaultFields,
existingResult: null, existingResult: null,
existingPreviewJobId: null, existingPreviewJobId: null,
existingRunningJobId: null,
existingRunningJobKind: null,
importResult: null, importResult: null,
importPreviewJobId: null, importPreviewJobId: null,
importRunningJobId: null,
importRunningJobKind: null,
existingSelection: {}, existingSelection: {},
importSelection: {}, importSelection: {},
syncingExisting: false, syncingExisting: false,
importingConsulting: false importingConsulting: false
}; };
}
let zammadSyncPageState: ZammadSyncPageState = emptyZammadSyncPageState();
let hydratedZammadSyncUserId: string | null = null;
let activeZammadSyncStorageUserId: string | null = null;
const zammadSyncPageListeners = new Set<() => void>(); const zammadSyncPageListeners = new Set<() => void>();
@@ -89,9 +107,104 @@ function setZammadSyncPageState(update: Partial<ZammadSyncPageState> | ((current
...zammadSyncPageState, ...zammadSyncPageState,
...(typeof update === "function" ? update(zammadSyncPageState) : update) ...(typeof update === "function" ? update(zammadSyncPageState) : update)
}; };
if (activeZammadSyncStorageUserId) {
saveZammadSyncPageState(activeZammadSyncStorageUserId, zammadSyncPageState);
}
zammadSyncPageListeners.forEach((listener) => listener()); 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() { function useZammadSyncPageState() {
return useSyncExternalStore(subscribeZammadSyncPageState, getZammadSyncPageState, getZammadSyncPageState); return useSyncExternalStore(subscribeZammadSyncPageState, getZammadSyncPageState, getZammadSyncPageState);
} }
@@ -206,7 +319,7 @@ function formatChangeValue(field: string, value: unknown) {
return formatMinutes(value); return formatMinutes(value);
} }
if (field === "Teamspace-Projekte") { if (field === "Teamspace-Projekte" || field === "Teamspace-Prüfung") {
return formatTeamspaceProjectList(value) || "-"; return formatTeamspaceProjectList(value) || "-";
} }
@@ -262,6 +375,10 @@ function changeAllowedByFields(item: ZammadSyncItem, change: ZammadSyncChange, f
return fields.projectName && consultingProjectCanBeTouched; return fields.projectName && consultingProjectCanBeTouched;
} }
if (change.field === "Teamspace-Prüfung") {
return fields.projectName && consultingProjectCanBeTouched;
}
if (change.field === "Teamspace-Budget") { if (change.field === "Teamspace-Budget") {
return fields.budget && consultingProjectCanBeTouched; return fields.budget && consultingProjectCanBeTouched;
} }
@@ -293,7 +410,7 @@ function cachedStringList(source: Record<string, unknown> | null | undefined, ke
function formatTeamspaceProjectList(value: unknown) { function formatTeamspaceProjectList(value: unknown) {
if (Array.isArray(value)) { 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() : ""; return typeof value === "string" ? value.trim() : "";
@@ -303,6 +420,50 @@ function splitTeamspaceProjects(value: unknown) {
return typeof value === "string" ? value.match(/[^\s+]+/g) ?? [] : []; 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>) { function zammadPendingTodoPreview(zammad: Record<string, unknown>) {
const stateId = numericValue(cachedValue(zammad, "stateId")); const stateId = numericValue(cachedValue(zammad, "stateId"));
const pendingTime = cachedValue(zammad, "pendingTime"); const pendingTime = cachedValue(zammad, "pendingTime");
@@ -355,8 +516,8 @@ function comparablePreviewValueForField(field: string, value: unknown) {
return comparablePreviewIdValue(value); return comparablePreviewIdValue(value);
} }
if (field === "Teamspace-Projekte" && Array.isArray(value)) { if (field === "Teamspace-Projekte") {
return value.map((entry) => comparablePreviewValue(entry)); return normalizedTeamspaceProjectList(value).sort((left, right) => left.localeCompare(right, "de", { numeric: true, sensitivity: "base" }));
} }
return comparablePreviewValue(value); 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) { function addCachedChange(changes: ZammadSyncChange[], field: string, from: unknown, to: unknown) {
if (cachedValueChanged(field, from, to)) { 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 localWorkType = cachedValue(local, "workType");
const zammadWorkType = cachedValue(zammad, "workType"); const zammadWorkType = cachedValue(zammad, "workType");
const zammadStateId = numericValue(cachedValue(zammad, "stateId")); const zammadStateId = numericValue(cachedValue(zammad, "stateId"));
const pendingTodoExists = cachedValue(local, "zammadPendingTodoExists");
const localTeamspaceProjects = cachedStringList(project, "teamspaceProjects"); const localTeamspaceProjects = cachedStringList(project, "teamspaceProjects");
const zammadTeamspaceProjects = cachedStringList(zammad, "teamspaceProjects"); const zammadTeamspaceProjects = cachedStringList(zammad, "teamspaceProjects");
const localTeamspaceProjectList = localTeamspaceProjects.length ? localTeamspaceProjects : [cachedValue(project, "teamspaceProject")].filter((entry): entry is string => typeof entry === "string"); 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")); addCachedChange(changes, "Zammad-Status", cachedValue(local, "zammadStateId"), cachedValue(zammad, "stateId"));
const pendingTodo = zammadPendingTodoPreview(zammad); const pendingTodo = zammadPendingTodoPreview(zammad);
if (fields.pendingTodo && pendingTodo) { if (fields.pendingTodo && pendingTodo && pendingTodoExists === false) {
changes.push({ field: "ToDo", from: null, to: pendingTodo }); changes.push({ field: "ToDo", from: null, to: pendingTodo });
} }
@@ -430,7 +592,7 @@ function changesFromExistingPreviewCache(item: ZammadSyncItem, fields: ZammadSyn
} }
if (fields.projectName) { if (fields.projectName) {
addCachedChange(changes, "Teamspace-Projekte", localTeamspaceProjectList, zammadTeamspaceProjectList); addCachedTeamspaceProjectChanges(changes, localTeamspaceProjectList, zammadTeamspaceProjectList);
} }
if (canSyncBudget) { if (canSyncBudget) {
@@ -587,6 +749,7 @@ function ResultSummary({
const selectable = selectableItems(result); const selectable = selectableItems(result);
const selectedCount = selection ? selectable.filter((item) => itemIsSelected(item, selection)).length : 0; const selectedCount = selection ? selectable.filter((item) => itemIsSelected(item, selection)).length : 0;
const allSelected = selectable.length > 0 && selectedCount === selectable.length; const allSelected = selectable.length > 0 && selectedCount === selectable.length;
const listedItems = result.items.filter((item) => item.action !== "skip" || visibleTicketTrackerChanges(item).length > 0);
const metrics = [ const metrics = [
["Geprüft", result.checked], ["Geprüft", result.checked],
[result.mode === "preview" ? "Würde ändern" : "Aktualisiert", result.updated], [result.mode === "preview" ? "Würde ändern" : "Aktualisiert", result.updated],
@@ -642,14 +805,14 @@ function ResultSummary({
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{result.items.length === 0 ? ( {listedItems.length === 0 ? (
<TableRow> <TableRow>
<TableCell colSpan={selection ? 5 : 4} className="py-8 text-center text-sm text-muted-foreground"> <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> </TableCell>
</TableRow> </TableRow>
) : ( ) : (
result.items.map((item) => { listedItems.map((item) => {
const action = formatAction(item); const action = formatAction(item);
const isSelectable = item.action === "update" || item.action === "create"; const isSelectable = item.action === "update" || item.action === "create";
const visibleChanges = visibleTicketTrackerChanges(item); const visibleChanges = visibleTicketTrackerChanges(item);
@@ -717,20 +880,155 @@ function ResultSummary({
} }
export function ZammadSyncPage({ currentUser }: ZammadSyncPageProps) { export function ZammadSyncPage({ currentUser }: ZammadSyncPageProps) {
const pageState = useZammadSyncPageState();
const { const {
fields, fields,
existingResult, existingResult,
existingRunningJobId,
existingRunningJobKind,
importResult, importResult,
importRunningJobId,
importRunningJobKind,
existingSelection, existingSelection,
importSelection, importSelection,
syncingExisting, syncingExisting,
importingConsulting importingConsulting
} = useZammadSyncPageState(); } = pageState;
const [storageReady, setStorageReady] = useState(false);
const displayedExistingResult = useMemo(() => applyFieldsToExistingPreview(existingResult, fields), [existingResult, fields]); const displayedExistingResult = useMemo(() => applyFieldsToExistingPreview(existingResult, fields), [existingResult, fields]);
const selectedExistingCount = useMemo(() => selectedTicketNumbers(displayedExistingResult, existingSelection).length, [displayedExistingResult, existingSelection]); const selectedExistingCount = useMemo(() => selectedTicketNumbers(displayedExistingResult, existingSelection).length, [displayedExistingResult, existingSelection]);
const selectedImportCount = useMemo(() => selectedTicketNumbers(importResult, importSelection).length, [importResult, importSelection]); const selectedImportCount = useMemo(() => selectedTicketNumbers(importResult, importSelection).length, [importResult, importSelection]);
const zammadReady = currentUser.has_zammad_api_token && Boolean(currentUser.zammad_user_id); 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) { function setField(key: keyof ZammadSyncExistingFields, checked: boolean) {
setZammadSyncPageState((current) => ({ setZammadSyncPageState((current) => ({
fields: { ...current.fields, [key]: checked, ...(key === "budget" && !checked ? { overrideBudget: false } : {}) } fields: { ...current.fields, [key]: checked, ...(key === "budget" && !checked ? { overrideBudget: false } : {}) }
@@ -740,7 +1038,11 @@ export function ZammadSyncPage({ currentUser }: ZammadSyncPageProps) {
function resetRunningState() { function resetRunningState() {
setZammadSyncPageState({ setZammadSyncPageState({
syncingExisting: false, syncingExisting: false,
importingConsulting: false importingConsulting: false,
existingRunningJobId: null,
existingRunningJobKind: null,
importRunningJobId: null,
importRunningJobKind: null
}); });
toast.info("Laufstatus zurückgesetzt"); toast.info("Laufstatus zurückgesetzt");
} }
@@ -751,13 +1053,17 @@ export function ZammadSyncPage({ currentUser }: ZammadSyncPageProps) {
} }
const syncFields = zammadSyncPageState.fields; const syncFields = zammadSyncPageState.fields;
setZammadSyncPageState({ syncingExisting: true }); setZammadSyncPageState({ syncingExisting: true, existingRunningJobId: null, existingRunningJobKind: null });
try { try {
const { jobId, result } = await previewExistingZammadTickets(syncFields); const { jobId, result } = await previewExistingZammadTickets(syncFields, (job: ZammadSyncJob) => {
setZammadSyncPageState({ syncingExisting: true, existingRunningJobId: job.id, existingRunningJobKind: "preview" });
});
setZammadSyncPageState({ setZammadSyncPageState({
existingResult: result, existingResult: result,
existingPreviewJobId: jobId, existingPreviewJobId: jobId,
existingSelection: selectionFromResult(result) existingSelection: selectionFromResult(result),
existingRunningJobId: null,
existingRunningJobKind: null
}); });
toast.success("Vorschau erstellt", { toast.success("Vorschau erstellt", {
description: `${result.updated + result.created} Datensatz/Datensätze können übernommen werden.` 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" description: error instanceof Error ? error.message : "Unbekannter Fehler"
}); });
} finally { } finally {
setZammadSyncPageState({ syncingExisting: false }); setZammadSyncPageState({ syncingExisting: false, existingRunningJobId: null, existingRunningJobKind: null });
} }
} }
@@ -790,13 +1096,17 @@ export function ZammadSyncPage({ currentUser }: ZammadSyncPageProps) {
} }
const syncFields = zammadSyncPageState.fields; const syncFields = zammadSyncPageState.fields;
setZammadSyncPageState({ syncingExisting: true }); setZammadSyncPageState({ syncingExisting: true, existingRunningJobId: null, existingRunningJobKind: null });
try { 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({ setZammadSyncPageState({
existingResult: result, existingResult: result,
existingPreviewJobId: null, existingPreviewJobId: null,
existingSelection: {} existingSelection: {},
existingRunningJobId: null,
existingRunningJobKind: null
}); });
toast.success("Auswahl synchronisiert", { toast.success("Auswahl synchronisiert", {
description: `${result.updated} aktualisiert, ${result.skipped} ohne Transfer.` 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" description: error instanceof Error ? error.message : "Unbekannter Fehler"
}); });
} finally { } finally {
setZammadSyncPageState({ syncingExisting: false }); setZammadSyncPageState({ syncingExisting: false, existingRunningJobId: null, existingRunningJobKind: null });
} }
} }
@@ -815,13 +1125,17 @@ export function ZammadSyncPage({ currentUser }: ZammadSyncPageProps) {
return; return;
} }
setZammadSyncPageState({ importingConsulting: true }); setZammadSyncPageState({ importingConsulting: true, importRunningJobId: null, importRunningJobKind: null });
try { try {
const { jobId, result } = await previewOwnZammadConsultingTickets(); const { jobId, result } = await previewOwnZammadConsultingTickets((job: ZammadSyncJob) => {
setZammadSyncPageState({ importingConsulting: true, importRunningJobId: job.id, importRunningJobKind: "preview" });
});
setZammadSyncPageState({ setZammadSyncPageState({
importResult: result, importResult: result,
importPreviewJobId: jobId, importPreviewJobId: jobId,
importSelection: selectionFromResult(result) importSelection: selectionFromResult(result),
importRunningJobId: null,
importRunningJobKind: null
}); });
toast.success("Import-Vorschau erstellt", { toast.success("Import-Vorschau erstellt", {
description: `${result.created} Consulting-Ticket(s) können angelegt werden.` 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" description: error instanceof Error ? error.message : "Unbekannter Fehler"
}); });
} finally { } finally {
setZammadSyncPageState({ importingConsulting: false }); setZammadSyncPageState({ importingConsulting: false, importRunningJobId: null, importRunningJobKind: null });
} }
} }
@@ -852,13 +1166,17 @@ export function ZammadSyncPage({ currentUser }: ZammadSyncPageProps) {
return; return;
} }
setZammadSyncPageState({ importingConsulting: true }); setZammadSyncPageState({ importingConsulting: true, importRunningJobId: null, importRunningJobKind: null });
try { 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({ setZammadSyncPageState({
importResult: result, importResult: result,
importPreviewJobId: null, importPreviewJobId: null,
importSelection: {} importSelection: {},
importRunningJobId: null,
importRunningJobKind: null
}); });
toast.success("Auswahl importiert", { toast.success("Auswahl importiert", {
description: `${result.created} angelegt, ${result.skipped} ohne Transfer.` 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" description: error instanceof Error ? error.message : "Unbekannter Fehler"
}); });
} finally { } 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")}> <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)} /> <Checkbox checked={fields.overrideBudget} onCheckedChange={(checked) => setField("overrideBudget", checked === true)} />
<span className="min-w-0"> <span className="min-w-0">
<span className="block text-sm font-medium">Manuelle Budget-Änderungen überschreiben</span> <span className="block text-sm font-medium">Budget-Schutz im Sync ignorieren</span>
<span className="block text-xs text-muted-foreground">Ausgeschaltet: geschützte Budgets bleiben in TicketTracker unverändert.</span> <span className="block text-xs text-muted-foreground">Aus: nur ungeschützte Budgets aktualisieren. An: Zammad überschreibt auch geschützte Budgets.</span>
</span> </span>
</label> </label>
) : null} ) : null}