Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
040f1903e6 | ||
|
|
89429c70cf | ||
|
|
904b123884 | ||
|
|
560b747d5d |
No files matched your search
@@ -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
@@ -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,
|
||||||
|
|||||||
@@ -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(`
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
@@ -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;
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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", {
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in new issue
Block a user