Compare commits
4
Commits
39690b1d2b
...
fc0dfd0556
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fc0dfd0556 | ||
|
|
e1456ed4ca | ||
|
|
d5952d82bb | ||
|
|
35cb578a96 |
No files matched your search
@@ -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
@@ -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
File diff suppressed because it is too large.
Load diff
+167
-3
@@ -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
@@ -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>
|
||||||
|
|||||||
@@ -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}`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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.";
|
||||||
|
|||||||
@@ -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" />
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -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}
|
||||||
|
|||||||
@@ -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, "&")
|
.replace(/&/g, "&")
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
Reference in new issue
Block a user