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