Update
This commit is contained in:
1 parent
35cb578a96
commit
d5952d82bb
11 files changed
+1074
-112
No files matched your search
+17
-2
@@ -24,6 +24,11 @@ export type AuthUser = {
|
||||
zammad_time_unit_rounding_minutes: number;
|
||||
zammad_atoss_email_sync_enabled: boolean;
|
||||
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 {
|
||||
@@ -91,7 +96,12 @@ function publicUser(user: AuthUser) {
|
||||
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_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_minutes,
|
||||
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
|
||||
JOIN users u ON u.id = s.user_id
|
||||
WHERE s.token_hash = $1
|
||||
|
||||
+542
-34
@@ -457,6 +457,20 @@ function parseZammadArticleTypeId(value: unknown, fallback = 6) {
|
||||
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) {
|
||||
if (value === undefined || value === null || value === "" || value === "all") {
|
||||
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)})`);
|
||||
}
|
||||
|
||||
const targetTicket = await fetchZammadTicketDetail(baseUrl, apiToken, targetTicketId, false);
|
||||
const targetTicket = await fetchZammadTicketDetail(baseUrl, apiToken, targetTicketId);
|
||||
const sourceOrganizationId = numberOrNull(rawTicket.organization_id);
|
||||
const normalizedTargetTicket = targetTicket && !numberOrNull(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 });
|
||||
}
|
||||
|
||||
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)) {
|
||||
await query(
|
||||
`
|
||||
@@ -1928,7 +1948,7 @@ async function createSessionForUser(input: SessionCreationInput) {
|
||||
LIMIT 1
|
||||
FOR UPDATE OF t;
|
||||
`,
|
||||
[ticketNumber, userId]
|
||||
[effectiveTicketNumber, userId]
|
||||
);
|
||||
const existingTicket = existingTicketResult.rows[0] ?? null;
|
||||
const effectiveOrganizationId = existingTicket?.organization_id ? Number(existingTicket.organization_id) : requestedOrganizationId;
|
||||
@@ -1960,7 +1980,7 @@ async function createSessionForUser(input: SessionCreationInput) {
|
||||
END
|
||||
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];
|
||||
@@ -2077,7 +2097,7 @@ async function createSessionForUser(input: SessionCreationInput) {
|
||||
userId,
|
||||
sessionId: session.id,
|
||||
ticketId: ticket.id,
|
||||
ticketNumber,
|
||||
ticketNumber: effectiveTicketNumber,
|
||||
zammadTicketId: zammadArticleTicketId,
|
||||
activity,
|
||||
articleBody: zammadArticleBody,
|
||||
@@ -2092,6 +2112,7 @@ async function createSessionForUser(input: SessionCreationInput) {
|
||||
if (workType === "consulting") {
|
||||
await reopenProjectsForTicket(client, ticket.id);
|
||||
await showProjectsForUserTicket(client, ticket.id, userId);
|
||||
await completeAutomaticProjectFollowupsForSession(client, userId, ticket.id, startedAt);
|
||||
}
|
||||
|
||||
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()) {
|
||||
const rawTicket = await fetchZammadTicketDetail(baseUrl, apiToken, id) ?? candidate;
|
||||
@@ -2600,11 +2621,15 @@ async function fetchZammadConsultingTicketsForOwner(userId: string): Promise<Zam
|
||||
const zammadTicket = mergedTargetTicket ?? zammadTicketImportFromRaw(rawTicket);
|
||||
|
||||
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) {
|
||||
@@ -2663,11 +2688,20 @@ async function importZammadTicketForUser(client: QueryClient, zammadTicket: Zamm
|
||||
zammad_priority_id,
|
||||
zammad_state_id,
|
||||
zammad_owner_id,
|
||||
zammad_merged_from_ticket_id,
|
||||
zammad_merged_from_ticket_number,
|
||||
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
|
||||
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;
|
||||
`,
|
||||
[
|
||||
@@ -2681,6 +2715,8 @@ async function importZammadTicketForUser(client: QueryClient, zammadTicket: Zamm
|
||||
zammadTicket.zammadPriorityId,
|
||||
zammadTicket.zammadStateId,
|
||||
zammadTicket.zammadOwnerId,
|
||||
zammadTicket.mergedFromTicketId,
|
||||
zammadTicket.mergedFromTicketNumber,
|
||||
JSON.stringify(zammadTicket.preferences)
|
||||
]
|
||||
);
|
||||
@@ -4225,6 +4261,36 @@ function isBlankTeamspaceProject(value: string | null | undefined) {
|
||||
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>) {
|
||||
const local = normalizedTeamspaceProjectList(localProjects);
|
||||
const zammad = normalizedTeamspaceProjectList(zammadProjects);
|
||||
@@ -4426,7 +4492,8 @@ async function mergeTeamspaceEntriesIntoTarget(client: QueryClient, sourceEntryI
|
||||
SELECT
|
||||
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,
|
||||
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
|
||||
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),
|
||||
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,
|
||||
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()
|
||||
FROM source_totals
|
||||
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) {
|
||||
await client.query(
|
||||
`
|
||||
@@ -4541,18 +4643,9 @@ async function syncProjectTeamspaceEntriesFromZammad(
|
||||
return;
|
||||
}
|
||||
|
||||
const existingResult = await client.query<{ id: string; teamspace_project: string | null; is_default: boolean; display_order: number }>(
|
||||
`
|
||||
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]
|
||||
);
|
||||
let existingEntries = await loadProjectTeamspaceEntriesForUpdate(client, projectId);
|
||||
|
||||
if (existingResult.rows.length === 0) {
|
||||
if (existingEntries.length === 0) {
|
||||
for (const [index, teamspaceProject] of zammadProjects.entries()) {
|
||||
await client.query(
|
||||
`
|
||||
@@ -4576,7 +4669,6 @@ async function syncProjectTeamspaceEntriesFromZammad(
|
||||
return;
|
||||
}
|
||||
|
||||
let existingEntries = existingResult.rows;
|
||||
let defaultEntry = existingEntries.find((entry) => entry.is_default) ?? existingEntries[0];
|
||||
|
||||
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)));
|
||||
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 dedupeProjectTeamspaceEntries(client, targetProject.id);
|
||||
}
|
||||
|
||||
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) {
|
||||
await ensureRecurringSessionsForUserPeriod(userId, period.startIso, period.endIso);
|
||||
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_atoss_email_sync_enabled: boolean;
|
||||
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)
|
||||
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)]
|
||||
);
|
||||
@@ -7482,6 +7932,11 @@ app.post("/api/auth/login", async (req, res) => {
|
||||
zammad_time_unit_rounding_minutes: number;
|
||||
zammad_atoss_email_sync_enabled: boolean;
|
||||
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
|
||||
@@ -7499,7 +7954,12 @@ app.post("/api/auth/login", async (req, res) => {
|
||||
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
|
||||
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
|
||||
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_minutes: user.zammad_time_unit_rounding_minutes,
|
||||
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"
|
||||
? parseZammadArticleTypeId(req.body.zammadArticleTypeId, user.zammad_article_type_id)
|
||||
: 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) {
|
||||
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_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,
|
||||
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()
|
||||
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,
|
||||
@@ -7828,7 +8313,12 @@ app.patch("/api/auth/me", async (req, res) => {
|
||||
zammadTimeUnitRoundingEnabled,
|
||||
zammadTimeUnitRoundingMinutes,
|
||||
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) => {
|
||||
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
|
||||
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)
|
||||
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]
|
||||
);
|
||||
@@ -7941,7 +8431,7 @@ app.patch("/api/admin/users/:userId", requireAdmin, async (req, res) => {
|
||||
password_hash = COALESCE($4, password_hash),
|
||||
updated_at = now()
|
||||
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]
|
||||
);
|
||||
@@ -9251,6 +9741,10 @@ app.get("/api/statistics/weeks/:week", requireUser, async (req, res) => {
|
||||
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) => {
|
||||
const ticketNumber = parseTicketNumber(req.query.ticketNumber);
|
||||
const userId = currentUser(req).id;
|
||||
@@ -9317,7 +9811,10 @@ app.get("/api/tickets/lookup", requireUser, async (req, res) => {
|
||||
FROM project_teamspace_entries pte
|
||||
WHERE pte.project_id = p.id
|
||||
) 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]);
|
||||
|
||||
@@ -9325,6 +9822,15 @@ app.get("/api/tickets/lookup", requireUser, async (req, res) => {
|
||||
const zammadTicket = await fetchZammadTicketByNumber(userId, ticketNumber);
|
||||
|
||||
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);
|
||||
|
||||
if (!organization) {
|
||||
@@ -10618,6 +11124,8 @@ app.post("/api/projects", requireUser, async (req, res) => {
|
||||
return;
|
||||
}
|
||||
|
||||
const effectiveTicketNumber = zammadTicket?.ticketNumber ?? ticketNumber;
|
||||
|
||||
if (zammadTicket) {
|
||||
const zammadTeamspaceProjects = zammadTeamspaceProjectList(zammadTicket);
|
||||
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
|
||||
FOR UPDATE;
|
||||
`,
|
||||
[ticketNumber]
|
||||
[effectiveTicketNumber]
|
||||
);
|
||||
|
||||
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'
|
||||
RETURNING id;
|
||||
`,
|
||||
[ticketNumber, title, organization.id, organization.name]
|
||||
[effectiveTicketNumber, title, organization.id, organization.name]
|
||||
);
|
||||
|
||||
const ticketId = ticketResult.rows[0].id;
|
||||
@@ -10796,7 +11304,7 @@ app.post("/api/projects", requireUser, async (req, res) => {
|
||||
endsOn,
|
||||
defaultTeamspaceEntry.hourBudgetMinutes,
|
||||
defaultTeamspaceEntry.manualConsumptionMinutes,
|
||||
ticketNumber,
|
||||
effectiveTicketNumber,
|
||||
organization.id,
|
||||
organization.name,
|
||||
title,
|
||||
|
||||
@@ -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_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 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("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;");
|
||||
@@ -106,6 +111,16 @@ export async function migrate() {
|
||||
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));
|
||||
`);
|
||||
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(`
|
||||
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_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 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("UPDATE recurring_billings SET active = true WHERE active = false;");
|
||||
await query(`
|
||||
|
||||
+60
-34
@@ -1,5 +1,4 @@
|
||||
import {
|
||||
AlertCircle,
|
||||
CalendarDays,
|
||||
CheckCircle2,
|
||||
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 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() {
|
||||
window.dispatchEvent(new Event("tickettracker:todos-changed"));
|
||||
}
|
||||
|
||||
function SidebarTodayTodos({ todos, onNavigate }: SidebarTodayTodosProps) {
|
||||
const [runningTodosOpen, setRunningTodosOpen] = useState(false);
|
||||
const [todosModalOpen, setTodosModalOpen] = useState(false);
|
||||
const [modalBusy, setModalBusy] = useState(false);
|
||||
const today = currentDay();
|
||||
const fixedTodos = todos.filter((todo) => 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 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;
|
||||
|
||||
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) {
|
||||
setModalBusy(true);
|
||||
|
||||
@@ -666,42 +702,30 @@ function SidebarTodayTodos({ todos, onNavigate }: SidebarTodayTodosProps) {
|
||||
|
||||
{hasTodos ? (
|
||||
<div className="space-y-1">
|
||||
{overdueTodos.slice(0, 3).map((todo) => (
|
||||
{visibleCurrentTodos.map((todo) => (
|
||||
<button
|
||||
key={`overdue-${todo.id}`}
|
||||
key={`current-${todo.id}`}
|
||||
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"
|
||||
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]}`}
|
||||
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="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>
|
||||
))}
|
||||
{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
|
||||
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"
|
||||
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>
|
||||
) : null}
|
||||
</div>
|
||||
@@ -712,22 +736,24 @@ function SidebarTodayTodos({ todos, onNavigate }: SidebarTodayTodosProps) {
|
||||
</SidebarGroupContent>
|
||||
</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">
|
||||
<DialogHeader>
|
||||
<DialogTitle>ToDos am Tag</DialogTitle>
|
||||
<DialogDescription>Heute · {summarizedTodos.length} laufende ToDo(s)</DialogDescription>
|
||||
<DialogTitle>Aktuelle ToDos</DialogTitle>
|
||||
<DialogDescription>
|
||||
{overdueTodos.length} überfällig · {fixedTodos.length} feste Termine · {summarizedTodos.length} laufende ToDo(s)
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<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 className="flex flex-col gap-2">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<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>
|
||||
<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>
|
||||
{isTodoOverdue(todo, today) ? <Badge variant="destructive">überfällig</Badge> : null}
|
||||
</div>
|
||||
<p className="mt-1 truncate text-xs text-muted-foreground" title={todo.organization_name ?? "Keine Organisation"}>
|
||||
{todo.organization_name ?? "Keine Organisation"}
|
||||
@@ -749,7 +775,7 @@ function SidebarTodayTodos({ todos, onNavigate }: SidebarTodayTodosProps) {
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
setRunningTodosOpen(false);
|
||||
setTodosModalOpen(false);
|
||||
onNavigate(`/tickets/${todo.ticket_id}`);
|
||||
}}
|
||||
>
|
||||
@@ -764,8 +790,8 @@ function SidebarTodayTodos({ todos, onNavigate }: SidebarTodayTodosProps) {
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{summarizedTodos.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>
|
||||
{currentTodos.length === 0 ? (
|
||||
<p className="rounded-md border bg-muted/20 p-4 text-center text-sm text-muted-foreground">Keine aktuellen ToDos.</p>
|
||||
) : null}
|
||||
</div>
|
||||
</DialogContent>
|
||||
|
||||
@@ -10,6 +10,7 @@ import type {
|
||||
ProjectSummary,
|
||||
RecurringBilling,
|
||||
StatisticsOverview,
|
||||
StatisticsProjectFollowupOverview,
|
||||
TicketMeta,
|
||||
TicketPeriod,
|
||||
TicketTodo,
|
||||
@@ -254,6 +255,11 @@ export function updateCurrentUser(payload: {
|
||||
zammadTimeUnitRoundingMinutes?: number;
|
||||
zammadAtossEmailSyncEnabled?: boolean;
|
||||
zammadArticleTypeId?: number;
|
||||
projectFollowupEnabled?: boolean;
|
||||
projectFollowupWeeks?: number;
|
||||
projectFollowupSecondEnabled?: boolean;
|
||||
projectFollowupSecondWeeks?: number;
|
||||
projectFollowupCompleteOnSession?: boolean;
|
||||
}) {
|
||||
return request<{ user: AuthUser }>("/api/auth/me", {
|
||||
method: "PATCH",
|
||||
@@ -889,6 +895,10 @@ export function getStatisticsOverview(period: string, type: "month" | "week" = "
|
||||
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) {
|
||||
return request<TicketPeriod>(`/api/periods/${periodPath(type)}/${period}/tickets/${ticketId}`);
|
||||
}
|
||||
|
||||
@@ -19,6 +19,11 @@ export type AuthUser = {
|
||||
zammad_time_unit_rounding_minutes: number;
|
||||
zammad_atoss_email_sync_enabled: boolean;
|
||||
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";
|
||||
@@ -479,6 +484,35 @@ export type StatisticsTeamspaceDay = {
|
||||
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 = {
|
||||
periodType: "month" | "week";
|
||||
period: string;
|
||||
|
||||
@@ -31,6 +31,11 @@ export function ProfilePage({ currentUser, onUserUpdated }: ProfilePageProps) {
|
||||
const [zammadTimeUnitRoundingEnabled, setZammadTimeUnitRoundingEnabled] = useState(currentUser.zammad_time_unit_rounding_enabled);
|
||||
const [zammadTimeUnitRoundingMinutes, setZammadTimeUnitRoundingMinutes] = useState(currentUser.zammad_time_unit_rounding_minutes);
|
||||
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 [loadingZammadQueue, setLoadingZammadQueue] = useState(false);
|
||||
const [retryingZammadQueue, setRetryingZammadQueue] = useState(false);
|
||||
@@ -48,6 +53,11 @@ export function ProfilePage({ currentUser, onUserUpdated }: ProfilePageProps) {
|
||||
setZammadTimeUnitRoundingEnabled(currentUser.zammad_time_unit_rounding_enabled);
|
||||
setZammadTimeUnitRoundingMinutes(currentUser.zammad_time_unit_rounding_minutes);
|
||||
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]);
|
||||
|
||||
async function loadZammadQueue() {
|
||||
@@ -132,7 +142,12 @@ export function ProfilePage({ currentUser, onUserUpdated }: ProfilePageProps) {
|
||||
zammadSessionNoteDefaultEnabled,
|
||||
zammadTimeUnitRoundingEnabled,
|
||||
zammadTimeUnitRoundingMinutes,
|
||||
zammadArticleTypeId
|
||||
zammadArticleTypeId,
|
||||
projectFollowupEnabled,
|
||||
projectFollowupWeeks,
|
||||
projectFollowupSecondEnabled,
|
||||
projectFollowupSecondWeeks,
|
||||
projectFollowupCompleteOnSession
|
||||
}
|
||||
: {})
|
||||
};
|
||||
@@ -334,6 +349,89 @@ export function ProfilePage({ currentUser, onUserUpdated }: ProfilePageProps) {
|
||||
onCheckedChange={setSessionBillingPromptEnabled}
|
||||
/>
|
||||
</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="flex items-center gap-2">
|
||||
<FileText className="size-4 text-muted-foreground" />
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import {
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
BellPlus,
|
||||
CircleAlert,
|
||||
Clock3,
|
||||
ExternalLink,
|
||||
ListChecks,
|
||||
RotateCcw,
|
||||
Ticket,
|
||||
TrendingUp,
|
||||
@@ -20,11 +22,12 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/com
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Pagination, PaginationContent, PaginationItem, PaginationNext, PaginationPrevious } from "@/components/ui/pagination";
|
||||
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 { HelpLink } from "@/components/HelpLink";
|
||||
import { getStatisticsOverview } from "../api";
|
||||
import { currentMonth, formatDate, formatMinutes } from "../format";
|
||||
import type { StatisticsDailyBucket, StatisticsGroup, StatisticsOverview, StatisticsTicket, WorkType } from "../types";
|
||||
import { getProjectFollowups, getStatisticsOverview } from "../api";
|
||||
import { currentMonth, formatDate, formatDateTime, formatMinutes } from "../format";
|
||||
import type { StatisticsDailyBucket, StatisticsGroup, StatisticsOverview, StatisticsProjectFollowup, StatisticsProjectFollowupOverview, StatisticsTicket, WorkType } from "../types";
|
||||
|
||||
type StatisticsPageProps = {
|
||||
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) {
|
||||
const [month, setMonth] = useState(currentMonth());
|
||||
const [stats, setStats] = useState<StatisticsOverview | null>(null);
|
||||
const [followups, setFollowups] = useState<StatisticsProjectFollowupOverview | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [loadingFollowups, setLoadingFollowups] = useState(false);
|
||||
const [organizationPage, setOrganizationPage] = useState(1);
|
||||
const loadRequestId = useRef(0);
|
||||
const followupRequestId = useRef(0);
|
||||
|
||||
async function load() {
|
||||
const requestId = loadRequestId.current + 1;
|
||||
@@ -396,6 +459,44 @@ export function StatisticsPage({ onNavigate }: StatisticsPageProps) {
|
||||
void load();
|
||||
}, [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(() => {
|
||||
function refreshVisible() {
|
||||
if (document.visibilityState === "visible") {
|
||||
@@ -476,6 +577,15 @@ export function StatisticsPage({ onNavigate }: StatisticsPageProps) {
|
||||
</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">
|
||||
<MetricCard
|
||||
label="Abgerechnet"
|
||||
@@ -597,6 +707,9 @@ export function StatisticsPage({ onNavigate }: StatisticsPageProps) {
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="groups" className="space-y-5">
|
||||
<div className="grid gap-3 xl:grid-cols-[1.45fr_0.8fr]">
|
||||
<Card>
|
||||
<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>
|
||||
</div>
|
||||
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="tickets" 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>
|
||||
@@ -768,6 +884,48 @@ export function StatisticsPage({ onNavigate }: StatisticsPageProps) {
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : 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>
|
||||
);
|
||||
}
|
||||
@@ -129,6 +129,10 @@ function notifyZammadArticleResult(result: CreateSessionResult) {
|
||||
}
|
||||
}
|
||||
|
||||
function notifyTodosChanged() {
|
||||
window.dispatchEvent(new Event("tickettracker:todos-changed"));
|
||||
}
|
||||
|
||||
function zammadSyncStorageKey(userId: string) {
|
||||
return `tickettracker.zammad-sync.${userId}`;
|
||||
}
|
||||
@@ -259,6 +263,18 @@ function teamspaceBillingKey(day: string, projectTeamspaceEntryId: string | null
|
||||
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 {
|
||||
const entries = projectTeamspaceEntries(project).map((entry, index) => ({
|
||||
clientId: entry.id || projectEntryId(),
|
||||
@@ -824,10 +840,12 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onS
|
||||
}, [periodType, period, ticketId]);
|
||||
|
||||
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(
|
||||
(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)
|
||||
])
|
||||
);
|
||||
@@ -920,11 +938,11 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onS
|
||||
() =>
|
||||
new Map(
|
||||
(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
|
||||
])
|
||||
),
|
||||
[data?.dayBillings, defaultProjectEntryId]
|
||||
[data?.dayBillings, defaultProjectEntryId, requiresProjectTeamspaceEntry]
|
||||
);
|
||||
const savedDayBillingInput = (key: string) => formatHoursInput(dayBillingMap.get(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.`;
|
||||
const notify = result.created === 0 && result.errors.length > 0 ? toast.error : toast.success;
|
||||
notify("Mail-Sessions importiert", { description });
|
||||
if (result.created > 0) {
|
||||
notifyTodosChanged();
|
||||
}
|
||||
await load();
|
||||
} catch (error) {
|
||||
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."
|
||||
});
|
||||
notifyZammadArticleResult(result);
|
||||
notifyTodosChanged();
|
||||
setTicketSession({
|
||||
...emptyTicketSessionForm(),
|
||||
organizationId: ticket.organization_id ?? "",
|
||||
|
||||
@@ -141,6 +141,10 @@ function notifyZammadArticleResult(result: CreateSessionResult) {
|
||||
}
|
||||
}
|
||||
|
||||
function notifyTodosChanged() {
|
||||
window.dispatchEvent(new Event("tickettracker:todos-changed"));
|
||||
}
|
||||
|
||||
function escapeHtml(value: string) {
|
||||
return value
|
||||
.replace(/&/g, "&")
|
||||
@@ -773,6 +777,7 @@ export function TimerPage({ timers, setTimers, selectedTimerId, setSelectedTimer
|
||||
description: `${finished.ticketNumber} wurde mit ${finished.roundedMinutes} Minute(n) erfasst.`
|
||||
});
|
||||
notifyZammadArticleResult(result);
|
||||
notifyTodosChanged();
|
||||
try {
|
||||
const timerResult = await deleteActiveTimer(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."
|
||||
});
|
||||
notifyZammadArticleResult(result);
|
||||
notifyTodosChanged();
|
||||
setManualSession({
|
||||
...emptyManualSession(),
|
||||
day: manualSession.day,
|
||||
|
||||
@@ -21,6 +21,7 @@ type TodosPageProps = {
|
||||
|
||||
type TodoSortMode = "custom" | "priority" | "title" | "ticket" | "organization" | "date" | "updated";
|
||||
type TodoGroupMode = "ticket" | "priority" | "organization" | "status";
|
||||
type CalendarDayMode = "current";
|
||||
|
||||
type TodoPreferences = {
|
||||
status: TodoStatus | "all";
|
||||
@@ -277,6 +278,23 @@ function sortTodos(todos: TicketTodo[], sortMode: TodoSortMode) {
|
||||
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) {
|
||||
const normalizedSearch = normalizeSearch(search);
|
||||
|
||||
@@ -400,9 +418,9 @@ export function TodosPage({ onNavigate }: TodosPageProps) {
|
||||
const [modalEndsOn, setModalEndsOn] = useState("");
|
||||
const [modalBusy, setModalBusy] = useState(false);
|
||||
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 [returnCalendarDayMode, setReturnCalendarDayMode] = useState<"summarized" | "overdue">("summarized");
|
||||
const [returnCalendarDayMode, setReturnCalendarDayMode] = useState<CalendarDayMode>("current");
|
||||
|
||||
async function load() {
|
||||
setLoading(true);
|
||||
@@ -422,6 +440,13 @@ export function TodosPage({ onNavigate }: TodosPageProps) {
|
||||
void load();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const onTodosChanged = () => void load();
|
||||
|
||||
window.addEventListener("tickettracker:todos-changed", onTodosChanged);
|
||||
return () => window.removeEventListener("tickettracker:todos-changed", onTodosChanged);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
window.localStorage.setItem(todoPreferencesKey, JSON.stringify(preferences));
|
||||
}, [preferences]);
|
||||
@@ -432,7 +457,7 @@ export function TodosPage({ onNavigate }: TodosPageProps) {
|
||||
if (selectedTodoId && !selectedTodo) {
|
||||
setSelectedTodoId(null);
|
||||
setReturnCalendarDay(null);
|
||||
setReturnCalendarDayMode("summarized");
|
||||
setReturnCalendarDayMode("current");
|
||||
}
|
||||
}, [selectedTodo, selectedTodoId]);
|
||||
|
||||
@@ -473,7 +498,19 @@ export function TodosPage({ onNavigate }: TodosPageProps) {
|
||||
|
||||
return entries;
|
||||
}, [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(
|
||||
() => ({
|
||||
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);
|
||||
setReturnCalendarDayMode(returnToDayMode);
|
||||
setSelectedTodoId(todo.id);
|
||||
@@ -545,7 +582,7 @@ export function TodosPage({ onNavigate }: TodosPageProps) {
|
||||
function closeTodoModal() {
|
||||
setSelectedTodoId(null);
|
||||
setReturnCalendarDay(null);
|
||||
setReturnCalendarDayMode("summarized");
|
||||
setReturnCalendarDayMode("current");
|
||||
}
|
||||
|
||||
function backToCalendarDay() {
|
||||
@@ -557,7 +594,7 @@ export function TodosPage({ onNavigate }: TodosPageProps) {
|
||||
const mode = returnCalendarDayMode;
|
||||
setSelectedTodoId(null);
|
||||
setReturnCalendarDay(null);
|
||||
setReturnCalendarDayMode("summarized");
|
||||
setReturnCalendarDayMode("current");
|
||||
setSelectedCalendarDayMode(mode);
|
||||
setSelectedCalendarDay(day);
|
||||
}
|
||||
@@ -904,6 +941,9 @@ export function TodosPage({ onNavigate }: TodosPageProps) {
|
||||
const summarizedTodos = dayTodos.summarized;
|
||||
const overdueTodos = dayTodos.overdue;
|
||||
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 (
|
||||
<div
|
||||
@@ -918,7 +958,7 @@ export function TodosPage({ onNavigate }: TodosPageProps) {
|
||||
</div>
|
||||
{!day.workingDay ? <div className="text-[10px] leading-tight text-muted-foreground">{day.holidayName ?? "Wochenende"}</div> : null}
|
||||
<div className="space-y-1">
|
||||
{singleTodos.slice(0, 3).map((todo) => (
|
||||
{visibleCalendarTodos.map((todo) => (
|
||||
<button
|
||||
key={todo.id}
|
||||
type="button"
|
||||
@@ -929,31 +969,18 @@ export function TodosPage({ onNavigate }: TodosPageProps) {
|
||||
<span className="block truncate text-muted-foreground">{todo.ticket_number ?? "Ticket"}</span>
|
||||
</button>
|
||||
))}
|
||||
{singleTodos.length > 3 ? <span className="block px-1 text-[11px] text-muted-foreground">+{singleTodos.length - 3} weitere fixe</span> : null}
|
||||
</div>
|
||||
<div className="mt-auto flex flex-wrap gap-1 pt-1">
|
||||
{summarizedTodos.length > 0 ? (
|
||||
<div className="mt-auto pt-1">
|
||||
{additionalTodosCount > 0 ? (
|
||||
<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={() => {
|
||||
setSelectedCalendarDayMode("summarized");
|
||||
setSelectedCalendarDayMode("current");
|
||||
setSelectedCalendarDay(day.iso);
|
||||
}}
|
||||
>
|
||||
+{summarizedTodos.length} ToDo{summarizedTodos.length === 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
|
||||
+{additionalTodosCount} weitere ToDo{additionalTodosCount === 1 ? "" : "s"}
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
@@ -1073,13 +1100,11 @@ export function TodosPage({ onNavigate }: TodosPageProps) {
|
||||
<Dialog open={Boolean(selectedCalendarDay)} onOpenChange={(open) => !open && setSelectedCalendarDay(null)}>
|
||||
<DialogContent className="max-h-[calc(100dvh-2rem)] overflow-y-auto sm:max-w-xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{selectedCalendarDayMode === "overdue" ? "Überfällige ToDos" : "ToDos am Tag"}</DialogTitle>
|
||||
<DialogTitle>Aktuelle ToDos</DialogTitle>
|
||||
<DialogDescription>
|
||||
{selectedCalendarDay
|
||||
? `${formatTodoDate(selectedCalendarDay)} · ${selectedCalendarDayTodos.length} ${selectedCalendarDayMode === "overdue" ? "überfällige" : "laufende"} ToDo(s)`
|
||||
: selectedCalendarDayMode === "overdue"
|
||||
? "Überfällige ToDos dieses Tages"
|
||||
: "Laufende ToDos dieses Tages"}
|
||||
? `${formatTodoDate(selectedCalendarDay)} · ${selectedCalendarDayTodos.length} aktuelle ToDo(s)`
|
||||
: "Aktuelle ToDos dieses Tages"}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-2">
|
||||
@@ -1142,9 +1167,7 @@ export function TodosPage({ onNavigate }: TodosPageProps) {
|
||||
</div>
|
||||
))}
|
||||
{selectedCalendarDayTodos.length === 0 ? (
|
||||
<p className="rounded-md border bg-muted/20 p-4 text-center text-sm text-muted-foreground">
|
||||
{selectedCalendarDayMode === "overdue" ? "Keine überfälligen ToDos an diesem Tag." : "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 an diesem Tag.</p>
|
||||
) : null}
|
||||
</div>
|
||||
</DialogContent>
|
||||
|
||||
Reference in new issue
Block a user