Compare commits

..
4 Commits
Author SHA1 Message Date
michael 040f1903e6 Tag 2026-09-21 09:53:48 +00:00
michael 89429c70cf Tag 2026-09-21 09:46:53 +00:00
michael 904b123884 Tagessteuerung 2026-09-16 08:29:58 +00:00
michael 560b747d5d first 2026-09-16 06:23:26 +00:00
10 changed files with 755 additions and 89 deletions

No files matched your search

+2 -2
View File
@@ -1,5 +1,5 @@
PP_PASSWORD='rabenNest7$' PP_PASSWORD='$ouzs4D$@RAi9'
DATABASE_URL=postgresql://tickettracker:${PP_PASSWORD}@172.22.0.2:5432/tickettracker DATABASE_URL=postgresql://tickettracker:${PP_PASSWORD}@172.22.0.2:5432/tickettracker_dev
APP_PORT=3911 APP_PORT=3911
TICKETTRACKER_IMAGE=git.d-razz.de/michael/tickettracker:latest TICKETTRACKER_IMAGE=git.d-razz.de/michael/tickettracker:latest
COOKIE_SECURE=false COOKIE_SECURE=false
+136 -13
View File
@@ -299,6 +299,7 @@ type ProjectRow = {
work_type: "support" | "consulting" | null; work_type: "support" | "consulting" | null;
zammad_priority_id?: string | null; zammad_priority_id?: string | null;
zammad_state_id?: string | null; zammad_state_id?: string | null;
zammad_pending_time?: string | null;
teamspace_project: string | null; teamspace_project: string | null;
description: string; description: string;
goals: string; goals: string;
@@ -457,6 +458,11 @@ function numberOrNull(value: unknown) {
return Number.isInteger(number) && number > 0 ? number : null; 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 zammadConsultingGroupId = 2;
const zammadSupportGroupIds = [3, 4]; const zammadSupportGroupIds = [3, 4];
@@ -699,22 +705,24 @@ function zammadTicketImportFromRaw(rawTicket: Record<string, unknown>, fallbackT
const preferences = isPlainObject(rawTicket.preferences) ? rawTicket.preferences : {}; const preferences = isPlainObject(rawTicket.preferences) ? rawTicket.preferences : {};
const teamspaceProjects = parseZammadTeamspaceProjects(zammadPreferenceValue(rawTicket, "project")); const teamspaceProjects = parseZammadTeamspaceProjects(zammadPreferenceValue(rawTicket, "project"));
const pendingTime = parseZammadPendingTime(zammadPreferenceValue(rawTicket, "pending_time"));
const storedPreferences = pendingTime ? { ...preferences, pending_time: pendingTime } : preferences;
return { return {
zammadTicketId, zammadTicketId,
ticketNumber, ticketNumber,
zammadGroupId: groupId, zammadGroupId: groupId,
zammadPriorityId: numberOrNull(rawTicket.priority_id), zammadPriorityId: numberOrNull(rawTicket.priority_id),
zammadStateId: numberOrNull(rawTicket.state_id), zammadStateId: zammadStateIdOrNull(rawTicket.state_id),
organizationId, organizationId,
title: typeof rawTicket.title === "string" && rawTicket.title.trim() ? rawTicket.title.trim() : null, title: typeof rawTicket.title === "string" && rawTicket.title.trim() ? rawTicket.title.trim() : null,
zammadOwnerId: numberOrNull(rawTicket.owner_id), zammadOwnerId: numberOrNull(rawTicket.owner_id),
workType, workType,
preferences, preferences: storedPreferences,
teamspaceBudgetMinutes: parseZammadBudgetMinutes(zammadPreferenceValue(rawTicket, "gesamtbudget")), teamspaceBudgetMinutes: parseZammadBudgetMinutes(zammadPreferenceValue(rawTicket, "gesamtbudget")),
teamspaceProject: teamspaceProjects[0] ?? null, teamspaceProject: teamspaceProjects[0] ?? null,
teamspaceProjects, 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, COALESCE(o.name, t.customer_name) AS customer_name,
t.work_type, t.work_type,
t.zammad_priority_id::text AS zammad_priority_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_preferences ->> 'pending_time' AS zammad_pending_time,
p.teamspace_project, p.teamspace_project,
p.description, p.description,
p.goals, p.goals,
@@ -2020,7 +2029,12 @@ function syncIdComparableValue(value: unknown) {
} }
function syncComparableValueForField(field: string, 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); return syncIdComparableValue(value);
} }
@@ -2072,7 +2086,7 @@ function zammadTicketFromSyncSnapshot(snapshot: Record<string, unknown> | null):
const ticketNumber = typeof snapshot.ticketNumber === "string" ? snapshot.ticketNumber : null; const ticketNumber = typeof snapshot.ticketNumber === "string" ? snapshot.ticketNumber : null;
const zammadGroupId = numberOrNull(snapshot.groupId); const zammadGroupId = numberOrNull(snapshot.groupId);
const zammadPriorityId = numberOrNull(snapshot.priorityId); const zammadPriorityId = numberOrNull(snapshot.priorityId);
const zammadStateId = numberOrNull(snapshot.stateId); const zammadStateId = zammadStateIdOrNull(snapshot.stateId);
const organizationId = numberOrNull(snapshot.organizationId); const organizationId = numberOrNull(snapshot.organizationId);
const zammadOwnerId = numberOrNull(snapshot.ownerId); const zammadOwnerId = numberOrNull(snapshot.ownerId);
const workType = zammadWorkType(zammadGroupId); 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 NOT NULL)::int AS recurring_session_count,
COUNT(*) FILTER (WHERE s.recurring_billing_id IS NULL)::int AS manual_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, 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 = '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, 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, 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 JOIN sessions s ON s.ticket_id = t.id
LEFT JOIN organizations o ON o.zammad_id = t.organization_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 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 WHERE s.started_at >= $1::timestamptz AND s.started_at < $2::timestamptz
AND s.user_id = $3 AND s.user_id = $3
GROUP BY GROUP BY
@@ -2893,7 +2916,8 @@ async function getPeriodOverview(config: PeriodConfig, period: ParsedPeriod, use
o.name, o.name,
t.customer_name, t.customer_name,
t.work_type, t.work_type,
p.id p.id,
ticket_crm.crm_billed_minutes
ORDER BY t.ticket_number ASC; ORDER BY t.ticket_number ASC;
`, `,
[period.startIso, period.endIso, userId] [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, COALESCE(o.name, t.customer_name) AS customer_name,
t.work_type, t.work_type,
t.zammad_priority_id::text AS zammad_priority_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,
NULL::timestamptz AS closed_at, NULL::timestamptz AS closed_at,
pc.closed_at AS period_closed_at, pc.closed_at AS period_closed_at,
COALESCE(period_sessions.session_count, 0)::int AS ticket_session_count, 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, COALESCE(o.name, t.customer_name) AS customer_name,
t.work_type, t.work_type,
t.zammad_priority_id::text AS zammad_priority_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,
NULL::timestamptz AS closed_at, NULL::timestamptz AS closed_at,
NULL::timestamptz AS period_closed_at, NULL::timestamptz AS period_closed_at,
COALESCE(all_sessions.session_count, 0)::int AS ticket_session_count, 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, COALESCE(o.name, t.customer_name) AS customer_name,
t.work_type, t.work_type,
t.zammad_priority_id::text AS zammad_priority_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,
p.teamspace_project, p.teamspace_project,
p.description, p.description,
p.goals, p.goals,
@@ -3995,6 +4019,103 @@ app.put("/api/dashboard/preferences", requireUser, async (req, res) => {
res.json({ preference: saved }); 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) => { app.post("/api/dashboard/favorites", requireUser, async (req, res) => {
const userId = currentUser(req).id; const userId = currentUser(req).id;
const ticketNumber = parseTrackableTicketNumber(req.body.ticketNumber); 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_ticket_id::text AS zammad_ticket_id,
t.zammad_group_id::text AS zammad_group_id, t.zammad_group_id::text AS zammad_group_id,
t.zammad_priority_id::text AS zammad_priority_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, t.zammad_owner_id::text AS zammad_owner_id,
p.id::text AS project_id, p.id::text AS project_id,
(p.id IS NOT NULL) AS is_project, (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, COALESCE(o.name, t.customer_name) AS customer_name,
t.work_type, t.work_type,
t.zammad_priority_id::text AS zammad_priority_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_preferences ->> 'pending_time' AS zammad_pending_time,
p.teamspace_project, p.teamspace_project,
p.description, p.description,
p.goals, p.goals,
@@ -6234,7 +6356,8 @@ app.get("/api/projects/:projectId", requireUser, async (req, res) => {
COALESCE(o.name, t.customer_name) AS customer_name, COALESCE(o.name, t.customer_name) AS customer_name,
t.work_type, t.work_type,
t.zammad_priority_id::text AS zammad_priority_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_preferences ->> 'pending_time' AS zammad_pending_time,
p.teamspace_project, p.teamspace_project,
p.description, p.description,
p.goals, 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_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_priority_id BIGINT;");
await query("ALTER TABLE tickets ADD COLUMN IF NOT EXISTS zammad_state_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_owner_id BIGINT;");
await query("ALTER TABLE tickets ADD COLUMN IF NOT EXISTS zammad_preferences JSONB NOT NULL DEFAULT '{}'::jsonb;"); await query("ALTER TABLE tickets ADD COLUMN IF NOT EXISTS zammad_preferences JSONB NOT NULL DEFAULT '{}'::jsonb;");
await query(` await query(`
+1 -1
View File
@@ -1378,7 +1378,7 @@ export function App() {
<SidebarMenuItem> <SidebarMenuItem>
<SidebarMenuButton tooltip="Worklog"> <SidebarMenuButton tooltip="Worklog">
<FolderGit /> <FolderGit />
<span className="text-xs text-muted-foreground">v1.0.0-rc1</span> <span className="text-xs text-muted-foreground">v1.0.1-rc1</span>
</SidebarMenuButton> </SidebarMenuButton>
</SidebarMenuItem> </SidebarMenuItem>
</SidebarMenu> </SidebarMenu>
+5
View File
@@ -2,6 +2,7 @@ import type {
AdminUser, AdminUser,
AuthUser, AuthUser,
BillingStatus, BillingStatus,
DashboardDayTicket,
DashboardPreference, DashboardPreference,
Organization, Organization,
PeriodOverview, PeriodOverview,
@@ -207,6 +208,10 @@ export function getDashboardPreference() {
return request<{ preference: Partial<DashboardPreference> }>("/api/dashboard/preferences"); return request<{ preference: Partial<DashboardPreference> }>("/api/dashboard/preferences");
} }
export function getDashboardDayControl() {
return request<{ tickets: DashboardDayTicket[] }>("/api/dashboard/day-control");
}
export function saveDashboardPreference(preference: DashboardPreference) { export function saveDashboardPreference(preference: DashboardPreference) {
return request<{ preference: Partial<DashboardPreference> }>("/api/dashboard/preferences", { return request<{ preference: Partial<DashboardPreference> }>("/api/dashboard/preferences", {
method: "PUT", method: "PUT",
+20 -1
View File
@@ -14,7 +14,7 @@ export type AuthUser = {
has_zammad_api_token: boolean; has_zammad_api_token: boolean;
}; };
export type DashboardWidgetType = "today" | "month" | "openRatings" | "projectBudgets" | "todos" | "teamspace" | "favorites" | "divider" | "statistic" | "notes"; export type DashboardWidgetType = "dayControl" | "today" | "month" | "openRatings" | "projectBudgets" | "todos" | "teamspace" | "favorites" | "divider" | "statistic" | "notes";
export type DashboardSortDirection = "asc" | "desc"; export type DashboardSortDirection = "asc" | "desc";
export type DashboardFavorite = { export type DashboardFavorite = {
@@ -40,6 +40,23 @@ export type DashboardPreference = {
favorites: DashboardFavorite[]; favorites: DashboardFavorite[];
}; };
export type DashboardDayTicket = {
ticket_id: string;
ticket_number: string;
title: string | null;
organization_name: string | null;
work_type: WorkType | null;
zammad_priority_id: string | null;
zammad_state_id: string | null;
zammad_pending_time: string | null;
latest_started_at: string | null;
has_project: boolean;
own_session_count: number;
open_session_count: number;
open_todo_count: number;
due_todo_count: number;
};
export type AdminUser = AuthUser & { export type AdminUser = AuthUser & {
created_at: string; created_at: string;
updated_at: string; updated_at: string;
@@ -138,6 +155,7 @@ export type ProjectSummary = {
work_type: WorkType | null; work_type: WorkType | null;
zammad_priority_id?: string | null; zammad_priority_id?: string | null;
zammad_state_id?: string | null; zammad_state_id?: string | null;
zammad_pending_time?: string | null;
teamspace_project: string | null; teamspace_project: string | null;
description: string; description: string;
goals: string; goals: string;
@@ -211,6 +229,7 @@ export type TicketSummary = {
project_id: string | null; project_id: string | null;
session_count: number; session_count: number;
total_minutes: number; total_minutes: number;
crm_billed_minutes: number;
non_billable_minutes: number; non_billable_minutes: number;
open_minutes: number; open_minutes: number;
open_count: number; open_count: number;
+572 -64
View File
@@ -1,6 +1,8 @@
import { import {
ArrowDown, ArrowDown,
ArrowUp, ArrowUp,
Bell,
CalendarCheck,
CheckCircle2, CheckCircle2,
ChevronsUpDown, ChevronsUpDown,
ChevronLeft, ChevronLeft,
@@ -18,6 +20,7 @@ import {
Star, Star,
Timer, Timer,
Trash2, Trash2,
WalletCards,
X X
} from "lucide-react"; } from "lucide-react";
import { useEffect, useMemo, useRef, useState } from "react"; import { useEffect, useMemo, useRef, useState } from "react";
@@ -35,6 +38,7 @@ import { Textarea } from "@/components/ui/textarea";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { import {
addFavoriteTicket, addFavoriteTicket,
getDashboardDayControl,
getDashboardPreference, getDashboardPreference,
getPeriodOverview, getPeriodOverview,
getProjects, getProjects,
@@ -58,6 +62,7 @@ import {
zammadStateSortRank zammadStateSortRank
} from "../zammad-meta"; } from "../zammad-meta";
import type { import type {
DashboardDayTicket,
DashboardFavorite, DashboardFavorite,
DashboardPreference, DashboardPreference,
DashboardSortDirection, DashboardSortDirection,
@@ -82,6 +87,7 @@ type WidgetDefinition = {
defaultSize: { w: number; h: number }; defaultSize: { w: number; h: number };
minSize?: { w: number; h: number }; minSize?: { w: number; h: number };
multiple?: boolean; multiple?: boolean;
fixed?: boolean;
}; };
type DashboardNote = { type DashboardNote = {
@@ -93,8 +99,11 @@ type DashboardNote = {
const dashboardPreferenceVersion = 3; const dashboardPreferenceVersion = 3;
const rowHeight = 48; const rowHeight = 48;
const gridColumns = 12; const gridColumns = 12;
const maxWidgetHeight = 40;
const maxWidgetY = 120;
const widgetDefinitions: WidgetDefinition[] = [ const widgetDefinitions: WidgetDefinition[] = [
{ type: "dayControl", title: "Tagessteuerung", defaultSize: { w: 6, h: 5 }, minSize: { w: 4, h: 4 }, fixed: true },
{ type: "today", title: "Heute", defaultSize: { w: 2, h: 2 }, multiple: true }, { type: "today", title: "Heute", defaultSize: { w: 2, h: 2 }, multiple: true },
{ type: "month", title: "Monat", defaultSize: { w: 2, h: 2 }, multiple: true }, { type: "month", title: "Monat", defaultSize: { w: 2, h: 2 }, multiple: true },
{ type: "openRatings", title: "Bewertungen", defaultSize: { w: 3, h: 3 }, multiple: true }, { type: "openRatings", title: "Bewertungen", defaultSize: { w: 3, h: 3 }, multiple: true },
@@ -108,14 +117,15 @@ const widgetDefinitions: WidgetDefinition[] = [
]; ];
const defaultWidgets: DashboardWidget[] = [ const defaultWidgets: DashboardWidget[] = [
{ id: "today", type: "today", x: 0, y: 0, w: 2, h: 2 }, { id: "day-control", type: "dayControl", x: 0, y: 0, w: 6, h: 5 },
{ id: "month", type: "month", x: 2, y: 0, w: 2, h: 2 }, { id: "today", type: "today", x: 6, y: 0, w: 2, h: 2 },
{ id: "teamspace", type: "teamspace", x: 4, y: 0, w: 2, h: 2 }, { id: "month", type: "month", x: 8, y: 0, w: 2, h: 2 },
{ id: "open-ratings", type: "openRatings", x: 6, y: 0, w: 3, h: 3 }, { id: "teamspace", type: "teamspace", x: 10, y: 0, w: 2, h: 2 },
{ id: "todos", type: "todos", x: 9, y: 0, w: 3, h: 3, config: { sortBy: "due", sortDirection: "asc" } }, { id: "open-ratings", type: "openRatings", x: 6, y: 2, w: 3, h: 3 },
{ id: "project-budgets", type: "projectBudgets", x: 0, y: 2, w: 6, h: 3, config: { sortBy: "remaining", sortDirection: "asc" } }, { id: "todos", type: "todos", x: 9, y: 2, w: 3, h: 3, config: { sortBy: "due", sortDirection: "asc" } },
{ id: "favorites", type: "favorites", x: 6, y: 3, w: 3, h: 3, config: { sortBy: "custom", sortDirection: "asc" } }, { id: "project-budgets", type: "projectBudgets", x: 0, y: 5, w: 6, h: 3, config: { sortBy: "remaining", sortDirection: "asc" } },
{ id: "statistic", type: "statistic", x: 9, y: 3, w: 3, h: 3, config: { view: "dailyBars", sortDirection: "desc" } } { id: "favorites", type: "favorites", x: 6, y: 5, w: 3, h: 3, config: { sortBy: "custom", sortDirection: "asc" } },
{ id: "statistic", type: "statistic", x: 9, y: 5, w: 3, h: 3, config: { view: "dailyBars", sortDirection: "desc" } }
]; ];
function widgetTitle(type: DashboardWidgetType) { function widgetTitle(type: DashboardWidgetType) {
@@ -134,6 +144,10 @@ function widgetSupportsMultiple(type: DashboardWidgetType) {
return widgetDefinition(type)?.multiple === true; return widgetDefinition(type)?.multiple === true;
} }
function widgetIsFixed(type: DashboardWidgetType) {
return widgetDefinition(type)?.fixed === true;
}
function defaultWidgetConfig(type: DashboardWidgetType): Record<string, unknown> { function defaultWidgetConfig(type: DashboardWidgetType): Record<string, unknown> {
if (type === "todos") { if (type === "todos") {
return { filterValues: ["open"], sortBy: "due", sortDirection: "asc" }; return { filterValues: ["open"], sortBy: "due", sortDirection: "asc" };
@@ -247,34 +261,52 @@ function normalizePreference(preference: Partial<DashboardPreference>): Dashboar
const widgets = preferenceVersion === dashboardPreferenceVersion && Array.isArray(preference.widgets) ? preference.widgets : cloneDefaultWidgets(); const widgets = preferenceVersion === dashboardPreferenceVersion && Array.isArray(preference.widgets) ? preference.widgets : cloneDefaultWidgets();
const favorites = Array.isArray(preference.favorites) ? preference.favorites : []; const favorites = Array.isArray(preference.favorites) ? preference.favorites : [];
const seenTypes = new Set<DashboardWidgetType>(); const seenTypes = new Set<DashboardWidgetType>();
const normalizedWidgets = widgets
.filter((widget) => {
const known = widgetDefinitions.some((definition) => definition.type === widget.type);
if (!known || (!widgetSupportsMultiple(widget.type) && seenTypes.has(widget.type))) {
return false;
}
if (!widgetSupportsMultiple(widget.type)) {
seenTypes.add(widget.type);
}
return true;
})
.map((widget, index) => ({
id: widget.id || `${widget.type}-${index}`,
type: widget.type,
title: typeof widget.title === "string" ? widget.title : widgetTitle(widget.type),
x: clampNumber(widget.x, 0, gridColumns - 1),
y: clampNumber(widget.y, 0, maxWidgetY),
w: clampNumber(widget.w, widgetMinSize(widget.type).w, gridColumns),
h: clampNumber(widget.h, widgetMinSize(widget.type).h, maxWidgetHeight),
config: normalizeWidgetConfig(widget.type, widget.config)
}))
.reduce<DashboardWidget[]>((placed, widget) => [...placed, resolveWidgetPlacement(placed, widget)], []);
for (const definition of widgetDefinitions.filter((item) => item.fixed)) {
if (!normalizedWidgets.some((widget) => widget.type === definition.type)) {
normalizedWidgets.push(
resolveWidgetPlacement(normalizedWidgets, {
id: definition.type,
type: definition.type,
title: definition.title,
x: 0,
y: Math.max(0, ...normalizedWidgets.map((widget) => widget.y + widget.h)),
w: definition.defaultSize.w,
h: definition.defaultSize.h,
config: defaultWidgetConfig(definition.type)
})
);
}
}
return { return {
version: dashboardPreferenceVersion, version: dashboardPreferenceVersion,
widgets: widgets widgets: normalizedWidgets,
.filter((widget) => {
const known = widgetDefinitions.some((definition) => definition.type === widget.type);
if (!known || (!widgetSupportsMultiple(widget.type) && seenTypes.has(widget.type))) {
return false;
}
if (!widgetSupportsMultiple(widget.type)) {
seenTypes.add(widget.type);
}
return true;
})
.map((widget, index) => ({
id: widget.id || `${widget.type}-${index}`,
type: widget.type,
title: typeof widget.title === "string" ? widget.title : widgetTitle(widget.type),
x: clampNumber(widget.x, 0, gridColumns - 1),
y: clampNumber(widget.y, 0, 30),
w: clampNumber(widget.w, widgetMinSize(widget.type).w, gridColumns),
h: clampNumber(widget.h, widgetMinSize(widget.type).h, 8),
config: normalizeWidgetConfig(widget.type, widget.config)
}))
.reduce<DashboardWidget[]>((placed, widget) => [...placed, resolveWidgetPlacement(placed, widget)], []),
favorites: favorites.map((favorite, index) => ({ favorites: favorites.map((favorite, index) => ({
ticketNumber: favorite.ticketNumber, ticketNumber: favorite.ticketNumber,
addedAt: favorite.addedAt || new Date().toISOString(), addedAt: favorite.addedAt || new Date().toISOString(),
@@ -295,30 +327,38 @@ function overlaps(left: DashboardWidget, right: DashboardWidget) {
return left.x < right.x + right.w && left.x + left.w > right.x && left.y < right.y + right.h && left.y + left.h > right.y; return left.x < right.x + right.w && left.x + left.w > right.x && left.y < right.y + right.h && left.y + left.h > right.y;
} }
function resolveWidgetPlacement(widgets: DashboardWidget[], changed: DashboardWidget) { function columnsOverlap(left: DashboardWidget, right: DashboardWidget) {
return left.x < right.x + right.w && left.x + left.w > right.x;
}
function normalizeWidgetPlacement(changed: DashboardWidget) {
const minSize = widgetMinSize(changed.type); const minSize = widgetMinSize(changed.type);
const w = clampNumber(changed.w, minSize.w, gridColumns); const w = clampNumber(changed.w, minSize.w, gridColumns);
const h = clampNumber(changed.h, minSize.h, 8); const h = clampNumber(changed.h, minSize.h, maxWidgetHeight);
const desiredY = clampNumber(changed.y, 0, 80);
const next = { return {
...changed, ...changed,
w, w,
h, h,
x: clampNumber(changed.x, 0, gridColumns - w), x: clampNumber(changed.x, 0, gridColumns - w),
y: desiredY y: clampNumber(changed.y, 0, maxWidgetY)
}; };
}
function resolveWidgetPlacement(widgets: DashboardWidget[], changed: DashboardWidget) {
const next = normalizeWidgetPlacement(changed);
const hasCollision = (y: number) => widgets.some((widget) => widget.id !== next.id && overlaps(widget, { ...next, y })); const hasCollision = (y: number) => widgets.some((widget) => widget.id !== next.id && overlaps(widget, { ...next, y }));
if (!hasCollision(desiredY)) { if (!hasCollision(next.y)) {
return next; return next;
} }
const maxExistingRow = Math.max(80, ...widgets.filter((widget) => widget.id !== next.id).map((widget) => widget.y + widget.h + h)); const maxExistingRow = Math.max(maxWidgetY, ...widgets.filter((widget) => widget.id !== next.id).map((widget) => widget.y + widget.h + next.h));
for (let distance = 1; distance <= maxExistingRow + h; distance += 1) { for (let distance = 1; distance <= maxExistingRow + next.h; distance += 1) {
const yAbove = desiredY - distance; const yAbove = next.y - distance;
const yBelow = desiredY + distance; const yBelow = next.y + distance;
if (yAbove >= 0 && !hasCollision(yAbove)) { if (yAbove >= 0 && !hasCollision(yAbove)) {
return { ...next, y: yAbove }; return { ...next, y: yAbove };
@@ -329,15 +369,102 @@ function resolveWidgetPlacement(widgets: DashboardWidget[], changed: DashboardWi
} }
} }
return { ...next, y: maxExistingRow + h }; return { ...next, y: maxExistingRow + next.h };
}
function moveCollidingWidgetsDown(widgets: DashboardWidget[], lockedWidgetId: string) {
const next = widgets.map((widget) => ({ ...widget }));
for (let guard = 0; guard < 500; guard += 1) {
let moved = false;
const sorted = [...next].sort((left, right) => {
if (left.id === lockedWidgetId && right.id !== lockedWidgetId) {
return -1;
}
if (right.id === lockedWidgetId && left.id !== lockedWidgetId) {
return 1;
}
return compareNumber(left.y, right.y) || compareNumber(left.x, right.x) || compareText(left.id, right.id);
});
for (let index = 0; index < sorted.length; index += 1) {
for (let otherIndex = index + 1; otherIndex < sorted.length; otherIndex += 1) {
const left = sorted[index];
const right = sorted[otherIndex];
if (!overlaps(left, right)) {
continue;
}
const anchor =
left.id === lockedWidgetId
? left
: right.id === lockedWidgetId
? right
: left.y < right.y || (left.y === right.y && left.x <= right.x)
? left
: right;
const mover = anchor.id === left.id ? right : left;
const movedWidget = next.find((widget) => widget.id === mover.id);
if (movedWidget && movedWidget.y < anchor.y + anchor.h) {
movedWidget.y = anchor.y + anchor.h;
moved = true;
}
}
}
if (!moved) {
return next;
}
}
return next;
}
function compactWidgetsUp(widgets: DashboardWidget[], lockedWidgetId: string) {
const next = widgets.map((widget) => ({ ...widget }));
const lockedWidget = next.find((widget) => widget.id === lockedWidgetId) ?? null;
const movingWidgets = next
.filter((widget) => widget.id !== lockedWidgetId)
.sort((left, right) => compareNumber(left.y, right.y) || compareNumber(left.x, right.x) || compareText(left.id, right.id));
for (const widget of movingWidgets) {
const minY = lockedWidget && columnsOverlap(widget, lockedWidget) && widget.y >= lockedWidget.y + lockedWidget.h ? lockedWidget.y + lockedWidget.h : 0;
for (let y = minY; y < widget.y; y += 1) {
const candidate = { ...widget, y };
const collides = next.some((other) => other.id !== widget.id && overlaps(other, candidate));
if (!collides) {
widget.y = y;
break;
}
}
}
return next;
}
function layoutWidgetsWithPlacement(widgets: DashboardWidget[], changed: DashboardWidget) {
const next = normalizeWidgetPlacement(changed);
const hasWidget = widgets.some((widget) => widget.id === next.id);
const withChanged = hasWidget
? widgets.map((widget) => (widget.id === next.id ? next : normalizeWidgetPlacement(widget)))
: [...widgets.map((widget) => normalizeWidgetPlacement(widget)), next];
const pushed = moveCollidingWidgetsDown(withChanged, next.id);
const compacted = compactWidgetsUp(pushed, next.id);
return moveCollidingWidgetsDown(compacted, next.id).map((widget) => ({ ...widget, y: clampNumber(widget.y, 0, 120) }));
} }
function placeWidget(widgets: DashboardWidget[], changed: DashboardWidget) { function placeWidget(widgets: DashboardWidget[], changed: DashboardWidget) {
const next = resolveWidgetPlacement(widgets, changed); return layoutWidgetsWithPlacement(widgets, changed);
return widgets.map((widget) => (widget.id === next.id ? next : widget));
} }
function widgetPlacementFromPoint(gridElement: HTMLDivElement, widgets: DashboardWidget[], widget: DashboardWidget, clientX: number, clientY: number) { function widgetPlacementFromPoint(gridElement: HTMLDivElement, widget: DashboardWidget, clientX: number, clientY: number) {
const rect = gridElement.getBoundingClientRect(); const rect = gridElement.getBoundingClientRect();
const styles = window.getComputedStyle(gridElement); const styles = window.getComputedStyle(gridElement);
const columnGap = Number.parseFloat(styles.columnGap) || 0; const columnGap = Number.parseFloat(styles.columnGap) || 0;
@@ -345,10 +472,10 @@ function widgetPlacementFromPoint(gridElement: HTMLDivElement, widgets: Dashboar
const columnStep = (rect.width + columnGap) / gridColumns; const columnStep = (rect.width + columnGap) / gridColumns;
const rowStep = rowHeight + rowGap; const rowStep = rowHeight + rowGap;
return resolveWidgetPlacement(widgets, { return normalizeWidgetPlacement({
...widget, ...widget,
x: clampNumber(Math.floor((clientX - rect.left) / columnStep), 0, gridColumns - widget.w), x: clampNumber(Math.floor((clientX - rect.left) / columnStep), 0, gridColumns - widget.w),
y: clampNumber(Math.floor((clientY - rect.top) / rowStep), 0, 80) y: clampNumber(Math.floor((clientY - rect.top) / rowStep), 0, maxWidgetY)
}); });
} }
@@ -403,6 +530,20 @@ function compareOptionalTimestamp(left: string | null | undefined, right: string
return compareNumber(leftTime, rightTime) * directionFactor(direction); return compareNumber(leftTime, rightTime) * directionFactor(direction);
} }
function pendingDay(value: string | null | undefined) {
if (!value) {
return null;
}
const date = new Date(value);
if (Number.isNaN(date.getTime())) {
return null;
}
return value.slice(0, 10);
}
function stableSort<T>(items: T[], compare: (left: T, right: T) => number) { function stableSort<T>(items: T[], compare: (left: T, right: T) => number) {
return items return items
.map((item, index) => ({ item, index })) .map((item, index) => ({ item, index }))
@@ -753,6 +894,7 @@ export function DashboardPage({ onNavigate }: DashboardPageProps) {
const [statisticsOverview, setStatisticsOverview] = useState<StatisticsOverview | null>(null); const [statisticsOverview, setStatisticsOverview] = useState<StatisticsOverview | null>(null);
const [projects, setProjects] = useState<ProjectSummary[]>([]); const [projects, setProjects] = useState<ProjectSummary[]>([]);
const [todos, setTodos] = useState<TicketTodo[]>([]); const [todos, setTodos] = useState<TicketTodo[]>([]);
const [dayControlTickets, setDayControlTickets] = useState<DashboardDayTicket[]>([]);
const [recurringBillings, setRecurringBillings] = useState<RecurringBilling[]>([]); const [recurringBillings, setRecurringBillings] = useState<RecurringBilling[]>([]);
const [favoriteMetas, setFavoriteMetas] = useState<Record<string, TicketMeta | null>>({}); const [favoriteMetas, setFavoriteMetas] = useState<Record<string, TicketMeta | null>>({});
const [favoriteInputs, setFavoriteInputs] = useState<Record<string, string>>({}); const [favoriteInputs, setFavoriteInputs] = useState<Record<string, string>>({});
@@ -764,8 +906,9 @@ export function DashboardPage({ onNavigate }: DashboardPageProps) {
setLoading(true); setLoading(true);
try { try {
const [dashboardResult, monthResult, todayResult, statisticsResult, projectsResult, todosResult, recurringResult] = await Promise.all([ const [dashboardResult, dayControlResult, monthResult, todayResult, statisticsResult, projectsResult, todosResult, recurringResult] = await Promise.all([
getDashboardPreference(), getDashboardPreference(),
getDashboardDayControl(),
getPeriodOverview("month", currentMonth()), getPeriodOverview("month", currentMonth()),
getPeriodOverview("day", currentDay()), getPeriodOverview("day", currentDay()),
getStatisticsOverview(currentMonth()), getStatisticsOverview(currentMonth()),
@@ -779,6 +922,7 @@ export function DashboardPage({ onNavigate }: DashboardPageProps) {
} }
setPreference(normalizePreference(dashboardResult.preference)); setPreference(normalizePreference(dashboardResult.preference));
setDayControlTickets(dayControlResult.tickets);
setMonthOverview(monthResult); setMonthOverview(monthResult);
setTodayOverview(todayResult); setTodayOverview(todayResult);
setStatisticsOverview(statisticsResult); setStatisticsOverview(statisticsResult);
@@ -899,6 +1043,9 @@ export function DashboardPage({ onNavigate }: DashboardPageProps) {
dayTotals, dayTotals,
statistics: statisticsOverview, statistics: statisticsOverview,
openRatings: monthOverview?.openSessions ?? [], openRatings: monthOverview?.openSessions ?? [],
todayTickets: todayOverview?.tickets ?? [],
todayOpenRatings: todayOverview?.openSessions ?? [],
dayControlTickets,
fixedBillingTickets, fixedBillingTickets,
teamspaceDelta: (monthTotals?.minutes ?? 0) - (monthTotals?.crmBilledMinutes ?? 0), teamspaceDelta: (monthTotals?.minutes ?? 0) - (monthTotals?.crmBilledMinutes ?? 0),
overBudget, overBudget,
@@ -906,7 +1053,7 @@ export function DashboardPage({ onNavigate }: DashboardPageProps) {
openTodos: todos.filter((todo) => todo.status === "open"), openTodos: todos.filter((todo) => todo.status === "open"),
overdueTodos: todos.filter((todo) => todo.status === "open" && todo.ends_on && todo.ends_on < currentDay()) overdueTodos: todos.filter((todo) => todo.status === "open" && todo.ends_on && todo.ends_on < currentDay())
}; };
}, [monthOverview, projects, recurringBillings, statisticsOverview, todayOverview, todos]); }, [dayControlTickets, monthOverview, projects, recurringBillings, statisticsOverview, todayOverview, todos]);
function updateWidget(widgetId: string, changes: Partial<DashboardWidget>) { function updateWidget(widgetId: string, changes: Partial<DashboardWidget>) {
setPreference((current) => ({ setPreference((current) => ({
@@ -960,7 +1107,7 @@ export function DashboardPage({ onNavigate }: DashboardPageProps) {
function removeWidget(widgetId: string) { function removeWidget(widgetId: string) {
setPreference((current) => ({ setPreference((current) => ({
...current, ...current,
widgets: current.widgets.filter((widget) => widget.id !== widgetId) widgets: current.widgets.filter((widget) => widget.id !== widgetId || widgetIsFixed(widget.type))
})); }));
} }
@@ -977,7 +1124,7 @@ export function DashboardPage({ onNavigate }: DashboardPageProps) {
const next = dragPreview?.id === widget.id const next = dragPreview?.id === widget.id
? dragPreview ? dragPreview
: widgetPlacementFromPoint(gridRef.current, preference.widgets, widget, clientX, clientY); : widgetPlacementFromPoint(gridRef.current, widget, clientX, clientY);
setPreference((current) => ({ setPreference((current) => ({
...current, ...current,
@@ -998,7 +1145,7 @@ export function DashboardPage({ onNavigate }: DashboardPageProps) {
return; return;
} }
setDragPreview(widgetPlacementFromPoint(gridRef.current, preference.widgets, widget, clientX, clientY)); setDragPreview(widgetPlacementFromPoint(gridRef.current, widget, clientX, clientY));
} }
function startResize(event: PointerEvent<HTMLButtonElement>, widget: DashboardWidget) { function startResize(event: PointerEvent<HTMLButtonElement>, widget: DashboardWidget) {
@@ -1016,7 +1163,7 @@ export function DashboardPage({ onNavigate }: DashboardPageProps) {
function onMove(moveEvent: globalThis.PointerEvent) { function onMove(moveEvent: globalThis.PointerEvent) {
updateWidget(widget.id, { updateWidget(widget.id, {
w: clampNumber(startW + Math.round((moveEvent.clientX - startX) / columnWidth), minSize.w, gridColumns - widget.x), w: clampNumber(startW + Math.round((moveEvent.clientX - startX) / columnWidth), minSize.w, gridColumns - widget.x),
h: clampNumber(startH + Math.round((moveEvent.clientY - startY) / rowHeight), minSize.h, 8) h: clampNumber(startH + Math.round((moveEvent.clientY - startY) / rowHeight), minSize.h, maxWidgetHeight)
}); });
} }
@@ -1110,10 +1257,10 @@ export function DashboardPage({ onNavigate }: DashboardPageProps) {
size="sm" size="sm"
variant={active ? "outline" : "secondary"} variant={active ? "outline" : "secondary"}
disabled={active} disabled={active}
title={active ? "Bereits im Dashboard" : "Widget hinzufügen"} title={definition.fixed ? "Festes Widget" : active ? "Bereits im Dashboard" : "Widget hinzufügen"}
onClick={() => addWidget(definition.type)} onClick={() => addWidget(definition.type)}
> >
{definition.type === "divider" ? <Minus className="size-3.5" /> : definition.type === "notes" ? <FileText className="size-3.5" /> : <Plus className="size-3.5" />} {definition.type === "divider" ? <Minus className="size-3.5" /> : definition.type === "notes" ? <FileText className="size-3.5" /> : definition.type === "dayControl" ? <CalendarCheck className="size-3.5" /> : <Plus className="size-3.5" />}
{definition.title} {definition.title}
</Button> </Button>
); );
@@ -1237,6 +1384,9 @@ function useDashboardStatsPlaceholder() {
teamspaceDelta: 0, teamspaceDelta: 0,
overBudget: [] as ProjectSummary[], overBudget: [] as ProjectSummary[],
nearlyFull: [] as ProjectSummary[], nearlyFull: [] as ProjectSummary[],
todayTickets: [] as PeriodOverview["tickets"],
todayOpenRatings: [] as PeriodOverview["openSessions"],
dayControlTickets: [] as DashboardDayTicket[],
openTodos: [] as TicketTodo[], openTodos: [] as TicketTodo[],
overdueTodos: [] as TicketTodo[] overdueTodos: [] as TicketTodo[]
}; };
@@ -1264,6 +1414,8 @@ function DashboardWidgetCard({
onDragEnd, onDragEnd,
onResizeStart onResizeStart
}: DashboardWidgetCardProps) { }: DashboardWidgetCardProps) {
const fixed = widgetIsFixed(widget.type);
if (widget.type === "divider") { if (widget.type === "divider") {
return ( return (
<div <div
@@ -1282,9 +1434,11 @@ function DashboardWidgetCard({
<button type="button" draggable className="rounded p-0.5 text-muted-foreground hover:bg-muted hover:text-foreground" onDragStart={onDragStart} onDragEnd={onDragEnd}> <button type="button" draggable className="rounded p-0.5 text-muted-foreground hover:bg-muted hover:text-foreground" onDragStart={onDragStart} onDragEnd={onDragEnd}>
<GripVertical className="size-3.5" /> <GripVertical className="size-3.5" />
</button> </button>
<Button type="button" size="icon" variant="ghost" className="size-6" onClick={onRemove}> {!fixed ? (
<X className="size-3.5" /> <Button type="button" size="icon" variant="ghost" className="size-6" onClick={onRemove}>
</Button> <X className="size-3.5" />
</Button>
) : null}
</div> </div>
<button type="button" className="absolute bottom-1 right-1 rounded bg-background/90 p-1 text-muted-foreground shadow-sm hover:bg-muted hover:text-foreground" onPointerDown={onResizeStart}> <button type="button" className="absolute bottom-1 right-1 rounded bg-background/90 p-1 text-muted-foreground shadow-sm hover:bg-muted hover:text-foreground" onPointerDown={onResizeStart}>
<Maximize2 className="size-3.5" /> <Maximize2 className="size-3.5" />
@@ -1305,7 +1459,7 @@ function DashboardWidgetCard({
}} }}
> >
<div className="flex h-7 shrink-0 items-center justify-between gap-1 border-b bg-muted/20 px-2"> <div className="flex h-7 shrink-0 items-center justify-between gap-1 border-b bg-muted/20 px-2">
{editing ? ( {editing && !fixed ? (
<Input <Input
className="h-6 min-w-0 flex-1 rounded-sm border-0 bg-background/70 px-1.5 text-xs font-semibold shadow-none" className="h-6 min-w-0 flex-1 rounded-sm border-0 bg-background/70 px-1.5 text-xs font-semibold shadow-none"
value={widget.title ?? widgetTitle(widget.type)} value={widget.title ?? widgetTitle(widget.type)}
@@ -1319,9 +1473,11 @@ function DashboardWidgetCard({
<button type="button" draggable className="rounded p-0.5 text-muted-foreground hover:bg-muted hover:text-foreground" onDragStart={onDragStart} onDragEnd={onDragEnd}> <button type="button" draggable className="rounded p-0.5 text-muted-foreground hover:bg-muted hover:text-foreground" onDragStart={onDragStart} onDragEnd={onDragEnd}>
<GripVertical className="size-3.5" /> <GripVertical className="size-3.5" />
</button> </button>
<Button type="button" size="icon" variant="ghost" className="size-6" onClick={onRemove}> {!fixed ? (
<X className="size-3.5" /> <Button type="button" size="icon" variant="ghost" className="size-6" onClick={onRemove}>
</Button> <X className="size-3.5" />
</Button>
) : null}
</div> </div>
) : null} ) : null}
</div> </div>
@@ -1372,6 +1528,10 @@ type WidgetContentProps = {
function WidgetContent(props: WidgetContentProps) { function WidgetContent(props: WidgetContentProps) {
const { widget, stats, onNavigate } = props; const { widget, stats, onNavigate } = props;
if (widget.type === "dayControl") {
return <DayControlWidget {...props} />;
}
if (widget.type === "today") { if (widget.type === "today") {
return ( return (
<MetricGrid <MetricGrid
@@ -1507,6 +1667,354 @@ function WidgetContent(props: WidgetContentProps) {
return null; return null;
} }
type DayControlTone = "normal" | "good" | "warning" | "danger" | "info";
type DayControlListItem = {
key: string;
title: string;
detail: string;
note?: string;
tone?: DayControlTone;
onClick?: () => void;
};
function DayControlWidget({ stats, projects, todos, onNavigate }: WidgetContentProps) {
const today = currentDay();
const overdueTodos = stableSort(
todos.filter((todo) => todo.status === "open" && todo.ends_on && todo.ends_on < today),
(left, right) => compareOptionalDate(left.ends_on, right.ends_on, "asc") || compareText(left.title, right.title)
);
const dueTodos = stableSort(
todos.filter((todo) => todo.status === "open" && (todo.ends_on === today || todo.starts_on === today || Boolean(todo.starts_on && todo.starts_on <= today && todo.ends_on && todo.ends_on >= today))),
(left, right) => compareOptionalDate(left.ends_on ?? left.starts_on, right.ends_on ?? right.starts_on, "asc") || compareText(left.title, right.title)
);
const waitingTickets = stableSort(
stats.dayControlTickets.filter((ticket) => {
const day = pendingDay(ticket.zammad_pending_time);
return (ticket.zammad_state_id === "3" || ticket.zammad_state_id === "7") && Boolean(day && day <= today);
}),
(left, right) => compareOptionalDate(pendingDay(left.zammad_pending_time), pendingDay(right.zammad_pending_time), "asc") || compareNumber(zammadStateSortRank(left.zammad_state_id), zammadStateSortRank(right.zammad_state_id))
);
const todayTickets = stableSort(stats.todayTickets, (left, right) => compareText(left.ticket_number, right.ticket_number));
const todayOpenRatings = stableSort(stats.todayOpenRatings, (left, right) => compareOptionalDate(left.started_at, right.started_at, "asc") || compareText(left.ticket_number, right.ticket_number));
const teamspaceReviewTickets = todayTickets.filter((ticket) => ticket.open_count === 0 && teamspaceDeltaMinutes(ticket) !== 0);
const budgetAttention = stableSort(
projects.filter((project) => {
const percent = projectConsumptionPercent(project);
return !project.closed_at && (Boolean(percent && percent >= 90) || Number(project.unassigned_teamspace_session_count ?? 0) > 0 || Boolean(project.close_review_required));
}),
(left, right) => (compareNumber(projectConsumptionPercent(right) ?? 0, projectConsumptionPercent(left) ?? 0)) || compareText(left.ticket_number, right.ticket_number)
);
const criticalCount = todayOpenRatings.length + overdueTodos.length + waitingTickets.length;
const warningCount = dueTodos.length + teamspaceReviewTickets.length + budgetAttention.length;
const trafficLight = criticalCount > 0 ? "danger" : warningCount > 0 ? "warning" : "good";
const trafficLightLabel = trafficLight === "danger" ? "Rot" : trafficLight === "warning" ? "Gelb" : "Grün";
const restParts = [
todayOpenRatings.length > 0 ? `${todayOpenRatings.length} Bewertung(en) setzen` : null,
overdueTodos.length > 0 ? `${overdueTodos.length} überfällige ToDo(s)` : null,
dueTodos.length > 0 ? `${dueTodos.length} heutige ToDo(s)` : null,
waitingTickets.length > 0 ? `${waitingTickets.length} Wiedervorlage(n)` : null,
teamspaceReviewTickets.length > 0 ? `${teamspaceReviewTickets.length} Teamspace-Abgleich(e)` : null,
budgetAttention.length > 0 ? `${budgetAttention.length} Budget-Hinweis(e)` : null
].filter(Boolean);
const checklistItems = [
{
key: "ratings",
label: "Sessions bewertet",
done: todayOpenRatings.length === 0,
action: todayOpenRatings.length === 0 ? "Keine offenen Bewertungen." : `${todayOpenRatings.length} heutige Bewertung(en) setzen.`
},
{
key: "todos",
label: "ToDos geprüft",
done: overdueTodos.length + dueTodos.length === 0,
action:
overdueTodos.length + dueTodos.length === 0
? "Keine fälligen ToDos."
: overdueTodos.length > 0
? `${overdueTodos.length} überfällige ToDo(s) erledigen oder neu terminieren.`
: `${dueTodos.length} heutige ToDo(s) erledigen.`
},
{
key: "waiting",
label: "Wiedervorlagen geprüft",
done: waitingTickets.length === 0,
action: waitingTickets.length === 0 ? "Keine fälligen Wiedervorlagen." : `${waitingTickets.length} Wiedervorlage(n) bearbeiten oder neues Datum setzen.`
},
{
key: "teamspace",
label: "Teamspace geprüft",
done: teamspaceReviewTickets.length === 0,
action: teamspaceReviewTickets.length === 0 ? "Teamspace passt für heute." : `${teamspaceReviewTickets.length} Teamspace-Abgleich(e) korrigieren.`
},
{
key: "budget",
label: "Budget geprüft",
done: budgetAttention.length === 0,
action: budgetAttention.length === 0 ? "Keine Budget-Hinweise." : `${budgetAttention.length} Budget-Hinweis(e) klären.`
}
];
const finalizationItems: DayControlListItem[] = [
...overdueTodos.slice(0, 4).map((todo) => ({
key: `overdue-${todo.id}`,
tone: "danger" as const,
title: todo.title,
detail: `${todo.ticket_number ?? "Ticket"} · überfällig seit ${todo.ends_on ? formatDate(`${todo.ends_on}T00:00:00`) : "ohne Datum"}`,
note: "ToDo erledigen oder Datum neu setzen.",
onClick: () => onNavigate(todo.ticket_id ? `/tickets/${todo.ticket_id}?month=${currentMonth()}` : "/todos")
})),
...dueTodos.slice(0, 4).map((todo) => ({
key: `due-${todo.id}`,
tone: "warning" as const,
title: todo.title,
detail: `${todo.ticket_number ?? "Ticket"} · fällig ${todo.ends_on ? formatDate(`${todo.ends_on}T00:00:00`) : "heute"}`,
note: "Heutiges ToDo erledigen.",
onClick: () => onNavigate(todo.ticket_id ? `/tickets/${todo.ticket_id}?month=${currentMonth()}` : "/todos")
})),
...todayOpenRatings.slice(0, 4).map((session) => ({
key: `open-session-${session.id}`,
tone: "warning" as const,
title: session.ticket_number,
detail: `${formatMinutes(session.rounded_minutes)} offen · ${session.organization_name ?? "Keine Organisation"}`,
note: "Bewertung setzen: Abgerechnet oder Nicht abrechenbar.",
onClick: () => onNavigate(`/tickets/${session.ticket_id}?month=${currentMonth()}&status=open`)
})),
...teamspaceReviewTickets.slice(0, 4).map((ticket) => ({
key: `teamspace-review-${ticket.id}`,
tone: "info" as const,
title: ticket.title || ticket.ticket_number,
detail: `${ticket.ticket_number} · TT ${formatMinutes(ticket.total_minutes)} · Teamspace ${formatMinutes(ticket.crm_billed_minutes ?? 0)}`,
note: dayControlTodaySessionReason(ticket),
onClick: () => onNavigate(`/tickets/${ticket.id}?month=${currentMonth()}`)
}))
].slice(0, 8);
return (
<div className="flex h-full min-h-0 flex-col gap-2">
<div className="grid gap-2 lg:grid-cols-[0.95fr_1.45fr]">
<div className="rounded-md border bg-background/60 px-2 py-1.5">
<div className="flex items-center gap-2">
<span
className={cn(
"size-3 shrink-0 rounded-full",
trafficLight === "danger" && "bg-red-500 shadow-[0_0_0_3px_rgba(239,68,68,0.18)]",
trafficLight === "warning" && "bg-amber-400 shadow-[0_0_0_3px_rgba(251,191,36,0.18)]",
trafficLight === "good" && "bg-emerald-500 shadow-[0_0_0_3px_rgba(16,185,129,0.18)]"
)}
/>
<div className="min-w-0">
<p className="truncate text-xs font-semibold">Ampel {trafficLightLabel}</p>
<p className="truncate text-[11px] text-muted-foreground">{restParts.length > 0 ? restParts.join(" · ") : "Alles erledigt."}</p>
</div>
</div>
</div>
<DayControlChecklist items={checklistItems} />
</div>
<div className="grid grid-cols-4 gap-1.5">
<DayControlMetric label="Heute" value={formatMinutes(stats.dayTotals?.minutes ?? 0)} icon={Timer} />
<DayControlMetric label="Offen" value={todayOpenRatings.length} icon={ClipboardCheck} tone={todayOpenRatings.length > 0 ? "warning" : "good"} />
<DayControlMetric label="ToDos" value={overdueTodos.length + dueTodos.length} icon={ClipboardCheck} tone={overdueTodos.length > 0 ? "danger" : dueTodos.length > 0 ? "warning" : "good"} />
<DayControlMetric label="Warten" value={waitingTickets.length} icon={Bell} tone={waitingTickets.length > 0 ? "info" : "good"} />
</div>
<div className="grid min-h-0 flex-1 gap-2 md:grid-cols-[1.25fr_1fr]">
<DayControlSection title="Heute finalisieren" empty="Tag sauber: alle heutigen Punkte sind erledigt." items={finalizationItems} />
<div className="min-h-0 space-y-2 overflow-y-auto">
<DayControlMiniSection
title="Nacharbeit"
icon={CalendarCheck}
empty="Heute noch keine Sessions."
items={todayTickets.slice(0, 4).map((ticket) => ({
key: `today-ticket-${ticket.id}`,
title: ticket.title || ticket.ticket_number,
detail: `${ticket.ticket_number} · ${formatMinutes(ticket.total_minutes + ticket.non_billable_minutes + ticket.open_minutes)} · ${ticket.session_count} Session(s)`,
note: dayControlTodaySessionReason(ticket),
onClick: () => onNavigate(`/tickets/${ticket.id}?month=${currentMonth()}`)
}))}
/>
<DayControlMiniSection
title="Wiedervorlagen"
icon={Bell}
empty="Keine fälligen Wiedervorlagen."
items={waitingTickets.slice(0, 4).map((ticket) => ({
key: `pending-${ticket.ticket_id}`,
title: ticket.title || ticket.ticket_number,
detail: `${ticket.ticket_number} · ${ticket.zammad_state_id === "7" ? "Abschluss" : "Wartend"} · ${pendingDay(ticket.zammad_pending_time) ? formatDate(`${pendingDay(ticket.zammad_pending_time)}T00:00:00`) : "ohne Datum"}`,
note: ticket.zammad_state_id === "7" ? "Abschluss-Wiedervorlage bearbeiten oder neues Datum setzen." : "Erinnerungs-Wiedervorlage bearbeiten oder neues Datum setzen.",
onClick: () => onNavigate(`/tickets/${ticket.ticket_id}?month=${currentMonth()}`)
}))}
/>
<DayControlMiniSection
title="Budget / Aufräumen"
icon={WalletCards}
empty="Keine Budget-Hinweise."
items={budgetAttention.slice(0, 4).map((project) => {
const percent = projectConsumptionPercent(project);
const hasUnassignedSessions = Number(project.unassigned_teamspace_session_count ?? 0) > 0;
const detail = project.close_review_required
? "Abschluss prüfen"
: hasUnassignedSessions
? `${project.unassigned_teamspace_session_count} Session(s) ohne TS-Zuordnung`
: `${percent ?? 0}% Kontingent verbraucht`;
const note = project.close_review_required
? "Abschluss in TT/Teamspace prüfen und ggf. Umbuchungen klären."
: hasUnassignedSessions
? "Sessions einem Teamspace-Projekt zuordnen."
: "Kontingent kritisch: Budget, Verbrauch und weitere Arbeit prüfen.";
return {
key: `budget-${project.id}`,
title: project.title || project.ticket_number,
detail: `${project.ticket_number} · ${detail}`,
note,
onClick: () => onNavigate(`/tickets/${project.ticket_id}?month=${currentMonth()}`)
};
})}
/>
</div>
</div>
</div>
);
}
function dayControlTodaySessionReason(ticket: PeriodOverview["tickets"][number]) {
if (ticket.open_count > 0) {
return `${ticket.open_count} offene Bewertung(en): Bewertungsstatus setzen.`;
}
const crmMinutes = ticket.crm_billed_minutes ?? 0;
const deltaMinutes = teamspaceDeltaMinutes(ticket);
if (ticket.total_minutes > 0 && deltaMinutes > 0) {
return `Teamspace nachtragen: ${formatMinutes(deltaMinutes)} fehlen.`;
}
if (deltaMinutes < 0) {
return `Teamspace prüfen: ${formatMinutes(Math.abs(deltaMinutes))} zu viel erfasst.`;
}
if (ticket.total_minutes > 0) {
return `Teamspace ok: ${formatMinutes(crmMinutes)} erfasst.`;
}
if (ticket.non_billable_count > 0) {
return `Nicht abrechenbar gesetzt: Zammad-Dokumentation kontrollieren.`;
}
return "Heute erfasst: Bewertung kontrollieren.";
}
function teamspaceDeltaMinutes(ticket: PeriodOverview["tickets"][number]) {
return (ticket.total_minutes ?? 0) - (ticket.crm_billed_minutes ?? 0);
}
function DayControlChecklist({ items }: { items: Array<{ key: string; label: string; done: boolean; action: string }> }) {
return (
<div className="rounded-md border bg-background/50 px-2 py-1.5">
<p className="mb-1 text-[11px] font-semibold text-muted-foreground">Tagesabschluss</p>
<div className="grid gap-x-2 gap-y-1 sm:grid-cols-2">
{items.map((item) => (
<div key={item.key} className="flex min-w-0 items-start gap-1.5">
<CheckCircle2 className={cn("mt-0.5 size-3 shrink-0", item.done ? "text-emerald-500" : "text-amber-500")} />
<div className="min-w-0">
<p className="truncate text-[11px] font-medium">{item.label}</p>
<p className="truncate text-[10px] text-muted-foreground">{item.action}</p>
</div>
</div>
))}
</div>
</div>
);
}
function DayControlMetric({ label, value, icon: Icon, tone = "normal" }: { label: string; value: string | number; icon: typeof Timer; tone?: DayControlTone }) {
return (
<div className="min-w-0 rounded-md border bg-background/60 px-2 py-1.5">
<div className="flex items-center gap-1 text-[10px] text-muted-foreground">
<Icon className="size-3" />
<span className="truncate">{label}</span>
</div>
<p
className={cn(
"truncate text-sm font-semibold",
tone === "good" && "text-emerald-500",
tone === "warning" && "text-amber-500",
tone === "danger" && "text-red-500",
tone === "info" && "text-blue-500"
)}
>
{value}
</p>
</div>
);
}
function DayControlSection({ title, empty, items }: { title: string; empty: string; items: DayControlListItem[] }) {
return (
<div className="min-h-0 overflow-y-auto rounded-md border bg-background/40 p-2">
<p className="mb-1.5 text-[11px] font-semibold text-muted-foreground">{title}</p>
{items.length === 0 ? (
<p className="py-4 text-center text-xs text-emerald-500">{empty}</p>
) : (
<div className="space-y-1">
{items.map((item) => (
<button key={item.key} type="button" className="block w-full min-w-0 rounded-md px-2 py-1.5 text-left hover:bg-muted" onClick={item.onClick}>
<span
className={cn(
"block truncate text-xs font-medium",
item.tone === "danger" && "text-red-500",
item.tone === "warning" && "text-amber-500",
item.tone === "info" && "text-blue-500"
)}
>
{item.title}
</span>
<span className="block truncate text-[11px] text-muted-foreground">{item.detail}</span>
{item.note ? <span className="block truncate text-[10px] text-muted-foreground/80">{item.note}</span> : null}
</button>
))}
</div>
)}
</div>
);
}
function DayControlMiniSection({
title,
icon: Icon,
empty,
items
}: {
title: string;
icon: typeof Timer;
empty: string;
items: DayControlListItem[];
}) {
return (
<div>
<div className="mb-1 flex items-center gap-1 text-[11px] font-semibold text-muted-foreground">
<Icon className="size-3" />
<span className="truncate">{title}</span>
</div>
{items.length === 0 ? (
<p className="rounded-md border bg-background/30 px-2 py-2 text-xs text-muted-foreground">{empty}</p>
) : (
<div className="space-y-1">
{items.map((item) => (
<button key={item.key} type="button" className="block w-full min-w-0 rounded-md px-2 py-1 text-left hover:bg-muted" onClick={item.onClick}>
<span className="block truncate text-xs font-medium">{item.title}</span>
<span className="block truncate text-[11px] text-muted-foreground">{item.detail}</span>
{item.note ? <span className="block truncate text-[10px] text-muted-foreground/80">{item.note}</span> : null}
</button>
))}
</div>
)}
</div>
);
}
function MetricGrid({ items }: { items: Array<{ label: string; value: string | number; icon: typeof Timer; tone?: "normal" | "good" | "warning" }> }) { function MetricGrid({ items }: { items: Array<{ label: string; value: string | number; icon: typeof Timer; tone?: "normal" | "good" | "warning" }> }) {
return ( return (
<div className="grid h-full grid-cols-2 gap-1.5"> <div className="grid h-full grid-cols-2 gap-1.5">
+1 -1
View File
@@ -21,7 +21,7 @@ export function LoginPage({ onLogin }: LoginPageProps) {
setLoading(true); setLoading(true);
try { try {
const result = await login({ username, password }); const result = await login({ username: username.trim(), password });
onLogin(result.user); onLogin(result.user);
} catch (error) { } catch (error) {
toast.error("Login fehlgeschlagen", { toast.error("Login fehlgeschlagen", {
+8 -3
View File
@@ -23,7 +23,7 @@ import {
} from "../api"; } from "../api";
import { getLocalStorageValue, setLocalStorageValue } from "@/lib/local-storage.client"; import { getLocalStorageValue, setLocalStorageValue } from "@/lib/local-storage.client";
import type { AuthUser } from "../types"; import type { AuthUser } from "../types";
import { zammadPriorityLabel, zammadStateLabel } from "../zammad-meta"; import { zammadPriorityLabel, zammadStateId, zammadStateLabel } from "../zammad-meta";
type ZammadSyncPageProps = { type ZammadSyncPageProps = {
currentUser: AuthUser; currentUser: AuthUser;
@@ -289,7 +289,8 @@ function formatZammadPriority(value: unknown) {
} }
function formatZammadState(value: unknown) { function formatZammadState(value: unknown) {
const stateId = numericValue(value); const normalizedStateId = zammadStateId(value);
const stateId = normalizedStateId ? numericValue(normalizedStateId) : null;
if (stateId !== null) { if (stateId !== null) {
return `${stateId} (${zammadStateLabel(stateId)})`; return `${stateId} (${zammadStateLabel(stateId)})`;
@@ -512,7 +513,11 @@ function comparablePreviewIdValue(value: unknown) {
} }
function comparablePreviewValueForField(field: string, value: unknown) { function comparablePreviewValueForField(field: string, value: unknown) {
if (field === "Organisation" || field === "Zammad-Priorität" || field === "Zammad-Status") { if (field === "Zammad-Status") {
return zammadStateId(value);
}
if (field === "Organisation" || field === "Zammad-Priorität") {
return comparablePreviewIdValue(value); return comparablePreviewIdValue(value);
} }
+9 -4
View File
@@ -80,8 +80,13 @@ export function zammadId(value: unknown) {
return /^\d+$/.test(normalized) ? normalized.replace(/^0+(?=\d)/, "") : normalized || null; return /^\d+$/.test(normalized) ? normalized.replace(/^0+(?=\d)/, "") : normalized || null;
} }
export function zammadStateLabel(value: unknown) { export function zammadStateId(value: unknown) {
const id = zammadId(value); const id = zammadId(value);
return id === "1" ? "2" : id;
}
export function zammadStateLabel(value: unknown) {
const id = zammadStateId(value);
return id ? zammadStateMeta[id as keyof typeof zammadStateMeta]?.label ?? `Status ${id}` : "-"; return id ? zammadStateMeta[id as keyof typeof zammadStateMeta]?.label ?? `Status ${id}` : "-";
} }
@@ -91,7 +96,7 @@ export function zammadPriorityLabel(value: unknown) {
} }
export function zammadStateSortRank(value: unknown) { export function zammadStateSortRank(value: unknown) {
const id = zammadId(value); const id = zammadStateId(value);
const fallback = id ? Number(id) : Number.NaN; const fallback = id ? Number(id) : Number.NaN;
return id ? zammadStateMeta[id as keyof typeof zammadStateMeta]?.order ?? (Number.isFinite(fallback) ? 900 + fallback : 999) : 999; return id ? zammadStateMeta[id as keyof typeof zammadStateMeta]?.order ?? (Number.isFinite(fallback) ? 900 + fallback : 999) : 999;
} }
@@ -103,7 +108,7 @@ export function zammadPrioritySortRank(value: unknown) {
} }
export function matchesZammadStateFilter(value: unknown, filter: ZammadStateFilter) { export function matchesZammadStateFilter(value: unknown, filter: ZammadStateFilter) {
const id = zammadId(value); const id = zammadStateId(value);
return filter === "all" || (filter === "none" ? !id : id === filter); return filter === "all" || (filter === "none" ? !id : id === filter);
} }
@@ -113,7 +118,7 @@ export function matchesZammadPriorityFilter(value: unknown, filter: ZammadPriori
} }
export function ZammadStateBadge({ stateId, className }: { stateId: unknown; className?: string }) { export function ZammadStateBadge({ stateId, className }: { stateId: unknown; className?: string }) {
const id = zammadId(stateId); const id = zammadStateId(stateId);
if (!id) { if (!id) {
return null; return null;