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