This commit is contained in:
michael committed 2026-09-30 16:59:14 +02:00
1 parent 35cb578a96
commit d5952d82bb
11 files changed
+1074 -112

No files matched your search

+17 -2
View File
@@ -24,6 +24,11 @@ export type AuthUser = {
zammad_time_unit_rounding_minutes: number; zammad_time_unit_rounding_minutes: number;
zammad_atoss_email_sync_enabled: boolean; zammad_atoss_email_sync_enabled: boolean;
zammad_article_type_id: number; zammad_article_type_id: number;
project_followup_enabled: boolean;
project_followup_weeks: number;
project_followup_second_enabled: boolean;
project_followup_second_weeks: number;
project_followup_complete_on_session: boolean;
}; };
declare global { declare global {
@@ -91,7 +96,12 @@ function publicUser(user: AuthUser) {
zammad_time_unit_rounding_enabled: Boolean(user.zammad_time_unit_rounding_enabled), zammad_time_unit_rounding_enabled: Boolean(user.zammad_time_unit_rounding_enabled),
zammad_time_unit_rounding_minutes: Number(user.zammad_time_unit_rounding_minutes ?? 15), zammad_time_unit_rounding_minutes: Number(user.zammad_time_unit_rounding_minutes ?? 15),
zammad_atoss_email_sync_enabled: Boolean(user.zammad_atoss_email_sync_enabled ?? true), zammad_atoss_email_sync_enabled: Boolean(user.zammad_atoss_email_sync_enabled ?? true),
zammad_article_type_id: Number(user.zammad_article_type_id ?? 6) zammad_article_type_id: Number(user.zammad_article_type_id ?? 6),
project_followup_enabled: Boolean(user.project_followup_enabled ?? true),
project_followup_weeks: Number(user.project_followup_weeks ?? 4),
project_followup_second_enabled: Boolean(user.project_followup_second_enabled ?? true),
project_followup_second_weeks: Number(user.project_followup_second_weeks ?? 2),
project_followup_complete_on_session: Boolean(user.project_followup_complete_on_session ?? true)
}; };
} }
@@ -155,7 +165,12 @@ export async function requireAuth(req: Request, res: Response, next: NextFunctio
u.zammad_time_unit_rounding_enabled, u.zammad_time_unit_rounding_enabled,
u.zammad_time_unit_rounding_minutes, u.zammad_time_unit_rounding_minutes,
u.zammad_atoss_email_sync_enabled, u.zammad_atoss_email_sync_enabled,
COALESCE(u.zammad_article_type_id, 6) AS zammad_article_type_id COALESCE(u.zammad_article_type_id, 6) AS zammad_article_type_id,
u.project_followup_enabled,
u.project_followup_weeks,
u.project_followup_second_enabled,
u.project_followup_second_weeks,
u.project_followup_complete_on_session
FROM auth_sessions s FROM auth_sessions s
JOIN users u ON u.id = s.user_id JOIN users u ON u.id = s.user_id
WHERE s.token_hash = $1 WHERE s.token_hash = $1
+542 -34
View File
@@ -457,6 +457,20 @@ function parseZammadArticleTypeId(value: unknown, fallback = 6) {
return typeId; return typeId;
} }
function parseProjectFollowupWeeks(value: unknown, fallback = 4) {
if (value === undefined || value === null || value === "") {
return fallback;
}
const weeks = Number(value);
if (!Number.isInteger(weeks) || weeks < 1 || weeks > 52) {
throw badRequest("projectFollowupWeeks must be an integer between 1 and 52");
}
return weeks;
}
function optionalTicketSessionMonth(value: unknown) { function optionalTicketSessionMonth(value: unknown) {
if (value === undefined || value === null || value === "" || value === "all") { if (value === undefined || value === null || value === "" || value === "all") {
return null; return null;
@@ -1653,7 +1667,7 @@ async function fetchZammadMergedTargetTicket(baseUrl: string, apiToken: string,
throw new Error(`Zammad-Merge-Ziel-ID für Ticket-ID ${sourceTicketId} wurde in ticket_history nicht gefunden (${zammadHistoryDebugSummary(history, sourceTicketId)})`); throw new Error(`Zammad-Merge-Ziel-ID für Ticket-ID ${sourceTicketId} wurde in ticket_history nicht gefunden (${zammadHistoryDebugSummary(history, sourceTicketId)})`);
} }
const targetTicket = await fetchZammadTicketDetail(baseUrl, apiToken, targetTicketId, false); const targetTicket = await fetchZammadTicketDetail(baseUrl, apiToken, targetTicketId);
const sourceOrganizationId = numberOrNull(rawTicket.organization_id); const sourceOrganizationId = numberOrNull(rawTicket.organization_id);
const normalizedTargetTicket = targetTicket && !numberOrNull(targetTicket.organization_id) && sourceOrganizationId const normalizedTargetTicket = targetTicket && !numberOrNull(targetTicket.organization_id) && sourceOrganizationId
? { ...targetTicket, organization_id: sourceOrganizationId } ? { ...targetTicket, organization_id: sourceOrganizationId }
@@ -1855,6 +1869,12 @@ async function createSessionForUser(input: SessionCreationInput) {
throw Object.assign(new Error("Ticket wurde in Zammad nicht gefunden"), { status: 404 }); throw Object.assign(new Error("Ticket wurde in Zammad nicht gefunden"), { status: 404 });
} }
const effectiveTicketNumber = zammadTicket?.ticketNumber ?? ticketNumber;
if (effectiveTicketNumber !== ticketNumber && await ticketHasRecurringBilling({ query }, effectiveTicketNumber, userId)) {
throw Object.assign(new Error("Für fixe Abrechnungen dürfen keine manuellen Sessions erstellt werden. Bitte die fixe Abrechnung bearbeiten."), { status: 409 });
}
if (sourceEvent && (sourceEvent.zammadArticleId || sourceEvent.deliveryId)) { if (sourceEvent && (sourceEvent.zammadArticleId || sourceEvent.deliveryId)) {
await query( await query(
` `
@@ -1928,7 +1948,7 @@ async function createSessionForUser(input: SessionCreationInput) {
LIMIT 1 LIMIT 1
FOR UPDATE OF t; FOR UPDATE OF t;
`, `,
[ticketNumber, userId] [effectiveTicketNumber, userId]
); );
const existingTicket = existingTicketResult.rows[0] ?? null; const existingTicket = existingTicketResult.rows[0] ?? null;
const effectiveOrganizationId = existingTicket?.organization_id ? Number(existingTicket.organization_id) : requestedOrganizationId; const effectiveOrganizationId = existingTicket?.organization_id ? Number(existingTicket.organization_id) : requestedOrganizationId;
@@ -1960,7 +1980,7 @@ async function createSessionForUser(input: SessionCreationInput) {
END END
RETURNING id, ticket_number, organization_id, customer_name, work_type, zammad_ticket_id::text AS zammad_ticket_id; RETURNING id, ticket_number, organization_id, customer_name, work_type, zammad_ticket_id::text AS zammad_ticket_id;
`, `,
[ticketNumber, organization.id, organization.name, existingTicket?.project_id ? "consulting" : requestedWorkType, userId] [effectiveTicketNumber, organization.id, organization.name, existingTicket?.project_id ? "consulting" : requestedWorkType, userId]
); );
const ticket = ticketResult.rows[0]; const ticket = ticketResult.rows[0];
@@ -2077,7 +2097,7 @@ async function createSessionForUser(input: SessionCreationInput) {
userId, userId,
sessionId: session.id, sessionId: session.id,
ticketId: ticket.id, ticketId: ticket.id,
ticketNumber, ticketNumber: effectiveTicketNumber,
zammadTicketId: zammadArticleTicketId, zammadTicketId: zammadArticleTicketId,
activity, activity,
articleBody: zammadArticleBody, articleBody: zammadArticleBody,
@@ -2092,6 +2112,7 @@ async function createSessionForUser(input: SessionCreationInput) {
if (workType === "consulting") { if (workType === "consulting") {
await reopenProjectsForTicket(client, ticket.id); await reopenProjectsForTicket(client, ticket.id);
await showProjectsForUserTicket(client, ticket.id, userId); await showProjectsForUserTicket(client, ticket.id, userId);
await completeAutomaticProjectFollowupsForSession(client, userId, ticket.id, startedAt);
} }
if (eventId) { if (eventId) {
@@ -2592,7 +2613,7 @@ async function fetchZammadConsultingTicketsForOwner(userId: string): Promise<Zam
} }
} }
const tickets: ZammadTicketImport[] = []; const ticketsByZammadId = new Map<number, ZammadTicketImport>();
for (const [id, candidate] of candidates.entries()) { for (const [id, candidate] of candidates.entries()) {
const rawTicket = await fetchZammadTicketDetail(baseUrl, apiToken, id) ?? candidate; const rawTicket = await fetchZammadTicketDetail(baseUrl, apiToken, id) ?? candidate;
@@ -2600,11 +2621,15 @@ async function fetchZammadConsultingTicketsForOwner(userId: string): Promise<Zam
const zammadTicket = mergedTargetTicket ?? zammadTicketImportFromRaw(rawTicket); const zammadTicket = mergedTargetTicket ?? zammadTicketImportFromRaw(rawTicket);
if (zammadTicket && zammadTicket.workType === "consulting" && zammadTicket.zammadOwnerId === Number(zammadUserId) && zammadTicket.zammadStateId !== 4) { if (zammadTicket && zammadTicket.workType === "consulting" && zammadTicket.zammadOwnerId === Number(zammadUserId) && zammadTicket.zammadStateId !== 4) {
tickets.push(zammadTicket); const existingTicket = ticketsByZammadId.get(zammadTicket.zammadTicketId);
if (!existingTicket || (!existingTicket.mergedFromTicketId && zammadTicket.mergedFromTicketId)) {
ticketsByZammadId.set(zammadTicket.zammadTicketId, zammadTicket);
}
} }
} }
return tickets; return Array.from(ticketsByZammadId.values()).sort((left, right) => left.ticketNumber.localeCompare(right.ticketNumber, "de", { numeric: true }));
} }
async function projectOwnerUserIdForZammadOwner(client: QueryClient, zammadOwnerId: number | null, fallbackUserId: string) { async function projectOwnerUserIdForZammadOwner(client: QueryClient, zammadOwnerId: number | null, fallbackUserId: string) {
@@ -2663,11 +2688,20 @@ async function importZammadTicketForUser(client: QueryClient, zammadTicket: Zamm
zammad_priority_id, zammad_priority_id,
zammad_state_id, zammad_state_id,
zammad_owner_id, zammad_owner_id,
zammad_merged_from_ticket_id,
zammad_merged_from_ticket_number,
zammad_preferences zammad_preferences
) )
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11::jsonb) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13::jsonb)
ON CONFLICT (ticket_number) DO UPDATE SET ON CONFLICT (ticket_number) DO UPDATE SET
ticket_number = tickets.ticket_number zammad_ticket_id = COALESCE(tickets.zammad_ticket_id, EXCLUDED.zammad_ticket_id),
zammad_group_id = EXCLUDED.zammad_group_id,
zammad_priority_id = EXCLUDED.zammad_priority_id,
zammad_state_id = EXCLUDED.zammad_state_id,
zammad_owner_id = EXCLUDED.zammad_owner_id,
zammad_merged_from_ticket_id = COALESCE(EXCLUDED.zammad_merged_from_ticket_id, tickets.zammad_merged_from_ticket_id),
zammad_merged_from_ticket_number = COALESCE(EXCLUDED.zammad_merged_from_ticket_number, tickets.zammad_merged_from_ticket_number),
zammad_preferences = EXCLUDED.zammad_preferences
RETURNING id::text; RETURNING id::text;
`, `,
[ [
@@ -2681,6 +2715,8 @@ async function importZammadTicketForUser(client: QueryClient, zammadTicket: Zamm
zammadTicket.zammadPriorityId, zammadTicket.zammadPriorityId,
zammadTicket.zammadStateId, zammadTicket.zammadStateId,
zammadTicket.zammadOwnerId, zammadTicket.zammadOwnerId,
zammadTicket.mergedFromTicketId,
zammadTicket.mergedFromTicketNumber,
JSON.stringify(zammadTicket.preferences) JSON.stringify(zammadTicket.preferences)
] ]
); );
@@ -4225,6 +4261,36 @@ function isBlankTeamspaceProject(value: string | null | undefined) {
return !value?.trim(); return !value?.trim();
} }
type ProjectTeamspaceEntrySyncRow = {
id: string;
teamspace_project: string | null;
teamspace_description: string | null;
is_default: boolean;
display_order: number;
budget_sync_disabled: boolean;
};
async function loadProjectTeamspaceEntriesForUpdate(client: QueryClient, projectId: string) {
const result = await client.query<ProjectTeamspaceEntrySyncRow>(
`
SELECT
id::text AS id,
teamspace_project,
teamspace_description,
is_default,
display_order,
budget_sync_disabled
FROM project_teamspace_entries
WHERE project_id = $1
ORDER BY is_default DESC, display_order ASC, id ASC
FOR UPDATE;
`,
[projectId]
);
return result.rows;
}
function teamspaceProjectDiff(localProjects: Array<string | null | undefined>, zammadProjects: Array<string | null | undefined>) { function teamspaceProjectDiff(localProjects: Array<string | null | undefined>, zammadProjects: Array<string | null | undefined>) {
const local = normalizedTeamspaceProjectList(localProjects); const local = normalizedTeamspaceProjectList(localProjects);
const zammad = normalizedTeamspaceProjectList(zammadProjects); const zammad = normalizedTeamspaceProjectList(zammadProjects);
@@ -4426,7 +4492,8 @@ async function mergeTeamspaceEntriesIntoTarget(client: QueryClient, sourceEntryI
SELECT SELECT
COALESCE(SUM(manual_consumption_minutes), 0)::int AS manual_consumption_minutes, COALESCE(SUM(manual_consumption_minutes), 0)::int AS manual_consumption_minutes,
COALESCE(SUM(hour_budget_minutes) FILTER (WHERE hour_budget_minutes IS NOT NULL), NULL)::int AS hour_budget_minutes, COALESCE(SUM(hour_budget_minutes) FILTER (WHERE hour_budget_minutes IS NOT NULL), NULL)::int AS hour_budget_minutes,
BOOL_OR(budget_sync_disabled) AS budget_sync_disabled BOOL_OR(budget_sync_disabled) AS budget_sync_disabled,
NULLIF(string_agg(DISTINCT NULLIF(teamspace_description, ''), E'\n\n'), '') AS teamspace_description
FROM project_teamspace_entries FROM project_teamspace_entries
WHERE id = ANY($1::bigint[]) WHERE id = ANY($1::bigint[])
) )
@@ -4434,6 +4501,10 @@ async function mergeTeamspaceEntriesIntoTarget(client: QueryClient, sourceEntryI
SET hour_budget_minutes = COALESCE(target.hour_budget_minutes, source_totals.hour_budget_minutes), SET hour_budget_minutes = COALESCE(target.hour_budget_minutes, source_totals.hour_budget_minutes),
budget_sync_disabled = target.budget_sync_disabled OR COALESCE(source_totals.budget_sync_disabled, false), budget_sync_disabled = target.budget_sync_disabled OR COALESCE(source_totals.budget_sync_disabled, false),
manual_consumption_minutes = target.manual_consumption_minutes + source_totals.manual_consumption_minutes, manual_consumption_minutes = target.manual_consumption_minutes + source_totals.manual_consumption_minutes,
teamspace_description = CASE
WHEN NULLIF(target.teamspace_description, '') IS NULL THEN COALESCE(source_totals.teamspace_description, target.teamspace_description)
ELSE target.teamspace_description
END,
updated_at = now() updated_at = now()
FROM source_totals FROM source_totals
WHERE target.id = $2::bigint; WHERE target.id = $2::bigint;
@@ -4481,6 +4552,37 @@ async function mergeTeamspaceEntriesIntoTarget(client: QueryClient, sourceEntryI
); );
} }
async function dedupeProjectTeamspaceEntries(client: QueryClient, projectId: string) {
const entries = await loadProjectTeamspaceEntriesForUpdate(client, projectId);
const groups = new Map<string, ProjectTeamspaceEntrySyncRow[]>();
for (const entry of entries) {
const teamspaceProject = entry.teamspace_project?.trim();
if (!teamspaceProject) {
continue;
}
groups.set(teamspaceProject, [...(groups.get(teamspaceProject) ?? []), entry]);
}
for (const groupEntries of groups.values()) {
if (groupEntries.length < 2) {
continue;
}
const [targetEntry, ...sourceEntries] = [...groupEntries].sort((left, right) =>
Number(right.is_default) - Number(left.is_default) ||
Number(Boolean(right.teamspace_description?.trim())) - Number(Boolean(left.teamspace_description?.trim())) ||
Number(right.budget_sync_disabled) - Number(left.budget_sync_disabled) ||
Number(left.display_order ?? 0) - Number(right.display_order ?? 0) ||
Number(left.id) - Number(right.id)
);
await mergeTeamspaceEntriesIntoTarget(client, sourceEntries.map((entry) => entry.id), targetEntry.id);
}
}
async function assignUnassignedProjectSessionsToTeamspaceEntry(client: QueryClient, projectId: string, targetEntryId: string) { async function assignUnassignedProjectSessionsToTeamspaceEntry(client: QueryClient, projectId: string, targetEntryId: string) {
await client.query( await client.query(
` `
@@ -4541,18 +4643,9 @@ async function syncProjectTeamspaceEntriesFromZammad(
return; return;
} }
const existingResult = await client.query<{ id: string; teamspace_project: string | null; is_default: boolean; display_order: number }>( let existingEntries = await loadProjectTeamspaceEntriesForUpdate(client, projectId);
`
SELECT id::text AS id, teamspace_project, is_default, display_order
FROM project_teamspace_entries
WHERE project_id = $1
ORDER BY is_default DESC, display_order ASC, id ASC
FOR UPDATE;
`,
[projectId]
);
if (existingResult.rows.length === 0) { if (existingEntries.length === 0) {
for (const [index, teamspaceProject] of zammadProjects.entries()) { for (const [index, teamspaceProject] of zammadProjects.entries()) {
await client.query( await client.query(
` `
@@ -4576,7 +4669,6 @@ async function syncProjectTeamspaceEntriesFromZammad(
return; return;
} }
let existingEntries = existingResult.rows;
let defaultEntry = existingEntries.find((entry) => entry.is_default) ?? existingEntries[0]; let defaultEntry = existingEntries.find((entry) => entry.is_default) ?? existingEntries[0];
if (!defaultEntry.is_default) { if (!defaultEntry.is_default) {
@@ -4624,6 +4716,10 @@ async function syncProjectTeamspaceEntriesFromZammad(
} }
} }
await dedupeProjectTeamspaceEntries(client, projectId);
existingEntries = await loadProjectTeamspaceEntriesForUpdate(client, projectId);
defaultEntry = existingEntries.find((entry) => entry.is_default) ?? existingEntries[0];
const existingTeamspaceProjects = new Set(normalizedTeamspaceProjectList(existingEntries.map((entry) => entry.teamspace_project))); const existingTeamspaceProjects = new Set(normalizedTeamspaceProjectList(existingEntries.map((entry) => entry.teamspace_project)));
let nextDisplayOrder = Math.max(...existingEntries.map((entry) => Number(entry.display_order ?? 0)), 0) + 1024; let nextDisplayOrder = Math.max(...existingEntries.map((entry) => Number(entry.display_order ?? 0)), 0) + 1024;
@@ -5813,6 +5909,7 @@ async function mergeProjectRowsIntoTicket(client: QueryClient, sourceTicketId: s
); );
await client.query("DELETE FROM projects WHERE id = $1;", [sourceProject.id]); await client.query("DELETE FROM projects WHERE id = $1;", [sourceProject.id]);
await dedupeProjectTeamspaceEntries(client, targetProject.id);
} }
await client.query( await client.query(
@@ -6852,6 +6949,354 @@ async function getStatisticsOverview(period: ParsedPeriod, userId: string, perio
}; };
} }
async function getProjectFollowups(userId: string) {
return withTransaction(async (client) => {
const userResult = await client.query<{
project_followup_enabled: boolean;
project_followup_weeks: number;
project_followup_second_enabled: boolean;
project_followup_second_weeks: number;
}>("SELECT project_followup_enabled, project_followup_weeks, project_followup_second_enabled, project_followup_second_weeks FROM users WHERE id = $1;", [userId]);
const enabled = Boolean(userResult.rows[0]?.project_followup_enabled ?? true);
const weeks = Number(userResult.rows[0]?.project_followup_weeks ?? 4);
const secondEnabled = Boolean(userResult.rows[0]?.project_followup_second_enabled ?? true);
const secondWeeks = Number(userResult.rows[0]?.project_followup_second_weeks ?? 2);
if (!enabled) {
return {
enabled,
weeks,
secondEnabled,
secondWeeks,
createdTodos: 0,
updatedTodos: 0,
projects: []
};
}
async function loadRows() {
return client.query<{
project_id: string;
ticket_id: string;
ticket_number: string;
title: string | null;
organization_name: string | null;
teamspace_project: string | null;
hour_budget_minutes: number | null;
consumption_minutes: number;
remaining_minutes: number | null;
latest_tt_update: string;
last_session_at: string | null;
project_updated_at: string;
open_followup_todo_id: string | null;
existing_followup_todo_id: string | null;
existing_followup_todo_status: "open" | "done" | null;
reminder_id: string | null;
followup_state: "open" | "done" | "dismissed" | null;
followup_stage: number;
followup_title: string;
open_todos: number;
overdue_todos: number;
followup_due_on: string;
}>(
`
WITH followup_projects AS (
SELECT
p.id,
p.ticket_id,
p.updated_at AS project_updated_at,
GREATEST(
p.updated_at,
COALESCE(entry_updates.latest_entry_updated_at, '-infinity'::timestamptz),
COALESCE(session_updates.latest_session_created_at, '-infinity'::timestamptz),
COALESCE(todo_updates.latest_todo_updated_at, '-infinity'::timestamptz),
COALESCE(day_billing_updates.latest_day_billing_updated_at, '-infinity'::timestamptz)
) AS latest_tt_update,
session_updates.latest_session_at,
session_updates.last_session_at,
entry_totals.total_hour_budget_minutes,
(COALESCE(teamspace_totals.teamspace_consumption_minutes, 0) + COALESCE(entry_totals.total_manual_consumption_minutes, 0))::int AS consumption_minutes
FROM projects p
JOIN tickets ft ON ft.id = p.ticket_id
LEFT JOIN LATERAL (
SELECT
COALESCE(SUM(pte.hour_budget_minutes) FILTER (WHERE pte.hour_budget_minutes IS NOT NULL), NULL)::int AS total_hour_budget_minutes,
COALESCE(SUM(pte.manual_consumption_minutes), 0)::int AS total_manual_consumption_minutes
FROM project_teamspace_entries pte
WHERE pte.project_id = p.id
) entry_totals ON true
LEFT JOIN LATERAL (
SELECT MAX(pte.updated_at) AS latest_entry_updated_at
FROM project_teamspace_entries pte
WHERE pte.project_id = p.id
) entry_updates ON true
LEFT JOIN LATERAL (
SELECT
MAX(s.created_at) AS latest_session_created_at,
MAX(s.started_at) AS latest_session_at,
MAX(s.started_at)::text AS last_session_at
FROM sessions s
WHERE s.ticket_id = p.ticket_id
AND s.user_id = $1
) session_updates ON true
LEFT JOIN LATERAL (
SELECT MAX(tt.updated_at) AS latest_todo_updated_at
FROM ticket_todos tt
WHERE tt.ticket_id = p.ticket_id
AND tt.user_id = $1
AND tt.title <> 'Wiedervorlage ' || ft.ticket_number
AND NOT EXISTS (
SELECT 1
FROM project_followup_reminders pfr
WHERE pfr.todo_id = tt.id
AND pfr.user_id = $1
)
) todo_updates ON true
LEFT JOIN LATERAL (
SELECT MAX(tdb.updated_at) AS latest_day_billing_updated_at
FROM ticket_day_billings tdb
WHERE tdb.ticket_id = p.ticket_id
AND tdb.user_id = $1
) day_billing_updates ON true
LEFT JOIN LATERAL (
SELECT COALESCE(SUM(tdb.billed_minutes), 0)::int AS teamspace_consumption_minutes
FROM ticket_day_billings tdb
WHERE tdb.ticket_id = p.ticket_id
) teamspace_totals ON true
WHERE p.closed_at IS NULL
AND COALESCE(ft.work_type, 'consulting') = 'consulting'
AND (
p.user_id = $1
OR EXISTS (
SELECT 1
FROM sessions viewer_sessions
WHERE viewer_sessions.ticket_id = p.ticket_id
AND viewer_sessions.user_id = $1
)
)
AND NOT EXISTS (
SELECT 1
FROM hidden_user_projects hup
WHERE hup.user_id = $1
AND hup.project_id = p.id
)
)
SELECT
fp.id::text AS project_id,
t.id::text AS ticket_id,
t.ticket_number,
t.title,
COALESCE(o.name, t.customer_name) AS organization_name,
p.teamspace_project,
fp.total_hour_budget_minutes AS hour_budget_minutes,
fp.consumption_minutes,
CASE
WHEN fp.total_hour_budget_minutes IS NULL THEN NULL
ELSE (fp.total_hour_budget_minutes - fp.consumption_minutes)::int
END AS remaining_minutes,
fp.latest_tt_update::text AS latest_tt_update,
fp.last_session_at,
fp.project_updated_at::text AS project_updated_at,
CASE WHEN stage1_reminder.id IS NULL THEN 1 ELSE 2 END AS followup_stage,
CASE
WHEN stage1_reminder.id IS NULL THEN 'Wiedervorlage ' || t.ticket_number
ELSE '2. Wiedervorlage ' || t.ticket_number
END AS followup_title,
existing_followup_todo.id::text AS existing_followup_todo_id,
existing_followup_todo.status AS existing_followup_todo_status,
CASE WHEN existing_followup_todo.status = 'open' THEN existing_followup_todo.id::text ELSE NULL END AS open_followup_todo_id,
CASE WHEN stage1_reminder.id IS NULL THEN NULL ELSE stage2_reminder.id::text END AS reminder_id,
CASE
WHEN stage1_reminder.id IS NULL THEN NULL
WHEN stage2_reminder.id IS NULL THEN NULL
WHEN stage2_reminder.todo_id IS NULL THEN 'dismissed'
WHEN stage2_todo.status = 'done' THEN 'done'
ELSE 'open'
END AS followup_state,
COALESCE(todo_totals.open_todos, 0)::int AS open_todos,
COALESCE(todo_totals.overdue_todos, 0)::int AS overdue_todos,
CASE
WHEN stage1_reminder.id IS NULL THEN ((fp.latest_tt_update AT TIME ZONE 'Europe/Berlin') + ($2::int * interval '1 week'))::date::text
ELSE (stage1_reminder.due_on + ($3::int * interval '1 week'))::date::text
END AS followup_due_on
FROM followup_projects fp
JOIN projects p ON p.id = fp.id
JOIN tickets t ON t.id = fp.ticket_id
LEFT JOIN organizations o ON o.zammad_id = t.organization_id
LEFT JOIN project_followup_reminders stage1_reminder
ON stage1_reminder.user_id = $1
AND stage1_reminder.project_id = fp.id
AND stage1_reminder.latest_tt_update_at = fp.latest_tt_update
AND stage1_reminder.stage = 1
LEFT JOIN ticket_todos stage1_todo ON stage1_todo.id = stage1_reminder.todo_id
LEFT JOIN project_followup_reminders stage2_reminder
ON stage2_reminder.user_id = $1
AND stage2_reminder.project_id = fp.id
AND stage2_reminder.latest_tt_update_at = fp.latest_tt_update
AND stage2_reminder.stage = 2
LEFT JOIN ticket_todos stage2_todo ON stage2_todo.id = stage2_reminder.todo_id
LEFT JOIN LATERAL (
SELECT id, status
FROM ticket_todos tt
WHERE tt.ticket_id = t.id
AND tt.user_id = $1
AND tt.title = CASE
WHEN stage1_reminder.id IS NULL THEN 'Wiedervorlage ' || t.ticket_number
ELSE '2. Wiedervorlage ' || t.ticket_number
END
ORDER BY CASE tt.status WHEN 'open' THEN 0 ELSE 1 END, tt.id ASC
LIMIT 1
) existing_followup_todo ON true
LEFT JOIN LATERAL (
SELECT
COUNT(*) FILTER (WHERE status = 'open')::int AS open_todos,
COUNT(*) FILTER (WHERE status = 'open' AND ends_on IS NOT NULL AND ends_on < (now() AT TIME ZONE 'Europe/Berlin')::date)::int AS overdue_todos
FROM ticket_todos tt
WHERE tt.ticket_id = t.id
AND tt.user_id = $1
) todo_totals ON true
WHERE (
stage1_reminder.id IS NULL
AND ((fp.latest_tt_update AT TIME ZONE 'Europe/Berlin') + ($2::int * interval '1 week'))::date <= (now() AT TIME ZONE 'Europe/Berlin')::date
)
OR (
$4::boolean
AND
stage1_reminder.id IS NOT NULL
AND stage2_reminder.id IS NULL
AND (stage1_reminder.todo_id IS NULL OR stage1_todo.status = 'done')
AND NOT EXISTS (
SELECT 1
FROM sessions reset_session
WHERE reset_session.ticket_id = fp.ticket_id
AND reset_session.user_id = $1
AND (reset_session.started_at AT TIME ZONE 'Europe/Berlin')::date >= stage1_reminder.due_on
)
AND (stage1_reminder.due_on + ($3::int * interval '1 week'))::date <= (now() AT TIME ZONE 'Europe/Berlin')::date
)
ORDER BY fp.latest_tt_update ASC, t.ticket_number ASC;
`,
[userId, weeks, secondWeeks, secondEnabled]
);
}
const firstResult = await loadRows();
let createdTodos = 0;
let registeredTodos = 0;
for (const row of firstResult.rows) {
const periodDescription = row.followup_stage === 1
? `seit ${weeks} Woche(n) keine TT-Aktualisierung`
: `zweite Wiedervorlage nach weiteren ${secondWeeks} Woche(n) ohne neue Session`;
const description = `Automatisch erstellt: Consulting-Projekt hatte ${periodDescription} (${row.hour_budget_minutes === null ? "ohne hinterlegtes Budget" : `mit ${Math.ceil(Number(row.remaining_minutes ?? 0) / 60)} h Budget-Rest`}).`;
if (row.reminder_id) {
continue;
}
if (row.existing_followup_todo_id) {
const reminderResult = await client.query(
`
INSERT INTO project_followup_reminders (user_id, project_id, ticket_id, todo_id, stage, latest_tt_update_at, due_on)
VALUES ($1, $2, $3, $4, $5, $6::timestamptz, $7::date)
ON CONFLICT (user_id, project_id, latest_tt_update_at, stage) DO NOTHING;
`,
[userId, row.project_id, row.ticket_id, row.existing_followup_todo_id, row.followup_stage, row.latest_tt_update, row.followup_due_on]
);
registeredTodos += reminderResult.rowCount ?? 0;
continue;
}
const insertResult = await client.query<{ id: string }>(
`
INSERT INTO ticket_todos (ticket_id, user_id, title, description, priority, starts_on, ends_on, sort_order)
SELECT
$1,
$2,
$3,
$4,
'medium',
$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 status = 'open'
AND title = $3
)
RETURNING id::text;
`,
[
row.ticket_id,
userId,
row.followup_title,
description,
row.followup_due_on
]
);
createdTodos += insertResult.rowCount ?? 0;
const todoId = insertResult.rows[0]?.id ?? null;
await client.query(
`
INSERT INTO project_followup_reminders (user_id, project_id, ticket_id, todo_id, stage, latest_tt_update_at, due_on)
VALUES ($1, $2, $3, $4::bigint, $5, $6::timestamptz, $7::date)
ON CONFLICT (user_id, project_id, latest_tt_update_at, stage) DO UPDATE
SET todo_id = COALESCE(project_followup_reminders.todo_id, EXCLUDED.todo_id),
due_on = EXCLUDED.due_on,
updated_at = now();
`,
[userId, row.project_id, row.ticket_id, todoId, row.followup_stage, row.latest_tt_update, row.followup_due_on]
);
}
const finalResult = createdTodos > 0 || registeredTodos > 0 ? await loadRows() : firstResult;
return {
enabled,
weeks,
secondEnabled,
secondWeeks,
createdTodos,
updatedTodos: 0,
projects: finalResult.rows
};
});
}
async function completeAutomaticProjectFollowupsForSession(client: QueryClient, userId: string, ticketId: string, completedAt: Date) {
const userResult = await client.query<{ project_followup_enabled: boolean; project_followup_complete_on_session: boolean }>(
"SELECT project_followup_enabled, project_followup_complete_on_session FROM users WHERE id = $1;",
[userId]
);
if (!Boolean(userResult.rows[0]?.project_followup_enabled ?? true) || !Boolean(userResult.rows[0]?.project_followup_complete_on_session ?? true)) {
return;
}
await client.query(
`
UPDATE ticket_todos tt
SET status = 'done',
completed_at = COALESCE(tt.completed_at, $3::timestamptz),
updated_at = now()
FROM project_followup_reminders pfr
WHERE pfr.todo_id = tt.id
AND pfr.user_id = $1
AND pfr.ticket_id = $2
AND tt.user_id = $1
AND tt.ticket_id = $2
AND tt.status = 'open';
`,
[userId, ticketId, completedAt.toISOString()]
);
}
async function getPeriodTicket(config: PeriodConfig, period: ParsedPeriod, ticketId: number, userId: string) { async function getPeriodTicket(config: PeriodConfig, period: ParsedPeriod, ticketId: number, userId: string) {
await ensureRecurringSessionsForUserPeriod(userId, period.startIso, period.endIso); await ensureRecurringSessionsForUserPeriod(userId, period.startIso, period.endIso);
await autoAssignSingleProjectTeamspaceEntry({ query }, String(ticketId), userId); await autoAssignSingleProjectTeamspaceEntry({ query }, String(ticketId), userId);
@@ -7415,11 +7860,16 @@ app.post("/api/setup/admin", async (req, res) => {
zammad_time_unit_rounding_minutes: number; zammad_time_unit_rounding_minutes: number;
zammad_atoss_email_sync_enabled: boolean; zammad_atoss_email_sync_enabled: boolean;
zammad_article_type_id: number; zammad_article_type_id: number;
project_followup_enabled: boolean;
project_followup_weeks: number;
project_followup_second_enabled: boolean;
project_followup_second_weeks: number;
project_followup_complete_on_session: boolean;
}>( }>(
` `
INSERT INTO users (username, display_name, password_hash, role, active) INSERT INTO users (username, display_name, password_hash, role, active)
VALUES ($1, $2, $3, 'admin', true) VALUES ($1, $2, $3, 'admin', true)
RETURNING id, username, display_name, role, active, zammad_user_id::text AS zammad_user_id, (zammad_api_token IS NOT NULL) AS has_zammad_api_token, zammad_email_session_webhook_enabled, session_billing_prompt_enabled, zammad_session_note_default_enabled, zammad_time_unit_rounding_enabled, zammad_time_unit_rounding_minutes, zammad_atoss_email_sync_enabled, COALESCE(zammad_article_type_id, 6) AS zammad_article_type_id; RETURNING id, username, display_name, role, active, zammad_user_id::text AS zammad_user_id, (zammad_api_token IS NOT NULL) AS has_zammad_api_token, zammad_email_session_webhook_enabled, session_billing_prompt_enabled, zammad_session_note_default_enabled, zammad_time_unit_rounding_enabled, zammad_time_unit_rounding_minutes, zammad_atoss_email_sync_enabled, COALESCE(zammad_article_type_id, 6) AS zammad_article_type_id, project_followup_enabled, project_followup_weeks, project_followup_second_enabled, project_followup_second_weeks, project_followup_complete_on_session;
`, `,
[username, displayName, await hashPassword(password)] [username, displayName, await hashPassword(password)]
); );
@@ -7482,6 +7932,11 @@ app.post("/api/auth/login", async (req, res) => {
zammad_time_unit_rounding_minutes: number; zammad_time_unit_rounding_minutes: number;
zammad_atoss_email_sync_enabled: boolean; zammad_atoss_email_sync_enabled: boolean;
zammad_article_type_id: number; zammad_article_type_id: number;
project_followup_enabled: boolean;
project_followup_weeks: number;
project_followup_second_enabled: boolean;
project_followup_second_weeks: number;
project_followup_complete_on_session: boolean;
}>( }>(
` `
SELECT SELECT
@@ -7499,7 +7954,12 @@ app.post("/api/auth/login", async (req, res) => {
zammad_time_unit_rounding_enabled, zammad_time_unit_rounding_enabled,
zammad_time_unit_rounding_minutes, zammad_time_unit_rounding_minutes,
zammad_atoss_email_sync_enabled, zammad_atoss_email_sync_enabled,
COALESCE(zammad_article_type_id, 6) AS zammad_article_type_id COALESCE(zammad_article_type_id, 6) AS zammad_article_type_id,
project_followup_enabled,
project_followup_weeks,
project_followup_second_enabled,
project_followup_second_weeks,
project_followup_complete_on_session
FROM users FROM users
WHERE lower(username) = lower($1); WHERE lower(username) = lower($1);
`, `,
@@ -7551,7 +8011,12 @@ app.post("/api/auth/login", async (req, res) => {
zammad_time_unit_rounding_enabled: user.zammad_time_unit_rounding_enabled, zammad_time_unit_rounding_enabled: user.zammad_time_unit_rounding_enabled,
zammad_time_unit_rounding_minutes: user.zammad_time_unit_rounding_minutes, zammad_time_unit_rounding_minutes: user.zammad_time_unit_rounding_minutes,
zammad_atoss_email_sync_enabled: user.zammad_atoss_email_sync_enabled, zammad_atoss_email_sync_enabled: user.zammad_atoss_email_sync_enabled,
zammad_article_type_id: Number(user.zammad_article_type_id ?? 6) zammad_article_type_id: Number(user.zammad_article_type_id ?? 6),
project_followup_enabled: Boolean(user.project_followup_enabled ?? true),
project_followup_weeks: Number(user.project_followup_weeks ?? 4),
project_followup_second_enabled: Boolean(user.project_followup_second_enabled ?? true),
project_followup_second_weeks: Number(user.project_followup_second_weeks ?? 2),
project_followup_complete_on_session: Boolean(user.project_followup_complete_on_session ?? true)
} }
}); });
}); });
@@ -7775,6 +8240,21 @@ app.patch("/api/auth/me", async (req, res) => {
const zammadArticleTypeId = user.role === "user" const zammadArticleTypeId = user.role === "user"
? parseZammadArticleTypeId(req.body.zammadArticleTypeId, user.zammad_article_type_id) ? parseZammadArticleTypeId(req.body.zammadArticleTypeId, user.zammad_article_type_id)
: 6; : 6;
const projectFollowupEnabled = user.role === "user"
? optionalBoolean(req.body.projectFollowupEnabled, user.project_followup_enabled)
: true;
const projectFollowupWeeks = user.role === "user"
? parseProjectFollowupWeeks(req.body.projectFollowupWeeks, user.project_followup_weeks)
: 4;
const projectFollowupSecondEnabled = user.role === "user"
? optionalBoolean(req.body.projectFollowupSecondEnabled, user.project_followup_second_enabled)
: true;
const projectFollowupSecondWeeks = user.role === "user"
? parseProjectFollowupWeeks(req.body.projectFollowupSecondWeeks, user.project_followup_second_weeks)
: 2;
const projectFollowupCompleteOnSession = user.role === "user"
? optionalBoolean(req.body.projectFollowupCompleteOnSession, user.project_followup_complete_on_session)
: true;
if (newPassword && newPassword.length < 6) { if (newPassword && newPassword.length < 6) {
throw badRequest("newPassword must be at least 6 characters"); throw badRequest("newPassword must be at least 6 characters");
@@ -7809,9 +8289,14 @@ app.patch("/api/auth/me", async (req, res) => {
zammad_time_unit_rounding_minutes = CASE WHEN $8::boolean THEN $13 ELSE zammad_time_unit_rounding_minutes END, zammad_time_unit_rounding_minutes = CASE WHEN $8::boolean THEN $13 ELSE zammad_time_unit_rounding_minutes END,
zammad_atoss_email_sync_enabled = CASE WHEN $8::boolean THEN $14 ELSE zammad_atoss_email_sync_enabled END, zammad_atoss_email_sync_enabled = CASE WHEN $8::boolean THEN $14 ELSE zammad_atoss_email_sync_enabled END,
zammad_article_type_id = CASE WHEN $8::boolean THEN $15 ELSE zammad_article_type_id END, zammad_article_type_id = CASE WHEN $8::boolean THEN $15 ELSE zammad_article_type_id END,
project_followup_enabled = CASE WHEN $8::boolean THEN $16 ELSE project_followup_enabled END,
project_followup_weeks = CASE WHEN $8::boolean THEN $17 ELSE project_followup_weeks END,
project_followup_second_enabled = CASE WHEN $8::boolean THEN $18 ELSE project_followup_second_enabled END,
project_followup_second_weeks = CASE WHEN $8::boolean THEN $19 ELSE project_followup_second_weeks END,
project_followup_complete_on_session = CASE WHEN $8::boolean THEN $20 ELSE project_followup_complete_on_session END,
updated_at = now() updated_at = now()
WHERE id = $4 WHERE id = $4
RETURNING id, username, display_name, role, active, zammad_user_id::text AS zammad_user_id, (zammad_api_token IS NOT NULL) AS has_zammad_api_token, zammad_email_session_webhook_enabled, session_billing_prompt_enabled, zammad_session_note_default_enabled, zammad_time_unit_rounding_enabled, zammad_time_unit_rounding_minutes, zammad_atoss_email_sync_enabled, COALESCE(zammad_article_type_id, 6) AS zammad_article_type_id; RETURNING id, username, display_name, role, active, zammad_user_id::text AS zammad_user_id, (zammad_api_token IS NOT NULL) AS has_zammad_api_token, zammad_email_session_webhook_enabled, session_billing_prompt_enabled, zammad_session_note_default_enabled, zammad_time_unit_rounding_enabled, zammad_time_unit_rounding_minutes, zammad_atoss_email_sync_enabled, COALESCE(zammad_article_type_id, 6) AS zammad_article_type_id, project_followup_enabled, project_followup_weeks, project_followup_second_enabled, project_followup_second_weeks, project_followup_complete_on_session;
`, `,
[ [
username, username,
@@ -7828,7 +8313,12 @@ app.patch("/api/auth/me", async (req, res) => {
zammadTimeUnitRoundingEnabled, zammadTimeUnitRoundingEnabled,
zammadTimeUnitRoundingMinutes, zammadTimeUnitRoundingMinutes,
zammadAtossEmailSyncEnabled, zammadAtossEmailSyncEnabled,
zammadArticleTypeId zammadArticleTypeId,
projectFollowupEnabled,
projectFollowupWeeks,
projectFollowupSecondEnabled,
projectFollowupSecondWeeks,
projectFollowupCompleteOnSession
] ]
); );
@@ -7856,7 +8346,7 @@ app.patch("/api/auth/me", async (req, res) => {
app.get("/api/admin/users", requireAdmin, async (_req, res) => { app.get("/api/admin/users", requireAdmin, async (_req, res) => {
const result = await query( const result = await query(
` `
SELECT id, username, display_name, role, active, zammad_user_id::text AS zammad_user_id, (zammad_api_token IS NOT NULL) AS has_zammad_api_token, zammad_email_session_webhook_enabled, session_billing_prompt_enabled, zammad_time_unit_rounding_minutes, zammad_session_note_default_enabled, zammad_time_unit_rounding_enabled, zammad_atoss_email_sync_enabled, COALESCE(zammad_article_type_id, 6) AS zammad_article_type_id, created_at, updated_at SELECT id, username, display_name, role, active, zammad_user_id::text AS zammad_user_id, (zammad_api_token IS NOT NULL) AS has_zammad_api_token, zammad_email_session_webhook_enabled, session_billing_prompt_enabled, zammad_time_unit_rounding_minutes, zammad_session_note_default_enabled, zammad_time_unit_rounding_enabled, zammad_atoss_email_sync_enabled, COALESCE(zammad_article_type_id, 6) AS zammad_article_type_id, project_followup_enabled, project_followup_weeks, project_followup_second_enabled, project_followup_second_weeks, project_followup_complete_on_session, created_at, updated_at
FROM users FROM users
ORDER BY username ASC; ORDER BY username ASC;
` `
@@ -7880,7 +8370,7 @@ app.post("/api/admin/users", requireAdmin, async (req, res) => {
` `
INSERT INTO users (username, display_name, password_hash, role, active) INSERT INTO users (username, display_name, password_hash, role, active)
VALUES ($1, $2, $3, $4, $5) VALUES ($1, $2, $3, $4, $5)
RETURNING id, username, display_name, role, active, zammad_user_id::text AS zammad_user_id, (zammad_api_token IS NOT NULL) AS has_zammad_api_token, zammad_email_session_webhook_enabled, session_billing_prompt_enabled, zammad_session_note_default_enabled, zammad_time_unit_rounding_enabled, zammad_time_unit_rounding_minutes, zammad_atoss_email_sync_enabled, COALESCE(zammad_article_type_id, 6) AS zammad_article_type_id, created_at, updated_at; RETURNING id, username, display_name, role, active, zammad_user_id::text AS zammad_user_id, (zammad_api_token IS NOT NULL) AS has_zammad_api_token, zammad_email_session_webhook_enabled, session_billing_prompt_enabled, zammad_session_note_default_enabled, zammad_time_unit_rounding_enabled, zammad_time_unit_rounding_minutes, zammad_atoss_email_sync_enabled, COALESCE(zammad_article_type_id, 6) AS zammad_article_type_id, project_followup_enabled, project_followup_weeks, project_followup_second_enabled, project_followup_second_weeks, project_followup_complete_on_session, created_at, updated_at;
`, `,
[username, displayName, await hashPassword(password), "user", active] [username, displayName, await hashPassword(password), "user", active]
); );
@@ -7941,7 +8431,7 @@ app.patch("/api/admin/users/:userId", requireAdmin, async (req, res) => {
password_hash = COALESCE($4, password_hash), password_hash = COALESCE($4, password_hash),
updated_at = now() updated_at = now()
WHERE id = $5 WHERE id = $5
RETURNING id, username, display_name, role, active, zammad_user_id::text AS zammad_user_id, (zammad_api_token IS NOT NULL) AS has_zammad_api_token, zammad_email_session_webhook_enabled, session_billing_prompt_enabled, zammad_session_note_default_enabled, zammad_time_unit_rounding_enabled, zammad_time_unit_rounding_minutes, zammad_atoss_email_sync_enabled, COALESCE(zammad_article_type_id, 6) AS zammad_article_type_id, created_at, updated_at; RETURNING id, username, display_name, role, active, zammad_user_id::text AS zammad_user_id, (zammad_api_token IS NOT NULL) AS has_zammad_api_token, zammad_email_session_webhook_enabled, session_billing_prompt_enabled, zammad_session_note_default_enabled, zammad_time_unit_rounding_enabled, zammad_time_unit_rounding_minutes, zammad_atoss_email_sync_enabled, COALESCE(zammad_article_type_id, 6) AS zammad_article_type_id, project_followup_enabled, project_followup_weeks, project_followup_second_enabled, project_followup_second_weeks, project_followup_complete_on_session, created_at, updated_at;
`, `,
[username, displayName, active, password ? await hashPassword(password) : null, userId] [username, displayName, active, password ? await hashPassword(password) : null, userId]
); );
@@ -9251,6 +9741,10 @@ app.get("/api/statistics/weeks/:week", requireUser, async (req, res) => {
res.json(await getStatisticsOverview(week, currentUser(req).id, "week")); res.json(await getStatisticsOverview(week, currentUser(req).id, "week"));
}); });
app.get("/api/statistics/project-followups", requireUser, async (req, res) => {
res.json(await getProjectFollowups(currentUser(req).id));
});
app.get("/api/tickets/lookup", requireUser, async (req, res) => { app.get("/api/tickets/lookup", requireUser, async (req, res) => {
const ticketNumber = parseTicketNumber(req.query.ticketNumber); const ticketNumber = parseTicketNumber(req.query.ticketNumber);
const userId = currentUser(req).id; const userId = currentUser(req).id;
@@ -9317,7 +9811,10 @@ app.get("/api/tickets/lookup", requireUser, async (req, res) => {
FROM project_teamspace_entries pte FROM project_teamspace_entries pte
WHERE pte.project_id = p.id WHERE pte.project_id = p.id
) project_entries ON true ) project_entries ON true
WHERE t.ticket_number = $1; WHERE t.ticket_number = $1
OR t.zammad_merged_from_ticket_number = $1
ORDER BY CASE WHEN t.ticket_number = $1 THEN 0 ELSE 1 END, t.id ASC
LIMIT 1;
`; `;
let result = await query(lookupSql, [ticketNumber, userId]); let result = await query(lookupSql, [ticketNumber, userId]);
@@ -9325,6 +9822,15 @@ app.get("/api/tickets/lookup", requireUser, async (req, res) => {
const zammadTicket = await fetchZammadTicketByNumber(userId, ticketNumber); const zammadTicket = await fetchZammadTicketByNumber(userId, ticketNumber);
if (zammadTicket) { if (zammadTicket) {
result = await query(lookupSql, [zammadTicket.ticketNumber, userId]);
if ((result.rowCount ?? 0) > 0) {
res.json({
ticket: result.rows[0]
});
return;
}
const organization = await getOrganizationByZammadId({ query }, zammadTicket.organizationId); const organization = await getOrganizationByZammadId({ query }, zammadTicket.organizationId);
if (!organization) { if (!organization) {
@@ -10618,6 +11124,8 @@ app.post("/api/projects", requireUser, async (req, res) => {
return; return;
} }
const effectiveTicketNumber = zammadTicket?.ticketNumber ?? ticketNumber;
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];
@@ -10671,7 +11179,7 @@ app.post("/api/projects", requireUser, async (req, res) => {
WHERE ticket_number = $1 WHERE ticket_number = $1
FOR UPDATE; FOR UPDATE;
`, `,
[ticketNumber] [effectiveTicketNumber]
); );
const organizationId = existingTicket.rows[0]?.organization_id ? Number(existingTicket.rows[0].organization_id) : requestOrganizationId; const organizationId = existingTicket.rows[0]?.organization_id ? Number(existingTicket.rows[0].organization_id) : requestOrganizationId;
@@ -10698,7 +11206,7 @@ app.post("/api/projects", requireUser, async (req, res) => {
work_type = 'consulting' work_type = 'consulting'
RETURNING id; RETURNING id;
`, `,
[ticketNumber, title, organization.id, organization.name] [effectiveTicketNumber, title, organization.id, organization.name]
); );
const ticketId = ticketResult.rows[0].id; const ticketId = ticketResult.rows[0].id;
@@ -10796,7 +11304,7 @@ app.post("/api/projects", requireUser, async (req, res) => {
endsOn, endsOn,
defaultTeamspaceEntry.hourBudgetMinutes, defaultTeamspaceEntry.hourBudgetMinutes,
defaultTeamspaceEntry.manualConsumptionMinutes, defaultTeamspaceEntry.manualConsumptionMinutes,
ticketNumber, effectiveTicketNumber,
organization.id, organization.id,
organization.name, organization.name,
title, title,
+62
View File
@@ -93,6 +93,11 @@ export async function migrate() {
await query("ALTER TABLE users ADD COLUMN IF NOT EXISTS zammad_time_unit_rounding_minutes INTEGER NOT NULL DEFAULT 15;"); await query("ALTER TABLE users ADD COLUMN IF NOT EXISTS zammad_time_unit_rounding_minutes INTEGER NOT NULL DEFAULT 15;");
await query("ALTER TABLE users ADD COLUMN IF NOT EXISTS zammad_atoss_email_sync_enabled BOOLEAN NOT NULL DEFAULT true;"); await query("ALTER TABLE users ADD COLUMN IF NOT EXISTS zammad_atoss_email_sync_enabled BOOLEAN NOT NULL DEFAULT true;");
await query("ALTER TABLE users ADD COLUMN IF NOT EXISTS zammad_article_type_id INTEGER DEFAULT 6;"); await query("ALTER TABLE users ADD COLUMN IF NOT EXISTS zammad_article_type_id INTEGER DEFAULT 6;");
await query("ALTER TABLE users ADD COLUMN IF NOT EXISTS project_followup_enabled BOOLEAN NOT NULL DEFAULT true;");
await query("ALTER TABLE users ADD COLUMN IF NOT EXISTS project_followup_weeks INTEGER NOT NULL DEFAULT 4;");
await query("ALTER TABLE users ADD COLUMN IF NOT EXISTS project_followup_second_enabled BOOLEAN NOT NULL DEFAULT true;");
await query("ALTER TABLE users ADD COLUMN IF NOT EXISTS project_followup_second_weeks INTEGER NOT NULL DEFAULT 2;");
await query("ALTER TABLE users ADD COLUMN IF NOT EXISTS project_followup_complete_on_session BOOLEAN NOT NULL DEFAULT true;");
await query("UPDATE users SET zammad_article_type_id = 6 WHERE zammad_article_type_id IS NULL;"); await query("UPDATE users SET zammad_article_type_id = 6 WHERE zammad_article_type_id IS NULL;");
await query("ALTER TABLE users ALTER COLUMN zammad_article_type_id SET DEFAULT 6;"); await query("ALTER TABLE users ALTER COLUMN zammad_article_type_id SET DEFAULT 6;");
await query("ALTER TABLE users ALTER COLUMN zammad_article_type_id SET NOT NULL;"); await query("ALTER TABLE users ALTER COLUMN zammad_article_type_id SET NOT NULL;");
@@ -106,6 +111,16 @@ export async function migrate() {
DROP CONSTRAINT IF EXISTS users_zammad_article_type_id_valid, DROP CONSTRAINT IF EXISTS users_zammad_article_type_id_valid,
ADD CONSTRAINT users_zammad_article_type_id_valid CHECK (zammad_article_type_id IN (1, 2, 3, 4, 5, 6)); ADD CONSTRAINT users_zammad_article_type_id_valid CHECK (zammad_article_type_id IN (1, 2, 3, 4, 5, 6));
`); `);
await query(`
ALTER TABLE users
DROP CONSTRAINT IF EXISTS users_project_followup_weeks_valid,
ADD CONSTRAINT users_project_followup_weeks_valid CHECK (project_followup_weeks BETWEEN 1 AND 52);
`);
await query(`
ALTER TABLE users
DROP CONSTRAINT IF EXISTS users_project_followup_second_weeks_valid,
ADD CONSTRAINT users_project_followup_second_weeks_valid CHECK (project_followup_second_weeks BETWEEN 1 AND 52);
`);
await query(` await query(`
CREATE TABLE IF NOT EXISTS auth_sessions ( CREATE TABLE IF NOT EXISTS auth_sessions (
@@ -920,6 +935,53 @@ export async function migrate() {
await query("CREATE INDEX IF NOT EXISTS idx_ticket_todos_ticket_user ON ticket_todos(ticket_id, user_id);"); await query("CREATE INDEX IF NOT EXISTS idx_ticket_todos_ticket_user ON ticket_todos(ticket_id, user_id);");
await query("CREATE INDEX IF NOT EXISTS idx_ticket_todos_user_period ON ticket_todos(user_id, starts_on, ends_on);"); await query("CREATE INDEX IF NOT EXISTS idx_ticket_todos_user_period ON ticket_todos(user_id, starts_on, ends_on);");
await query("CREATE INDEX IF NOT EXISTS idx_ticket_todos_user_sort_order ON ticket_todos(user_id, sort_order, id);"); await query("CREATE INDEX IF NOT EXISTS idx_ticket_todos_user_sort_order ON ticket_todos(user_id, sort_order, id);");
await query(`
CREATE TABLE IF NOT EXISTS project_followup_reminders (
id BIGSERIAL PRIMARY KEY,
user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
project_id BIGINT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
ticket_id BIGINT NOT NULL REFERENCES tickets(id) ON DELETE CASCADE,
todo_id BIGINT REFERENCES ticket_todos(id) ON DELETE SET NULL,
stage INTEGER NOT NULL DEFAULT 1,
latest_tt_update_at TIMESTAMPTZ NOT NULL,
due_on DATE NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
CONSTRAINT project_followup_reminders_stage_valid CHECK (stage IN (1, 2))
);
`);
await query("ALTER TABLE project_followup_reminders ADD COLUMN IF NOT EXISTS stage INTEGER NOT NULL DEFAULT 1;");
await query(`
ALTER TABLE project_followup_reminders
DROP CONSTRAINT IF EXISTS project_followup_reminders_stage_valid,
ADD CONSTRAINT project_followup_reminders_stage_valid CHECK (stage IN (1, 2));
`);
await query(`
DO $$
DECLARE
constraint_name text;
BEGIN
SELECT c.conname
INTO constraint_name
FROM pg_constraint c
WHERE c.conrelid = 'project_followup_reminders'::regclass
AND c.contype = 'u'
AND c.conkey = ARRAY[
(SELECT attnum FROM pg_attribute WHERE attrelid = 'project_followup_reminders'::regclass AND attname = 'user_id'),
(SELECT attnum FROM pg_attribute WHERE attrelid = 'project_followup_reminders'::regclass AND attname = 'project_id'),
(SELECT attnum FROM pg_attribute WHERE attrelid = 'project_followup_reminders'::regclass AND attname = 'latest_tt_update_at')
]::smallint[];
IF constraint_name IS NOT NULL THEN
EXECUTE format('ALTER TABLE project_followup_reminders DROP CONSTRAINT %I', constraint_name);
END IF;
END $$;
`);
await query("CREATE UNIQUE INDEX IF NOT EXISTS idx_project_followup_reminders_unique_stage ON project_followup_reminders(user_id, project_id, latest_tt_update_at, stage);");
await query("CREATE INDEX IF NOT EXISTS idx_project_followup_reminders_user_project ON project_followup_reminders(user_id, project_id, latest_tt_update_at DESC);");
await query("CREATE INDEX IF NOT EXISTS idx_project_followup_reminders_todo ON project_followup_reminders(todo_id) WHERE todo_id IS NOT NULL;");
await query("CREATE INDEX IF NOT EXISTS idx_recurring_billings_user_active ON recurring_billings(user_id, active);"); await query("CREATE INDEX IF NOT EXISTS idx_recurring_billings_user_active ON recurring_billings(user_id, active);");
await query("UPDATE recurring_billings SET active = true WHERE active = false;"); await query("UPDATE recurring_billings SET active = true WHERE active = false;");
await query(` await query(`
+60 -34
View File
@@ -1,5 +1,4 @@
import { import {
AlertCircle,
CalendarDays, CalendarDays,
CheckCircle2, CheckCircle2,
ClipboardCheck, ClipboardCheck,
@@ -591,20 +590,57 @@ const sidebarTodoCalendarClasses: Record<TicketTodo["priority"], string> = {
}; };
const sidebarOverdueTodoClass = "border-red-500/60 bg-red-500/15 text-red-100"; const sidebarOverdueTodoClass = "border-red-500/60 bg-red-500/15 text-red-100";
const sidebarTodoPriorityOrder: Record<TicketTodo["priority"], number> = {
urgent: 0,
high: 1,
medium: 2,
low: 3
};
function sortSidebarCurrentTodos(todos: TicketTodo[], today: string) {
return [...todos].sort((left, right) => {
const leftOverdue = isTodoOverdue(left, today);
const rightOverdue = isTodoOverdue(right, today);
if (leftOverdue !== rightOverdue) {
return leftOverdue ? -1 : 1;
}
return sidebarTodoPriorityOrder[left.priority] - sidebarTodoPriorityOrder[right.priority] || left.sort_order - right.sort_order || left.title.localeCompare(right.title, "de-DE", { sensitivity: "base" });
});
}
function notifyTodosChanged() { function notifyTodosChanged() {
window.dispatchEvent(new Event("tickettracker:todos-changed")); window.dispatchEvent(new Event("tickettracker:todos-changed"));
} }
function SidebarTodayTodos({ todos, onNavigate }: SidebarTodayTodosProps) { function SidebarTodayTodos({ todos, onNavigate }: SidebarTodayTodosProps) {
const [runningTodosOpen, setRunningTodosOpen] = useState(false); const [todosModalOpen, setTodosModalOpen] = useState(false);
const [modalBusy, setModalBusy] = useState(false); const [modalBusy, setModalBusy] = useState(false);
const today = currentDay(); const today = currentDay();
const fixedTodos = todos.filter((todo) => isTodoFixedOnDay(todo, today)); const fixedTodos = todos.filter((todo) => isTodoFixedOnDay(todo, today));
const summarizedTodos = todos.filter((todo) => todo.status === "open" && isTodoActiveOnDay(todo, today) && !isTodoFixedOnDay(todo, today)); const summarizedTodos = todos.filter((todo) => todo.status === "open" && isTodoActiveOnDay(todo, today) && !isTodoFixedOnDay(todo, today));
const overdueTodos = todos.filter((todo) => isTodoOverdue(todo, today)); const overdueTodos = todos.filter((todo) => isTodoOverdue(todo, today));
const currentTodos = sortSidebarCurrentTodos(
[...overdueTodos, ...fixedTodos, ...summarizedTodos].filter((todo, index, list) => list.findIndex((item) => item.id === todo.id) === index),
today
);
const visibleCurrentTodos = currentTodos.slice(0, 6);
const additionalTodosCount = Math.max(0, currentTodos.length - visibleCurrentTodos.length);
const hasTodos = fixedTodos.length > 0 || summarizedTodos.length > 0 || overdueTodos.length > 0; const hasTodos = fixedTodos.length > 0 || summarizedTodos.length > 0 || overdueTodos.length > 0;
function todoStateLabel(todo: TicketTodo) {
if (isTodoOverdue(todo, today)) {
return "Überfällig";
}
if (isTodoFixedOnDay(todo, today)) {
return "Fester Termin";
}
return "Laufend";
}
async function toggleTodo(todo: TicketTodo) { async function toggleTodo(todo: TicketTodo) {
setModalBusy(true); setModalBusy(true);
@@ -666,42 +702,30 @@ function SidebarTodayTodos({ todos, onNavigate }: SidebarTodayTodosProps) {
{hasTodos ? ( {hasTodos ? (
<div className="space-y-1"> <div className="space-y-1">
{overdueTodos.slice(0, 3).map((todo) => ( {visibleCurrentTodos.map((todo) => (
<button <button
key={`overdue-${todo.id}`} key={`current-${todo.id}`}
type="button" type="button"
className="block w-full rounded-md border border-red-500/45 bg-red-500/10 px-2 py-1.5 text-left hover:bg-red-500/15" className={`block w-full rounded-md border px-2 py-1.5 text-left hover:brightness-110 ${isTodoOverdue(todo, today) ? sidebarOverdueTodoClass : sidebarTodoCalendarClasses[todo.priority]}`}
onClick={() => onNavigate(`/tickets/${todo.ticket_id}`)}
>
<span className="flex min-w-0 items-center gap-1.5 text-[11px] font-semibold text-red-200">
<AlertCircle className="size-3.5 shrink-0" />
<span className="truncate">{todo.title}</span>
</span>
<span className="block truncate text-[11px] text-muted-foreground">{todo.ticket_number ?? "Ticket"} · {formatTodoPeriod(todo)}</span>
</button>
))}
{overdueTodos.length > 3 ? <p className="px-1 text-[11px] text-red-200">+{overdueTodos.length - 3} weitere überfällig</p> : null}
{fixedTodos.slice(0, 3).map((todo) => (
<button
key={`fixed-${todo.id}`}
type="button"
className={`block w-full rounded-md border px-2 py-1.5 text-left hover:brightness-110 ${sidebarTodoCalendarClasses[todo.priority]}`}
onClick={() => onNavigate(`/tickets/${todo.ticket_id}`)} onClick={() => onNavigate(`/tickets/${todo.ticket_id}`)}
> >
<span className="block truncate text-[11px] font-semibold">{todo.title}</span> <span className="block truncate text-[11px] font-semibold">{todo.title}</span>
<span className="block truncate text-[11px] text-muted-foreground">{todo.ticket_number ?? "Ticket"} · {todo.organization_name ?? "Keine Organisation"}</span> <span className="block truncate text-[11px] text-muted-foreground">
{todo.ticket_number ?? "Ticket"} · {todoStateLabel(todo)}
</span>
</button> </button>
))} ))}
{fixedTodos.length > 3 ? <p className="px-1 text-[11px] text-muted-foreground">+{fixedTodos.length - 3} weitere feste Termine</p> : null}
{summarizedTodos.length > 0 ? ( {additionalTodosCount > 0 ? (
<button <button
type="button" type="button"
className="block w-full rounded-md border border-muted-foreground/20 bg-background/60 px-2 py-1.5 text-left text-[11px] font-medium text-muted-foreground hover:bg-muted" className="block w-full rounded-md border border-muted-foreground/20 bg-background/60 px-2 py-1.5 text-left text-[11px] font-medium text-muted-foreground hover:bg-muted"
onClick={() => setRunningTodosOpen(true)} onClick={() => setTodosModalOpen(true)}
> >
+{summarizedTodos.length} laufende ToDo{summarizedTodos.length === 1 ? "" : "s"} <span className="block">+{additionalTodosCount} weitere aktuelle ToDo{additionalTodosCount === 1 ? "" : "s"}</span>
<span className="block truncate font-normal">
{overdueTodos.length} überfällig · {summarizedTodos.length} laufend · {fixedTodos.length} feste
</span>
</button> </button>
) : null} ) : null}
</div> </div>
@@ -712,22 +736,24 @@ function SidebarTodayTodos({ todos, onNavigate }: SidebarTodayTodosProps) {
</SidebarGroupContent> </SidebarGroupContent>
</SidebarGroup> </SidebarGroup>
<Dialog open={runningTodosOpen} onOpenChange={setRunningTodosOpen}> <Dialog open={todosModalOpen} onOpenChange={setTodosModalOpen}>
<DialogContent className="max-h-[calc(100dvh-2rem)] overflow-y-auto sm:max-w-xl"> <DialogContent className="max-h-[calc(100dvh-2rem)] overflow-y-auto sm:max-w-xl">
<DialogHeader> <DialogHeader>
<DialogTitle>ToDos am Tag</DialogTitle> <DialogTitle>Aktuelle ToDos</DialogTitle>
<DialogDescription>Heute · {summarizedTodos.length} laufende ToDo(s)</DialogDescription> <DialogDescription>
{overdueTodos.length} überfällig · {fixedTodos.length} feste Termine · {summarizedTodos.length} laufende ToDo(s)
</DialogDescription>
</DialogHeader> </DialogHeader>
<div className="space-y-2"> <div className="space-y-2">
{summarizedTodos.map((todo) => ( {currentTodos.map((todo) => (
<div key={todo.id} className={`rounded-md border p-2.5 ${isTodoOverdue(todo, today) ? sidebarOverdueTodoClass : sidebarTodoCalendarClasses[todo.priority]}`}> <div key={todo.id} className={`rounded-md border p-2.5 ${isTodoOverdue(todo, today) ? sidebarOverdueTodoClass : sidebarTodoCalendarClasses[todo.priority]}`}>
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<div className="flex items-start justify-between gap-3"> <div className="flex items-start justify-between gap-3">
<div className="min-w-0"> <div className="min-w-0">
<div className="flex min-w-0 flex-wrap items-center gap-2"> <div className="flex min-w-0 flex-wrap items-center gap-2">
<span className="min-w-0 truncate text-sm font-semibold">{todo.title}</span> <span className="min-w-0 truncate text-sm font-semibold">{todo.title}</span>
<Badge variant={isTodoOverdue(todo, today) ? "destructive" : isTodoFixedOnDay(todo, today) ? "outline" : "secondary"}>{todoStateLabel(todo)}</Badge>
<span className={`shrink-0 rounded-full border px-2 py-0.5 text-xs font-medium ${sidebarTodoPriorityClasses[todo.priority]}`}>{sidebarTodoPriorityLabels[todo.priority]}</span> <span className={`shrink-0 rounded-full border px-2 py-0.5 text-xs font-medium ${sidebarTodoPriorityClasses[todo.priority]}`}>{sidebarTodoPriorityLabels[todo.priority]}</span>
{isTodoOverdue(todo, today) ? <Badge variant="destructive">überfällig</Badge> : null}
</div> </div>
<p className="mt-1 truncate text-xs text-muted-foreground" title={todo.organization_name ?? "Keine Organisation"}> <p className="mt-1 truncate text-xs text-muted-foreground" title={todo.organization_name ?? "Keine Organisation"}>
{todo.organization_name ?? "Keine Organisation"} {todo.organization_name ?? "Keine Organisation"}
@@ -749,7 +775,7 @@ function SidebarTodayTodos({ todos, onNavigate }: SidebarTodayTodosProps) {
size="sm" size="sm"
variant="ghost" variant="ghost"
onClick={() => { onClick={() => {
setRunningTodosOpen(false); setTodosModalOpen(false);
onNavigate(`/tickets/${todo.ticket_id}`); onNavigate(`/tickets/${todo.ticket_id}`);
}} }}
> >
@@ -764,8 +790,8 @@ function SidebarTodayTodos({ todos, onNavigate }: SidebarTodayTodosProps) {
</div> </div>
</div> </div>
))} ))}
{summarizedTodos.length === 0 ? ( {currentTodos.length === 0 ? (
<p className="rounded-md border bg-muted/20 p-4 text-center text-sm text-muted-foreground">Keine laufenden ToDos an diesem Tag.</p> <p className="rounded-md border bg-muted/20 p-4 text-center text-sm text-muted-foreground">Keine aktuellen ToDos.</p>
) : null} ) : null}
</div> </div>
</DialogContent> </DialogContent>
+10
View File
@@ -10,6 +10,7 @@ import type {
ProjectSummary, ProjectSummary,
RecurringBilling, RecurringBilling,
StatisticsOverview, StatisticsOverview,
StatisticsProjectFollowupOverview,
TicketMeta, TicketMeta,
TicketPeriod, TicketPeriod,
TicketTodo, TicketTodo,
@@ -254,6 +255,11 @@ export function updateCurrentUser(payload: {
zammadTimeUnitRoundingMinutes?: number; zammadTimeUnitRoundingMinutes?: number;
zammadAtossEmailSyncEnabled?: boolean; zammadAtossEmailSyncEnabled?: boolean;
zammadArticleTypeId?: number; zammadArticleTypeId?: number;
projectFollowupEnabled?: boolean;
projectFollowupWeeks?: number;
projectFollowupSecondEnabled?: boolean;
projectFollowupSecondWeeks?: number;
projectFollowupCompleteOnSession?: boolean;
}) { }) {
return request<{ user: AuthUser }>("/api/auth/me", { return request<{ user: AuthUser }>("/api/auth/me", {
method: "PATCH", method: "PATCH",
@@ -889,6 +895,10 @@ export function getStatisticsOverview(period: string, type: "month" | "week" = "
return request<StatisticsOverview>(`/api/statistics/${type === "week" ? "weeks" : "months"}/${period}`); return request<StatisticsOverview>(`/api/statistics/${type === "week" ? "weeks" : "months"}/${period}`);
} }
export function getProjectFollowups() {
return request<StatisticsProjectFollowupOverview>("/api/statistics/project-followups");
}
export function getTicketPeriod(type: PeriodType, period: string, ticketId: string) { export function getTicketPeriod(type: PeriodType, period: string, ticketId: string) {
return request<TicketPeriod>(`/api/periods/${periodPath(type)}/${period}/tickets/${ticketId}`); return request<TicketPeriod>(`/api/periods/${periodPath(type)}/${period}/tickets/${ticketId}`);
} }
+34
View File
@@ -19,6 +19,11 @@ export type AuthUser = {
zammad_time_unit_rounding_minutes: number; zammad_time_unit_rounding_minutes: number;
zammad_atoss_email_sync_enabled: boolean; zammad_atoss_email_sync_enabled: boolean;
zammad_article_type_id: number; zammad_article_type_id: number;
project_followup_enabled: boolean;
project_followup_weeks: number;
project_followup_second_enabled: boolean;
project_followup_second_weeks: number;
project_followup_complete_on_session: boolean;
}; };
export type DashboardWidgetType = "dayControl" | "today" | "month" | "openRatings" | "projectBudgets" | "todos" | "teamspace" | "favorites" | "divider" | "statistic" | "notes"; export type DashboardWidgetType = "dayControl" | "today" | "month" | "openRatings" | "projectBudgets" | "todos" | "teamspace" | "favorites" | "divider" | "statistic" | "notes";
@@ -479,6 +484,35 @@ export type StatisticsTeamspaceDay = {
project_teamspace_project: string | null; project_teamspace_project: string | null;
}; };
export type StatisticsProjectFollowup = {
project_id: string;
ticket_id: string;
ticket_number: string;
title: string | null;
organization_name: string | null;
teamspace_project: string | null;
hour_budget_minutes: number | null;
consumption_minutes: number;
remaining_minutes: number | null;
latest_tt_update: string;
last_session_at: string | null;
project_updated_at: string;
open_followup_todo_id: string | null;
followup_state: "open" | "done" | "dismissed" | null;
open_todos: number;
overdue_todos: number;
};
export type StatisticsProjectFollowupOverview = {
enabled: boolean;
weeks: number;
secondEnabled: boolean;
secondWeeks: number;
createdTodos: number;
updatedTodos: number;
projects: StatisticsProjectFollowup[];
};
export type StatisticsOverview = { export type StatisticsOverview = {
periodType: "month" | "week"; periodType: "month" | "week";
period: string; period: string;
+99 -1
View File
@@ -31,6 +31,11 @@ export function ProfilePage({ currentUser, onUserUpdated }: ProfilePageProps) {
const [zammadTimeUnitRoundingEnabled, setZammadTimeUnitRoundingEnabled] = useState(currentUser.zammad_time_unit_rounding_enabled); const [zammadTimeUnitRoundingEnabled, setZammadTimeUnitRoundingEnabled] = useState(currentUser.zammad_time_unit_rounding_enabled);
const [zammadTimeUnitRoundingMinutes, setZammadTimeUnitRoundingMinutes] = useState(currentUser.zammad_time_unit_rounding_minutes); const [zammadTimeUnitRoundingMinutes, setZammadTimeUnitRoundingMinutes] = useState(currentUser.zammad_time_unit_rounding_minutes);
const [zammadArticleTypeId, setZammadArticleTypeId] = useState<ZammadArticleTypeId>(currentUser.zammad_article_type_id ?? 6); const [zammadArticleTypeId, setZammadArticleTypeId] = useState<ZammadArticleTypeId>(currentUser.zammad_article_type_id ?? 6);
const [projectFollowupEnabled, setProjectFollowupEnabled] = useState(currentUser.project_followup_enabled ?? true);
const [projectFollowupWeeks, setProjectFollowupWeeks] = useState(currentUser.project_followup_weeks ?? 4);
const [projectFollowupSecondEnabled, setProjectFollowupSecondEnabled] = useState(currentUser.project_followup_second_enabled ?? true);
const [projectFollowupSecondWeeks, setProjectFollowupSecondWeeks] = useState(currentUser.project_followup_second_weeks ?? 2);
const [projectFollowupCompleteOnSession, setProjectFollowupCompleteOnSession] = useState(currentUser.project_followup_complete_on_session ?? true);
const [zammadQueue, setZammadQueue] = useState<ZammadSessionArticleQueueItem[]>([]); const [zammadQueue, setZammadQueue] = useState<ZammadSessionArticleQueueItem[]>([]);
const [loadingZammadQueue, setLoadingZammadQueue] = useState(false); const [loadingZammadQueue, setLoadingZammadQueue] = useState(false);
const [retryingZammadQueue, setRetryingZammadQueue] = useState(false); const [retryingZammadQueue, setRetryingZammadQueue] = useState(false);
@@ -48,6 +53,11 @@ export function ProfilePage({ currentUser, onUserUpdated }: ProfilePageProps) {
setZammadTimeUnitRoundingEnabled(currentUser.zammad_time_unit_rounding_enabled); setZammadTimeUnitRoundingEnabled(currentUser.zammad_time_unit_rounding_enabled);
setZammadTimeUnitRoundingMinutes(currentUser.zammad_time_unit_rounding_minutes); setZammadTimeUnitRoundingMinutes(currentUser.zammad_time_unit_rounding_minutes);
setZammadArticleTypeId(currentUser.zammad_article_type_id ?? 6); setZammadArticleTypeId(currentUser.zammad_article_type_id ?? 6);
setProjectFollowupEnabled(currentUser.project_followup_enabled ?? true);
setProjectFollowupWeeks(currentUser.project_followup_weeks ?? 4);
setProjectFollowupSecondEnabled(currentUser.project_followup_second_enabled ?? true);
setProjectFollowupSecondWeeks(currentUser.project_followup_second_weeks ?? 2);
setProjectFollowupCompleteOnSession(currentUser.project_followup_complete_on_session ?? true);
}, [currentUser]); }, [currentUser]);
async function loadZammadQueue() { async function loadZammadQueue() {
@@ -132,7 +142,12 @@ export function ProfilePage({ currentUser, onUserUpdated }: ProfilePageProps) {
zammadSessionNoteDefaultEnabled, zammadSessionNoteDefaultEnabled,
zammadTimeUnitRoundingEnabled, zammadTimeUnitRoundingEnabled,
zammadTimeUnitRoundingMinutes, zammadTimeUnitRoundingMinutes,
zammadArticleTypeId zammadArticleTypeId,
projectFollowupEnabled,
projectFollowupWeeks,
projectFollowupSecondEnabled,
projectFollowupSecondWeeks,
projectFollowupCompleteOnSession
} }
: {}) : {})
}; };
@@ -334,6 +349,89 @@ export function ProfilePage({ currentUser, onUserUpdated }: ProfilePageProps) {
onCheckedChange={setSessionBillingPromptEnabled} onCheckedChange={setSessionBillingPromptEnabled}
/> />
</div> </div>
<div className="mt-3 space-y-3 rounded-md border bg-background p-3">
<div className="flex items-center justify-between gap-3">
<div>
<label className="text-sm font-medium" htmlFor="profile-project-followup-enabled">
Automatische Wiedervorlagen
</label>
<p className="text-xs text-muted-foreground">Consulting-Projekte automatisch auf Wiedervorlage setzen.</p>
</div>
<Switch
id="profile-project-followup-enabled"
checked={projectFollowupEnabled}
onCheckedChange={setProjectFollowupEnabled}
/>
</div>
{projectFollowupEnabled ? (
<div className="space-y-3 border-t pt-3">
<div>
<p className="text-sm font-medium">1. Wiedervorlage aktiv</p>
<p className="text-xs text-muted-foreground">Wenn automatische Wiedervorlagen aktiv sind, wird die erste WVL immer geprüft.</p>
</div>
<div className="grid gap-2 sm:max-w-xs">
<label className="text-sm font-medium" htmlFor="profile-project-followup-weeks">
1. Wiedervorlage nach Wochen
</label>
<Input
id="profile-project-followup-weeks"
type="number"
min={1}
max={52}
value={projectFollowupWeeks}
onChange={(event) => setProjectFollowupWeeks(Math.max(1, Math.min(52, Number(event.currentTarget.value) || 1)))}
/>
</div>
<div className="flex items-center justify-between gap-3">
<div>
<label className="text-sm font-medium" htmlFor="profile-project-followup-second-enabled">
2. Wiedervorlage
</label>
<p className="text-xs text-muted-foreground">Nur anlegen, wenn die erste WVL erledigt oder gelöscht wurde und keine Session entstanden ist.</p>
</div>
<Switch
id="profile-project-followup-second-enabled"
checked={projectFollowupSecondEnabled}
onCheckedChange={setProjectFollowupSecondEnabled}
/>
</div>
{projectFollowupSecondEnabled ? (
<div className="grid gap-2 sm:max-w-xs">
<label className="text-sm font-medium" htmlFor="profile-project-followup-second-weeks">
2. Wiedervorlage nach weiteren Wochen
</label>
<Input
id="profile-project-followup-second-weeks"
type="number"
min={1}
max={52}
value={projectFollowupSecondWeeks}
onChange={(event) => setProjectFollowupSecondWeeks(Math.max(1, Math.min(52, Number(event.currentTarget.value) || 1)))}
/>
</div>
) : null}
</div>
) : null}
<div className="flex items-center justify-between gap-3">
<div>
<label className="text-sm font-medium" htmlFor="profile-project-followup-complete-on-session">
Automatische Wiedervorlagen durch Session erledigen
</label>
<p className="text-xs text-muted-foreground">Gilt für beide Zeiträume. Betrifft nur automatisch erzeugte Wiedervorlagen, niemals manuelle ToDos.</p>
</div>
<Switch
id="profile-project-followup-complete-on-session"
checked={projectFollowupCompleteOnSession}
onCheckedChange={setProjectFollowupCompleteOnSession}
/>
</div>
</div>
<div className="mt-3 flex items-center justify-between gap-3 rounded-md border bg-background p-3"> <div className="mt-3 flex items-center justify-between gap-3 rounded-md border bg-background p-3">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<FileText className="size-4 text-muted-foreground" /> <FileText className="size-4 text-muted-foreground" />
+161 -3
View File
@@ -1,9 +1,11 @@
import { import {
ChevronLeft, ChevronLeft,
ChevronRight, ChevronRight,
BellPlus,
CircleAlert, CircleAlert,
Clock3, Clock3,
ExternalLink, ExternalLink,
ListChecks,
RotateCcw, RotateCcw,
Ticket, Ticket,
TrendingUp, TrendingUp,
@@ -20,11 +22,12 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/com
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Pagination, PaginationContent, PaginationItem, PaginationNext, PaginationPrevious } from "@/components/ui/pagination"; import { Pagination, PaginationContent, PaginationItem, PaginationNext, PaginationPrevious } from "@/components/ui/pagination";
import { Progress } from "@/components/ui/progress"; import { Progress } from "@/components/ui/progress";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { HelpLink } from "@/components/HelpLink"; import { HelpLink } from "@/components/HelpLink";
import { getStatisticsOverview } from "../api"; import { getProjectFollowups, getStatisticsOverview } from "../api";
import { currentMonth, formatDate, formatMinutes } from "../format"; import { currentMonth, formatDate, formatDateTime, formatMinutes } from "../format";
import type { StatisticsDailyBucket, StatisticsGroup, StatisticsOverview, StatisticsTicket, WorkType } from "../types"; import type { StatisticsDailyBucket, StatisticsGroup, StatisticsOverview, StatisticsProjectFollowup, StatisticsProjectFollowupOverview, StatisticsTicket, WorkType } from "../types";
type StatisticsPageProps = { type StatisticsPageProps = {
onNavigate: (to: string) => void; onNavigate: (to: string) => void;
@@ -361,12 +364,72 @@ function TicketHotspot({ ticket, month, onNavigate }: { ticket: StatisticsTicket
); );
} }
function daysSince(value: string) {
const then = new Date(value).getTime();
if (!Number.isFinite(then)) {
return null;
}
return Math.max(0, Math.floor((Date.now() - then) / 86_400_000));
}
function ProjectFollowupRow({ project, onNavigate }: { project: StatisticsProjectFollowup; onNavigate: (to: string) => void }) {
const inactiveDays = daysSince(project.latest_tt_update);
const hasBudget = project.hour_budget_minutes !== null;
return (
<div className="grid gap-3 rounded-md border bg-background p-3 lg:grid-cols-[minmax(0,1fr)_auto] lg:items-center">
<div className="min-w-0 space-y-2">
<div className="flex flex-wrap items-center gap-2">
<span className="font-semibold">{project.ticket_number}</span>
{hasBudget ? (
<Badge variant={project.remaining_minutes !== null && project.remaining_minutes > 0 ? "warning" : "secondary"}>
{project.remaining_minutes !== null && project.remaining_minutes > 0 ? `${formatMinutes(project.remaining_minutes)} offen` : "Budget verbraucht"}
</Badge>
) : (
<Badge variant="info">ohne Budget</Badge>
)}
{project.open_followup_todo_id ? <Badge variant="secondary">ToDo erstellt</Badge> : null}
{project.followup_state === "done" ? <Badge variant="success">erledigt</Badge> : null}
{project.followup_state === "dismissed" ? <Badge variant="outline">gelöscht</Badge> : null}
{project.overdue_todos > 0 ? <Badge variant="destructive">{project.overdue_todos} überfällig</Badge> : null}
</div>
<div className="min-w-0">
<p className="truncate text-sm">{project.title ?? project.teamspace_project ?? "Projekt ohne Titel"}</p>
<p className="truncate text-xs text-muted-foreground">{project.organization_name ?? "Keine Organisation"}</p>
</div>
<div className="flex flex-wrap gap-x-3 gap-y-1 text-xs text-muted-foreground">
<span>Letztes TT-Update {formatDateTime(project.latest_tt_update)}</span>
{inactiveDays !== null ? <span>{inactiveDays} Tag(e) inaktiv</span> : null}
<span>Budget {hasBudget ? formatMinutes(project.hour_budget_minutes!) : "nicht gesetzt"}</span>
<span>Verbraucht {formatMinutes(project.consumption_minutes)}</span>
<span>{project.open_todos} offene ToDo(s)</span>
</div>
</div>
<div className="flex flex-wrap gap-2 lg:justify-end">
<Button variant="secondary" size="sm" onClick={() => onNavigate(`/tickets/${project.ticket_id}?month=${currentMonth()}`)}>
Ticket
<ExternalLink className="size-4" />
</Button>
<Button variant="secondary" size="sm" onClick={() => onNavigate("/projects")}>
Projekte
<ExternalLink className="size-4" />
</Button>
</div>
</div>
);
}
export function StatisticsPage({ onNavigate }: StatisticsPageProps) { export function StatisticsPage({ onNavigate }: StatisticsPageProps) {
const [month, setMonth] = useState(currentMonth()); const [month, setMonth] = useState(currentMonth());
const [stats, setStats] = useState<StatisticsOverview | null>(null); const [stats, setStats] = useState<StatisticsOverview | null>(null);
const [followups, setFollowups] = useState<StatisticsProjectFollowupOverview | null>(null);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [loadingFollowups, setLoadingFollowups] = useState(false);
const [organizationPage, setOrganizationPage] = useState(1); const [organizationPage, setOrganizationPage] = useState(1);
const loadRequestId = useRef(0); const loadRequestId = useRef(0);
const followupRequestId = useRef(0);
async function load() { async function load() {
const requestId = loadRequestId.current + 1; const requestId = loadRequestId.current + 1;
@@ -396,6 +459,44 @@ export function StatisticsPage({ onNavigate }: StatisticsPageProps) {
void load(); void load();
}, [month]); }, [month]);
async function loadFollowups() {
const requestId = followupRequestId.current + 1;
followupRequestId.current = requestId;
setLoadingFollowups(true);
try {
const result = await getProjectFollowups();
if (requestId === followupRequestId.current) {
setFollowups(result);
if (result.createdTodos > 0 || result.updatedTodos > 0) {
window.dispatchEvent(new Event("tickettracker:todos-changed"));
}
if (result.createdTodos > 0) {
toast.success("Wiedervorlagen erstellt", {
description: `${result.createdTodos} ToDo(s) automatisch angelegt.`
});
}
}
} catch (error) {
if (requestId === followupRequestId.current) {
toast.error("Wiedervorlagen konnten nicht geladen werden", {
description: error instanceof Error ? error.message : "Unbekannter Fehler"
});
}
} finally {
if (requestId === followupRequestId.current) {
setLoadingFollowups(false);
}
}
}
useEffect(() => {
void loadFollowups();
}, []);
useEffect(() => { useEffect(() => {
function refreshVisible() { function refreshVisible() {
if (document.visibilityState === "visible") { if (document.visibilityState === "visible") {
@@ -476,6 +577,15 @@ export function StatisticsPage({ onNavigate }: StatisticsPageProps) {
</div> </div>
</div> </div>
<Tabs defaultValue="overview" className="space-y-5">
<TabsList className="grid h-auto w-full grid-cols-2 gap-1 sm:grid-cols-4">
<TabsTrigger value="overview">Überblick</TabsTrigger>
<TabsTrigger value="groups">Kunden & Typen</TabsTrigger>
<TabsTrigger value="tickets">Tickets</TabsTrigger>
<TabsTrigger value="followups">Wiedervorlagen</TabsTrigger>
</TabsList>
<TabsContent value="overview" className="space-y-5">
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-4"> <div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
<MetricCard <MetricCard
label="Abgerechnet" label="Abgerechnet"
@@ -597,6 +707,9 @@ export function StatisticsPage({ onNavigate }: StatisticsPageProps) {
</CardContent> </CardContent>
</Card> </Card>
</TabsContent>
<TabsContent value="groups" className="space-y-5">
<div className="grid gap-3 xl:grid-cols-[1.45fr_0.8fr]"> <div className="grid gap-3 xl:grid-cols-[1.45fr_0.8fr]">
<Card> <Card>
<CardHeader className="gap-3 p-4 sm:flex-row sm:items-start sm:justify-between sm:space-y-0"> <CardHeader className="gap-3 p-4 sm:flex-row sm:items-start sm:justify-between sm:space-y-0">
@@ -731,6 +844,9 @@ export function StatisticsPage({ onNavigate }: StatisticsPageProps) {
</Card> </Card>
</div> </div>
</TabsContent>
<TabsContent value="tickets" className="space-y-5">
<Card> <Card>
<CardHeader className="gap-3 p-4 sm:flex-row sm:items-start sm:justify-between sm:space-y-0"> <CardHeader className="gap-3 p-4 sm:flex-row sm:items-start sm:justify-between sm:space-y-0">
<div> <div>
@@ -768,6 +884,48 @@ export function StatisticsPage({ onNavigate }: StatisticsPageProps) {
</CardContent> </CardContent>
</Card> </Card>
) : null} ) : null}
</TabsContent>
<TabsContent value="followups" className="space-y-5">
<Card>
<CardHeader className="gap-3 p-4 sm:flex-row sm:items-start sm:justify-between sm:space-y-0">
<div>
<CardTitle>Automatische Wiedervorlagen</CardTitle>
<CardDescription>
{followups?.enabled === false
? "Automatische Wiedervorlagen sind im Profil deaktiviert."
: followups?.secondEnabled === false
? `Erste Wiedervorlage nach ${followups?.weeks ?? "-"} Woche(n).`
: `Erste Wiedervorlage nach ${followups?.weeks ?? "-"} Woche(n), zweite nach weiteren ${followups?.secondWeeks ?? "-"} Woche(n) ohne Session.`}
</CardDescription>
</div>
<div className="flex items-center gap-2">
<Badge variant="outline">{followups?.projects.length ?? 0} Projekt(e)</Badge>
{loadingFollowups ? <Badge variant="secondary">lädt...</Badge> : null}
<Button type="button" size="sm" variant="secondary" onClick={() => void loadFollowups()} disabled={loadingFollowups}>
<BellPlus className="size-4" />
Prüfen
</Button>
</div>
</CardHeader>
<CardContent className="space-y-3 px-4 pb-4">
{(followups?.projects ?? []).map((project) => (
<ProjectFollowupRow key={project.project_id} project={project} onNavigate={onNavigate} />
))}
{!loadingFollowups && followups && followups.projects.length === 0 ? (
<div className="grid place-items-center gap-2 rounded-md border bg-muted/20 p-8 text-center">
<ListChecks className="size-8 text-muted-foreground" />
<div>
<p className="font-medium">Keine Wiedervorlagen nötig</p>
<p className="text-sm text-muted-foreground">Alle offenen Projektbudgets wurden innerhalb des eingestellten Zeitraums aktualisiert.</p>
</div>
</div>
) : null}
{loadingFollowups && !followups ? <p className="py-8 text-center text-sm text-muted-foreground">Wiedervorlagen werden geprüft...</p> : null}
</CardContent>
</Card>
</TabsContent>
</Tabs>
</div> </div>
); );
} }
+26 -4
View File
@@ -129,6 +129,10 @@ function notifyZammadArticleResult(result: CreateSessionResult) {
} }
} }
function notifyTodosChanged() {
window.dispatchEvent(new Event("tickettracker:todos-changed"));
}
function zammadSyncStorageKey(userId: string) { function zammadSyncStorageKey(userId: string) {
return `tickettracker.zammad-sync.${userId}`; return `tickettracker.zammad-sync.${userId}`;
} }
@@ -259,6 +263,18 @@ function teamspaceBillingKey(day: string, projectTeamspaceEntryId: string | null
return `${projectTeamspaceEntryId ?? "ticket"}::${day}`; return `${projectTeamspaceEntryId ?? "ticket"}::${day}`;
} }
function dayBillingProjectEntryIdForView(
projectTeamspaceEntryId: string | null | undefined,
hasMultipleProjectTeamspaceEntries: boolean,
defaultProjectEntryId: string | null
) {
if (!hasMultipleProjectTeamspaceEntries) {
return null;
}
return projectTeamspaceEntryId ?? defaultProjectEntryId;
}
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(),
@@ -824,10 +840,12 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onS
}, [periodType, period, ticketId]); }, [periodType, period, ticketId]);
useEffect(() => { useEffect(() => {
const defaultEntryId = data?.project ? projectTeamspaceEntries(data.project).find((entry) => entry.is_default)?.id ?? null : null; const loadedProjectEntries = data?.project ? projectTeamspaceEntries(data.project) : [];
const hasMultipleEntries = loadedProjectEntries.length > 1;
const defaultEntryId = loadedProjectEntries.find((entry) => entry.is_default)?.id ?? null;
const nextInputs = Object.fromEntries( const nextInputs = Object.fromEntries(
(data?.dayBillings ?? []).map((billing) => [ (data?.dayBillings ?? []).map((billing) => [
teamspaceBillingKey(billing.day, billing.project_teamspace_entry_id ?? defaultEntryId), teamspaceBillingKey(billing.day, dayBillingProjectEntryIdForView(billing.project_teamspace_entry_id, hasMultipleEntries, defaultEntryId)),
formatHoursInput(billing.billed_minutes) formatHoursInput(billing.billed_minutes)
]) ])
); );
@@ -920,11 +938,11 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onS
() => () =>
new Map( new Map(
(data?.dayBillings ?? []).map((billing) => [ (data?.dayBillings ?? []).map((billing) => [
teamspaceBillingKey(billing.day, billing.project_teamspace_entry_id ?? defaultProjectEntryId), teamspaceBillingKey(billing.day, dayBillingProjectEntryIdForView(billing.project_teamspace_entry_id, requiresProjectTeamspaceEntry, defaultProjectEntryId)),
billing.billed_minutes billing.billed_minutes
]) ])
), ),
[data?.dayBillings, defaultProjectEntryId] [data?.dayBillings, defaultProjectEntryId, requiresProjectTeamspaceEntry]
); );
const savedDayBillingInput = (key: string) => formatHoursInput(dayBillingMap.get(key)); const savedDayBillingInput = (key: string) => formatHoursInput(dayBillingMap.get(key));
const isDayBillingDirty = (key: string) => (dayBillingInputs[key] ?? "") !== savedDayBillingInput(key); const isDayBillingDirty = (key: string) => (dayBillingInputs[key] ?? "") !== savedDayBillingInput(key);
@@ -1186,6 +1204,9 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onS
: `${result.created} angelegt, ${result.skipped} ohne Transfer.`; : `${result.created} angelegt, ${result.skipped} ohne Transfer.`;
const notify = result.created === 0 && result.errors.length > 0 ? toast.error : toast.success; const notify = result.created === 0 && result.errors.length > 0 ? toast.error : toast.success;
notify("Mail-Sessions importiert", { description }); notify("Mail-Sessions importiert", { description });
if (result.created > 0) {
notifyTodosChanged();
}
await load(); await load();
} catch (error) { } catch (error) {
toast.error("Mail-Sessions konnten nicht importiert werden", { toast.error("Mail-Sessions konnten nicht importiert werden", {
@@ -1514,6 +1535,7 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onS
description: "Der Eintrag wurde diesem Ticket zugeordnet. Betroffene Abschlüsse wurden wieder geöffnet." description: "Der Eintrag wurde diesem Ticket zugeordnet. Betroffene Abschlüsse wurden wieder geöffnet."
}); });
notifyZammadArticleResult(result); notifyZammadArticleResult(result);
notifyTodosChanged();
setTicketSession({ setTicketSession({
...emptyTicketSessionForm(), ...emptyTicketSessionForm(),
organizationId: ticket.organization_id ?? "", organizationId: ticket.organization_id ?? "",
+6
View File
@@ -141,6 +141,10 @@ function notifyZammadArticleResult(result: CreateSessionResult) {
} }
} }
function notifyTodosChanged() {
window.dispatchEvent(new Event("tickettracker:todos-changed"));
}
function escapeHtml(value: string) { function escapeHtml(value: string) {
return value return value
.replace(/&/g, "&amp;") .replace(/&/g, "&amp;")
@@ -773,6 +777,7 @@ export function TimerPage({ timers, setTimers, selectedTimerId, setSelectedTimer
description: `${finished.ticketNumber} wurde mit ${finished.roundedMinutes} Minute(n) erfasst.` description: `${finished.ticketNumber} wurde mit ${finished.roundedMinutes} Minute(n) erfasst.`
}); });
notifyZammadArticleResult(result); notifyZammadArticleResult(result);
notifyTodosChanged();
try { try {
const timerResult = await deleteActiveTimer(finished.timerId); const timerResult = await deleteActiveTimer(finished.timerId);
removeTimerActivityDraft(currentUser.id, finished.timerId); removeTimerActivityDraft(currentUser.id, finished.timerId);
@@ -868,6 +873,7 @@ export function TimerPage({ timers, setTimers, selectedTimerId, setSelectedTimer
description: manualLookupTicket?.is_project ? "Der Eintrag wurde dem Projekt zugeordnet." : "Der Eintrag ist in deiner Ticketliste offen." description: manualLookupTicket?.is_project ? "Der Eintrag wurde dem Projekt zugeordnet." : "Der Eintrag ist in deiner Ticketliste offen."
}); });
notifyZammadArticleResult(result); notifyZammadArticleResult(result);
notifyTodosChanged();
setManualSession({ setManualSession({
...emptyManualSession(), ...emptyManualSession(),
day: manualSession.day, day: manualSession.day,
+57 -34
View File
@@ -21,6 +21,7 @@ type TodosPageProps = {
type TodoSortMode = "custom" | "priority" | "title" | "ticket" | "organization" | "date" | "updated"; type TodoSortMode = "custom" | "priority" | "title" | "ticket" | "organization" | "date" | "updated";
type TodoGroupMode = "ticket" | "priority" | "organization" | "status"; type TodoGroupMode = "ticket" | "priority" | "organization" | "status";
type CalendarDayMode = "current";
type TodoPreferences = { type TodoPreferences = {
status: TodoStatus | "all"; status: TodoStatus | "all";
@@ -277,6 +278,23 @@ function sortTodos(todos: TicketTodo[], sortMode: TodoSortMode) {
return [...todos].sort((left, right) => compareTodos(left, right, sortMode)); return [...todos].sort((left, right) => compareTodos(left, right, sortMode));
} }
function uniqueTodos(todos: TicketTodo[]) {
return todos.filter((todo, index, list) => list.findIndex((item) => item.id === todo.id) === index);
}
function sortCurrentTodos(todos: TicketTodo[], today: string) {
return [...todos].sort((left, right) => {
const leftOverdue = isTodoOverdue(left, today);
const rightOverdue = isTodoOverdue(right, today);
if (leftOverdue !== rightOverdue) {
return leftOverdue ? -1 : 1;
}
return priorityOrder[left.priority] - priorityOrder[right.priority] || left.sort_order - right.sort_order || compareText(left.title, right.title);
});
}
function matchesTicketSearch(todo: TicketTodo, search: string) { function matchesTicketSearch(todo: TicketTodo, search: string) {
const normalizedSearch = normalizeSearch(search); const normalizedSearch = normalizeSearch(search);
@@ -400,9 +418,9 @@ export function TodosPage({ onNavigate }: TodosPageProps) {
const [modalEndsOn, setModalEndsOn] = useState(""); const [modalEndsOn, setModalEndsOn] = useState("");
const [modalBusy, setModalBusy] = useState(false); const [modalBusy, setModalBusy] = useState(false);
const [selectedCalendarDay, setSelectedCalendarDay] = useState<string | null>(null); const [selectedCalendarDay, setSelectedCalendarDay] = useState<string | null>(null);
const [selectedCalendarDayMode, setSelectedCalendarDayMode] = useState<"summarized" | "overdue">("summarized"); const [selectedCalendarDayMode, setSelectedCalendarDayMode] = useState<CalendarDayMode>("current");
const [returnCalendarDay, setReturnCalendarDay] = useState<string | null>(null); const [returnCalendarDay, setReturnCalendarDay] = useState<string | null>(null);
const [returnCalendarDayMode, setReturnCalendarDayMode] = useState<"summarized" | "overdue">("summarized"); const [returnCalendarDayMode, setReturnCalendarDayMode] = useState<CalendarDayMode>("current");
async function load() { async function load() {
setLoading(true); setLoading(true);
@@ -422,6 +440,13 @@ export function TodosPage({ onNavigate }: TodosPageProps) {
void load(); void load();
}, []); }, []);
useEffect(() => {
const onTodosChanged = () => void load();
window.addEventListener("tickettracker:todos-changed", onTodosChanged);
return () => window.removeEventListener("tickettracker:todos-changed", onTodosChanged);
}, []);
useEffect(() => { useEffect(() => {
window.localStorage.setItem(todoPreferencesKey, JSON.stringify(preferences)); window.localStorage.setItem(todoPreferencesKey, JSON.stringify(preferences));
}, [preferences]); }, [preferences]);
@@ -432,7 +457,7 @@ export function TodosPage({ onNavigate }: TodosPageProps) {
if (selectedTodoId && !selectedTodo) { if (selectedTodoId && !selectedTodo) {
setSelectedTodoId(null); setSelectedTodoId(null);
setReturnCalendarDay(null); setReturnCalendarDay(null);
setReturnCalendarDayMode("summarized"); setReturnCalendarDayMode("current");
} }
}, [selectedTodo, selectedTodoId]); }, [selectedTodo, selectedTodoId]);
@@ -473,7 +498,19 @@ export function TodosPage({ onNavigate }: TodosPageProps) {
return entries; return entries;
}, [calendarDays, todos]); }, [calendarDays, todos]);
const selectedCalendarDayTodos = selectedCalendarDay ? calendarTodosByDay.get(selectedCalendarDay)?.[selectedCalendarDayMode] ?? [] : []; const selectedCalendarDayTodos = useMemo(() => {
if (!selectedCalendarDay) {
return [];
}
const dayTodos = calendarTodosByDay.get(selectedCalendarDay);
if (!dayTodos) {
return [];
}
return sortCurrentTodos(uniqueTodos([...dayTodos.overdue, ...dayTodos.single, ...dayTodos.summarized]), currentDay());
}, [calendarTodosByDay, selectedCalendarDay]);
const counts = useMemo( const counts = useMemo(
() => ({ () => ({
open: todos.filter((todo) => todo.status === "open").length, open: todos.filter((todo) => todo.status === "open").length,
@@ -531,7 +568,7 @@ export function TodosPage({ onNavigate }: TodosPageProps) {
}); });
} }
function openTodoModal(todo: TicketTodo, returnToDay: string | null = null, returnToDayMode: "summarized" | "overdue" = "summarized") { function openTodoModal(todo: TicketTodo, returnToDay: string | null = null, returnToDayMode: CalendarDayMode = "current") {
setReturnCalendarDay(returnToDay); setReturnCalendarDay(returnToDay);
setReturnCalendarDayMode(returnToDayMode); setReturnCalendarDayMode(returnToDayMode);
setSelectedTodoId(todo.id); setSelectedTodoId(todo.id);
@@ -545,7 +582,7 @@ export function TodosPage({ onNavigate }: TodosPageProps) {
function closeTodoModal() { function closeTodoModal() {
setSelectedTodoId(null); setSelectedTodoId(null);
setReturnCalendarDay(null); setReturnCalendarDay(null);
setReturnCalendarDayMode("summarized"); setReturnCalendarDayMode("current");
} }
function backToCalendarDay() { function backToCalendarDay() {
@@ -557,7 +594,7 @@ export function TodosPage({ onNavigate }: TodosPageProps) {
const mode = returnCalendarDayMode; const mode = returnCalendarDayMode;
setSelectedTodoId(null); setSelectedTodoId(null);
setReturnCalendarDay(null); setReturnCalendarDay(null);
setReturnCalendarDayMode("summarized"); setReturnCalendarDayMode("current");
setSelectedCalendarDayMode(mode); setSelectedCalendarDayMode(mode);
setSelectedCalendarDay(day); setSelectedCalendarDay(day);
} }
@@ -904,6 +941,9 @@ export function TodosPage({ onNavigate }: TodosPageProps) {
const summarizedTodos = dayTodos.summarized; const summarizedTodos = dayTodos.summarized;
const overdueTodos = dayTodos.overdue; const overdueTodos = dayTodos.overdue;
const totalTodos = singleTodos.length + summarizedTodos.length + overdueTodos.length; const totalTodos = singleTodos.length + summarizedTodos.length + overdueTodos.length;
const currentTodos = sortCurrentTodos(uniqueTodos([...overdueTodos, ...singleTodos, ...summarizedTodos]), currentDay());
const visibleCalendarTodos = currentTodos.slice(0, 2);
const additionalTodosCount = Math.max(0, currentTodos.length - visibleCalendarTodos.length);
return ( return (
<div <div
@@ -918,7 +958,7 @@ export function TodosPage({ onNavigate }: TodosPageProps) {
</div> </div>
{!day.workingDay ? <div className="text-[10px] leading-tight text-muted-foreground">{day.holidayName ?? "Wochenende"}</div> : null} {!day.workingDay ? <div className="text-[10px] leading-tight text-muted-foreground">{day.holidayName ?? "Wochenende"}</div> : null}
<div className="space-y-1"> <div className="space-y-1">
{singleTodos.slice(0, 3).map((todo) => ( {visibleCalendarTodos.map((todo) => (
<button <button
key={todo.id} key={todo.id}
type="button" type="button"
@@ -929,31 +969,18 @@ export function TodosPage({ onNavigate }: TodosPageProps) {
<span className="block truncate text-muted-foreground">{todo.ticket_number ?? "Ticket"}</span> <span className="block truncate text-muted-foreground">{todo.ticket_number ?? "Ticket"}</span>
</button> </button>
))} ))}
{singleTodos.length > 3 ? <span className="block px-1 text-[11px] text-muted-foreground">+{singleTodos.length - 3} weitere fixe</span> : null}
</div> </div>
<div className="mt-auto flex flex-wrap gap-1 pt-1"> <div className="mt-auto pt-1">
{summarizedTodos.length > 0 ? ( {additionalTodosCount > 0 ? (
<button <button
type="button" type="button"
className="min-w-0 flex-1 rounded-md border border-muted-foreground/20 bg-background/60 px-2 py-1 text-left text-[11px] leading-tight text-muted-foreground hover:bg-muted" className="block w-full rounded-md border border-muted-foreground/20 bg-background/60 px-2 py-1 text-left text-[11px] leading-tight text-muted-foreground hover:bg-muted"
onClick={() => { onClick={() => {
setSelectedCalendarDayMode("summarized"); setSelectedCalendarDayMode("current");
setSelectedCalendarDay(day.iso); setSelectedCalendarDay(day.iso);
}} }}
> >
+{summarizedTodos.length} ToDo{summarizedTodos.length === 1 ? "" : "s"} +{additionalTodosCount} weitere ToDo{additionalTodosCount === 1 ? "" : "s"}
</button>
) : null}
{overdueTodos.length > 0 ? (
<button
type="button"
className="shrink-0 rounded-md border border-red-500/50 bg-red-500/10 px-2 py-1 text-left text-[11px] font-semibold leading-tight text-red-200 hover:bg-red-500/20"
onClick={() => {
setSelectedCalendarDayMode("overdue");
setSelectedCalendarDay(day.iso);
}}
>
+{overdueTodos.length} üf
</button> </button>
) : null} ) : null}
</div> </div>
@@ -1073,13 +1100,11 @@ export function TodosPage({ onNavigate }: TodosPageProps) {
<Dialog open={Boolean(selectedCalendarDay)} onOpenChange={(open) => !open && setSelectedCalendarDay(null)}> <Dialog open={Boolean(selectedCalendarDay)} onOpenChange={(open) => !open && setSelectedCalendarDay(null)}>
<DialogContent className="max-h-[calc(100dvh-2rem)] overflow-y-auto sm:max-w-xl"> <DialogContent className="max-h-[calc(100dvh-2rem)] overflow-y-auto sm:max-w-xl">
<DialogHeader> <DialogHeader>
<DialogTitle>{selectedCalendarDayMode === "overdue" ? "Überfällige ToDos" : "ToDos am Tag"}</DialogTitle> <DialogTitle>Aktuelle ToDos</DialogTitle>
<DialogDescription> <DialogDescription>
{selectedCalendarDay {selectedCalendarDay
? `${formatTodoDate(selectedCalendarDay)} · ${selectedCalendarDayTodos.length} ${selectedCalendarDayMode === "overdue" ? "überfällige" : "laufende"} ToDo(s)` ? `${formatTodoDate(selectedCalendarDay)} · ${selectedCalendarDayTodos.length} aktuelle ToDo(s)`
: selectedCalendarDayMode === "overdue" : "Aktuelle ToDos dieses Tages"}
? "Überfällige ToDos dieses Tages"
: "Laufende ToDos dieses Tages"}
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<div className="space-y-2"> <div className="space-y-2">
@@ -1142,9 +1167,7 @@ export function TodosPage({ onNavigate }: TodosPageProps) {
</div> </div>
))} ))}
{selectedCalendarDayTodos.length === 0 ? ( {selectedCalendarDayTodos.length === 0 ? (
<p className="rounded-md border bg-muted/20 p-4 text-center text-sm text-muted-foreground"> <p className="rounded-md border bg-muted/20 p-4 text-center text-sm text-muted-foreground">Keine aktuellen ToDos an diesem Tag.</p>
{selectedCalendarDayMode === "overdue" ? "Keine überfälligen ToDos an diesem Tag." : "Keine laufenden ToDos an diesem Tag."}
</p>
) : null} ) : null}
</div> </div>
</DialogContent> </DialogContent>