Tagessteuerung

This commit is contained in:
michael committed 2026-09-16 08:29:58 +00:00
1 parent 560b747d5d
commit 904b123884
8 files changed
+744 -80

No files matched your search

+136 -13
View File
@@ -299,6 +299,7 @@ type ProjectRow = {
work_type: "support" | "consulting" | null;
zammad_priority_id?: string | null;
zammad_state_id?: string | null;
zammad_pending_time?: string | null;
teamspace_project: string | null;
description: string;
goals: string;
@@ -457,6 +458,11 @@ function numberOrNull(value: unknown) {
return Number.isInteger(number) && number > 0 ? number : null;
}
function zammadStateIdOrNull(value: unknown) {
const stateId = numberOrNull(value);
return stateId === 1 ? 2 : stateId;
}
const zammadConsultingGroupId = 2;
const zammadSupportGroupIds = [3, 4];
@@ -699,22 +705,24 @@ function zammadTicketImportFromRaw(rawTicket: Record<string, unknown>, fallbackT
const preferences = isPlainObject(rawTicket.preferences) ? rawTicket.preferences : {};
const teamspaceProjects = parseZammadTeamspaceProjects(zammadPreferenceValue(rawTicket, "project"));
const pendingTime = parseZammadPendingTime(zammadPreferenceValue(rawTicket, "pending_time"));
const storedPreferences = pendingTime ? { ...preferences, pending_time: pendingTime } : preferences;
return {
zammadTicketId,
ticketNumber,
zammadGroupId: groupId,
zammadPriorityId: numberOrNull(rawTicket.priority_id),
zammadStateId: numberOrNull(rawTicket.state_id),
zammadStateId: zammadStateIdOrNull(rawTicket.state_id),
organizationId,
title: typeof rawTicket.title === "string" && rawTicket.title.trim() ? rawTicket.title.trim() : null,
zammadOwnerId: numberOrNull(rawTicket.owner_id),
workType,
preferences,
preferences: storedPreferences,
teamspaceBudgetMinutes: parseZammadBudgetMinutes(zammadPreferenceValue(rawTicket, "gesamtbudget")),
teamspaceProject: teamspaceProjects[0] ?? null,
teamspaceProjects,
pendingTime: parseZammadPendingTime(zammadPreferenceValue(rawTicket, "pending_time"))
pendingTime
};
}
@@ -1622,7 +1630,8 @@ async function getProjectRowForTicket(client: QueryClient, ticketId: string, pro
COALESCE(o.name, t.customer_name) AS customer_name,
t.work_type,
t.zammad_priority_id::text AS zammad_priority_id,
t.zammad_state_id::text AS zammad_state_id,
CASE WHEN t.zammad_state_id = 1 THEN '2' ELSE t.zammad_state_id::text END AS zammad_state_id,
t.zammad_preferences ->> 'pending_time' AS zammad_pending_time,
p.teamspace_project,
p.description,
p.goals,
@@ -2020,7 +2029,12 @@ function syncIdComparableValue(value: unknown) {
}
function syncComparableValueForField(field: string, value: unknown) {
if (field === "Organisation" || field === "Zammad-Priorität" || field === "Zammad-Status") {
if (field === "Zammad-Status") {
const stateId = zammadStateIdOrNull(value);
return stateId === null ? null : String(stateId);
}
if (field === "Organisation" || field === "Zammad-Priorität") {
return syncIdComparableValue(value);
}
@@ -2072,7 +2086,7 @@ function zammadTicketFromSyncSnapshot(snapshot: Record<string, unknown> | null):
const ticketNumber = typeof snapshot.ticketNumber === "string" ? snapshot.ticketNumber : null;
const zammadGroupId = numberOrNull(snapshot.groupId);
const zammadPriorityId = numberOrNull(snapshot.priorityId);
const zammadStateId = numberOrNull(snapshot.stateId);
const zammadStateId = zammadStateIdOrNull(snapshot.stateId);
const organizationId = numberOrNull(snapshot.organizationId);
const zammadOwnerId = numberOrNull(snapshot.ownerId);
const workType = zammadWorkType(zammadGroupId);
@@ -2870,6 +2884,7 @@ async function getPeriodOverview(config: PeriodConfig, period: ParsedPeriod, use
COUNT(*) FILTER (WHERE s.recurring_billing_id IS NOT NULL)::int AS recurring_session_count,
COUNT(*) FILTER (WHERE s.recurring_billing_id IS NULL)::int AS manual_session_count,
COALESCE(SUM(s.rounded_minutes) FILTER (WHERE s.billing_status = 'billed'), 0)::int AS total_minutes,
ticket_crm.crm_billed_minutes,
COALESCE(SUM(s.rounded_minutes) FILTER (WHERE s.billing_status = 'non_billable'), 0)::int AS non_billable_minutes,
COALESCE(SUM(s.rounded_minutes) FILTER (WHERE s.billing_status IS NULL), 0)::int AS open_minutes,
COUNT(*) FILTER (WHERE s.billing_status IS NULL)::int AS open_count,
@@ -2883,6 +2898,14 @@ async function getPeriodOverview(config: PeriodConfig, period: ParsedPeriod, use
JOIN sessions s ON s.ticket_id = t.id
LEFT JOIN organizations o ON o.zammad_id = t.organization_id
LEFT JOIN projects p ON p.ticket_id = t.id AND p.user_id = $3
LEFT JOIN LATERAL (
SELECT COALESCE(SUM(tdb.billed_minutes), 0)::int AS crm_billed_minutes
FROM ticket_day_billings tdb
WHERE tdb.ticket_id = t.id
AND tdb.user_id = $3
AND tdb.day >= ($1::timestamptz AT TIME ZONE 'Europe/Berlin')::date
AND tdb.day < ($2::timestamptz AT TIME ZONE 'Europe/Berlin')::date
) ticket_crm ON true
WHERE s.started_at >= $1::timestamptz AND s.started_at < $2::timestamptz
AND s.user_id = $3
GROUP BY
@@ -2893,7 +2916,8 @@ async function getPeriodOverview(config: PeriodConfig, period: ParsedPeriod, use
o.name,
t.customer_name,
t.work_type,
p.id
p.id,
ticket_crm.crm_billed_minutes
ORDER BY t.ticket_number ASC;
`,
[period.startIso, period.endIso, userId]
@@ -3390,7 +3414,7 @@ async function getPeriodTicket(config: PeriodConfig, period: ParsedPeriod, ticke
COALESCE(o.name, t.customer_name) AS customer_name,
t.work_type,
t.zammad_priority_id::text AS zammad_priority_id,
t.zammad_state_id::text AS zammad_state_id,
CASE WHEN t.zammad_state_id = 1 THEN '2' ELSE t.zammad_state_id::text END AS zammad_state_id,
NULL::timestamptz AS closed_at,
pc.closed_at AS period_closed_at,
COALESCE(period_sessions.session_count, 0)::int AS ticket_session_count,
@@ -3527,7 +3551,7 @@ async function getTicketDetails(ticketId: number, userId: string, filters: { mon
COALESCE(o.name, t.customer_name) AS customer_name,
t.work_type,
t.zammad_priority_id::text AS zammad_priority_id,
t.zammad_state_id::text AS zammad_state_id,
CASE WHEN t.zammad_state_id = 1 THEN '2' ELSE t.zammad_state_id::text END AS zammad_state_id,
NULL::timestamptz AS closed_at,
NULL::timestamptz AS period_closed_at,
COALESCE(all_sessions.session_count, 0)::int AS ticket_session_count,
@@ -3670,7 +3694,7 @@ async function getTicketDetails(ticketId: number, userId: string, filters: { mon
COALESCE(o.name, t.customer_name) AS customer_name,
t.work_type,
t.zammad_priority_id::text AS zammad_priority_id,
t.zammad_state_id::text AS zammad_state_id,
CASE WHEN t.zammad_state_id = 1 THEN '2' ELSE t.zammad_state_id::text END AS zammad_state_id,
p.teamspace_project,
p.description,
p.goals,
@@ -3995,6 +4019,103 @@ app.put("/api/dashboard/preferences", requireUser, async (req, res) => {
res.json({ preference: saved });
});
app.get("/api/dashboard/day-control", requireUser, async (req, res) => {
const userId = currentUser(req).id;
const result = await query(
`
SELECT
t.id::text AS ticket_id,
t.ticket_number,
t.title,
COALESCE(o.name, t.customer_name) AS organization_name,
t.work_type,
t.zammad_priority_id::text AS zammad_priority_id,
CASE WHEN t.zammad_state_id = 1 THEN '2' ELSE t.zammad_state_id::text END AS zammad_state_id,
t.zammad_preferences ->> 'pending_time' AS zammad_pending_time,
MAX(s.started_at) FILTER (WHERE s.user_id = $1)::text AS latest_started_at,
EXISTS (
SELECT 1
FROM projects own_projects
WHERE own_projects.ticket_id = t.id
AND own_projects.user_id = $1
) AS has_project,
COUNT(s.id) FILTER (WHERE s.user_id = $1)::int AS own_session_count,
COUNT(s.id) FILTER (WHERE s.user_id = $1 AND s.billing_status IS NULL)::int AS open_session_count,
COALESCE(todo_stats.open_todo_count, 0)::int AS open_todo_count,
COALESCE(todo_stats.due_todo_count, 0)::int AS due_todo_count
FROM tickets t
LEFT JOIN organizations o ON o.zammad_id = t.organization_id
LEFT JOIN sessions s ON s.ticket_id = t.id
LEFT JOIN LATERAL (
SELECT
COUNT(*) FILTER (WHERE tt.status = 'open')::int AS open_todo_count,
COUNT(*) FILTER (
WHERE tt.status = 'open'
AND (
tt.ends_on < (now() AT TIME ZONE 'Europe/Berlin')::date
OR tt.ends_on = (now() AT TIME ZONE 'Europe/Berlin')::date
OR tt.starts_on = (now() AT TIME ZONE 'Europe/Berlin')::date
OR (tt.starts_on <= (now() AT TIME ZONE 'Europe/Berlin')::date AND tt.ends_on >= (now() AT TIME ZONE 'Europe/Berlin')::date)
)
)::int AS due_todo_count
FROM ticket_todos tt
WHERE tt.ticket_id = t.id
AND tt.user_id = $1
) todo_stats ON true
WHERE (
EXISTS (
SELECT 1
FROM sessions own_sessions
WHERE own_sessions.ticket_id = t.id
AND own_sessions.user_id = $1
)
OR EXISTS (
SELECT 1
FROM projects own_projects
WHERE own_projects.ticket_id = t.id
AND own_projects.user_id = $1
)
OR EXISTS (
SELECT 1
FROM ticket_todos own_todos
WHERE own_todos.ticket_id = t.id
AND own_todos.user_id = $1
)
)
AND (
t.zammad_state_id IN (1, 2, 3, 7)
OR EXISTS (
SELECT 1
FROM sessions open_sessions
WHERE open_sessions.ticket_id = t.id
AND open_sessions.user_id = $1
AND open_sessions.billing_status IS NULL
)
)
GROUP BY
t.id,
t.ticket_number,
t.title,
o.name,
t.customer_name,
t.work_type,
t.zammad_priority_id,
t.zammad_state_id,
t.zammad_preferences,
todo_stats.open_todo_count,
todo_stats.due_todo_count
ORDER BY
CASE t.zammad_priority_id WHEN 3 THEN 0 WHEN 2 THEN 1 WHEN 1 THEN 2 ELSE 3 END,
MAX(s.started_at) FILTER (WHERE s.user_id = $1) DESC NULLS LAST,
t.ticket_number ASC
LIMIT 200;
`,
[userId]
);
res.json({ tickets: result.rows });
});
app.post("/api/dashboard/favorites", requireUser, async (req, res) => {
const userId = currentUser(req).id;
const ticketNumber = parseTrackableTicketNumber(req.body.ticketNumber);
@@ -5360,7 +5481,7 @@ app.get("/api/tickets/lookup", requireUser, async (req, res) => {
t.zammad_ticket_id::text AS zammad_ticket_id,
t.zammad_group_id::text AS zammad_group_id,
t.zammad_priority_id::text AS zammad_priority_id,
t.zammad_state_id::text AS zammad_state_id,
CASE WHEN t.zammad_state_id = 1 THEN '2' ELSE t.zammad_state_id::text END AS zammad_state_id,
t.zammad_owner_id::text AS zammad_owner_id,
p.id::text AS project_id,
(p.id IS NOT NULL) AS is_project,
@@ -6074,7 +6195,8 @@ app.get("/api/projects", requireUser, async (req, res) => {
COALESCE(o.name, t.customer_name) AS customer_name,
t.work_type,
t.zammad_priority_id::text AS zammad_priority_id,
t.zammad_state_id::text AS zammad_state_id,
CASE WHEN t.zammad_state_id = 1 THEN '2' ELSE t.zammad_state_id::text END AS zammad_state_id,
t.zammad_preferences ->> 'pending_time' AS zammad_pending_time,
p.teamspace_project,
p.description,
p.goals,
@@ -6234,7 +6356,8 @@ app.get("/api/projects/:projectId", requireUser, async (req, res) => {
COALESCE(o.name, t.customer_name) AS customer_name,
t.work_type,
t.zammad_priority_id::text AS zammad_priority_id,
t.zammad_state_id::text AS zammad_state_id,
CASE WHEN t.zammad_state_id = 1 THEN '2' ELSE t.zammad_state_id::text END AS zammad_state_id,
t.zammad_preferences ->> 'pending_time' AS zammad_pending_time,
p.teamspace_project,
p.description,
p.goals,
+1
View File
@@ -169,6 +169,7 @@ export async function migrate() {
await query("ALTER TABLE tickets ADD COLUMN IF NOT EXISTS zammad_group_id BIGINT;");
await query("ALTER TABLE tickets ADD COLUMN IF NOT EXISTS zammad_priority_id BIGINT;");
await query("ALTER TABLE tickets ADD COLUMN IF NOT EXISTS zammad_state_id BIGINT;");
await query("UPDATE tickets SET zammad_state_id = 2 WHERE zammad_state_id = 1;");
await query("ALTER TABLE tickets ADD COLUMN IF NOT EXISTS zammad_owner_id BIGINT;");
await query("ALTER TABLE tickets ADD COLUMN IF NOT EXISTS zammad_preferences JSONB NOT NULL DEFAULT '{}'::jsonb;");
await query(`