Compare commits

...
4 Commits
Author SHA1 Message Date
michael fc0dfd0556 Update 2026-10-06 08:57:37 +02:00
michael e1456ed4ca v1.0.11-rc1 2026-10-02 08:50:10 +02:00
michael d5952d82bb Update 2026-09-30 16:59:14 +02:00
michael 35cb578a96 Update 2026-09-30 12:43:01 +02:00
15 changed files with 3460 additions and 577 deletions

No files matched your search

+1 -1
View File
@@ -1,5 +1,5 @@
PP_PASSWORD='$ouzs4D$@RAi9'
DATABASE_URL=postgresql://tickettracker:${PP_PASSWORD}@172.22.0.3:5432/tickettracker_dev
DATABASE_URL=postgresql://tickettracker:${PP_PASSWORD}@172.22.0.4:5432/tickettracker_dev
APP_PORT=3911
TICKETTRACKER_IMAGE=git.d-razz.de/michael/tickettracker:latest
COOKIE_SECURE=false
+17 -2
View File
@@ -24,6 +24,11 @@ export type AuthUser = {
zammad_time_unit_rounding_minutes: number;
zammad_atoss_email_sync_enabled: boolean;
zammad_article_type_id: number;
project_followup_enabled: boolean;
project_followup_weeks: number;
project_followup_second_enabled: boolean;
project_followup_second_weeks: number;
project_followup_complete_on_session: boolean;
};
declare global {
@@ -91,7 +96,12 @@ function publicUser(user: AuthUser) {
zammad_time_unit_rounding_enabled: Boolean(user.zammad_time_unit_rounding_enabled),
zammad_time_unit_rounding_minutes: Number(user.zammad_time_unit_rounding_minutes ?? 15),
zammad_atoss_email_sync_enabled: Boolean(user.zammad_atoss_email_sync_enabled ?? true),
zammad_article_type_id: Number(user.zammad_article_type_id ?? 6)
zammad_article_type_id: Number(user.zammad_article_type_id ?? 6),
project_followup_enabled: Boolean(user.project_followup_enabled ?? true),
project_followup_weeks: Number(user.project_followup_weeks ?? 4),
project_followup_second_enabled: Boolean(user.project_followup_second_enabled ?? true),
project_followup_second_weeks: Number(user.project_followup_second_weeks ?? 2),
project_followup_complete_on_session: Boolean(user.project_followup_complete_on_session ?? true)
};
}
@@ -155,7 +165,12 @@ export async function requireAuth(req: Request, res: Response, next: NextFunctio
u.zammad_time_unit_rounding_enabled,
u.zammad_time_unit_rounding_minutes,
u.zammad_atoss_email_sync_enabled,
COALESCE(u.zammad_article_type_id, 6) AS zammad_article_type_id
COALESCE(u.zammad_article_type_id, 6) AS zammad_article_type_id,
u.project_followup_enabled,
u.project_followup_weeks,
u.project_followup_second_enabled,
u.project_followup_second_weeks,
u.project_followup_complete_on_session
FROM auth_sessions s
JOIN users u ON u.id = s.user_id
WHERE s.token_hash = $1
+2595 -468
View File
File diff suppressed because it is too large. Load diff
+167 -3
View File
@@ -93,6 +93,11 @@ export async function migrate() {
await query("ALTER TABLE users ADD COLUMN IF NOT EXISTS zammad_time_unit_rounding_minutes INTEGER NOT NULL DEFAULT 15;");
await query("ALTER TABLE users ADD COLUMN IF NOT EXISTS zammad_atoss_email_sync_enabled BOOLEAN NOT NULL DEFAULT true;");
await query("ALTER TABLE users ADD COLUMN IF NOT EXISTS zammad_article_type_id INTEGER DEFAULT 6;");
await query("ALTER TABLE users ADD COLUMN IF NOT EXISTS project_followup_enabled BOOLEAN NOT NULL DEFAULT true;");
await query("ALTER TABLE users ADD COLUMN IF NOT EXISTS project_followup_weeks INTEGER NOT NULL DEFAULT 4;");
await query("ALTER TABLE users ADD COLUMN IF NOT EXISTS project_followup_second_enabled BOOLEAN NOT NULL DEFAULT true;");
await query("ALTER TABLE users ADD COLUMN IF NOT EXISTS project_followup_second_weeks INTEGER NOT NULL DEFAULT 2;");
await query("ALTER TABLE users ADD COLUMN IF NOT EXISTS project_followup_complete_on_session BOOLEAN NOT NULL DEFAULT true;");
await query("UPDATE users SET zammad_article_type_id = 6 WHERE zammad_article_type_id IS NULL;");
await query("ALTER TABLE users ALTER COLUMN zammad_article_type_id SET DEFAULT 6;");
await query("ALTER TABLE users ALTER COLUMN zammad_article_type_id SET NOT NULL;");
@@ -106,6 +111,16 @@ export async function migrate() {
DROP CONSTRAINT IF EXISTS users_zammad_article_type_id_valid,
ADD CONSTRAINT users_zammad_article_type_id_valid CHECK (zammad_article_type_id IN (1, 2, 3, 4, 5, 6));
`);
await query(`
ALTER TABLE users
DROP CONSTRAINT IF EXISTS users_project_followup_weeks_valid,
ADD CONSTRAINT users_project_followup_weeks_valid CHECK (project_followup_weeks BETWEEN 1 AND 52);
`);
await query(`
ALTER TABLE users
DROP CONSTRAINT IF EXISTS users_project_followup_second_weeks_valid,
ADD CONSTRAINT users_project_followup_second_weeks_valid CHECK (project_followup_second_weeks BETWEEN 1 AND 52);
`);
await query(`
CREATE TABLE IF NOT EXISTS auth_sessions (
@@ -191,6 +206,9 @@ export async function migrate() {
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 user_id BIGINT REFERENCES users(id) ON DELETE SET NULL;");
await query("ALTER TABLE tickets ADD COLUMN IF NOT EXISTS zammad_merged_from_ticket_id BIGINT;");
await query("ALTER TABLE tickets ADD COLUMN IF NOT EXISTS zammad_merged_from_ticket_number TEXT;");
await query("ALTER TABLE tickets ADD COLUMN IF NOT EXISTS zammad_preferences JSONB NOT NULL DEFAULT '{}'::jsonb;");
await query(`
ALTER TABLE tickets
@@ -208,6 +226,55 @@ export async function migrate() {
END IF;
END $$;
`);
await query(`
WITH unique_zammad_users AS (
SELECT zammad_user_id, MIN(id) AS user_id
FROM users
WHERE role = 'user'
AND active
AND zammad_user_id IS NOT NULL
GROUP BY zammad_user_id
HAVING COUNT(*) = 1
),
zammad_ticket_owners AS (
SELECT t.id, uzu.user_id
FROM tickets t
LEFT JOIN unique_zammad_users uzu ON uzu.zammad_user_id = t.zammad_owner_id
WHERE t.zammad_owner_id IS NOT NULL
)
UPDATE tickets t
SET user_id = zto.user_id
FROM zammad_ticket_owners zto
WHERE t.id = zto.id
AND t.user_id IS DISTINCT FROM zto.user_id;
`);
await query(`
UPDATE tickets t
SET user_id = owner.user_id
FROM (
SELECT ticket_id, MIN(user_id) AS user_id
FROM projects
GROUP BY ticket_id
) owner
WHERE t.user_id IS NULL
AND t.zammad_owner_id IS NULL
AND t.id = owner.ticket_id;
`);
await query(`
UPDATE tickets t
SET user_id = owner.user_id
FROM (
SELECT ticket_id, MIN(user_id) AS user_id
FROM sessions
WHERE user_id IS NOT NULL
GROUP BY ticket_id
) owner
WHERE t.user_id IS NULL
AND t.zammad_owner_id IS NULL
AND t.id = owner.ticket_id;
`);
await query("CREATE INDEX IF NOT EXISTS idx_tickets_user ON tickets(user_id);");
await query("CREATE INDEX IF NOT EXISTS idx_tickets_zammad_owner ON tickets(zammad_owner_id);");
await query(`
CREATE TABLE IF NOT EXISTS sessions (
@@ -691,6 +758,43 @@ export async function migrate() {
await query("ALTER TABLE projects ADD COLUMN IF NOT EXISTS closed_at TIMESTAMPTZ;");
await query("ALTER TABLE projects ADD COLUMN IF NOT EXISTS close_review_required BOOLEAN NOT NULL DEFAULT false;");
await query("ALTER TABLE projects ADD COLUMN IF NOT EXISTS manual_consumption_minutes INTEGER NOT NULL DEFAULT 0;");
await query("ALTER TABLE projects ADD COLUMN IF NOT EXISTS zammad_owner_id BIGINT;");
await query("ALTER TABLE projects ALTER COLUMN user_id DROP NOT NULL;");
await query(`
UPDATE projects p
SET zammad_owner_id = t.zammad_owner_id
FROM tickets t
WHERE p.ticket_id = t.id
AND p.zammad_owner_id IS NULL
AND t.zammad_owner_id IS NOT NULL;
`);
await query(`
UPDATE projects p
SET user_id = t.user_id,
zammad_owner_id = t.zammad_owner_id
FROM tickets t
WHERE p.ticket_id = t.id
AND t.zammad_owner_id IS NOT NULL
AND (
p.user_id IS DISTINCT FROM t.user_id
OR p.zammad_owner_id IS DISTINCT FROM t.zammad_owner_id
)
AND NOT EXISTS (
SELECT 1
FROM projects existing
WHERE existing.ticket_id = p.ticket_id
AND existing.id <> p.id
AND (
(t.user_id IS NOT NULL AND existing.user_id = t.user_id)
OR (
t.user_id IS NULL
AND existing.user_id IS NULL
AND existing.zammad_owner_id IS NOT DISTINCT FROM t.zammad_owner_id
)
)
);
`);
await query("CREATE UNIQUE INDEX IF NOT EXISTS idx_projects_zammad_owner_ticket ON projects(zammad_owner_id, ticket_id) WHERE user_id IS NULL AND zammad_owner_id IS NOT NULL;");
await query(`
DO $$
BEGIN
@@ -782,13 +886,26 @@ export async function migrate() {
`);
await query(`
INSERT INTO projects (user_id, ticket_id, teamspace_project, description, goals)
SELECT DISTINCT s.user_id, s.ticket_id, COALESCE(t.title, ''), '', ''
INSERT INTO projects (user_id, zammad_owner_id, ticket_id, teamspace_project, description, goals)
SELECT DISTINCT
CASE
WHEN t.zammad_owner_id IS NOT NULL THEN zammad_owner_user.id
ELSE s.user_id
END,
t.zammad_owner_id,
s.ticket_id,
COALESCE(t.title, ''),
'',
''
FROM sessions s
JOIN tickets t ON t.id = s.ticket_id
LEFT JOIN users zammad_owner_user
ON zammad_owner_user.role = 'user'
AND zammad_owner_user.active
AND zammad_owner_user.zammad_user_id = t.zammad_owner_id
WHERE s.work_type = 'consulting'
AND s.user_id IS NOT NULL
ON CONFLICT (user_id, ticket_id) DO NOTHING;
ON CONFLICT DO NOTHING;
`);
await query(`
@@ -918,6 +1035,53 @@ export async function migrate() {
await query("CREATE INDEX IF NOT EXISTS idx_ticket_todos_ticket_user ON ticket_todos(ticket_id, user_id);");
await query("CREATE INDEX IF NOT EXISTS idx_ticket_todos_user_period ON ticket_todos(user_id, starts_on, ends_on);");
await query("CREATE INDEX IF NOT EXISTS idx_ticket_todos_user_sort_order ON ticket_todos(user_id, sort_order, id);");
await query(`
CREATE TABLE IF NOT EXISTS project_followup_reminders (
id BIGSERIAL PRIMARY KEY,
user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
project_id BIGINT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
ticket_id BIGINT NOT NULL REFERENCES tickets(id) ON DELETE CASCADE,
todo_id BIGINT REFERENCES ticket_todos(id) ON DELETE SET NULL,
stage INTEGER NOT NULL DEFAULT 1,
latest_tt_update_at TIMESTAMPTZ NOT NULL,
due_on DATE NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
CONSTRAINT project_followup_reminders_stage_valid CHECK (stage IN (1, 2))
);
`);
await query("ALTER TABLE project_followup_reminders ADD COLUMN IF NOT EXISTS stage INTEGER NOT NULL DEFAULT 1;");
await query(`
ALTER TABLE project_followup_reminders
DROP CONSTRAINT IF EXISTS project_followup_reminders_stage_valid,
ADD CONSTRAINT project_followup_reminders_stage_valid CHECK (stage IN (1, 2));
`);
await query(`
DO $$
DECLARE
constraint_name text;
BEGIN
SELECT c.conname
INTO constraint_name
FROM pg_constraint c
WHERE c.conrelid = 'project_followup_reminders'::regclass
AND c.contype = 'u'
AND c.conkey = ARRAY[
(SELECT attnum FROM pg_attribute WHERE attrelid = 'project_followup_reminders'::regclass AND attname = 'user_id'),
(SELECT attnum FROM pg_attribute WHERE attrelid = 'project_followup_reminders'::regclass AND attname = 'project_id'),
(SELECT attnum FROM pg_attribute WHERE attrelid = 'project_followup_reminders'::regclass AND attname = 'latest_tt_update_at')
]::smallint[];
IF constraint_name IS NOT NULL THEN
EXECUTE format('ALTER TABLE project_followup_reminders DROP CONSTRAINT %I', constraint_name);
END IF;
END $$;
`);
await query("CREATE UNIQUE INDEX IF NOT EXISTS idx_project_followup_reminders_unique_stage ON project_followup_reminders(user_id, project_id, latest_tt_update_at, stage);");
await query("CREATE INDEX IF NOT EXISTS idx_project_followup_reminders_user_project ON project_followup_reminders(user_id, project_id, latest_tt_update_at DESC);");
await query("CREATE INDEX IF NOT EXISTS idx_project_followup_reminders_todo ON project_followup_reminders(todo_id) WHERE todo_id IS NOT NULL;");
await query("CREATE INDEX IF NOT EXISTS idx_recurring_billings_user_active ON recurring_billings(user_id, active);");
await query("UPDATE recurring_billings SET active = true WHERE active = false;");
await query(`
+62 -36
View File
@@ -1,5 +1,4 @@
import {
AlertCircle,
CalendarDays,
CheckCircle2,
ClipboardCheck,
@@ -591,20 +590,57 @@ const sidebarTodoCalendarClasses: Record<TicketTodo["priority"], string> = {
};
const sidebarOverdueTodoClass = "border-red-500/60 bg-red-500/15 text-red-100";
const sidebarTodoPriorityOrder: Record<TicketTodo["priority"], number> = {
urgent: 0,
high: 1,
medium: 2,
low: 3
};
function sortSidebarCurrentTodos(todos: TicketTodo[], today: string) {
return [...todos].sort((left, right) => {
const leftOverdue = isTodoOverdue(left, today);
const rightOverdue = isTodoOverdue(right, today);
if (leftOverdue !== rightOverdue) {
return leftOverdue ? -1 : 1;
}
return sidebarTodoPriorityOrder[left.priority] - sidebarTodoPriorityOrder[right.priority] || left.sort_order - right.sort_order || left.title.localeCompare(right.title, "de-DE", { sensitivity: "base" });
});
}
function notifyTodosChanged() {
window.dispatchEvent(new Event("tickettracker:todos-changed"));
}
function SidebarTodayTodos({ todos, onNavigate }: SidebarTodayTodosProps) {
const [runningTodosOpen, setRunningTodosOpen] = useState(false);
const [todosModalOpen, setTodosModalOpen] = useState(false);
const [modalBusy, setModalBusy] = useState(false);
const today = currentDay();
const fixedTodos = todos.filter((todo) => isTodoFixedOnDay(todo, today));
const summarizedTodos = todos.filter((todo) => todo.status === "open" && isTodoActiveOnDay(todo, today) && !isTodoFixedOnDay(todo, today));
const overdueTodos = todos.filter((todo) => isTodoOverdue(todo, today));
const currentTodos = sortSidebarCurrentTodos(
[...overdueTodos, ...fixedTodos, ...summarizedTodos].filter((todo, index, list) => list.findIndex((item) => item.id === todo.id) === index),
today
);
const visibleCurrentTodos = currentTodos.slice(0, 6);
const additionalTodosCount = Math.max(0, currentTodos.length - visibleCurrentTodos.length);
const hasTodos = fixedTodos.length > 0 || summarizedTodos.length > 0 || overdueTodos.length > 0;
function todoStateLabel(todo: TicketTodo) {
if (isTodoOverdue(todo, today)) {
return "Überfällig";
}
if (isTodoFixedOnDay(todo, today)) {
return "Fester Termin";
}
return "Laufend";
}
async function toggleTodo(todo: TicketTodo) {
setModalBusy(true);
@@ -666,42 +702,30 @@ function SidebarTodayTodos({ todos, onNavigate }: SidebarTodayTodosProps) {
{hasTodos ? (
<div className="space-y-1">
{overdueTodos.slice(0, 3).map((todo) => (
{visibleCurrentTodos.map((todo) => (
<button
key={`overdue-${todo.id}`}
key={`current-${todo.id}`}
type="button"
className="block w-full rounded-md border border-red-500/45 bg-red-500/10 px-2 py-1.5 text-left hover:bg-red-500/15"
onClick={() => onNavigate(`/tickets/${todo.ticket_id}`)}
>
<span className="flex min-w-0 items-center gap-1.5 text-[11px] font-semibold text-red-200">
<AlertCircle className="size-3.5 shrink-0" />
<span className="truncate">{todo.title}</span>
</span>
<span className="block truncate text-[11px] text-muted-foreground">{todo.ticket_number ?? "Ticket"} · {formatTodoPeriod(todo)}</span>
</button>
))}
{overdueTodos.length > 3 ? <p className="px-1 text-[11px] text-red-200">+{overdueTodos.length - 3} weitere überfällig</p> : null}
{fixedTodos.slice(0, 3).map((todo) => (
<button
key={`fixed-${todo.id}`}
type="button"
className={`block w-full rounded-md border px-2 py-1.5 text-left hover:brightness-110 ${sidebarTodoCalendarClasses[todo.priority]}`}
className={`block w-full rounded-md border px-2 py-1.5 text-left hover:brightness-110 ${isTodoOverdue(todo, today) ? sidebarOverdueTodoClass : sidebarTodoCalendarClasses[todo.priority]}`}
onClick={() => onNavigate(`/tickets/${todo.ticket_id}`)}
>
<span className="block truncate text-[11px] font-semibold">{todo.title}</span>
<span className="block truncate text-[11px] text-muted-foreground">{todo.ticket_number ?? "Ticket"} · {todo.organization_name ?? "Keine Organisation"}</span>
<span className="block truncate text-[11px] text-muted-foreground">
{todo.ticket_number ?? "Ticket"} · {todoStateLabel(todo)}
</span>
</button>
))}
{fixedTodos.length > 3 ? <p className="px-1 text-[11px] text-muted-foreground">+{fixedTodos.length - 3} weitere feste Termine</p> : null}
{summarizedTodos.length > 0 ? (
{additionalTodosCount > 0 ? (
<button
type="button"
className="block w-full rounded-md border border-muted-foreground/20 bg-background/60 px-2 py-1.5 text-left text-[11px] font-medium text-muted-foreground hover:bg-muted"
onClick={() => setRunningTodosOpen(true)}
onClick={() => setTodosModalOpen(true)}
>
+{summarizedTodos.length} laufende ToDo{summarizedTodos.length === 1 ? "" : "s"}
<span className="block">+{additionalTodosCount} weitere aktuelle ToDo{additionalTodosCount === 1 ? "" : "s"}</span>
<span className="block truncate font-normal">
{overdueTodos.length} überfällig · {summarizedTodos.length} laufend · {fixedTodos.length} feste
</span>
</button>
) : null}
</div>
@@ -712,22 +736,24 @@ function SidebarTodayTodos({ todos, onNavigate }: SidebarTodayTodosProps) {
</SidebarGroupContent>
</SidebarGroup>
<Dialog open={runningTodosOpen} onOpenChange={setRunningTodosOpen}>
<Dialog open={todosModalOpen} onOpenChange={setTodosModalOpen}>
<DialogContent className="max-h-[calc(100dvh-2rem)] overflow-y-auto sm:max-w-xl">
<DialogHeader>
<DialogTitle>ToDos am Tag</DialogTitle>
<DialogDescription>Heute · {summarizedTodos.length} laufende ToDo(s)</DialogDescription>
<DialogTitle>Aktuelle ToDos</DialogTitle>
<DialogDescription>
{overdueTodos.length} überfällig · {fixedTodos.length} feste Termine · {summarizedTodos.length} laufende ToDo(s)
</DialogDescription>
</DialogHeader>
<div className="space-y-2">
{summarizedTodos.map((todo) => (
{currentTodos.map((todo) => (
<div key={todo.id} className={`rounded-md border p-2.5 ${isTodoOverdue(todo, today) ? sidebarOverdueTodoClass : sidebarTodoCalendarClasses[todo.priority]}`}>
<div className="flex flex-col gap-2">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<div className="flex min-w-0 flex-wrap items-center gap-2">
<span className="min-w-0 truncate text-sm font-semibold">{todo.title}</span>
<Badge variant={isTodoOverdue(todo, today) ? "destructive" : isTodoFixedOnDay(todo, today) ? "outline" : "secondary"}>{todoStateLabel(todo)}</Badge>
<span className={`shrink-0 rounded-full border px-2 py-0.5 text-xs font-medium ${sidebarTodoPriorityClasses[todo.priority]}`}>{sidebarTodoPriorityLabels[todo.priority]}</span>
{isTodoOverdue(todo, today) ? <Badge variant="destructive">überfällig</Badge> : null}
</div>
<p className="mt-1 truncate text-xs text-muted-foreground" title={todo.organization_name ?? "Keine Organisation"}>
{todo.organization_name ?? "Keine Organisation"}
@@ -749,7 +775,7 @@ function SidebarTodayTodos({ todos, onNavigate }: SidebarTodayTodosProps) {
size="sm"
variant="ghost"
onClick={() => {
setRunningTodosOpen(false);
setTodosModalOpen(false);
onNavigate(`/tickets/${todo.ticket_id}`);
}}
>
@@ -764,8 +790,8 @@ function SidebarTodayTodos({ todos, onNavigate }: SidebarTodayTodosProps) {
</div>
</div>
))}
{summarizedTodos.length === 0 ? (
<p className="rounded-md border bg-muted/20 p-4 text-center text-sm text-muted-foreground">Keine laufenden ToDos an diesem Tag.</p>
{currentTodos.length === 0 ? (
<p className="rounded-md border bg-muted/20 p-4 text-center text-sm text-muted-foreground">Keine aktuellen ToDos.</p>
) : null}
</div>
</DialogContent>
@@ -1289,7 +1315,7 @@ export function App() {
setOpenTickets((items) => items.filter((item) => item.key !== ticket.key));
if (pathWithoutSearch(path) === pathWithoutSearch(ticket.path)) {
if (route.page === "ticket" && route.ticketId === ticket.ticketId) {
const returnPath = current.returnPath && pathWithoutSearch(current.returnPath) !== pathWithoutSearch(ticket.path) ? current.returnPath : "/analysis";
routerNavigate(returnPath);
}
@@ -1505,7 +1531,7 @@ export function App() {
<SidebarMenuItem>
<SidebarMenuButton tooltip="Worklog">
<FolderGit />
<span className="text-xs text-muted-foreground">v1.0.10-rc1</span>
<span className="text-xs text-muted-foreground">v1.0.11-rc1</span>
</SidebarMenuButton>
</SidebarMenuItem>
</SidebarMenu>
+17
View File
@@ -10,6 +10,7 @@ import type {
ProjectSummary,
RecurringBilling,
StatisticsOverview,
StatisticsProjectFollowupOverview,
TicketMeta,
TicketPeriod,
TicketTodo,
@@ -254,6 +255,11 @@ export function updateCurrentUser(payload: {
zammadTimeUnitRoundingMinutes?: number;
zammadAtossEmailSyncEnabled?: boolean;
zammadArticleTypeId?: number;
projectFollowupEnabled?: boolean;
projectFollowupWeeks?: number;
projectFollowupSecondEnabled?: boolean;
projectFollowupSecondWeeks?: number;
projectFollowupCompleteOnSession?: boolean;
}) {
return request<{ user: AuthUser }>("/api/auth/me", {
method: "PATCH",
@@ -672,6 +678,13 @@ export function updateProjectStatus(projectId: string, closed: boolean) {
});
}
export function acknowledgeProjectReview(projectId: string) {
return request<{ project: { id: string; ticket_id: string; close_review_required: boolean } }>(`/api/projects/${projectId}/review`, {
method: "PATCH",
body: JSON.stringify({ acknowledged: true })
});
}
export function deleteProject(projectId: string) {
return request<{ deleted: { id: string; ticket_id: string } }>(`/api/projects/${projectId}`, {
method: "DELETE"
@@ -882,6 +895,10 @@ export function getStatisticsOverview(period: string, type: "month" | "week" = "
return request<StatisticsOverview>(`/api/statistics/${type === "week" ? "weeks" : "months"}/${period}`);
}
export function getProjectFollowups() {
return request<StatisticsProjectFollowupOverview>("/api/statistics/project-followups");
}
export function getTicketPeriod(type: PeriodType, period: string, ticketId: string) {
return request<TicketPeriod>(`/api/periods/${periodPath(type)}/${period}/tickets/${ticketId}`);
}
+52
View File
@@ -19,6 +19,11 @@ export type AuthUser = {
zammad_time_unit_rounding_minutes: number;
zammad_atoss_email_sync_enabled: boolean;
zammad_article_type_id: number;
project_followup_enabled: boolean;
project_followup_weeks: number;
project_followup_second_enabled: boolean;
project_followup_second_weeks: number;
project_followup_complete_on_session: boolean;
};
export type DashboardWidgetType = "dayControl" | "today" | "month" | "openRatings" | "projectBudgets" | "todos" | "teamspace" | "favorites" | "divider" | "statistic" | "notes";
@@ -135,6 +140,11 @@ export type TicketMeta = {
zammad_priority_id?: string | null;
zammad_state_id?: string | null;
zammad_owner_id?: string | null;
zammad_owner_user_id?: string | null;
zammad_owner_display_name?: string | null;
zammad_owner_username?: string | null;
is_foreign_zammad_owner?: boolean;
can_edit_ticket_data?: boolean;
project_id?: string | null;
is_project?: boolean;
project_name?: string | null;
@@ -179,7 +189,12 @@ export type ProjectSummary = {
work_type: WorkType | null;
zammad_priority_id?: string | null;
zammad_state_id?: string | null;
zammad_ticket_id?: string | null;
zammad_owner_id?: string | null;
ticket_zammad_owner_id?: string | null;
zammad_pending_time?: string | null;
zammad_merged_from_ticket_id?: string | null;
zammad_merged_from_ticket_number?: string | null;
teamspace_project: string | null;
description: string;
goals: string;
@@ -369,8 +384,16 @@ export type TicketPeriod = {
organization_name: string | null;
customer_name: string | null;
work_type: WorkType | null;
zammad_ticket_id?: string | null;
zammad_group_id?: string | null;
zammad_priority_id?: string | null;
zammad_state_id?: string | null;
zammad_owner_id?: string | null;
zammad_owner_user_id?: string | null;
zammad_owner_display_name?: string | null;
zammad_owner_username?: string | null;
is_foreign_zammad_owner?: boolean;
can_edit_ticket_data?: boolean;
closed_at: string | null;
requires_closure: boolean;
recurring_session_count: number;
@@ -477,6 +500,35 @@ export type StatisticsTeamspaceDay = {
project_teamspace_project: string | null;
};
export type StatisticsProjectFollowup = {
project_id: string;
ticket_id: string;
ticket_number: string;
title: string | null;
organization_name: string | null;
teamspace_project: string | null;
hour_budget_minutes: number | null;
consumption_minutes: number;
remaining_minutes: number | null;
latest_tt_update: string;
last_session_at: string | null;
project_updated_at: string;
open_followup_todo_id: string | null;
followup_state: "open" | "done" | "dismissed" | null;
open_todos: number;
overdue_todos: number;
};
export type StatisticsProjectFollowupOverview = {
enabled: boolean;
weeks: number;
secondEnabled: boolean;
secondWeeks: number;
createdTodos: number;
updatedTodos: number;
projects: StatisticsProjectFollowup[];
};
export type StatisticsOverview = {
periodType: "month" | "week";
period: string;
+4 -2
View File
@@ -1861,12 +1861,14 @@ function DayControlWidget({ stats, projects, todos, onNavigate }: WidgetContentP
const percent = projectConsumptionPercent(project);
const hasUnassignedSessions = Number(project.unassigned_teamspace_session_count ?? 0) > 0;
const detail = project.close_review_required
? "Abschluss prüfen"
? project.zammad_merged_from_ticket_id || project.zammad_state_id !== "4" ? "Merge prüfen" : "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."
? project.zammad_merged_from_ticket_id || project.zammad_state_id !== "4"
? "Lokale Projektdaten aus dem alten Ticket prüfen und in Zammad nachtragen."
: "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.";
+99 -1
View File
@@ -31,6 +31,11 @@ export function ProfilePage({ currentUser, onUserUpdated }: ProfilePageProps) {
const [zammadTimeUnitRoundingEnabled, setZammadTimeUnitRoundingEnabled] = useState(currentUser.zammad_time_unit_rounding_enabled);
const [zammadTimeUnitRoundingMinutes, setZammadTimeUnitRoundingMinutes] = useState(currentUser.zammad_time_unit_rounding_minutes);
const [zammadArticleTypeId, setZammadArticleTypeId] = useState<ZammadArticleTypeId>(currentUser.zammad_article_type_id ?? 6);
const [projectFollowupEnabled, setProjectFollowupEnabled] = useState(currentUser.project_followup_enabled ?? true);
const [projectFollowupWeeks, setProjectFollowupWeeks] = useState(currentUser.project_followup_weeks ?? 4);
const [projectFollowupSecondEnabled, setProjectFollowupSecondEnabled] = useState(currentUser.project_followup_second_enabled ?? true);
const [projectFollowupSecondWeeks, setProjectFollowupSecondWeeks] = useState(currentUser.project_followup_second_weeks ?? 2);
const [projectFollowupCompleteOnSession, setProjectFollowupCompleteOnSession] = useState(currentUser.project_followup_complete_on_session ?? true);
const [zammadQueue, setZammadQueue] = useState<ZammadSessionArticleQueueItem[]>([]);
const [loadingZammadQueue, setLoadingZammadQueue] = useState(false);
const [retryingZammadQueue, setRetryingZammadQueue] = useState(false);
@@ -48,6 +53,11 @@ export function ProfilePage({ currentUser, onUserUpdated }: ProfilePageProps) {
setZammadTimeUnitRoundingEnabled(currentUser.zammad_time_unit_rounding_enabled);
setZammadTimeUnitRoundingMinutes(currentUser.zammad_time_unit_rounding_minutes);
setZammadArticleTypeId(currentUser.zammad_article_type_id ?? 6);
setProjectFollowupEnabled(currentUser.project_followup_enabled ?? true);
setProjectFollowupWeeks(currentUser.project_followup_weeks ?? 4);
setProjectFollowupSecondEnabled(currentUser.project_followup_second_enabled ?? true);
setProjectFollowupSecondWeeks(currentUser.project_followup_second_weeks ?? 2);
setProjectFollowupCompleteOnSession(currentUser.project_followup_complete_on_session ?? true);
}, [currentUser]);
async function loadZammadQueue() {
@@ -132,7 +142,12 @@ export function ProfilePage({ currentUser, onUserUpdated }: ProfilePageProps) {
zammadSessionNoteDefaultEnabled,
zammadTimeUnitRoundingEnabled,
zammadTimeUnitRoundingMinutes,
zammadArticleTypeId
zammadArticleTypeId,
projectFollowupEnabled,
projectFollowupWeeks,
projectFollowupSecondEnabled,
projectFollowupSecondWeeks,
projectFollowupCompleteOnSession
}
: {})
};
@@ -334,6 +349,89 @@ export function ProfilePage({ currentUser, onUserUpdated }: ProfilePageProps) {
onCheckedChange={setSessionBillingPromptEnabled}
/>
</div>
<div className="mt-3 space-y-3 rounded-md border bg-background p-3">
<div className="flex items-center justify-between gap-3">
<div>
<label className="text-sm font-medium" htmlFor="profile-project-followup-enabled">
Automatische Wiedervorlagen
</label>
<p className="text-xs text-muted-foreground">Consulting-Projekte automatisch auf Wiedervorlage setzen.</p>
</div>
<Switch
id="profile-project-followup-enabled"
checked={projectFollowupEnabled}
onCheckedChange={setProjectFollowupEnabled}
/>
</div>
{projectFollowupEnabled ? (
<div className="space-y-3 border-t pt-3">
<div>
<p className="text-sm font-medium">1. Wiedervorlage aktiv</p>
<p className="text-xs text-muted-foreground">Wenn automatische Wiedervorlagen aktiv sind, wird die erste WVL immer geprüft.</p>
</div>
<div className="grid gap-2 sm:max-w-xs">
<label className="text-sm font-medium" htmlFor="profile-project-followup-weeks">
1. Wiedervorlage nach Wochen
</label>
<Input
id="profile-project-followup-weeks"
type="number"
min={1}
max={52}
value={projectFollowupWeeks}
onChange={(event) => setProjectFollowupWeeks(Math.max(1, Math.min(52, Number(event.currentTarget.value) || 1)))}
/>
</div>
<div className="flex items-center justify-between gap-3">
<div>
<label className="text-sm font-medium" htmlFor="profile-project-followup-second-enabled">
2. Wiedervorlage
</label>
<p className="text-xs text-muted-foreground">Nur anlegen, wenn die erste WVL erledigt oder gelöscht wurde und keine Session entstanden ist.</p>
</div>
<Switch
id="profile-project-followup-second-enabled"
checked={projectFollowupSecondEnabled}
onCheckedChange={setProjectFollowupSecondEnabled}
/>
</div>
{projectFollowupSecondEnabled ? (
<div className="grid gap-2 sm:max-w-xs">
<label className="text-sm font-medium" htmlFor="profile-project-followup-second-weeks">
2. Wiedervorlage nach weiteren Wochen
</label>
<Input
id="profile-project-followup-second-weeks"
type="number"
min={1}
max={52}
value={projectFollowupSecondWeeks}
onChange={(event) => setProjectFollowupSecondWeeks(Math.max(1, Math.min(52, Number(event.currentTarget.value) || 1)))}
/>
</div>
) : null}
</div>
) : null}
<div className="flex items-center justify-between gap-3">
<div>
<label className="text-sm font-medium" htmlFor="profile-project-followup-complete-on-session">
Automatische Wiedervorlagen durch Session erledigen
</label>
<p className="text-xs text-muted-foreground">Gilt für beide Zeiträume. Betrifft nur automatisch erzeugte Wiedervorlagen, niemals manuelle ToDos.</p>
</div>
<Switch
id="profile-project-followup-complete-on-session"
checked={projectFollowupCompleteOnSession}
onCheckedChange={setProjectFollowupCompleteOnSession}
/>
</div>
</div>
<div className="mt-3 flex items-center justify-between gap-3 rounded-md border bg-background p-3">
<div className="flex items-center gap-2">
<FileText className="size-4 text-muted-foreground" />
+37 -5
View File
@@ -15,7 +15,7 @@ import { HelpLink } from "@/components/HelpLink";
import { MultiFilterDropdown, type MultiFilterOption } from "@/components/MultiFilterDropdown";
import { OrganizationSelect } from "@/components/OrganizationSelect";
import { cn } from "@/lib/utils";
import { addFavoriteTicket, createProject, getProjects, hideProject, lookupTicket, unhideProject, updateProject, updateProjectStatus } from "../api";
import { acknowledgeProjectReview, addFavoriteTicket, createProject, getProjects, hideProject, lookupTicket, unhideProject, updateProject, updateProjectStatus } from "../api";
import { currentMonth, formatMinutes } from "../format";
import { projectHasRecurringBilling } from "../recurring-billings";
import { ticketPattern } from "../timers";
@@ -1359,6 +1359,21 @@ export function ProjectsPage({ onNavigate, onStartTimer }: ProjectsPageProps) {
}
}
async function acknowledgeProjectReviewCheck(project: ProjectSummary) {
setSavingProjectStatusId(project.id);
try {
await acknowledgeProjectReview(project.id);
toast.success("Prüfung bestätigt");
await load();
} catch (error) {
toast.error("Prüfung konnte nicht bestätigt werden", {
description: error instanceof Error ? error.message : "Unbekannter Fehler"
});
} finally {
setSavingProjectStatusId(null);
}
}
async function saveProjectFavorite(project: ProjectSummary) {
try {
await addFavoriteTicket(project.ticket_number);
@@ -1649,7 +1664,7 @@ export function ProjectsPage({ onNavigate, onStartTimer }: ProjectsPageProps) {
const remaining = projectRemaining(project, viewSettings.standardTeamspaceOnly);
const usedPercent = projectConsumptionPercent(project, viewSettings.standardTeamspaceOnly);
const showBudgetedTeamspaceEntries = !viewSettings.standardTeamspaceOnly && budgetedTeamspaceEntries.length > 1;
const canEditProject = project.can_edit !== false;
const canEditProject = project.can_edit === true;
return (
<div key={project.id} className="border-b last:border-b-0">
@@ -1769,9 +1784,26 @@ export function ProjectsPage({ onNavigate, onStartTimer }: ProjectsPageProps) {
</p>
) : null}
{project.close_review_required ? (
<p className="rounded-md border border-amber-500/30 bg-amber-500/10 px-2 py-1 text-xs text-amber-700 dark:text-amber-300">
Zammad meldet das Consulting-Ticket als geschlossen. Bitte Zeiten in Teamspace und TicketTracker prüfen und das Projekt danach schließen.
</p>
<div className="flex flex-col gap-2 rounded-md border border-amber-500/30 bg-amber-500/10 px-2 py-1 text-xs text-amber-700 dark:text-amber-300 sm:flex-row sm:items-center sm:justify-between">
<span>
{project.zammad_merged_from_ticket_id || project.zammad_state_id !== "4"
? project.zammad_merged_from_ticket_id
? `Dieses Projekt wurde aus ${project.zammad_merged_from_ticket_number ?? `Zammad-ID ${project.zammad_merged_from_ticket_id}`} übernommen. Teamspace-Projekte und Budget bleiben lokal erhalten; bitte in Zammad prüfen und nachtragen.`
: "Dieses Projekt steht zur TS/TT-Prüfung, obwohl das Zammad-Ticket offen ist. Teamspace-Projekte und Budget bleiben lokal erhalten; bitte in Zammad prüfen und nachtragen."
: "Zammad meldet das Consulting-Ticket als geschlossen. Bitte Zeiten in Teamspace und TicketTracker prüfen und das Projekt danach schließen."}
</span>
<Button
type="button"
variant="outline"
size="sm"
className="h-7 shrink-0 px-2 text-xs"
disabled={savingProjectStatusId === project.id || !canEditProject}
onClick={() => void acknowledgeProjectReviewCheck(project)}
>
<CheckCircle2 className="mr-1.5 h-3.5 w-3.5" />
Geprüft
</Button>
</div>
) : null}
{showBudgetedTeamspaceEntries ? (
<div className="grid gap-1.5 md:grid-cols-2">
+161 -3
View File
@@ -1,9 +1,11 @@
import {
ChevronLeft,
ChevronRight,
BellPlus,
CircleAlert,
Clock3,
ExternalLink,
ListChecks,
RotateCcw,
Ticket,
TrendingUp,
@@ -20,11 +22,12 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/com
import { Input } from "@/components/ui/input";
import { Pagination, PaginationContent, PaginationItem, PaginationNext, PaginationPrevious } from "@/components/ui/pagination";
import { Progress } from "@/components/ui/progress";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { HelpLink } from "@/components/HelpLink";
import { getStatisticsOverview } from "../api";
import { currentMonth, formatDate, formatMinutes } from "../format";
import type { StatisticsDailyBucket, StatisticsGroup, StatisticsOverview, StatisticsTicket, WorkType } from "../types";
import { getProjectFollowups, getStatisticsOverview } from "../api";
import { currentMonth, formatDate, formatDateTime, formatMinutes } from "../format";
import type { StatisticsDailyBucket, StatisticsGroup, StatisticsOverview, StatisticsProjectFollowup, StatisticsProjectFollowupOverview, StatisticsTicket, WorkType } from "../types";
type StatisticsPageProps = {
onNavigate: (to: string) => void;
@@ -361,12 +364,72 @@ function TicketHotspot({ ticket, month, onNavigate }: { ticket: StatisticsTicket
);
}
function daysSince(value: string) {
const then = new Date(value).getTime();
if (!Number.isFinite(then)) {
return null;
}
return Math.max(0, Math.floor((Date.now() - then) / 86_400_000));
}
function ProjectFollowupRow({ project, onNavigate }: { project: StatisticsProjectFollowup; onNavigate: (to: string) => void }) {
const inactiveDays = daysSince(project.latest_tt_update);
const hasBudget = project.hour_budget_minutes !== null;
return (
<div className="grid gap-3 rounded-md border bg-background p-3 lg:grid-cols-[minmax(0,1fr)_auto] lg:items-center">
<div className="min-w-0 space-y-2">
<div className="flex flex-wrap items-center gap-2">
<span className="font-semibold">{project.ticket_number}</span>
{hasBudget ? (
<Badge variant={project.remaining_minutes !== null && project.remaining_minutes > 0 ? "warning" : "secondary"}>
{project.remaining_minutes !== null && project.remaining_minutes > 0 ? `${formatMinutes(project.remaining_minutes)} offen` : "Budget verbraucht"}
</Badge>
) : (
<Badge variant="info">ohne Budget</Badge>
)}
{project.open_followup_todo_id ? <Badge variant="secondary">ToDo erstellt</Badge> : null}
{project.followup_state === "done" ? <Badge variant="success">erledigt</Badge> : null}
{project.followup_state === "dismissed" ? <Badge variant="outline">gelöscht</Badge> : null}
{project.overdue_todos > 0 ? <Badge variant="destructive">{project.overdue_todos} überfällig</Badge> : null}
</div>
<div className="min-w-0">
<p className="truncate text-sm">{project.title ?? project.teamspace_project ?? "Projekt ohne Titel"}</p>
<p className="truncate text-xs text-muted-foreground">{project.organization_name ?? "Keine Organisation"}</p>
</div>
<div className="flex flex-wrap gap-x-3 gap-y-1 text-xs text-muted-foreground">
<span>Letztes TT-Update {formatDateTime(project.latest_tt_update)}</span>
{inactiveDays !== null ? <span>{inactiveDays} Tag(e) inaktiv</span> : null}
<span>Budget {hasBudget ? formatMinutes(project.hour_budget_minutes!) : "nicht gesetzt"}</span>
<span>Verbraucht {formatMinutes(project.consumption_minutes)}</span>
<span>{project.open_todos} offene ToDo(s)</span>
</div>
</div>
<div className="flex flex-wrap gap-2 lg:justify-end">
<Button variant="secondary" size="sm" onClick={() => onNavigate(`/tickets/${project.ticket_id}?month=${currentMonth()}`)}>
Ticket
<ExternalLink className="size-4" />
</Button>
<Button variant="secondary" size="sm" onClick={() => onNavigate("/projects")}>
Projekte
<ExternalLink className="size-4" />
</Button>
</div>
</div>
);
}
export function StatisticsPage({ onNavigate }: StatisticsPageProps) {
const [month, setMonth] = useState(currentMonth());
const [stats, setStats] = useState<StatisticsOverview | null>(null);
const [followups, setFollowups] = useState<StatisticsProjectFollowupOverview | null>(null);
const [loading, setLoading] = useState(false);
const [loadingFollowups, setLoadingFollowups] = useState(false);
const [organizationPage, setOrganizationPage] = useState(1);
const loadRequestId = useRef(0);
const followupRequestId = useRef(0);
async function load() {
const requestId = loadRequestId.current + 1;
@@ -396,6 +459,44 @@ export function StatisticsPage({ onNavigate }: StatisticsPageProps) {
void load();
}, [month]);
async function loadFollowups() {
const requestId = followupRequestId.current + 1;
followupRequestId.current = requestId;
setLoadingFollowups(true);
try {
const result = await getProjectFollowups();
if (requestId === followupRequestId.current) {
setFollowups(result);
if (result.createdTodos > 0 || result.updatedTodos > 0) {
window.dispatchEvent(new Event("tickettracker:todos-changed"));
}
if (result.createdTodos > 0) {
toast.success("Wiedervorlagen erstellt", {
description: `${result.createdTodos} ToDo(s) automatisch angelegt.`
});
}
}
} catch (error) {
if (requestId === followupRequestId.current) {
toast.error("Wiedervorlagen konnten nicht geladen werden", {
description: error instanceof Error ? error.message : "Unbekannter Fehler"
});
}
} finally {
if (requestId === followupRequestId.current) {
setLoadingFollowups(false);
}
}
}
useEffect(() => {
void loadFollowups();
}, []);
useEffect(() => {
function refreshVisible() {
if (document.visibilityState === "visible") {
@@ -476,6 +577,15 @@ export function StatisticsPage({ onNavigate }: StatisticsPageProps) {
</div>
</div>
<Tabs defaultValue="overview" className="space-y-5">
<TabsList className="grid h-auto w-full grid-cols-2 gap-1 sm:grid-cols-4">
<TabsTrigger value="overview">Überblick</TabsTrigger>
<TabsTrigger value="groups">Kunden & Typen</TabsTrigger>
<TabsTrigger value="tickets">Tickets</TabsTrigger>
<TabsTrigger value="followups">Wiedervorlagen</TabsTrigger>
</TabsList>
<TabsContent value="overview" className="space-y-5">
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
<MetricCard
label="Abgerechnet"
@@ -597,6 +707,9 @@ export function StatisticsPage({ onNavigate }: StatisticsPageProps) {
</CardContent>
</Card>
</TabsContent>
<TabsContent value="groups" className="space-y-5">
<div className="grid gap-3 xl:grid-cols-[1.45fr_0.8fr]">
<Card>
<CardHeader className="gap-3 p-4 sm:flex-row sm:items-start sm:justify-between sm:space-y-0">
@@ -731,6 +844,9 @@ export function StatisticsPage({ onNavigate }: StatisticsPageProps) {
</Card>
</div>
</TabsContent>
<TabsContent value="tickets" className="space-y-5">
<Card>
<CardHeader className="gap-3 p-4 sm:flex-row sm:items-start sm:justify-between sm:space-y-0">
<div>
@@ -768,6 +884,48 @@ export function StatisticsPage({ onNavigate }: StatisticsPageProps) {
</CardContent>
</Card>
) : null}
</TabsContent>
<TabsContent value="followups" className="space-y-5">
<Card>
<CardHeader className="gap-3 p-4 sm:flex-row sm:items-start sm:justify-between sm:space-y-0">
<div>
<CardTitle>Automatische Wiedervorlagen</CardTitle>
<CardDescription>
{followups?.enabled === false
? "Automatische Wiedervorlagen sind im Profil deaktiviert."
: followups?.secondEnabled === false
? `Erste Wiedervorlage nach ${followups?.weeks ?? "-"} Woche(n).`
: `Erste Wiedervorlage nach ${followups?.weeks ?? "-"} Woche(n), zweite nach weiteren ${followups?.secondWeeks ?? "-"} Woche(n) ohne Session.`}
</CardDescription>
</div>
<div className="flex items-center gap-2">
<Badge variant="outline">{followups?.projects.length ?? 0} Projekt(e)</Badge>
{loadingFollowups ? <Badge variant="secondary">lädt...</Badge> : null}
<Button type="button" size="sm" variant="secondary" onClick={() => void loadFollowups()} disabled={loadingFollowups}>
<BellPlus className="size-4" />
Prüfen
</Button>
</div>
</CardHeader>
<CardContent className="space-y-3 px-4 pb-4">
{(followups?.projects ?? []).map((project) => (
<ProjectFollowupRow key={project.project_id} project={project} onNavigate={onNavigate} />
))}
{!loadingFollowups && followups && followups.projects.length === 0 ? (
<div className="grid place-items-center gap-2 rounded-md border bg-muted/20 p-8 text-center">
<ListChecks className="size-8 text-muted-foreground" />
<div>
<p className="font-medium">Keine Wiedervorlagen nötig</p>
<p className="text-sm text-muted-foreground">Alle offenen Projektbudgets wurden innerhalb des eingestellten Zeitraums aktualisiert.</p>
</div>
</div>
) : null}
{loadingFollowups && !followups ? <p className="py-8 text-center text-sm text-muted-foreground">Wiedervorlagen werden geprüft...</p> : null}
</CardContent>
</Card>
</TabsContent>
</Tabs>
</div>
);
}
+118 -16
View File
@@ -15,6 +15,7 @@ import { OrganizationSelect } from "@/components/OrganizationSelect";
import { readRichActivityEditorValue, RichActivityEditor } from "@/components/RichActivityEditor";
import { TodoPanel } from "@/components/TodoPanel";
import {
acknowledgeProjectReview,
createSession,
deleteProject,
deleteSession,
@@ -128,6 +129,10 @@ function notifyZammadArticleResult(result: CreateSessionResult) {
}
}
function notifyTodosChanged() {
window.dispatchEvent(new Event("tickettracker:todos-changed"));
}
function zammadSyncStorageKey(userId: string) {
return `tickettracker.zammad-sync.${userId}`;
}
@@ -258,6 +263,18 @@ function teamspaceBillingKey(day: string, projectTeamspaceEntryId: string | null
return `${projectTeamspaceEntryId ?? "ticket"}::${day}`;
}
function dayBillingProjectEntryIdForView(
projectTeamspaceEntryId: string | null | undefined,
hasMultipleProjectTeamspaceEntries: boolean,
defaultProjectEntryId: string | null
) {
if (!hasMultipleProjectTeamspaceEntries) {
return null;
}
return projectTeamspaceEntryId ?? defaultProjectEntryId;
}
function projectEditForm(project: ProjectSummary): ProjectEditFormState {
const entries = projectTeamspaceEntries(project).map((entry, index) => ({
clientId: entry.id || projectEntryId(),
@@ -823,10 +840,12 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onS
}, [periodType, period, ticketId]);
useEffect(() => {
const defaultEntryId = data?.project ? projectTeamspaceEntries(data.project).find((entry) => entry.is_default)?.id ?? null : null;
const loadedProjectEntries = data?.project ? projectTeamspaceEntries(data.project) : [];
const hasMultipleEntries = loadedProjectEntries.length > 1;
const defaultEntryId = loadedProjectEntries.find((entry) => entry.is_default)?.id ?? null;
const nextInputs = Object.fromEntries(
(data?.dayBillings ?? []).map((billing) => [
teamspaceBillingKey(billing.day, billing.project_teamspace_entry_id ?? defaultEntryId),
teamspaceBillingKey(billing.day, dayBillingProjectEntryIdForView(billing.project_teamspace_entry_id, hasMultipleEntries, defaultEntryId)),
formatHoursInput(billing.billed_minutes)
])
);
@@ -893,8 +912,17 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onS
const projectBudgetMinutes = project?.hour_budget_minutes ?? null;
const projectConsumptionUsagePercent = project ? projectConsumptionPercent(projectConsumptionMinutes, projectBudgetMinutes) : null;
const projectRemainingMinutes = projectBudgetMinutes && projectBudgetMinutes > 0 ? projectBudgetMinutes - projectConsumptionMinutes : null;
const canEditProject = project?.can_edit !== false;
const canEditTicketData = !project || canEditProject;
const canEditProject = project?.can_edit === true;
const ticketManagedByZammad = Boolean(ticket?.zammad_ticket_id);
const canEditTicketData = ticket?.can_edit_ticket_data === true;
const zammadOwnerLabel = ticket?.zammad_owner_display_name
|| ticket?.zammad_owner_username
|| (ticket?.zammad_owner_id ? `Zammad-Owner ${ticket.zammad_owner_id}` : null);
const projectOwnerLabel = project?.owner_display_name
|| project?.owner_username
|| (project?.zammad_owner_id ? `Zammad-Owner ${project.zammad_owner_id}` : null)
|| zammadOwnerLabel;
const currentZammadUserLabel = currentUser.zammad_user_id ? `Zammad-User ${currentUser.zammad_user_id}` : "dein Zammad-User";
const isProject = Boolean(project);
const isGlobalTicket = periodType === "all";
const isPeriodClosed = Boolean(data?.closed);
@@ -919,11 +947,11 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onS
() =>
new Map(
(data?.dayBillings ?? []).map((billing) => [
teamspaceBillingKey(billing.day, billing.project_teamspace_entry_id ?? defaultProjectEntryId),
teamspaceBillingKey(billing.day, dayBillingProjectEntryIdForView(billing.project_teamspace_entry_id, requiresProjectTeamspaceEntry, defaultProjectEntryId)),
billing.billed_minutes
])
),
[data?.dayBillings, defaultProjectEntryId]
[data?.dayBillings, defaultProjectEntryId, requiresProjectTeamspaceEntry]
);
const savedDayBillingInput = (key: string) => formatHoursInput(dayBillingMap.get(key));
const isDayBillingDirty = (key: string) => (dayBillingInputs[key] ?? "") !== savedDayBillingInput(key);
@@ -1026,6 +1054,7 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onS
const periodTeamspaceMinutes = (data?.dayBillings ?? []).reduce((sum, billing) => sum + Number(billing.billed_minutes ?? 0), 0);
const ticketDataDirty = Boolean(
ticket &&
canEditTicketData &&
(editTicketNumber !== ticket.ticket_number ||
editTicketTitle !== (ticket.title ?? "") ||
editOrganizationId !== (ticket.organization_id ?? "") ||
@@ -1185,6 +1214,9 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onS
: `${result.created} angelegt, ${result.skipped} ohne Transfer.`;
const notify = result.created === 0 && result.errors.length > 0 ? toast.error : toast.success;
notify("Mail-Sessions importiert", { description });
if (result.created > 0) {
notifyTodosChanged();
}
await load();
} catch (error) {
toast.error("Mail-Sessions konnten nicht importiert werden", {
@@ -1262,8 +1294,10 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onS
}
if (!canEditTicketData) {
toast.error("Projekt ist geteilt", {
description: "Nur der Zammad-Owner kann Ticket- und Projektdaten ändern."
toast.error(ticketManagedByZammad ? "Ticketdaten kommen aus Zammad" : "Ticket gehört einem anderen Owner", {
description: ticketManagedByZammad
? "Nummer, Art, Titel und Organisation werden durch den Zammad-Sync verwaltet."
: "Nur der Owner kann Ticketdaten ändern."
});
return;
}
@@ -1312,7 +1346,7 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onS
return;
}
if (project.can_edit === false) {
if (!canEditProject) {
toast.error("Projekt ist geteilt", {
description: "Nur der Zammad-Owner kann die Projektdaten bearbeiten."
});
@@ -1330,7 +1364,7 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onS
return;
}
if (project.can_edit === false) {
if (!canEditProject) {
toast.error("Projekt ist geteilt", {
description: "Nur der Zammad-Owner kann die Projektdaten bearbeiten."
});
@@ -1385,7 +1419,7 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onS
return;
}
if (project.can_edit === false) {
if (!canEditProject) {
toast.error("Projekt ist geteilt", {
description: "Nur der Zammad-Owner kann den Projektstatus ändern."
});
@@ -1406,6 +1440,32 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onS
}
}
async function acknowledgeProjectReviewCheck() {
if (!project) {
return;
}
if (!canEditProject) {
toast.error("Projekt ist geteilt", {
description: "Nur der Zammad-Owner kann die Prüfung bestätigen."
});
return;
}
setSavingProjectStatus(true);
try {
await acknowledgeProjectReview(project.id);
toast.success("Prüfung bestätigt");
await load();
} catch (error) {
toast.error("Prüfung konnte nicht bestätigt werden", {
description: error instanceof Error ? error.message : "Unbekannter Fehler"
});
} finally {
setSavingProjectStatus(false);
}
}
async function submitTicketSession(event: FormEvent) {
event.preventDefault();
@@ -1487,6 +1547,7 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onS
description: "Der Eintrag wurde diesem Ticket zugeordnet. Betroffene Abschlüsse wurden wieder geöffnet."
});
notifyZammadArticleResult(result);
notifyTodosChanged();
setTicketSession({
...emptyTicketSessionForm(),
organizationId: ticket.organization_id ?? "",
@@ -1762,6 +1823,26 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onS
</Alert>
) : null}
{ticket?.is_foreign_zammad_owner ? (
<Alert className="py-3 text-sm">
<AlertCircle className="size-4" />
<AlertTitle>Anderer Zammad-Owner</AlertTitle>
<AlertDescription>
Dieses Ticket gehört in Zammad {zammadOwnerLabel ? `${zammadOwnerLabel}` : "einem anderen Owner"}; du bist {currentZammadUserLabel}. Du kannst deine Sessions sehen, Projekt- und Ticketdaten sind für dich geschützt.
</AlertDescription>
</Alert>
) : null}
{ticketManagedByZammad ? (
<Alert className="py-3 text-sm">
<AlertCircle className="size-4" />
<AlertTitle>Ticketdaten werden von Zammad verwaltet</AlertTitle>
<AlertDescription>
Nummer, Art, Titel und Organisation wurden durch den Zammad-Sync übernommen und können in TicketTracker nicht manuell geändert werden.
</AlertDescription>
</Alert>
) : null}
<div className="grid gap-4">
<Card>
<CardHeader className="p-4 pb-2">
@@ -1771,7 +1852,9 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onS
<CardDescription>
{canEditTicketData
? "Änderungen an Organisation und Art werden auf alle vorhandenen Sessions dieses Tickets übernommen."
: "Dieses Projekt ist geteilt. Nur der Zammad-Owner kann Ticket- und Projektdaten ändern."}
: ticketManagedByZammad
? "Nummer, Art, Titel und Organisation kommen aus Zammad und sind lokal gesperrt."
: "Nur der Owner kann Ticketdaten ändern."}
</CardDescription>
</div>
<HelpLink anchor="ticketdaten" label="Hilfe zu Ticketdaten" />
@@ -1842,8 +1925,8 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onS
{(project.unassigned_teamspace_session_count ?? 0) > 0 ? <Badge variant="warning">{project.unassigned_teamspace_session_count} ohne TS</Badge> : null}
</CardTitle>
<CardDescription>
{!canEditProject && project.owner_display_name
? `Eigentümer: ${project.owner_display_name}`
{!canEditProject && projectOwnerLabel
? `Eigentümer: ${projectOwnerLabel}`
: showBudgetedProjectEntries
? `${budgetedProjectEntries.length} budgetierte Teamspace-Einträge`
: budgetedProjectEntries[0] ? teamspaceEntryDisplayLabel(budgetedProjectEntries[0]) : project.teamspace_project || "Kein Teamspace-Projekt hinterlegt"}
@@ -1879,9 +1962,28 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onS
) : null}
{project.close_review_required ? (
<Alert variant="warning">
<div className="font-medium">Zeiten prüfen</div>
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div className="space-y-1">
<div className="font-medium">{project.zammad_merged_from_ticket_id || project.zammad_state_id !== "4" ? "Merge prüfen" : "Zeiten prüfen"}</div>
<div className="text-sm text-muted-foreground">
Zammad meldet dieses Consulting-Ticket als geschlossen. Bitte Zeiten in Teamspace und TicketTracker prüfen und das Projekt danach schließen.
{project.zammad_merged_from_ticket_id || project.zammad_state_id !== "4"
? project.zammad_merged_from_ticket_id
? `Dieses Projekt wurde aus ${project.zammad_merged_from_ticket_number ?? `Zammad-ID ${project.zammad_merged_from_ticket_id}`} übernommen. Teamspace-Projekte, Budget und Zuordnungen bleiben lokal erhalten; bitte in Zammad prüfen und nachtragen.`
: "Dieses Projekt steht zur TS/TT-Prüfung, obwohl das Zammad-Ticket offen ist. Teamspace-Projekte, Budget und Zuordnungen bleiben lokal erhalten; bitte in Zammad prüfen und nachtragen."
: "Zammad meldet dieses Consulting-Ticket als geschlossen. Bitte Zeiten in Teamspace und TicketTracker prüfen und das Projekt danach schließen."}
</div>
</div>
<Button
type="button"
variant="outline"
size="sm"
className="shrink-0"
disabled={savingProjectStatus || !canEditProject}
onClick={() => void acknowledgeProjectReviewCheck()}
>
<CheckCircle2 className="mr-2 h-4 w-4" />
Geprüft
</Button>
</div>
</Alert>
) : null}
+6
View File
@@ -141,6 +141,10 @@ function notifyZammadArticleResult(result: CreateSessionResult) {
}
}
function notifyTodosChanged() {
window.dispatchEvent(new Event("tickettracker:todos-changed"));
}
function escapeHtml(value: string) {
return value
.replace(/&/g, "&amp;")
@@ -773,6 +777,7 @@ export function TimerPage({ timers, setTimers, selectedTimerId, setSelectedTimer
description: `${finished.ticketNumber} wurde mit ${finished.roundedMinutes} Minute(n) erfasst.`
});
notifyZammadArticleResult(result);
notifyTodosChanged();
try {
const timerResult = await deleteActiveTimer(finished.timerId);
removeTimerActivityDraft(currentUser.id, finished.timerId);
@@ -868,6 +873,7 @@ export function TimerPage({ timers, setTimers, selectedTimerId, setSelectedTimer
description: manualLookupTicket?.is_project ? "Der Eintrag wurde dem Projekt zugeordnet." : "Der Eintrag ist in deiner Ticketliste offen."
});
notifyZammadArticleResult(result);
notifyTodosChanged();
setManualSession({
...emptyManualSession(),
day: manualSession.day,
+57 -34
View File
@@ -21,6 +21,7 @@ type TodosPageProps = {
type TodoSortMode = "custom" | "priority" | "title" | "ticket" | "organization" | "date" | "updated";
type TodoGroupMode = "ticket" | "priority" | "organization" | "status";
type CalendarDayMode = "current";
type TodoPreferences = {
status: TodoStatus | "all";
@@ -277,6 +278,23 @@ function sortTodos(todos: TicketTodo[], sortMode: TodoSortMode) {
return [...todos].sort((left, right) => compareTodos(left, right, sortMode));
}
function uniqueTodos(todos: TicketTodo[]) {
return todos.filter((todo, index, list) => list.findIndex((item) => item.id === todo.id) === index);
}
function sortCurrentTodos(todos: TicketTodo[], today: string) {
return [...todos].sort((left, right) => {
const leftOverdue = isTodoOverdue(left, today);
const rightOverdue = isTodoOverdue(right, today);
if (leftOverdue !== rightOverdue) {
return leftOverdue ? -1 : 1;
}
return priorityOrder[left.priority] - priorityOrder[right.priority] || left.sort_order - right.sort_order || compareText(left.title, right.title);
});
}
function matchesTicketSearch(todo: TicketTodo, search: string) {
const normalizedSearch = normalizeSearch(search);
@@ -400,9 +418,9 @@ export function TodosPage({ onNavigate }: TodosPageProps) {
const [modalEndsOn, setModalEndsOn] = useState("");
const [modalBusy, setModalBusy] = useState(false);
const [selectedCalendarDay, setSelectedCalendarDay] = useState<string | null>(null);
const [selectedCalendarDayMode, setSelectedCalendarDayMode] = useState<"summarized" | "overdue">("summarized");
const [selectedCalendarDayMode, setSelectedCalendarDayMode] = useState<CalendarDayMode>("current");
const [returnCalendarDay, setReturnCalendarDay] = useState<string | null>(null);
const [returnCalendarDayMode, setReturnCalendarDayMode] = useState<"summarized" | "overdue">("summarized");
const [returnCalendarDayMode, setReturnCalendarDayMode] = useState<CalendarDayMode>("current");
async function load() {
setLoading(true);
@@ -422,6 +440,13 @@ export function TodosPage({ onNavigate }: TodosPageProps) {
void load();
}, []);
useEffect(() => {
const onTodosChanged = () => void load();
window.addEventListener("tickettracker:todos-changed", onTodosChanged);
return () => window.removeEventListener("tickettracker:todos-changed", onTodosChanged);
}, []);
useEffect(() => {
window.localStorage.setItem(todoPreferencesKey, JSON.stringify(preferences));
}, [preferences]);
@@ -432,7 +457,7 @@ export function TodosPage({ onNavigate }: TodosPageProps) {
if (selectedTodoId && !selectedTodo) {
setSelectedTodoId(null);
setReturnCalendarDay(null);
setReturnCalendarDayMode("summarized");
setReturnCalendarDayMode("current");
}
}, [selectedTodo, selectedTodoId]);
@@ -473,7 +498,19 @@ export function TodosPage({ onNavigate }: TodosPageProps) {
return entries;
}, [calendarDays, todos]);
const selectedCalendarDayTodos = selectedCalendarDay ? calendarTodosByDay.get(selectedCalendarDay)?.[selectedCalendarDayMode] ?? [] : [];
const selectedCalendarDayTodos = useMemo(() => {
if (!selectedCalendarDay) {
return [];
}
const dayTodos = calendarTodosByDay.get(selectedCalendarDay);
if (!dayTodos) {
return [];
}
return sortCurrentTodos(uniqueTodos([...dayTodos.overdue, ...dayTodos.single, ...dayTodos.summarized]), currentDay());
}, [calendarTodosByDay, selectedCalendarDay]);
const counts = useMemo(
() => ({
open: todos.filter((todo) => todo.status === "open").length,
@@ -531,7 +568,7 @@ export function TodosPage({ onNavigate }: TodosPageProps) {
});
}
function openTodoModal(todo: TicketTodo, returnToDay: string | null = null, returnToDayMode: "summarized" | "overdue" = "summarized") {
function openTodoModal(todo: TicketTodo, returnToDay: string | null = null, returnToDayMode: CalendarDayMode = "current") {
setReturnCalendarDay(returnToDay);
setReturnCalendarDayMode(returnToDayMode);
setSelectedTodoId(todo.id);
@@ -545,7 +582,7 @@ export function TodosPage({ onNavigate }: TodosPageProps) {
function closeTodoModal() {
setSelectedTodoId(null);
setReturnCalendarDay(null);
setReturnCalendarDayMode("summarized");
setReturnCalendarDayMode("current");
}
function backToCalendarDay() {
@@ -557,7 +594,7 @@ export function TodosPage({ onNavigate }: TodosPageProps) {
const mode = returnCalendarDayMode;
setSelectedTodoId(null);
setReturnCalendarDay(null);
setReturnCalendarDayMode("summarized");
setReturnCalendarDayMode("current");
setSelectedCalendarDayMode(mode);
setSelectedCalendarDay(day);
}
@@ -904,6 +941,9 @@ export function TodosPage({ onNavigate }: TodosPageProps) {
const summarizedTodos = dayTodos.summarized;
const overdueTodos = dayTodos.overdue;
const totalTodos = singleTodos.length + summarizedTodos.length + overdueTodos.length;
const currentTodos = sortCurrentTodos(uniqueTodos([...overdueTodos, ...singleTodos, ...summarizedTodos]), currentDay());
const visibleCalendarTodos = currentTodos.slice(0, 2);
const additionalTodosCount = Math.max(0, currentTodos.length - visibleCalendarTodos.length);
return (
<div
@@ -918,7 +958,7 @@ export function TodosPage({ onNavigate }: TodosPageProps) {
</div>
{!day.workingDay ? <div className="text-[10px] leading-tight text-muted-foreground">{day.holidayName ?? "Wochenende"}</div> : null}
<div className="space-y-1">
{singleTodos.slice(0, 3).map((todo) => (
{visibleCalendarTodos.map((todo) => (
<button
key={todo.id}
type="button"
@@ -929,31 +969,18 @@ export function TodosPage({ onNavigate }: TodosPageProps) {
<span className="block truncate text-muted-foreground">{todo.ticket_number ?? "Ticket"}</span>
</button>
))}
{singleTodos.length > 3 ? <span className="block px-1 text-[11px] text-muted-foreground">+{singleTodos.length - 3} weitere fixe</span> : null}
</div>
<div className="mt-auto flex flex-wrap gap-1 pt-1">
{summarizedTodos.length > 0 ? (
<div className="mt-auto pt-1">
{additionalTodosCount > 0 ? (
<button
type="button"
className="min-w-0 flex-1 rounded-md border border-muted-foreground/20 bg-background/60 px-2 py-1 text-left text-[11px] leading-tight text-muted-foreground hover:bg-muted"
className="block w-full rounded-md border border-muted-foreground/20 bg-background/60 px-2 py-1 text-left text-[11px] leading-tight text-muted-foreground hover:bg-muted"
onClick={() => {
setSelectedCalendarDayMode("summarized");
setSelectedCalendarDayMode("current");
setSelectedCalendarDay(day.iso);
}}
>
+{summarizedTodos.length} ToDo{summarizedTodos.length === 1 ? "" : "s"}
</button>
) : null}
{overdueTodos.length > 0 ? (
<button
type="button"
className="shrink-0 rounded-md border border-red-500/50 bg-red-500/10 px-2 py-1 text-left text-[11px] font-semibold leading-tight text-red-200 hover:bg-red-500/20"
onClick={() => {
setSelectedCalendarDayMode("overdue");
setSelectedCalendarDay(day.iso);
}}
>
+{overdueTodos.length} üf
+{additionalTodosCount} weitere ToDo{additionalTodosCount === 1 ? "" : "s"}
</button>
) : null}
</div>
@@ -1073,13 +1100,11 @@ export function TodosPage({ onNavigate }: TodosPageProps) {
<Dialog open={Boolean(selectedCalendarDay)} onOpenChange={(open) => !open && setSelectedCalendarDay(null)}>
<DialogContent className="max-h-[calc(100dvh-2rem)] overflow-y-auto sm:max-w-xl">
<DialogHeader>
<DialogTitle>{selectedCalendarDayMode === "overdue" ? "Überfällige ToDos" : "ToDos am Tag"}</DialogTitle>
<DialogTitle>Aktuelle ToDos</DialogTitle>
<DialogDescription>
{selectedCalendarDay
? `${formatTodoDate(selectedCalendarDay)} · ${selectedCalendarDayTodos.length} ${selectedCalendarDayMode === "overdue" ? "überfällige" : "laufende"} ToDo(s)`
: selectedCalendarDayMode === "overdue"
? "Überfällige ToDos dieses Tages"
: "Laufende ToDos dieses Tages"}
? `${formatTodoDate(selectedCalendarDay)} · ${selectedCalendarDayTodos.length} aktuelle ToDo(s)`
: "Aktuelle ToDos dieses Tages"}
</DialogDescription>
</DialogHeader>
<div className="space-y-2">
@@ -1142,9 +1167,7 @@ export function TodosPage({ onNavigate }: TodosPageProps) {
</div>
))}
{selectedCalendarDayTodos.length === 0 ? (
<p className="rounded-md border bg-muted/20 p-4 text-center text-sm text-muted-foreground">
{selectedCalendarDayMode === "overdue" ? "Keine überfälligen ToDos an diesem Tag." : "Keine laufenden ToDos an diesem Tag."}
</p>
<p className="rounded-md border bg-muted/20 p-4 text-center text-sm text-muted-foreground">Keine aktuellen ToDos an diesem Tag.</p>
) : null}
</div>
</DialogContent>
+67 -6
View File
@@ -433,7 +433,7 @@ function formatChangeValue(field: string, value: unknown) {
return formatMinutes(value);
}
if (field === "Teamspace-Projekte" || field === "Teamspace-Prüfung") {
if (field === "Teamspace-Projekte" || field === "Teamspace-Prüfung" || field === "Merge-Projektdaten") {
return formatTeamspaceProjectList(value) || "-";
}
@@ -493,6 +493,10 @@ function changeAllowedByFields(item: ZammadSyncItem, change: ZammadSyncChange, f
return fields.projectName && consultingProjectCanBeTouched;
}
if (change.field === "Merge-Projektdaten" || change.field === "Merge-Prüfung") {
return true;
}
if (change.field === "Teamspace-Budget") {
return fields.budget && consultingProjectCanBeTouched;
}
@@ -578,6 +582,10 @@ function addCachedTeamspaceProjectChanges(changes: ZammadSyncChange[], localProj
}
}
function mergeReviewMatchesCachedZammad(localTeamspaceProjects: unknown, zammadTeamspaceProjects: unknown) {
return !cachedValueChanged("Teamspace-Projekte", localTeamspaceProjects, zammadTeamspaceProjects);
}
function zammadPendingTodoPreview(zammad: Record<string, unknown>) {
const stateId = numericValue(cachedValue(zammad, "stateId"));
const pendingTime = cachedValue(zammad, "pendingTime");
@@ -651,6 +659,33 @@ function addCachedChange(changes: ZammadSyncChange[], field: string, from: unkno
}
}
const preservedExistingPreviewChangeFields = new Set([
"Fremdes Ticket",
"Fremder Zammad-Owner",
"Owner",
"Ticket-Zuordnung",
"Hinweis",
"Ticketnummer",
"TicketTracker-Zusammenführung"
]);
const informationalExistingPreviewChangeFields = new Set([
"Fremdes Ticket",
"Fremder Zammad-Owner",
"Hinweis"
]);
function preserveExistingPreviewChanges(item: ZammadSyncItem, changes: ZammadSyncChange[], fields: ZammadSyncExistingFields) {
const existingFields = new Set(changes.map((change) => change.field));
const resultChanges = [
...item.changes.filter((change) => preservedExistingPreviewChangeFields.has(change.field) && !existingFields.has(change.field) && changeAllowedByFields(item, change, fields)),
...changes
];
return resultChanges.some((change) => !informationalExistingPreviewChangeFields.has(change.field)) ? resultChanges : [];
}
function changesFromExistingPreviewCache(item: ZammadSyncItem, fields: ZammadSyncExistingFields) {
if (!item.zammad) {
return item.changes.filter((change) => changeAllowedByFields(item, change, fields));
@@ -666,6 +701,11 @@ function changesFromExistingPreviewCache(item: ZammadSyncItem, fields: ZammadSyn
const localWorkType = cachedValue(local, "workType");
const zammadWorkType = cachedValue(zammad, "workType");
const zammadStateId = numericValue(cachedValue(zammad, "stateId"));
const mergeProtected = Boolean(
cachedValue(local, "zammadMergedFromTicketId")
|| cachedValue(zammad, "mergedFromTicketId")
|| (cachedValue(project, "closeReviewRequired") === true && zammadStateId !== 4)
);
const pendingTodoExists = cachedValue(local, "zammadPendingTodoExists");
const localTeamspaceProjects = cachedStringList(project, "teamspaceProjects");
const zammadTeamspaceProjects = cachedStringList(zammad, "teamspaceProjects");
@@ -694,18 +734,18 @@ function changesFromExistingPreviewCache(item: ZammadSyncItem, fields: ZammadSyn
addCachedChange(changes, "Organisation", cachedValue(local, "organizationId"), cachedValue(zammad, "organizationId"));
}
if (fields.type) {
if (fields.type && !mergeProtected) {
addCachedChange(changes, "Typ", localWorkType, zammadWorkType);
}
if (fields.type && zammadWorkType === "support" && project) {
if (!mergeProtected && fields.type && zammadWorkType === "support" && project) {
changes.push({ field: "Projekt/Teamspace-Daten", from: "vorhanden", to: "werden gelöscht" });
}
const consultingProjectCanBeTouched = zammadWorkType === "consulting" && (fields.type || localWorkType === "consulting");
const consultingProjectCanBeTouched = !mergeProtected && zammadWorkType === "consulting" && (fields.type || localWorkType === "consulting");
if (consultingProjectCanBeTouched) {
if (fields.type && !project) {
if (fields.type && localWorkType !== "consulting" && !project) {
changes.push({ field: "Projekt", from: null, to: "wird angelegt" });
}
@@ -722,7 +762,28 @@ function changesFromExistingPreviewCache(item: ZammadSyncItem, fields: ZammadSyn
}
}
return changes;
if (mergeProtected && project && cachedValue(project, "closeReviewRequired") === true && zammadStateId !== 4) {
if (mergeReviewMatchesCachedZammad(localTeamspaceProjectList, zammadTeamspaceProjectList)) {
changes.push({ field: "Merge-Prüfung", from: true, to: false });
} else {
if (localTeamspaceProjectList.length > 0 && zammadTeamspaceProjectList.length === 0) {
changes.push({
field: "Merge-Projektdaten",
from: normalizedTeamspaceProjectList(localTeamspaceProjectList),
to: "in Zammad fehlen; lokale Projektdaten bleiben erhalten und muessen dort nachgetragen werden"
});
} else if (cachedValueChanged("Teamspace-Projekte", localTeamspaceProjectList, zammadTeamspaceProjectList)) {
changes.push({
field: "Merge-Projektdaten",
from: normalizedTeamspaceProjectList(localTeamspaceProjectList),
to: normalizedTeamspaceProjectList(zammadTeamspaceProjectList)
});
}
}
}
return preserveExistingPreviewChanges(item, changes, fields);
}
function rebuildZammadSyncResultFromItems(mode: ZammadSyncResult["mode"], items: ZammadSyncItem[], checked = items.length): ZammadSyncResult {