Compare commits

..
10 Commits
Author SHA1 Message Date
michael 649dd6f028 Final 2026-10-08 09:03:15 +02:00
michael fc0dfd0556 Update 2026-10-06 08:57:37 +02:00
michael e1456ed4ca v1.0.11-rc1 2026-10-02 08:50:10 +02:00
michael d5952d82bb Update 2026-09-30 16:59:14 +02:00
michael 35cb578a96 Update 2026-09-30 12:43:01 +02:00
michael 39690b1d2b Update 2026-09-29 14:17:08 +02:00
michael 62794f72f1 Update 2026-09-29 13:26:18 +02:00
michael c89d4825e2 Update 2026-09-29 07:46:21 +02:00
michael 2105a0c5f7 Update 2026-09-23 13:34:38 +00:00
michael 0c14a3acbd Update 2026-09-23 11:37:33 +00:00
35 changed files with 13298 additions and 2097 deletions

No files matched your search

+2 -1
View File
@@ -1,7 +1,8 @@
PP_PASSWORD='$ouzs4D$@RAi9'
DATABASE_URL=postgresql://tickettracker:${PP_PASSWORD}@172.22.0.2:5432/tickettracker_dev
DATABASE_URL=postgresql://tickettracker:${PP_PASSWORD}@172.22.0.4:5432/tickettracker_dev
APP_PORT=3911
TICKETTRACKER_IMAGE=git.d-razz.de/michael/tickettracker:latest
COOKIE_SECURE=false
DB_POOL_SIZE=10
ZAMMAD_EMAIL_WEBHOOK_TOKEN='sPXJc87R@ySW3uTFKjGFMrti8BcDkNv8mN2g71hRkdg='
SECRET_ENCRYPTION_KEY='7qDJ1bcJprs$eSv57c2QXmQKh4kT^kb/w0vPBeJ4z^4='
+32 -2
View File
@@ -17,11 +17,21 @@ export type AuthUser = {
active: boolean;
zammad_user_id: string | null;
has_zammad_api_token: boolean;
teamspace_user_id: string | null;
has_teamspace_credentials: boolean;
zammad_email_session_webhook_enabled: boolean;
session_billing_prompt_enabled: boolean;
teamspace_time_tracking_enabled: boolean;
zammad_session_note_default_enabled: boolean;
zammad_time_unit_rounding_enabled: boolean;
zammad_time_unit_rounding_minutes: number;
zammad_atoss_email_sync_enabled: boolean;
zammad_article_type_id: number;
project_followup_enabled: boolean;
project_followup_weeks: number;
project_followup_second_enabled: boolean;
project_followup_second_weeks: number;
project_followup_complete_on_session: boolean;
};
declare global {
@@ -83,11 +93,21 @@ function publicUser(user: AuthUser) {
active: user.active,
zammad_user_id: user.zammad_user_id ?? null,
has_zammad_api_token: Boolean(user.has_zammad_api_token),
teamspace_user_id: user.teamspace_user_id ?? null,
has_teamspace_credentials: Boolean(user.has_teamspace_credentials),
zammad_email_session_webhook_enabled: Boolean(user.zammad_email_session_webhook_enabled),
session_billing_prompt_enabled: Boolean(user.session_billing_prompt_enabled),
teamspace_time_tracking_enabled: Boolean(user.teamspace_time_tracking_enabled ?? true),
zammad_session_note_default_enabled: Boolean(user.zammad_session_note_default_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_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)
};
}
@@ -145,11 +165,21 @@ export async function requireAuth(req: Request, res: Response, next: NextFunctio
u.active,
u.zammad_user_id::text AS zammad_user_id,
(u.zammad_api_token IS NOT NULL) AS has_zammad_api_token,
u.teamspace_user_id::text AS teamspace_user_id,
(u.teamspace_device_id IS NOT NULL AND u.teamspace_device_password IS NOT NULL) AS has_teamspace_credentials,
u.zammad_email_session_webhook_enabled,
u.session_billing_prompt_enabled,
u.teamspace_time_tracking_enabled,
u.zammad_session_note_default_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,
COALESCE(u.zammad_article_type_id, 6) AS zammad_article_type_id,
u.project_followup_enabled,
u.project_followup_weeks,
u.project_followup_second_enabled,
u.project_followup_second_weeks,
u.project_followup_complete_on_session
FROM auth_sessions s
JOIN users u ON u.id = s.user_id
WHERE s.token_hash = $1
+1
View File
@@ -22,6 +22,7 @@ export const databaseExportTables = [
"recurring_billing_slots",
"recurring_billing_exceptions",
"sessions",
"active_timers",
"ticket_month_closures",
"month_closures",
"ticket_day_closures",
+7139 -968
View File
File diff suppressed because it is too large. Load diff
+492 -5
View File
@@ -55,6 +55,33 @@ async function encryptStoredApiSecrets() {
}
}
const teamspaceCredentialResult = await query<{ id: string; teamspace_device_id: string | null; teamspace_device_password: string | null }>(`
SELECT id::text AS id, teamspace_device_id, teamspace_device_password
FROM users
WHERE teamspace_device_id IS NOT NULL
OR teamspace_device_password IS NOT NULL;
`);
for (const row of teamspaceCredentialResult.rows) {
const updates: string[] = [];
const values: unknown[] = [];
if (row.teamspace_device_id && !isEncryptedSecret(row.teamspace_device_id)) {
values.push(encryptSecret(row.teamspace_device_id));
updates.push(`teamspace_device_id = $${values.length}`);
}
if (row.teamspace_device_password && !isEncryptedSecret(row.teamspace_device_password)) {
values.push(encryptSecret(row.teamspace_device_password));
updates.push(`teamspace_device_password = $${values.length}`);
}
if (updates.length > 0) {
values.push(row.id);
await query(`UPDATE users SET ${updates.join(", ")}, updated_at = now() WHERE id = $${values.length};`, values);
}
}
const zammadApiKeyResult = await query<{ value: string }>("SELECT value FROM app_settings WHERE key = $1;", [zammadApiKeySettingKey]);
const zammadApiKey = zammadApiKeyResult.rows[0]?.value ?? null;
@@ -86,16 +113,46 @@ export async function migrate() {
`);
await query("ALTER TABLE users ADD COLUMN IF NOT EXISTS zammad_api_token TEXT;");
await query("ALTER TABLE users ADD COLUMN IF NOT EXISTS zammad_user_id BIGINT;");
await query("ALTER TABLE users ADD COLUMN IF NOT EXISTS teamspace_device_id TEXT;");
await query("ALTER TABLE users ADD COLUMN IF NOT EXISTS teamspace_device_password TEXT;");
await query("ALTER TABLE users ADD COLUMN IF NOT EXISTS teamspace_user_id BIGINT;");
await query("ALTER TABLE users ADD COLUMN IF NOT EXISTS zammad_email_session_webhook_enabled BOOLEAN NOT NULL DEFAULT false;");
await query("ALTER TABLE users ADD COLUMN IF NOT EXISTS session_billing_prompt_enabled BOOLEAN NOT NULL DEFAULT true;");
await query("ALTER TABLE users ADD COLUMN IF NOT EXISTS teamspace_time_tracking_enabled BOOLEAN NOT NULL DEFAULT true;");
await query("ALTER TABLE users ADD COLUMN IF NOT EXISTS zammad_session_note_default_enabled BOOLEAN NOT NULL DEFAULT false;");
await query("ALTER TABLE users ADD COLUMN IF NOT EXISTS zammad_time_unit_rounding_enabled BOOLEAN NOT NULL DEFAULT false;");
await query("ALTER TABLE users ADD COLUMN IF NOT EXISTS zammad_time_unit_rounding_minutes INTEGER NOT NULL DEFAULT 15;");
await query("ALTER TABLE users ADD COLUMN IF NOT EXISTS zammad_atoss_email_sync_enabled BOOLEAN NOT NULL DEFAULT true;");
await query("ALTER TABLE users ADD COLUMN IF NOT EXISTS zammad_article_type_id INTEGER DEFAULT 6;");
await query("ALTER TABLE users ADD COLUMN IF NOT EXISTS project_followup_enabled BOOLEAN NOT NULL DEFAULT true;");
await query("ALTER TABLE users ADD COLUMN IF NOT EXISTS project_followup_weeks INTEGER NOT NULL DEFAULT 4;");
await query("ALTER TABLE users ADD COLUMN IF NOT EXISTS project_followup_second_enabled BOOLEAN NOT NULL DEFAULT true;");
await query("ALTER TABLE users ADD COLUMN IF NOT EXISTS project_followup_second_weeks INTEGER NOT NULL DEFAULT 2;");
await query("ALTER TABLE users ADD COLUMN IF NOT EXISTS project_followup_complete_on_session BOOLEAN NOT NULL DEFAULT true;");
await query("ALTER TABLE users DROP COLUMN IF EXISTS teamspace_subproject_assignment_enabled;");
await query("UPDATE users SET zammad_article_type_id = 6 WHERE zammad_article_type_id IS NULL;");
await query("ALTER TABLE users ALTER COLUMN zammad_article_type_id SET DEFAULT 6;");
await query("ALTER TABLE users ALTER COLUMN zammad_article_type_id SET NOT NULL;");
await query(`
ALTER TABLE users
DROP CONSTRAINT IF EXISTS users_zammad_time_unit_rounding_minutes_valid,
ADD CONSTRAINT users_zammad_time_unit_rounding_minutes_valid CHECK (zammad_time_unit_rounding_minutes IN (5, 10, 15, 30));
`);
await query(`
ALTER TABLE users
DROP CONSTRAINT IF EXISTS users_zammad_article_type_id_valid,
ADD CONSTRAINT users_zammad_article_type_id_valid CHECK (zammad_article_type_id IN (1, 2, 3, 4, 5, 6));
`);
await query(`
ALTER TABLE users
DROP CONSTRAINT IF EXISTS users_project_followup_weeks_valid,
ADD CONSTRAINT users_project_followup_weeks_valid CHECK (project_followup_weeks BETWEEN 1 AND 52);
`);
await query(`
ALTER TABLE users
DROP CONSTRAINT IF EXISTS users_project_followup_second_weeks_valid,
ADD CONSTRAINT users_project_followup_second_weeks_valid CHECK (project_followup_second_weeks BETWEEN 1 AND 52);
`);
await query(`
CREATE TABLE IF NOT EXISTS auth_sessions (
@@ -181,6 +238,9 @@ export async function migrate() {
await query("ALTER TABLE tickets ADD COLUMN IF NOT EXISTS zammad_state_id BIGINT;");
await query("UPDATE tickets SET zammad_state_id = 2 WHERE zammad_state_id = 1;");
await query("ALTER TABLE tickets ADD COLUMN IF NOT EXISTS zammad_owner_id BIGINT;");
await query("ALTER TABLE tickets ADD COLUMN IF NOT EXISTS user_id BIGINT REFERENCES users(id) ON DELETE SET NULL;");
await query("ALTER TABLE tickets ADD COLUMN IF NOT EXISTS zammad_merged_from_ticket_id BIGINT;");
await query("ALTER TABLE tickets ADD COLUMN IF NOT EXISTS zammad_merged_from_ticket_number TEXT;");
await query("ALTER TABLE tickets ADD COLUMN IF NOT EXISTS zammad_preferences JSONB NOT NULL DEFAULT '{}'::jsonb;");
await query(`
ALTER TABLE tickets
@@ -198,6 +258,55 @@ export async function migrate() {
END IF;
END $$;
`);
await query(`
WITH unique_zammad_users AS (
SELECT zammad_user_id, MIN(id) AS user_id
FROM users
WHERE role = 'user'
AND active
AND zammad_user_id IS NOT NULL
GROUP BY zammad_user_id
HAVING COUNT(*) = 1
),
zammad_ticket_owners AS (
SELECT t.id, uzu.user_id
FROM tickets t
LEFT JOIN unique_zammad_users uzu ON uzu.zammad_user_id = t.zammad_owner_id
WHERE t.zammad_owner_id IS NOT NULL
)
UPDATE tickets t
SET user_id = zto.user_id
FROM zammad_ticket_owners zto
WHERE t.id = zto.id
AND t.user_id IS DISTINCT FROM zto.user_id;
`);
await query(`
UPDATE tickets t
SET user_id = owner.user_id
FROM (
SELECT ticket_id, MIN(user_id) AS user_id
FROM projects
GROUP BY ticket_id
) owner
WHERE t.user_id IS NULL
AND t.zammad_owner_id IS NULL
AND t.id = owner.ticket_id;
`);
await query(`
UPDATE tickets t
SET user_id = owner.user_id
FROM (
SELECT ticket_id, MIN(user_id) AS user_id
FROM sessions
WHERE user_id IS NOT NULL
GROUP BY ticket_id
) owner
WHERE t.user_id IS NULL
AND t.zammad_owner_id IS NULL
AND t.id = owner.ticket_id;
`);
await query("CREATE INDEX IF NOT EXISTS idx_tickets_user ON tickets(user_id);");
await query("CREATE INDEX IF NOT EXISTS idx_tickets_zammad_owner ON tickets(zammad_owner_id);");
await query(`
CREATE TABLE IF NOT EXISTS sessions (
@@ -230,6 +339,86 @@ export async function migrate() {
await query("ALTER TABLE sessions ADD COLUMN IF NOT EXISTS recurring_occurrence_date DATE;");
await query("ALTER TABLE sessions ADD COLUMN IF NOT EXISTS recurring_occurrence_key TEXT;");
await query("ALTER TABLE sessions ADD COLUMN IF NOT EXISTS project_teamspace_entry_id BIGINT;");
await query("ALTER TABLE sessions ADD COLUMN IF NOT EXISTS activity_html TEXT;");
await query(`
CREATE TABLE IF NOT EXISTS active_timers (
id BIGSERIAL PRIMARY KEY,
user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
ticket_number TEXT NOT NULL,
organization_name TEXT,
work_type TEXT,
is_project BOOLEAN NOT NULL DEFAULT false,
project_name TEXT,
ticket_lookup_done BOOLEAN NOT NULL DEFAULT false,
activity TEXT NOT NULL DEFAULT '',
activity_html TEXT,
billing_status TEXT,
started_at TIMESTAMPTZ NOT NULL,
paused_total_ms BIGINT NOT NULL DEFAULT 0,
paused_at TIMESTAMPTZ,
phase TEXT NOT NULL DEFAULT 'running',
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
CONSTRAINT active_timers_ticket_number_format CHECK (ticket_number ~ '^Ticket#[0-9]{6}$'),
CONSTRAINT active_timers_work_type_valid CHECK (work_type IS NULL OR work_type IN ('support', 'consulting')),
CONSTRAINT active_timers_billing_status_valid CHECK (billing_status IS NULL OR billing_status IN ('billable', 'billed', 'non_billable')),
CONSTRAINT active_timers_phase_valid CHECK (phase IN ('running', 'paused')),
CONSTRAINT active_timers_paused_total_non_negative CHECK (paused_total_ms >= 0),
CONSTRAINT active_timers_paused_state_valid CHECK (
(phase = 'running' AND paused_at IS NULL)
OR (phase = 'paused' AND paused_at IS NOT NULL)
)
);
`);
await query("ALTER TABLE active_timers ADD COLUMN IF NOT EXISTS organization_name TEXT;");
await query("ALTER TABLE active_timers ADD COLUMN IF NOT EXISTS work_type TEXT;");
await query("ALTER TABLE active_timers ADD COLUMN IF NOT EXISTS is_project BOOLEAN NOT NULL DEFAULT false;");
await query("ALTER TABLE active_timers ADD COLUMN IF NOT EXISTS project_name TEXT;");
await query("ALTER TABLE active_timers ADD COLUMN IF NOT EXISTS ticket_lookup_done BOOLEAN NOT NULL DEFAULT false;");
await query("ALTER TABLE active_timers ADD COLUMN IF NOT EXISTS activity TEXT NOT NULL DEFAULT '';");
await query("ALTER TABLE active_timers ADD COLUMN IF NOT EXISTS activity_html TEXT;");
await query("ALTER TABLE active_timers ADD COLUMN IF NOT EXISTS billing_status TEXT;");
await query("ALTER TABLE active_timers ADD COLUMN IF NOT EXISTS paused_total_ms BIGINT NOT NULL DEFAULT 0;");
await query("ALTER TABLE active_timers ADD COLUMN IF NOT EXISTS paused_at TIMESTAMPTZ;");
await query("ALTER TABLE active_timers ADD COLUMN IF NOT EXISTS phase TEXT NOT NULL DEFAULT 'running';");
await query("ALTER TABLE active_timers ADD COLUMN IF NOT EXISTS created_at TIMESTAMPTZ NOT NULL DEFAULT now();");
await query("ALTER TABLE active_timers ADD COLUMN IF NOT EXISTS updated_at TIMESTAMPTZ NOT NULL DEFAULT now();");
await query(`
ALTER TABLE active_timers
DROP CONSTRAINT IF EXISTS active_timers_ticket_number_format,
ADD CONSTRAINT active_timers_ticket_number_format CHECK (ticket_number ~ '^Ticket#[0-9]{6}$');
`);
await query(`
ALTER TABLE active_timers
DROP CONSTRAINT IF EXISTS active_timers_work_type_valid,
ADD CONSTRAINT active_timers_work_type_valid CHECK (work_type IS NULL OR work_type IN ('support', 'consulting'));
`);
await query(`
ALTER TABLE active_timers
DROP CONSTRAINT IF EXISTS active_timers_billing_status_valid,
ADD CONSTRAINT active_timers_billing_status_valid CHECK (billing_status IS NULL OR billing_status IN ('billable', 'billed', 'non_billable'));
`);
await query(`
ALTER TABLE active_timers
DROP CONSTRAINT IF EXISTS active_timers_phase_valid,
ADD CONSTRAINT active_timers_phase_valid CHECK (phase IN ('running', 'paused'));
`);
await query(`
ALTER TABLE active_timers
DROP CONSTRAINT IF EXISTS active_timers_paused_total_non_negative,
ADD CONSTRAINT active_timers_paused_total_non_negative CHECK (paused_total_ms >= 0);
`);
await query(`
ALTER TABLE active_timers
DROP CONSTRAINT IF EXISTS active_timers_paused_state_valid,
ADD CONSTRAINT active_timers_paused_state_valid CHECK (
(phase = 'running' AND paused_at IS NULL)
OR (phase = 'paused' AND paused_at IS NOT NULL)
);
`);
await query("CREATE UNIQUE INDEX IF NOT EXISTS idx_active_timers_user_ticket ON active_timers(user_id, ticket_number);");
await query("CREATE UNIQUE INDEX IF NOT EXISTS idx_active_timers_single_running ON active_timers(user_id) WHERE phase = 'running';");
await query("CREATE INDEX IF NOT EXISTS idx_active_timers_user_updated ON active_timers(user_id, updated_at);");
await query(`
ALTER TABLE sessions
DROP CONSTRAINT IF EXISTS billing_status_valid,
@@ -256,6 +445,7 @@ export async function migrate() {
ON zammad_email_session_events(delivery_id)
WHERE delivery_id IS NOT NULL;
`);
await query("CREATE INDEX IF NOT EXISTS idx_zammad_email_session_events_session ON zammad_email_session_events(session_id);");
await query(`
CREATE TABLE IF NOT EXISTS zammad_session_article_queue (
id BIGSERIAL PRIMARY KEY,
@@ -265,7 +455,10 @@ export async function migrate() {
ticket_number TEXT NOT NULL,
zammad_ticket_id BIGINT,
activity TEXT NOT NULL,
article_body TEXT,
article_content_type TEXT NOT NULL DEFAULT 'text/plain',
time_unit NUMERIC(10, 2) NOT NULL,
zammad_article_type_id INTEGER NOT NULL DEFAULT 6,
status TEXT NOT NULL DEFAULT 'pending',
attempt_count INTEGER NOT NULL DEFAULT 0,
last_error TEXT,
@@ -277,6 +470,23 @@ export async function migrate() {
CONSTRAINT zammad_session_article_queue_status_valid CHECK (status IN ('pending', 'sent'))
);
`);
await query("ALTER TABLE zammad_session_article_queue ADD COLUMN IF NOT EXISTS article_body TEXT;");
await query("ALTER TABLE zammad_session_article_queue ADD COLUMN IF NOT EXISTS article_content_type TEXT NOT NULL DEFAULT 'text/plain';");
await query("UPDATE zammad_session_article_queue SET article_body = activity WHERE article_body IS NULL;");
await query(`
ALTER TABLE zammad_session_article_queue
DROP CONSTRAINT IF EXISTS zammad_session_article_queue_content_type_valid,
ADD CONSTRAINT zammad_session_article_queue_content_type_valid CHECK (article_content_type IN ('text/plain', 'text/html'));
`);
await query("ALTER TABLE zammad_session_article_queue ADD COLUMN IF NOT EXISTS zammad_article_type_id INTEGER DEFAULT 6;");
await query("UPDATE zammad_session_article_queue SET zammad_article_type_id = 6 WHERE zammad_article_type_id IS NULL;");
await query("ALTER TABLE zammad_session_article_queue ALTER COLUMN zammad_article_type_id SET DEFAULT 6;");
await query("ALTER TABLE zammad_session_article_queue ALTER COLUMN zammad_article_type_id SET NOT NULL;");
await query(`
ALTER TABLE zammad_session_article_queue
DROP CONSTRAINT IF EXISTS zammad_session_article_queue_type_id_valid,
ADD CONSTRAINT zammad_session_article_queue_type_id_valid CHECK (zammad_article_type_id IN (1, 2, 3, 4, 5, 6));
`);
await query(`
CREATE UNIQUE INDEX IF NOT EXISTS idx_zammad_session_article_queue_pending_session
ON zammad_session_article_queue(session_id)
@@ -531,14 +741,19 @@ export async function migrate() {
CREATE TABLE IF NOT EXISTS ticket_day_billings (
ticket_id BIGINT NOT NULL REFERENCES tickets(id) ON DELETE CASCADE,
user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
project_teamspace_entry_id BIGINT,
day DATE NOT NULL,
billed_minutes INTEGER NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (ticket_id, user_id, day),
CONSTRAINT ticket_day_billings_minutes_non_negative CHECK (billed_minutes >= 0)
);
`);
await query("ALTER TABLE ticket_day_billings ADD COLUMN IF NOT EXISTS project_teamspace_entry_id BIGINT;");
await query("ALTER TABLE ticket_day_billings DROP CONSTRAINT IF EXISTS ticket_day_billings_pkey;");
await query("DROP INDEX IF EXISTS idx_ticket_day_billings_ticket_user_day_default;");
await query("DROP INDEX IF EXISTS idx_ticket_day_billings_ticket_user_day_entry;");
await query("DROP INDEX IF EXISTS idx_ticket_day_billings_ticket_user_day_subentry;");
await query(`
CREATE TABLE IF NOT EXISTS ticket_day_billing_acknowledgements (
@@ -576,6 +791,43 @@ export async function migrate() {
await query("ALTER TABLE projects ADD COLUMN IF NOT EXISTS closed_at TIMESTAMPTZ;");
await query("ALTER TABLE projects ADD COLUMN IF NOT EXISTS close_review_required BOOLEAN NOT NULL DEFAULT false;");
await query("ALTER TABLE projects ADD COLUMN IF NOT EXISTS manual_consumption_minutes INTEGER NOT NULL DEFAULT 0;");
await query("ALTER TABLE projects ADD COLUMN IF NOT EXISTS zammad_owner_id BIGINT;");
await query("ALTER TABLE projects ALTER COLUMN user_id DROP NOT NULL;");
await query(`
UPDATE projects p
SET zammad_owner_id = t.zammad_owner_id
FROM tickets t
WHERE p.ticket_id = t.id
AND p.zammad_owner_id IS NULL
AND t.zammad_owner_id IS NOT NULL;
`);
await query(`
UPDATE projects p
SET user_id = t.user_id,
zammad_owner_id = t.zammad_owner_id
FROM tickets t
WHERE p.ticket_id = t.id
AND t.zammad_owner_id IS NOT NULL
AND (
p.user_id IS DISTINCT FROM t.user_id
OR p.zammad_owner_id IS DISTINCT FROM t.zammad_owner_id
)
AND NOT EXISTS (
SELECT 1
FROM projects existing
WHERE existing.ticket_id = p.ticket_id
AND existing.id <> p.id
AND (
(t.user_id IS NOT NULL AND existing.user_id = t.user_id)
OR (
t.user_id IS NULL
AND existing.user_id IS NULL
AND existing.zammad_owner_id IS NOT DISTINCT FROM t.zammad_owner_id
)
)
);
`);
await query("CREATE UNIQUE INDEX IF NOT EXISTS idx_projects_zammad_owner_ticket ON projects(zammad_owner_id, ticket_id) WHERE user_id IS NULL AND zammad_owner_id IS NOT NULL;");
await query(`
DO $$
BEGIN
@@ -595,6 +847,7 @@ export async function migrate() {
id BIGSERIAL PRIMARY KEY,
project_id BIGINT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
teamspace_project TEXT,
teamspace_description TEXT NOT NULL DEFAULT '',
is_default BOOLEAN NOT NULL DEFAULT false,
hour_budget_minutes INTEGER,
budget_sync_disabled BOOLEAN NOT NULL DEFAULT false,
@@ -607,9 +860,110 @@ export async function migrate() {
);
`);
await query("ALTER TABLE project_teamspace_entries ADD COLUMN IF NOT EXISTS teamspace_description TEXT NOT NULL DEFAULT '';");
await query("ALTER TABLE project_teamspace_entries ADD COLUMN IF NOT EXISTS budget_sync_disabled BOOLEAN NOT NULL DEFAULT false;");
await query("ALTER TABLE project_teamspace_entries ADD COLUMN IF NOT EXISTS teamspace_api_project_id BIGINT;");
await query("ALTER TABLE project_teamspace_entries ADD COLUMN IF NOT EXISTS teamspace_api_project_url TEXT;");
await query("CREATE UNIQUE INDEX IF NOT EXISTS idx_project_teamspace_entries_default ON project_teamspace_entries(project_id) WHERE is_default;");
await query("CREATE INDEX IF NOT EXISTS idx_project_teamspace_entries_project_order ON project_teamspace_entries(project_id, display_order, id);");
await query("CREATE INDEX IF NOT EXISTS idx_project_teamspace_entries_ts_api_id ON project_teamspace_entries(teamspace_api_project_id) WHERE teamspace_api_project_id IS NOT NULL;");
await query(`
CREATE TABLE IF NOT EXISTS project_teamspace_subentries (
id BIGSERIAL PRIMARY KEY,
user_id BIGINT REFERENCES users(id) ON DELETE CASCADE,
project_teamspace_entry_id BIGINT NOT NULL REFERENCES project_teamspace_entries(id) ON DELETE CASCADE,
teamspace_api_project_id BIGINT NOT NULL,
teamspace_api_project_url TEXT,
teamspace_project_id TEXT,
billing_type TEXT,
name TEXT,
alias TEXT NOT NULL DEFAULT '',
description TEXT NOT NULL DEFAULT '',
progress INTEGER,
planned_minutes INTEGER,
sum_minutes INTEGER,
planned_minutes_billable INTEGER,
sum_minutes_billable INTEGER,
planned_income NUMERIC,
sum_income NUMERIC,
display_order INTEGER NOT NULL DEFAULT 0,
synced_at TIMESTAMPTZ NOT NULL DEFAULT now(),
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
`);
await query("ALTER TABLE project_teamspace_subentries ADD COLUMN IF NOT EXISTS user_id BIGINT REFERENCES users(id) ON DELETE CASCADE;");
await query(`
UPDATE project_teamspace_subentries pts
SET user_id = p.user_id
FROM project_teamspace_entries pte
JOIN projects p ON p.id = pte.project_id
WHERE pte.id = pts.project_teamspace_entry_id
AND pts.user_id IS NULL
AND p.user_id IS NOT NULL;
`);
await query(`
DO $$
DECLARE
constraint_name text;
BEGIN
SELECT c.conname
INTO constraint_name
FROM pg_constraint c
JOIN pg_class t ON t.oid = c.conrelid
JOIN pg_namespace n ON n.oid = t.relnamespace
WHERE n.nspname = current_schema()
AND t.relname = 'project_teamspace_subentries'
AND c.contype = 'u'
AND pg_get_constraintdef(c.oid) LIKE '%project_teamspace_entry_id%'
AND pg_get_constraintdef(c.oid) LIKE '%teamspace_api_project_id%'
AND pg_get_constraintdef(c.oid) NOT LIKE '%user_id%'
LIMIT 1;
IF constraint_name IS NOT NULL THEN
EXECUTE format('ALTER TABLE project_teamspace_subentries DROP CONSTRAINT %I', constraint_name);
END IF;
END $$;
`);
await query("ALTER TABLE project_teamspace_subentries ADD COLUMN IF NOT EXISTS alias TEXT NOT NULL DEFAULT '';");
await query("ALTER TABLE project_teamspace_subentries ADD COLUMN IF NOT EXISTS description TEXT NOT NULL DEFAULT '';");
await query("ALTER TABLE project_teamspace_subentries ADD COLUMN IF NOT EXISTS billing_type TEXT;");
await query("ALTER TABLE project_teamspace_subentries ADD COLUMN IF NOT EXISTS planned_income NUMERIC;");
await query("ALTER TABLE project_teamspace_subentries ADD COLUMN IF NOT EXISTS sum_income NUMERIC;");
await query("ALTER TABLE project_teamspace_subentries DROP CONSTRAINT IF EXISTS project_teamspace_subentries_hour_budget_nonnegative;");
await query("ALTER TABLE project_teamspace_subentries DROP CONSTRAINT IF EXISTS project_teamspace_subentries_manual_consumption_nonnegative;");
await query("ALTER TABLE project_teamspace_subentries DROP CONSTRAINT IF EXISTS project_teamspace_subentries_manual_unit_quantity_nonnegative;");
await query("ALTER TABLE project_teamspace_subentries DROP COLUMN IF EXISTS hour_budget_minutes;");
await query("ALTER TABLE project_teamspace_subentries DROP COLUMN IF EXISTS manual_consumption_minutes;");
await query("ALTER TABLE project_teamspace_subentries DROP COLUMN IF EXISTS manual_unit_quantity;");
await query("ALTER TABLE project_teamspace_subentries DROP COLUMN IF EXISTS is_fulfilled;");
await query("CREATE INDEX IF NOT EXISTS idx_project_teamspace_subentries_entry_order ON project_teamspace_subentries(project_teamspace_entry_id, display_order, id);");
await query("CREATE INDEX IF NOT EXISTS idx_project_teamspace_subentries_user_entry_order ON project_teamspace_subentries(user_id, project_teamspace_entry_id, display_order, id);");
await query("CREATE UNIQUE INDEX IF NOT EXISTS idx_project_teamspace_subentries_user_entry_api ON project_teamspace_subentries(user_id, project_teamspace_entry_id, teamspace_api_project_id);");
await query(`
DO $$
BEGIN
IF EXISTS (
SELECT 1 FROM pg_constraint
WHERE conname = 'ticket_day_billings_project_teamspace_entry_fkey'
AND confdeltype <> 'n'
) THEN
ALTER TABLE ticket_day_billings
DROP CONSTRAINT ticket_day_billings_project_teamspace_entry_fkey;
END IF;
IF NOT EXISTS (
SELECT 1 FROM pg_constraint WHERE conname = 'ticket_day_billings_project_teamspace_entry_fkey'
) THEN
ALTER TABLE ticket_day_billings
ADD CONSTRAINT ticket_day_billings_project_teamspace_entry_fkey
FOREIGN KEY (project_teamspace_entry_id) REFERENCES project_teamspace_entries(id) ON DELETE SET NULL;
END IF;
ALTER TABLE ticket_day_billings
DROP CONSTRAINT IF EXISTS ticket_day_billings_project_teamspace_subentry_fkey;
END $$;
`);
await query(`
CREATE TABLE IF NOT EXISTS hidden_user_projects (
user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
@@ -631,6 +985,78 @@ export async function migrate() {
END IF;
END $$;
`);
await query("ALTER TABLE sessions DROP CONSTRAINT IF EXISTS sessions_project_teamspace_subentry_fkey;");
await query(`
DO $$
BEGIN
IF EXISTS (
SELECT 1
FROM information_schema.columns
WHERE table_schema = current_schema()
AND table_name = 'sessions'
AND column_name = 'project_teamspace_subentry_id'
) THEN
UPDATE sessions s
SET project_teamspace_entry_id = COALESCE(s.project_teamspace_entry_id, pts.project_teamspace_entry_id)
FROM project_teamspace_subentries pts
WHERE s.project_teamspace_subentry_id = pts.id;
END IF;
END $$;
`);
await query(`
DO $$
BEGIN
IF EXISTS (
SELECT 1
FROM information_schema.columns
WHERE table_schema = current_schema()
AND table_name = 'ticket_day_billings'
AND column_name = 'project_teamspace_subentry_id'
) THEN
CREATE TEMP TABLE migrated_subentry_day_billings ON COMMIT DROP AS
SELECT
tdb.ticket_id,
tdb.user_id,
tdb.day,
pts.project_teamspace_entry_id,
SUM(tdb.billed_minutes)::int AS billed_minutes
FROM ticket_day_billings tdb
JOIN project_teamspace_subentries pts ON pts.id = tdb.project_teamspace_subentry_id
GROUP BY tdb.ticket_id, tdb.user_id, tdb.day, pts.project_teamspace_entry_id;
DELETE FROM ticket_day_billings tdb
WHERE tdb.project_teamspace_subentry_id IS NOT NULL;
UPDATE ticket_day_billings target
SET billed_minutes = target.billed_minutes + source.billed_minutes,
updated_at = now()
FROM migrated_subentry_day_billings source
WHERE target.ticket_id = source.ticket_id
AND target.user_id = source.user_id
AND target.day = source.day
AND target.project_teamspace_entry_id = source.project_teamspace_entry_id;
INSERT INTO ticket_day_billings (ticket_id, user_id, project_teamspace_entry_id, day, billed_minutes)
SELECT source.ticket_id, source.user_id, source.project_teamspace_entry_id, source.day, source.billed_minutes
FROM migrated_subentry_day_billings source
WHERE NOT EXISTS (
SELECT 1
FROM ticket_day_billings target
WHERE target.ticket_id = source.ticket_id
AND target.user_id = source.user_id
AND target.day = source.day
AND target.project_teamspace_entry_id = source.project_teamspace_entry_id
);
END IF;
END $$;
`);
await query("DROP INDEX IF EXISTS idx_sessions_project_teamspace_subentry;");
await query("DROP INDEX IF EXISTS idx_ticket_day_billings_teamspace_subentry;");
await query("DROP INDEX IF EXISTS idx_ticket_day_billings_ticket_user_day_subentry;");
await query("ALTER TABLE sessions DROP COLUMN IF EXISTS project_teamspace_subentry_id;");
await query("ALTER TABLE ticket_day_billings DROP COLUMN IF EXISTS project_teamspace_subentry_id;");
await query("CREATE UNIQUE INDEX IF NOT EXISTS idx_ticket_day_billings_ticket_user_day_default ON ticket_day_billings(ticket_id, user_id, day) WHERE project_teamspace_entry_id IS NULL;");
await query("CREATE UNIQUE INDEX IF NOT EXISTS idx_ticket_day_billings_ticket_user_day_entry ON ticket_day_billings(ticket_id, user_id, day, project_teamspace_entry_id) WHERE project_teamspace_entry_id IS NOT NULL;");
await query(`
UPDATE tickets t
@@ -644,13 +1070,26 @@ export async function migrate() {
`);
await query(`
INSERT INTO projects (user_id, ticket_id, teamspace_project, description, goals)
SELECT DISTINCT s.user_id, s.ticket_id, COALESCE(t.title, ''), '', ''
INSERT INTO projects (user_id, zammad_owner_id, ticket_id, teamspace_project, description, goals)
SELECT DISTINCT
CASE
WHEN t.zammad_owner_id IS NOT NULL THEN zammad_owner_user.id
ELSE s.user_id
END,
t.zammad_owner_id,
s.ticket_id,
COALESCE(t.title, ''),
'',
''
FROM sessions s
JOIN tickets t ON t.id = s.ticket_id
LEFT JOIN users zammad_owner_user
ON zammad_owner_user.role = 'user'
AND zammad_owner_user.active
AND zammad_owner_user.zammad_user_id = t.zammad_owner_id
WHERE s.work_type = 'consulting'
AND s.user_id IS NOT NULL
ON CONFLICT (user_id, ticket_id) DO NOTHING;
ON CONFLICT DO NOTHING;
`);
await query(`
@@ -770,7 +1209,8 @@ export async function migrate() {
await query("CREATE UNIQUE INDEX IF NOT EXISTS idx_sessions_recurring_occurrence_key ON sessions(recurring_occurrence_key) WHERE recurring_occurrence_key IS NOT NULL;");
await query("CREATE INDEX IF NOT EXISTS idx_sessions_billing_status ON sessions(billing_status);");
await query("CREATE INDEX IF NOT EXISTS idx_ticket_day_closures_day ON ticket_day_closures(day);");
await query("CREATE INDEX IF NOT EXISTS idx_ticket_day_billings_user_day ON ticket_day_billings(user_id, day);");
await query("CREATE INDEX IF NOT EXISTS idx_ticket_day_billings_user_day ON ticket_day_billings(user_id, day);");
await query("CREATE INDEX IF NOT EXISTS idx_ticket_day_billings_teamspace_entry ON ticket_day_billings(project_teamspace_entry_id);");
await query("CREATE INDEX IF NOT EXISTS idx_ticket_day_billing_ack_user_day ON ticket_day_billing_acknowledgements(user_id, day);");
await query("CREATE INDEX IF NOT EXISTS idx_projects_user_ticket ON projects(user_id, ticket_id);");
await query("CREATE INDEX IF NOT EXISTS idx_projects_user_updated ON projects(user_id, updated_at DESC);");
@@ -779,6 +1219,53 @@ export async function migrate() {
await query("CREATE INDEX IF NOT EXISTS idx_ticket_todos_ticket_user ON ticket_todos(ticket_id, user_id);");
await query("CREATE INDEX IF NOT EXISTS idx_ticket_todos_user_period ON ticket_todos(user_id, starts_on, ends_on);");
await query("CREATE INDEX IF NOT EXISTS idx_ticket_todos_user_sort_order ON ticket_todos(user_id, sort_order, id);");
await query(`
CREATE TABLE IF NOT EXISTS project_followup_reminders (
id BIGSERIAL PRIMARY KEY,
user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
project_id BIGINT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
ticket_id BIGINT NOT NULL REFERENCES tickets(id) ON DELETE CASCADE,
todo_id BIGINT REFERENCES ticket_todos(id) ON DELETE SET NULL,
stage INTEGER NOT NULL DEFAULT 1,
latest_tt_update_at TIMESTAMPTZ NOT NULL,
due_on DATE NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
CONSTRAINT project_followup_reminders_stage_valid CHECK (stage IN (1, 2))
);
`);
await query("ALTER TABLE project_followup_reminders ADD COLUMN IF NOT EXISTS stage INTEGER NOT NULL DEFAULT 1;");
await query(`
ALTER TABLE project_followup_reminders
DROP CONSTRAINT IF EXISTS project_followup_reminders_stage_valid,
ADD CONSTRAINT project_followup_reminders_stage_valid CHECK (stage IN (1, 2));
`);
await query(`
DO $$
DECLARE
constraint_name text;
BEGIN
SELECT c.conname
INTO constraint_name
FROM pg_constraint c
WHERE c.conrelid = 'project_followup_reminders'::regclass
AND c.contype = 'u'
AND c.conkey = ARRAY[
(SELECT attnum FROM pg_attribute WHERE attrelid = 'project_followup_reminders'::regclass AND attname = 'user_id'),
(SELECT attnum FROM pg_attribute WHERE attrelid = 'project_followup_reminders'::regclass AND attname = 'project_id'),
(SELECT attnum FROM pg_attribute WHERE attrelid = 'project_followup_reminders'::regclass AND attname = 'latest_tt_update_at')
]::smallint[];
IF constraint_name IS NOT NULL THEN
EXECUTE format('ALTER TABLE project_followup_reminders DROP CONSTRAINT %I', constraint_name);
END IF;
END $$;
`);
await query("CREATE UNIQUE INDEX IF NOT EXISTS idx_project_followup_reminders_unique_stage ON project_followup_reminders(user_id, project_id, latest_tt_update_at, stage);");
await query("CREATE INDEX IF NOT EXISTS idx_project_followup_reminders_user_project ON project_followup_reminders(user_id, project_id, latest_tt_update_at DESC);");
await query("CREATE INDEX IF NOT EXISTS idx_project_followup_reminders_todo ON project_followup_reminders(todo_id) WHERE todo_id IS NOT NULL;");
await query("CREATE INDEX IF NOT EXISTS idx_recurring_billings_user_active ON recurring_billings(user_id, active);");
await query("UPDATE recurring_billings SET active = true WHERE active = false;");
await query(`
+36
View File
@@ -1,6 +1,7 @@
export const ticketPattern = /^Ticket#\d{6}$/;
export const trackableTicketPattern = /^(Ticket#\d{6}|Fix#\d+)$/;
export const monthPattern = /^\d{4}-\d{2}$/;
export const weekPattern = /^\d{4}-W\d{2}$/;
export const dayPattern = /^\d{4}-\d{2}-\d{2}$/;
export const usernamePattern = /^\S{3,40}$/u;
@@ -87,6 +88,41 @@ export function parseDay(value: string) {
};
}
export function parseWeek(value: string) {
if (!weekPattern.test(value)) {
throw badRequest("week must use YYYY-Www");
}
const year = Number(value.slice(0, 4));
const week = Number(value.slice(6, 8));
if (!Number.isInteger(year) || !Number.isInteger(week) || week < 1 || week > 53) {
throw badRequest("week is invalid");
}
const jan4 = new Date(Date.UTC(year, 0, 4));
const jan4Weekday = jan4.getUTCDay() || 7;
const weekStart = new Date(jan4);
weekStart.setUTCDate(jan4.getUTCDate() - jan4Weekday + 1 + (week - 1) * 7);
const checkThursday = new Date(weekStart);
checkThursday.setUTCDate(weekStart.getUTCDate() + 3);
if (checkThursday.getUTCFullYear() !== year) {
throw badRequest("week is invalid");
}
const next = new Date(weekStart);
next.setUTCDate(weekStart.getUTCDate() + 7);
return {
label: value,
start: weekStart.toISOString().slice(0, 10),
startIso: weekStart.toISOString(),
endIso: next.toISOString()
};
}
export function parsePositiveInteger(value: unknown, field: string) {
const number = Number(value);
+1
View File
@@ -0,0 +1 @@
{"_url":"https://app1.teamspace.de/api/project;limit=1000;depth=1;sort=projectId?parent=18260325&isActive=true&isTemplate=false","_links":[{"caption":"Self","href":"https://app1.teamspace.de/api/project;limit=1000;depth=1;sort=projectId?parent=18260325&isActive=true&isTemplate=false","rel":"self"},{"caption":"|< firstPage","href":"https://app1.teamspace.de/api/project;offset=0;limit=1000;sort=projectId?parent=18260325&isActive=true&isTemplate=false","rel":"first","title":"First page of projects of this CollectionResource"},{"caption":">| lastPage","href":"https://app1.teamspace.de/api/project;offset=0;limit=1000;sort=projectId?parent=18260325&isActive=true&isTemplate=false","rel":"last","title":"Last page of projects of this CollectionResource"}],"_etag":{"value":"ee49b390d3cca9c74112cb0d0dc361be","weak":false},"size":13,"offset":0,"limit":1000,"items":[{"caption":"Time Control Installation Software","title":"referenced resource","href":"https://app1.teamspace.de/api/project/18260341","lastModifiedDate":"2026-09-30T12:01:22.000+00:00","value":18260341,"idKey":"22","data":{"_url":"https://app1.teamspace.de/api/project/18260341","_id":18260341,"_idKey":"22","_links":[{"caption":"Holzwerk Schilling GmbH & Co. KG ∕ 49951 ∕ Neukunde","title":"parent project","href":"https://app1.teamspace.de/api/project/18260325","value":18260325,"idKey":"22","rel":"parent"}],"_lastModifiedDate":"2026-09-30T12:01:22.000+00:00","name":"Time Control Installation Software","mainprojectId":"EP-2025-Comp-009","mainProject":{"caption":"Holzwerk Schilling GmbH & Co. KG / 49951 / Neukunde","title":"main project","href":"https://app1.teamspace.de/api/project/18260325","value":18260325,"idKey":"22"},"projectId":"0001","path":"Holzwerk Schilling GmbH & Co. KG ∕ 49951 ∕ Neukunde/Time Control Installation Software","description":"","isMainproject":false,"isWorkpackage":true,"isActive":true,"type":null,"projectFolder":null,"projectWorkflow":null,"priority":null,"state":{"caption":"Abgeschlossen","title":"projectState","href":"https://app1.teamspace.de/api/projectstate/13338166","value":13338166,"idKey":"154","rel":"projectState set to this project","optionsUrl":"https://app1.teamspace.de/api/projectstate?workflow=13338231"},"stateRelevant":{"caption":"Abgeschlossen","title":"projectStateRelevant","href":"https://app1.teamspace.de/api/projectstate/13338166","value":13338166,"idKey":"154","rel":"effective projectState of this project"},"progress":100,"progressRelevant":100,"plannedMinutesEnabled":true,"sumMinutes":360,"plannedMinutesBillableEnabled":true,"sumMinutesBillable":180,"plannedMinutesPerformanceEnabled":true,"sumMinutesPerformance":0,"plannedWorkingCostsEnabled":true,"sumWorkingCosts":0.0,"plannedExpensesEnabled":true,"sumExpenses":0.0,"plannedIncomeEnabled":true,"plannedIncome":360.0,"plannedIncomeCalculated":360.0,"sumIncome":360.0,"customer":{"caption":"CompData Computer GmbH","title":"customer","href":"https://app1.teamspace.de/api/organization/13905417","value":13905417,"idKey":"78"},"customerContact":null,"contractor":{"caption":"AIKAVA GmbH","title":"contractor","href":"https://app1.teamspace.de/api/organization/13343077","value":13343077,"idKey":"78"},"contractorContact":null,"currency":{"title":"currency","href":"https://app1.teamspace.de/api/currency/13337775","value":13337775,"idKey":"105"},"colorlabel":null,"colorlabelRelevant":null,"contractPositions":[{"caption":"Contract position","title":"contractPosition","href":"https://app1.teamspace.de/api/contractposition/18260213","value":18260213,"idKey":"104"}],"exportId":null,"exportComment":null,"exportDate":null,"customfields":[],"article":null,"articleRelevant":null,"ticket":null,"endless":false,"parent":{"caption":"Holzwerk Schilling GmbH & Co. KG ∕ 49951 ∕ Neukunde","title":"parent project","href":"https://app1.teamspace.de/api/project/18260325","value":18260325,"idKey":"22","rel":"parent"}},"path":"Holzwerk Schilling GmbH & Co. KG ∕ 49951 ∕ Neukunde/Time Control Installation Software","rel":"link"},{"caption":"Time Control Installation Kommunikationssoftware","title":"referenced resource","href":"https://app1.teamspace.de/api/project/18260349","lastModifiedDate":"2026-09-30T12:01:22.000+00:00","value":18260349,"idKey":"22","data":{"_url":"https://app1.teamspace.de/api/project/18260349","_id":18260349,"_idKey":"22","_links":[{"caption":"Holzwerk Schilling GmbH & Co. KG ∕ 49951 ∕ Neukunde","title":"parent project","href":"https://app1.teamspace.de/api/project/18260325","value":18260325,"idKey":"22","rel":"parent"}],"_lastModifiedDate":"2026-09-30T12:01:22.000+00:00","name":"Time Control Installation Kommunikationssoftware","mainprojectId":"EP-2025-Comp-009","mainProject":{"caption":"Holzwerk Schilling GmbH & Co. KG / 49951 / Neukunde","title":"main project","href":"https://app1.teamspace.de/api/project/18260325","value":18260325,"idKey":"22"},"projectId":"0002","path":"Holzwerk Schilling GmbH & Co. KG ∕ 49951 ∕ Neukunde/Time Control Installation KoLine truncated
+1 -1
View File
@@ -8,7 +8,7 @@ services:
POSTGRES_USER: ${POSTGRES_USER:-tickettracker}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?POSTGRES_PASSWORD muss gesetzt sein}
volumes:
- tickettracker_postgres_data:/var/lib/postgresql/data
- ${POSTGRES_DATA_PATH:-tickettracker_postgres_data}:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U \"$${POSTGRES_USER}\" -d \"$${POSTGRES_DB}\""]
interval: 10s
+23 -14
View File
@@ -21,7 +21,7 @@ Es gibt drei zentrale Zeitebenen:
- Session-Zeit: Die gemessene oder manuell erfasste Arbeitszeit einer einzelnen Taetigkeit.
- Teamspace-Zeit: Die Zeit, die fuer ein Ticket an einem konkreten Tag wirklich im CRM beziehungsweise Teamspace eingetragen wurde.
- Monatsabschluss: Der Abschluss eines ganzen Monats, sobald alle Sessions des Monats bewertet sind.
- Monatsabschluss: Der Abschluss eines ganzen Monats, sobald alle Sessions des Monats final bewertet sind.
Wichtig: Tickets werden nicht geschlossen. Tickets dienen nur als Gruppierung fuer Sessions. Abgeschlossen wird ausschliesslich der Monat.
@@ -84,7 +84,7 @@ Beim Klick auf `Beenden` stoppt der Timer sofort. Die vorher im Timer erfasste T
Die Bewertung hat drei Optionen: `---`, `Abrechenbar` und `Nicht abrechenbar`. `---` ist der Standard und laesst die Session offen. Wenn im Profil die Bewertungsabfrage aktiv ist, fragt TicketTracker beim Beenden eines Timers mit `---` noch einmal nach.
Im Abschlussdialog kann die Taetigkeit als interner Zammad-Article an das Ticket uebertragen werden. Der Article wird mit `internal: true` angelegt und enthaelt die Taetigkeit sowie den `time_unit`-Wert aus der gemessenen Zeit abzueglich Pausen.
Im Abschlussdialog kann die Taetigkeit als interner Zammad-Article an das Ticket uebertragen werden. Der Article wird mit `internal: true` angelegt und enthaelt die Taetigkeit sowie den `time_unit`-Wert aus der gemessenen Zeit abzueglich Pausen. Wenn die interne Zammad-Notiz aktiv ist, kann dort auch der Zammad-Zeitbuchungstyp gewaehlt werden.
Wenn Zammad beim Speichern nicht erreichbar ist, wird die Session trotzdem in TicketTracker gespeichert. Der Zammad-Article wird dann in einer Parktabelle vorgemerkt und kann spaeter im Profil erneut gesendet werden.
@@ -111,11 +111,14 @@ Beim Nachtragen werden angegeben:
- Bis-Uhrzeit
- Bewertung: `---`, `Abrechenbar` oder `Nicht abrechenbar`
- Taetigkeit
- optional interne Zammad-Notiz
Das Taetigkeitsfeld ist mehrzeilig. Zeilenumbrueche bleiben in der Auswertung erhalten.
Mit Bewertung `---` bleibt eine nachgetragene Session offen. `Abrechenbar` und `Nicht abrechenbar` werden direkt an der neuen Session gespeichert.
Wenn die interne Zammad-Notiz aktiviert ist, wird die Taetigkeit auch beim Nachtragen als interner Zammad-Article uebertragen. Der Zammad-Typ kann dabei angepasst werden. Bei Zammad-Fehlern wird der Eintrag wie beim Timerabschluss geparkt und kann spaeter im Profil gesendet werden.
Das Ende muss nach dem Beginn liegen. Die Dauer wird automatisch berechnet und auf Minuten gerundet.
In der Ticketdetailansicht gibt es ebenfalls `Session nachtragen`. Dort wird die neue Session direkt dem geoeffneten Ticket zugeordnet.
@@ -158,7 +161,7 @@ Die oberen Karten zeigen:
- Abgerechnet: Summe der Sessions mit Bewertung `Abgerechnet` und, sofern vorhanden, die dazugehoerige Teamspace-Zeit als Vergleichswert
- Abrechenbar: Summe der Sessions mit Bewertung `Abrechenbar`, noch ohne Teamspace-Wirkung
- Nicht abrechenbar: Summe der Sessions mit Bewertung `Nicht abrechenbar`
- Offen: Anzahl unbewerteter Sessions
- Offen: Anzahl nicht final bewerteter Sessions, inklusive `Abrechenbar`
Der Verlauf der abgerechneten Zeit liegt auf der Seite `Statistiken`. Dort wird der Monatsverlauf gemeinsam mit Teamspace und Bewertungsstand betrachtet.
@@ -182,8 +185,8 @@ Neben der Ticketnummer gibt es ein Copy-Icon. Ein Klick kopiert die Ticketnummer
Der Status bedeutet:
- `offen`: Es gibt noch unbewertete Sessions.
- `bewertet`: Alle Sessions dieses Tickets im gewaehlten Zeitraum sind bewertet.
- `offen`: Es gibt noch nicht final bewertete Sessions oder Sessions mit Status `Abrechenbar`.
- `bewertet`: Alle Sessions dieses Tickets im gewaehlten Zeitraum sind `Abgerechnet` oder `Nicht abrechenbar`.
### Filtern, Gruppieren und Sortieren
@@ -225,8 +228,11 @@ In der Ticketdetailansicht sieht man:
- ToDos zu diesem Ticket oder Projekt
- Session-Eintraege nach Tagen gruppiert
- Teamspace-Wert pro Ticket und Tag
- Moeglichkeit, direkt einen Timer fuer dieses Ticket zu starten
- Moeglichkeit, direkt eine Session zu diesem Ticket nachzutragen
`Timer starten` nutzt dieselbe Timerlogik wie der globale Schnellstart. Existiert bereits ein Timer fuer das Ticket, wird dieser aktiviert.
Geoeffnete Tickets erscheinen in der Seitenleiste unter `Geoeffnete Tickets`. Die Liste bleibt nach Aktualisierung und Browserneustart erhalten. Mit einem Klick auf die Ticketnummer gelangt man direkt zur Ticketdetailansicht zurueck. Die Reihenfolge bleibt beim Anklicken unveraendert und kann nur per Drag and Drop angepasst werden. Der Schliessen-Button entfernt das Ticket aus der Seitenleiste. Wenn im aktuell geoeffneten Ticket noch ungespeicherte Werte vorhanden sind, muss zuerst gespeichert werden.
## Projekte und ToDos
@@ -320,7 +326,7 @@ Die Sessions sind pro Tag gruppiert. In jeder Tagesgruppe stehen:
- `Tag abgerechnet`: Summe der als `Abgerechnet` bewerteten Session-Zeit dieses Tages
- `Abrechenbar`: Summe der als `Abrechenbar` bewerteten Session-Zeit dieses Tages
- `Nicht`: Summe der als `Nicht abrechenbar` bewerteten Session-Zeit dieses Tages
- `Offen`: Summe der noch unbewerteten Session-Zeit dieses Tages
- `Offen`: Summe der noch nicht final bewerteten Session-Zeit dieses Tages, inklusive `Abrechenbar`
- `Teamspace`: Stunden, die fuer dieses Ticket an diesem Tag wirklich im CRM eingetragen wurden
Das Teamspace-Feld wird in Stunden eingetragen, zum Beispiel:
@@ -348,7 +354,7 @@ Jede Session kann bewertet werden als:
Ein erneuter Klick auf den bereits gewaehlten Zustand nimmt die Bewertung zurueck. Dadurch wird die Session wieder offen.
Abrechenbare und nicht abrechenbare Sessions gelten als bewertet. Sie blockieren den Monatsabschluss nicht. `Abrechenbar` fliesst nicht in Teamspace-Statistiken ein; erst `Abgerechnet` zaehlt dort.
`Abgerechnet` und `Nicht abrechenbar` schliessen die Bewertung ab. `Abrechenbar` ist nur ein Zwischenstatus, gilt fuer Abschluss und offene Bewertungen weiterhin als offen und blockiert den Monatsabschluss. In Teamspace-Statistiken zaehlt erst `Abgerechnet`.
Es gibt keinen Ticketabschluss. Tickets dienen nur als Gruppierung fuer Sessions. Abgeschlossen wird ausschliesslich der Monat.
@@ -387,7 +393,7 @@ Der Monatsabschluss ist eine eigene Unterseite.
Ein Monat kann abgeschlossen werden, wenn:
- Alle Sessions im Monat bewertet sind.
- Keine offenen Bewertungen mehr vorhanden sind. Sessions mit Status `Abrechenbar` gelten dabei weiterhin als offen.
Einen separaten Tagesabschluss gibt es nicht. Die Tagesansicht ist nur eine gefilterte Kontrolle einzelner Tage. Bewertungen und Teamspace-Tageswerte wirken in Monats- und Tagesansicht auf dieselben Daten, abgeschlossen wird aber ausschliesslich der Monat.
@@ -470,9 +476,9 @@ Farben:
- Nicht abrechenbar: indigo
- Offen: gelb/orange
`Abrechenbar` und `Nicht abrechenbar` zeigen bewertete Sessions, die nicht als abgerechnet gelten. Diese Sessions werden nicht mit Teamspace verglichen.
`Abrechenbar` zeigt Sessions im Zwischenstatus, die noch offen bleiben. `Nicht abrechenbar` zeigt final bewertete Sessions, die nicht als abgerechnet gelten. Beide werden nicht mit Teamspace verglichen.
`Offen` zeigt Sessions ohne Bewertung. Diese blockieren den Monatsabschluss.
`Offen` zeigt Sessions ohne finalen Status, also auch `Abrechenbar`. Diese blockieren den Monatsabschluss.
### Monatsverlauf und Abrechnungsqualitaet
@@ -480,7 +486,7 @@ Diese Card kombiniert den Tagesverlauf und die Bewertungsqualitaet.
- Balken zeigen abgerechnete Session-Zeit.
- Die Linie zeigt Teamspace-Zeit.
- Der Bewertungsstand trennt abgerechnete, abrechenbare, nicht abrechenbare und offene Sessions.
- Der Bewertungsstand trennt abgerechnete, abrechenbare, nicht abrechenbare und offene Sessions. Abrechenbar bleibt dabei zusaetzlich offen.
- Der Teamspace-Abgleich zeigt die Gesamtdifferenz.
- Manuell und Fix trennen manuell erfasste abrechenbare Sessions von automatisch erzeugten abrechenbaren Sessions.
@@ -528,6 +534,7 @@ Unter `Profil` kann jeder Benutzer seine eigenen Daten pflegen:
- persoenlicher Zammad API-Token
- Bewertungsabfrage beim Timerabschluss
- interne Zammad-Notiz beim Timerabschluss
- Standard-Typ fuer interne Zammad-Notizen
- Zammad-Zeitaufrundung fuer `time_unit`
Passwortaenderungen erfordern das aktuelle Passwort. Bleiben die Passwortfelder leer, werden nur Benutzername und voller Name gespeichert.
@@ -540,7 +547,9 @@ Der persoenliche Zammad Token ist unabhaengig vom Admin-Token fuer den Organisat
Die Bewertungsabfrage beim Timerabschluss kann im Profil ein- oder ausgeschaltet werden. Sie erscheint nur, wenn der Timer beim Beenden noch auf Bewertung `---` steht.
Die interne Zammad-Notiz beim Timerabschluss steuert, ob die Checkbox im Abschlussdialog standardmaessig aktiv ist. Ist sie aktiv, legt TicketTracker beim Speichern einen internen Zammad-Article zum Ticket an.
Die interne Zammad-Notiz beim Timerabschluss steuert, ob die Checkbox im Abschlussdialog standardmaessig aktiv ist. Ist sie aktiv, legt TicketTracker beim Speichern einen internen Zammad-Article zum Ticket an. Nur dann werden auch der Standard-Typ und die Zeitaufrundung im Profil angezeigt.
Der Standard-Typ belegt den Zammad-Zeitbuchungstyp fuer neue interne Notizen vor. Es muss immer eine Kategorie gewaehlt sein; falls noch kein Profilwert gespeichert ist, verwendet TicketTracker `Nicht kategorisiert`. Beim Timerabschluss und beim Nachtragen kann der Typ vor dem Speichern noch geaendert werden.
Fuer `time_unit` kann optional eine Aufrundung aktiviert werden. Dann wird die tatsaechlich getrackte Zeit immer auf 5, 10, 15 oder 30 Minuten aufgerundet und als Stundenwert an Zammad gesendet. Ohne Rundung wird der konkrete Stundenwert mit zwei Nachkommastellen gesendet.
@@ -588,12 +597,12 @@ Neue Organisationen werden hinzugefuegt, geaenderte Namen aktualisiert und nicht
3. Am Monatsende `Tickets` oeffnen.
4. Monat auswaehlen.
5. Offene Sessions pruefen.
6. Pro Ticket jede Session als `Abrechenbar`, `Abgerechnet` oder `Nicht abrechenbar` markieren.
6. Pro Ticket jede Session final als `Abgerechnet` oder `Nicht abrechenbar` markieren. `Abrechenbar` kann als Zwischenstatus stehen bleiben, blockiert dann aber den Abschluss.
7. Pro Tag und Ticket die wirklich in Teamspace eingetragene Zeit im Feld `Teamspace` pflegen.
8. `Monatsabschluss` oeffnen.
9. Offene Bewertungen abarbeiten.
10. Tage ohne Teamspace-Wert pruefen oder zur Kenntnis nehmen.
11. Wenn keine Sessions im Monat mehr offen sind, Monat abschliessen.
11. Wenn keine Sessions im Monat mehr offen sind und keine Session mehr auf `Abrechenbar` steht, Monat abschliessen.
## Hinweise
+291 -138
View File
@@ -1,5 +1,4 @@
import {
AlertCircle,
CalendarDays,
CheckCircle2,
ClipboardCheck,
@@ -67,10 +66,25 @@ import {
import { usePreferencesStore } from "@/stores/preferences/preferences-provider";
import { cn } from "./lib/utils";
import { ApiError, deleteTodo, getCurrentUser, getRecurringBillings, getSetupStatus, getTodos, logout, lookupTicket, searchTickets, updateTodo } from "./api";
import {
ApiError,
createActiveTimer,
deleteActiveTimer,
deleteTodo,
getActiveTimers,
getCurrentUser,
getRecurringBillings,
getSetupStatus,
getTodos,
logout,
lookupTicket,
searchTickets,
updateActiveTimer,
updateTodo
} from "./api";
import { currentDay, formatDateTime, formatMinutes, formatTimer } from "./format";
import { isRecurringBillingTicketNumber, ticketHasRecurringBilling } from "./recurring-billings";
import { activeElapsedMs, createClientId, pauseEntry, readStoredTimers, resumeEntry, storageKeyForUser, ticketPattern, type TimerEntry } from "./timers";
import { activeElapsedMs, mergeTimerActivityDrafts, readStoredTimers, storageKeyForUser, ticketPattern, type TimerEntry } from "./timers";
import { formatTodoPeriod, isTodoActiveOnDay, isTodoFixedOnDay, isTodoOverdue } from "./todos";
import { AdminUsersPage } from "./views/AdminUsersPage";
import { AnalysisPage } from "./views/AnalysisPage";
@@ -447,12 +461,12 @@ type SidebarTimersProps = {
timers: TimerEntry[];
selectedTimerId: string | null;
setSelectedTimerId: Dispatch<SetStateAction<string | null>>;
setTimers: Dispatch<SetStateAction<TimerEntry[]>>;
tick: number;
onToggleTimer: (timerId: string) => void;
onOpenTimer: () => void;
};
function SidebarTimers({ timers, selectedTimerId, setSelectedTimerId, setTimers, tick, onOpenTimer }: SidebarTimersProps) {
function SidebarTimers({ timers, selectedTimerId, setSelectedTimerId, tick, onToggleTimer, onOpenTimer }: SidebarTimersProps) {
const sortedTimers = useMemo(
() =>
[...timers].sort((first, second) => {
@@ -473,17 +487,8 @@ function SidebarTimers({ timers, selectedTimerId, setSelectedTimerId, setTimers,
return;
}
const now = Date.now();
setSelectedTimerId(timerId);
setTimers((current) =>
current.map((entry) => {
if (entry.id !== timerId) {
return pauseEntry(entry, now);
}
return timer.phase === "running" ? pauseEntry(entry, now) : resumeEntry(entry, now);
}),
);
onToggleTimer(timerId);
}
return (
@@ -585,20 +590,57 @@ const sidebarTodoCalendarClasses: Record<TicketTodo["priority"], string> = {
};
const sidebarOverdueTodoClass = "border-red-500/60 bg-red-500/15 text-red-100";
const sidebarTodoPriorityOrder: Record<TicketTodo["priority"], number> = {
urgent: 0,
high: 1,
medium: 2,
low: 3
};
function sortSidebarCurrentTodos(todos: TicketTodo[], today: string) {
return [...todos].sort((left, right) => {
const leftOverdue = isTodoOverdue(left, today);
const rightOverdue = isTodoOverdue(right, today);
if (leftOverdue !== rightOverdue) {
return leftOverdue ? -1 : 1;
}
return sidebarTodoPriorityOrder[left.priority] - sidebarTodoPriorityOrder[right.priority] || left.sort_order - right.sort_order || left.title.localeCompare(right.title, "de-DE", { sensitivity: "base" });
});
}
function notifyTodosChanged() {
window.dispatchEvent(new Event("tickettracker:todos-changed"));
}
function SidebarTodayTodos({ todos, onNavigate }: SidebarTodayTodosProps) {
const [runningTodosOpen, setRunningTodosOpen] = useState(false);
const [todosModalOpen, setTodosModalOpen] = useState(false);
const [modalBusy, setModalBusy] = useState(false);
const today = currentDay();
const fixedTodos = todos.filter((todo) => isTodoFixedOnDay(todo, today));
const summarizedTodos = todos.filter((todo) => todo.status === "open" && isTodoActiveOnDay(todo, today) && !isTodoFixedOnDay(todo, today));
const overdueTodos = todos.filter((todo) => isTodoOverdue(todo, today));
const currentTodos = sortSidebarCurrentTodos(
[...overdueTodos, ...fixedTodos, ...summarizedTodos].filter((todo, index, list) => list.findIndex((item) => item.id === todo.id) === index),
today
);
const visibleCurrentTodos = currentTodos.slice(0, 6);
const additionalTodosCount = Math.max(0, currentTodos.length - visibleCurrentTodos.length);
const hasTodos = fixedTodos.length > 0 || summarizedTodos.length > 0 || overdueTodos.length > 0;
function todoStateLabel(todo: TicketTodo) {
if (isTodoOverdue(todo, today)) {
return "Überfällig";
}
if (isTodoFixedOnDay(todo, today)) {
return "Fester Termin";
}
return "Laufend";
}
async function toggleTodo(todo: TicketTodo) {
setModalBusy(true);
@@ -660,42 +702,30 @@ function SidebarTodayTodos({ todos, onNavigate }: SidebarTodayTodosProps) {
{hasTodos ? (
<div className="space-y-1">
{overdueTodos.slice(0, 3).map((todo) => (
{visibleCurrentTodos.map((todo) => (
<button
key={`overdue-${todo.id}`}
key={`current-${todo.id}`}
type="button"
className="block w-full rounded-md border border-red-500/45 bg-red-500/10 px-2 py-1.5 text-left hover:bg-red-500/15"
onClick={() => onNavigate(`/tickets/${todo.ticket_id}`)}
>
<span className="flex min-w-0 items-center gap-1.5 text-[11px] font-semibold text-red-200">
<AlertCircle className="size-3.5 shrink-0" />
<span className="truncate">{todo.title}</span>
</span>
<span className="block truncate text-[11px] text-muted-foreground">{todo.ticket_number ?? "Ticket"} · {formatTodoPeriod(todo)}</span>
</button>
))}
{overdueTodos.length > 3 ? <p className="px-1 text-[11px] text-red-200">+{overdueTodos.length - 3} weitere überfällig</p> : null}
{fixedTodos.slice(0, 3).map((todo) => (
<button
key={`fixed-${todo.id}`}
type="button"
className={`block w-full rounded-md border px-2 py-1.5 text-left hover:brightness-110 ${sidebarTodoCalendarClasses[todo.priority]}`}
className={`block w-full rounded-md border px-2 py-1.5 text-left hover:brightness-110 ${isTodoOverdue(todo, today) ? sidebarOverdueTodoClass : sidebarTodoCalendarClasses[todo.priority]}`}
onClick={() => onNavigate(`/tickets/${todo.ticket_id}`)}
>
<span className="block truncate text-[11px] font-semibold">{todo.title}</span>
<span className="block truncate text-[11px] text-muted-foreground">{todo.ticket_number ?? "Ticket"} · {todo.organization_name ?? "Keine Organisation"}</span>
<span className="block truncate text-[11px] text-muted-foreground">
{todo.ticket_number ?? "Ticket"} · {todoStateLabel(todo)}
</span>
</button>
))}
{fixedTodos.length > 3 ? <p className="px-1 text-[11px] text-muted-foreground">+{fixedTodos.length - 3} weitere feste Termine</p> : null}
{summarizedTodos.length > 0 ? (
{additionalTodosCount > 0 ? (
<button
type="button"
className="block w-full rounded-md border border-muted-foreground/20 bg-background/60 px-2 py-1.5 text-left text-[11px] font-medium text-muted-foreground hover:bg-muted"
onClick={() => setRunningTodosOpen(true)}
onClick={() => setTodosModalOpen(true)}
>
+{summarizedTodos.length} laufende ToDo{summarizedTodos.length === 1 ? "" : "s"}
<span className="block">+{additionalTodosCount} weitere aktuelle ToDo{additionalTodosCount === 1 ? "" : "s"}</span>
<span className="block truncate font-normal">
{overdueTodos.length} überfällig · {summarizedTodos.length} laufend · {fixedTodos.length} feste
</span>
</button>
) : null}
</div>
@@ -706,22 +736,24 @@ function SidebarTodayTodos({ todos, onNavigate }: SidebarTodayTodosProps) {
</SidebarGroupContent>
</SidebarGroup>
<Dialog open={runningTodosOpen} onOpenChange={setRunningTodosOpen}>
<Dialog open={todosModalOpen} onOpenChange={setTodosModalOpen}>
<DialogContent className="max-h-[calc(100dvh-2rem)] overflow-y-auto sm:max-w-xl">
<DialogHeader>
<DialogTitle>ToDos am Tag</DialogTitle>
<DialogDescription>Heute · {summarizedTodos.length} laufende ToDo(s)</DialogDescription>
<DialogTitle>Aktuelle ToDos</DialogTitle>
<DialogDescription>
{overdueTodos.length} überfällig · {fixedTodos.length} feste Termine · {summarizedTodos.length} laufende ToDo(s)
</DialogDescription>
</DialogHeader>
<div className="space-y-2">
{summarizedTodos.map((todo) => (
{currentTodos.map((todo) => (
<div key={todo.id} className={`rounded-md border p-2.5 ${isTodoOverdue(todo, today) ? sidebarOverdueTodoClass : sidebarTodoCalendarClasses[todo.priority]}`}>
<div className="flex flex-col gap-2">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<div className="flex min-w-0 flex-wrap items-center gap-2">
<span className="min-w-0 truncate text-sm font-semibold">{todo.title}</span>
<Badge variant={isTodoOverdue(todo, today) ? "destructive" : isTodoFixedOnDay(todo, today) ? "outline" : "secondary"}>{todoStateLabel(todo)}</Badge>
<span className={`shrink-0 rounded-full border px-2 py-0.5 text-xs font-medium ${sidebarTodoPriorityClasses[todo.priority]}`}>{sidebarTodoPriorityLabels[todo.priority]}</span>
{isTodoOverdue(todo, today) ? <Badge variant="destructive">überfällig</Badge> : null}
</div>
<p className="mt-1 truncate text-xs text-muted-foreground" title={todo.organization_name ?? "Keine Organisation"}>
{todo.organization_name ?? "Keine Organisation"}
@@ -743,7 +775,7 @@ function SidebarTodayTodos({ todos, onNavigate }: SidebarTodayTodosProps) {
size="sm"
variant="ghost"
onClick={() => {
setRunningTodosOpen(false);
setTodosModalOpen(false);
onNavigate(`/tickets/${todo.ticket_id}`);
}}
>
@@ -758,8 +790,8 @@ function SidebarTodayTodos({ todos, onNavigate }: SidebarTodayTodosProps) {
</div>
</div>
))}
{summarizedTodos.length === 0 ? (
<p className="rounded-md border bg-muted/20 p-4 text-center text-sm text-muted-foreground">Keine laufenden ToDos an diesem Tag.</p>
{currentTodos.length === 0 ? (
<p className="rounded-md border bg-muted/20 p-4 text-center text-sm text-muted-foreground">Keine aktuellen ToDos.</p>
) : null}
</div>
</DialogContent>
@@ -783,6 +815,7 @@ export function App() {
const [draggedOpenTicketKey, setDraggedOpenTicketKey] = useState<string | null>(null);
const [sidebarTodos, setSidebarTodos] = useState<TicketTodo[]>([]);
const openTicketsListRef = useRef<HTMLDivElement | null>(null);
const hydratingTimerIds = useRef(new Set<string>());
const navigate = useCallback(
(to: string) => {
@@ -860,10 +893,48 @@ export function App() {
return;
}
setTimers(readStoredTimers(currentUser.id));
let cancelled = false;
const userId = currentUser.id;
setTimersLoaded(false);
setTimers([]);
setOpenTickets(readOpenTickets(currentUser.id));
setTimerOwnerId(currentUser.id);
setTimersLoaded(true);
async function loadTimers() {
try {
let serverTimers = (await getActiveTimers()).timers;
const storedTimers = readStoredTimers(userId);
if (serverTimers.length === 0 && storedTimers.length > 0) {
for (const timer of storedTimers) {
serverTimers = (await createActiveTimer(timer)).timers;
}
}
localStorage.removeItem(storageKeyForUser(userId));
if (!cancelled) {
setTimers(mergeTimerActivityDrafts(userId, serverTimers));
}
} catch (error) {
if (!cancelled) {
setTimers(mergeTimerActivityDrafts(userId, readStoredTimers(userId)));
toast.error("Timer konnten nicht geladen werden", {
description: error instanceof Error ? error.message : "Unbekannter Fehler"
});
}
} finally {
if (!cancelled) {
setTimersLoaded(true);
}
}
}
void loadTimers();
return () => {
cancelled = true;
};
}, [currentUser]);
useEffect(() => {
@@ -887,8 +958,6 @@ export function App() {
return;
}
localStorage.setItem(storageKeyForUser(currentUser.id), JSON.stringify(timers));
if (timers.length === 0) {
setSelectedTimerId(null);
return;
@@ -899,6 +968,31 @@ export function App() {
}
}, [currentUser, timers, selectedTimerId, timersLoaded, timerOwnerId]);
const refreshActiveTimers = useCallback(async () => {
if (!currentUser || currentUser.role === "admin") {
return;
}
try {
const result = await getActiveTimers();
setTimers(mergeTimerActivityDrafts(currentUser.id, result.timers));
} catch {
// Keep the currently displayed timers if the refresh fails.
}
}, [currentUser]);
useEffect(() => {
if (!currentUser || currentUser.role === "admin" || !timersLoaded || timerOwnerId !== currentUser.id) {
return;
}
window.addEventListener("focus", refreshActiveTimers);
return () => {
window.removeEventListener("focus", refreshActiveTimers);
};
}, [currentUser, refreshActiveTimers, timersLoaded, timerOwnerId]);
const loadSidebarTodos = useCallback(async () => {
if (!currentUser || currentUser.role === "admin") {
return;
@@ -938,60 +1032,77 @@ export function App() {
return;
}
const timersToHydrate = timers.filter((timer) => !timer.ticketLookupDone);
const timersToHydrate = timers.filter((timer) => !timer.ticketLookupDone && !hydratingTimerIds.current.has(timer.id));
if (timersToHydrate.length === 0) {
return;
}
let cancelled = false;
timersToHydrate.forEach((timer) => hydratingTimerIds.current.add(timer.id));
async function hydrateTimers() {
const results = await Promise.all(
timersToHydrate.map(async (timer) => {
try {
const result = await lookupTicket(timer.ticketNumber);
return {
id: timer.id,
organizationName: result.ticket?.organization_name ?? result.ticket?.customer_name ?? null,
workType: result.ticket?.work_type ?? null,
isProject: Boolean(result.ticket?.is_project),
projectName: result.ticket?.project_name ?? null
};
} catch {
return {
id: timer.id,
organizationName: null,
workType: null,
isProject: false,
projectName: null
};
}
})
);
try {
const results = await Promise.all(
timersToHydrate.map(async (timer) => {
try {
const result = await lookupTicket(timer.ticketNumber);
return {
id: timer.id,
organizationName: result.ticket?.organization_name ?? result.ticket?.customer_name ?? null,
workType: result.ticket?.work_type ?? null,
isProject: Boolean(result.ticket?.is_project),
projectName: result.ticket?.project_name ?? null
};
} catch {
return {
id: timer.id,
organizationName: null,
workType: null,
isProject: false,
projectName: null
};
}
})
);
if (cancelled) {
return;
if (cancelled) {
return;
}
setTimers((current) =>
current.map((timer) => {
const result = results.find((entry) => entry.id === timer.id);
if (!result) {
return timer;
}
return {
...timer,
organizationName: result.organizationName,
workType: result.workType,
isProject: result.isProject,
projectName: result.projectName,
ticketLookupDone: true
};
})
);
await Promise.all(
results.map((result) =>
updateActiveTimer(result.id, {
organizationName: result.organizationName,
workType: result.workType,
isProject: result.isProject,
projectName: result.projectName,
ticketLookupDone: true
}).catch(() => undefined)
)
);
} finally {
timersToHydrate.forEach((timer) => hydratingTimerIds.current.delete(timer.id));
}
setTimers((current) =>
current.map((timer) => {
const result = results.find((entry) => entry.id === timer.id);
if (!result) {
return timer;
}
return {
...timer,
organizationName: result.organizationName,
workType: result.workType,
isProject: result.isProject,
projectName: result.projectName,
ticketLookupDone: true
};
})
);
}
void hydrateTimers();
@@ -1006,6 +1117,26 @@ export function App() {
return () => window.clearInterval(interval);
}, []);
async function toggleActiveTimer(timerId: string) {
const userId = currentUser?.id;
const timer = timers.find((entry) => entry.id === timerId);
if (!timer || !userId) {
return;
}
try {
const result = await updateActiveTimer(timerId, {
phase: timer.phase === "running" ? "paused" : "running"
});
setTimers(mergeTimerActivityDrafts(userId, result.timers));
} catch (error) {
toast.error("Timer konnte nicht aktualisiert werden", {
description: error instanceof Error ? error.message : "Unbekannter Fehler"
});
}
}
useEffect(() => {
if (!currentUser || currentUser.role !== "admin") {
return;
@@ -1019,6 +1150,12 @@ export function App() {
}, [currentUser, route.page]);
async function startQuickTimer(rawTicketNumber: string) {
const userId = currentUser?.id;
if (!userId) {
return false;
}
if (currentUser?.role === "admin") {
toast.error("Admin erfasst keine Sessions", {
description: "Der Adminbereich ist nur für die Systemverwaltung vorgesehen."
@@ -1026,14 +1163,14 @@ export function App() {
return false;
}
const ticketNumber = rawTicketNumber.trim();
const ticketNumber = rawTicketNumber.trim();
if (!ticketPattern.test(ticketNumber)) {
if (!ticketPattern.test(ticketNumber)) {
toast.error("Ticketnummer prüfen", {
description: "Das Format muss Ticket#XXXXXX sein."
});
return false;
}
return false;
}
const [ticketResult, recurringResult] = await Promise.all([
lookupTicket(ticketNumber).catch(() => ({ ticket: null })),
@@ -1041,7 +1178,9 @@ export function App() {
]);
if (ticketHasRecurringBilling(ticketResult.ticket, recurringResult.recurringBillings) || isRecurringBillingTicketNumber(ticketNumber, recurringResult.recurringBillings)) {
setTimers((current) => current.filter((timer) => timer.ticketNumber !== ticketNumber));
const existingRecurringTimer = timers.find((timer) => timer.ticketNumber === ticketNumber);
const result = existingRecurringTimer ? await deleteActiveTimer(existingRecurringTimer.id).catch(() => null) : null;
setTimers((current) => result?.timers ?? current.filter((timer) => timer.ticketNumber !== ticketNumber));
toast.error("Fixe Abrechnung", {
description: "Für fixe Abrechnungen dürfen keine manuellen Sessions gestartet werden. Bitte die Definition bearbeiten."
});
@@ -1052,38 +1191,50 @@ export function App() {
const now = Date.now();
if (existingTimer) {
setTimers((current) =>
current.map((timer) => (timer.id === existingTimer.id ? resumeEntry(timer, now) : pauseEntry(timer, now)))
);
setSelectedTimerId(existingTimer.id);
toast.info("Bestehender Timer aktiviert", {
description: ticketNumber
});
return true;
try {
const result = await updateActiveTimer(existingTimer.id, { phase: "running" });
setTimers(mergeTimerActivityDrafts(userId, result.timers));
setSelectedTimerId(existingTimer.id);
toast.info("Bestehender Timer aktiviert", {
description: ticketNumber
});
return true;
} catch (error) {
toast.error("Timer konnte nicht aktiviert werden", {
description: error instanceof Error ? error.message : "Unbekannter Fehler"
});
return false;
}
}
const newTimer: TimerEntry = {
id: createClientId("timer"),
ticketNumber,
organizationName: ticketResult.ticket?.organization_name ?? ticketResult.ticket?.customer_name ?? null,
workType: ticketResult.ticket?.work_type ?? null,
isProject: Boolean(ticketResult.ticket?.is_project),
projectName: ticketResult.ticket?.project_name ?? null,
ticketLookupDone: true,
startedAt: now,
pausedTotalMs: 0,
pausedAt: null,
activity: "",
billingStatus: null,
phase: "running"
};
setTimers((current) => [...current.map((timer) => pauseEntry(timer, now)), newTimer]);
setSelectedTimerId(newTimer.id);
toast.success("Timer gestartet", {
description: ticketResult.ticket?.is_project ? `${ticketNumber} ist ein Projekt` : ticketNumber
});
return true;
try {
const result = await createActiveTimer({
ticketNumber,
organizationName: ticketResult.ticket?.organization_name ?? ticketResult.ticket?.customer_name ?? null,
workType: ticketResult.ticket?.work_type ?? null,
isProject: Boolean(ticketResult.ticket?.is_project),
projectName: ticketResult.ticket?.project_name ?? null,
ticketLookupDone: true,
startedAt: now,
pausedTotalMs: 0,
pausedAt: null,
activity: "",
billingStatus: null,
phase: "running"
});
const timersWithDrafts = mergeTimerActivityDrafts(userId, result.timers);
setTimers(timersWithDrafts);
setSelectedTimerId(timersWithDrafts.find((timer) => timer.ticketNumber === ticketNumber)?.id ?? null);
toast.success("Timer gestartet", {
description: ticketResult.ticket?.is_project ? `${ticketNumber} ist ein Projekt` : ticketNumber
});
return true;
} catch (error) {
toast.error("Timer konnte nicht gestartet werden", {
description: error instanceof Error ? error.message : "Unbekannter Fehler"
});
return false;
}
}
async function handleLogout() {
@@ -1164,7 +1315,7 @@ export function App() {
setOpenTickets((items) => items.filter((item) => item.key !== ticket.key));
if (pathWithoutSearch(path) === pathWithoutSearch(ticket.path)) {
if (route.page === "ticket" && route.ticketId === ticket.ticketId) {
const returnPath = current.returnPath && pathWithoutSearch(current.returnPath) !== pathWithoutSearch(ticket.path) ? current.returnPath : "/analysis";
routerNavigate(returnPath);
}
@@ -1281,8 +1432,8 @@ export function App() {
timers={timers}
selectedTimerId={selectedTimerId}
setSelectedTimerId={setSelectedTimerId}
setTimers={setTimers}
tick={tick}
onToggleTimer={(timerId) => void toggleActiveTimer(timerId)}
onOpenTimer={() => navigate("/timer")}
/>
</>
@@ -1380,7 +1531,7 @@ export function App() {
<SidebarMenuItem>
<SidebarMenuButton tooltip="Worklog">
<FolderGit />
<span className="text-xs text-muted-foreground">v1.0.1-rc1</span>
<span className="text-xs text-muted-foreground">v1.0.11-rc1</span>
</SidebarMenuButton>
</SidebarMenuItem>
</SidebarMenu>
@@ -1477,11 +1628,11 @@ export function App() {
currentUser={currentUser}
/>
) : null}
{!isAdmin && route.page === "dashboard" ? <DashboardPage onNavigate={navigate} /> : null}
{!isAdmin && route.page === "analysis" ? <AnalysisPage onNavigate={navigate} /> : null}
{!isAdmin && route.page === "dashboard" ? <DashboardPage currentUser={currentUser} onNavigate={navigate} /> : null}
{!isAdmin && route.page === "analysis" ? <AnalysisPage currentUser={currentUser} onNavigate={navigate} /> : null}
{!isAdmin && route.page === "monthly-close" ? <MonthlyClosePage onNavigate={navigate} /> : null}
{!isAdmin && route.page === "statistics" ? <StatisticsPage onNavigate={navigate} /> : null}
{!isAdmin && route.page === "projects" ? <ProjectsPage onNavigate={navigate} onStartTimer={startQuickTimer} /> : null}
{!isAdmin && route.page === "statistics" ? <StatisticsPage currentUser={currentUser} onNavigate={navigate} /> : null}
{!isAdmin && route.page === "projects" ? <ProjectsPage currentUser={currentUser} onNavigate={navigate} onStartTimer={startQuickTimer} /> : null}
{!isAdmin && route.page === "todos" ? <TodosPage onNavigate={navigate} /> : null}
{!isAdmin && route.page === "recurring" ? <RecurringBillingsPage /> : null}
{!isAdmin && route.page === "zammad-sync" ? <ZammadSyncPage currentUser={currentUser} /> : null}
@@ -1504,6 +1655,8 @@ export function App() {
period={route.period}
ticketId={route.ticketId}
onNavigate={navigate}
onStartTimer={startQuickTimer}
currentUser={currentUser}
onOpenTicketChange={updateOpenTicket}
returnPath={ticketReturnPaths[route.ticketId] ?? openTickets.find((ticket) => ticket.ticketId === route.ticketId)?.returnPath ?? "/analysis"}
/>
+232 -5
View File
@@ -7,9 +7,11 @@ import type {
Organization,
PeriodOverview,
PeriodType,
ProjectTeamspaceSubentry,
ProjectSummary,
RecurringBilling,
StatisticsOverview,
StatisticsProjectFollowupOverview,
TicketMeta,
TicketPeriod,
TicketTodo,
@@ -19,11 +21,13 @@ import type {
WorkType,
ZammadSessionArticleQueueItem
} from "./types";
import type { TimerEntry, TimerPhase } from "./timers";
type CreateSessionPayload = {
ticketNumber: string;
organizationId: string;
activity: string;
zammadArticleBody?: string | null;
workType: WorkType;
startedAt: string;
endedAt: string;
@@ -32,8 +36,25 @@ type CreateSessionPayload = {
projectTeamspaceEntryId?: string | null;
billingStatus?: BillingStatus;
createZammadArticle?: boolean;
zammadArticleTypeId?: number;
};
type CreateTimerPayload = Omit<TimerEntry, "id"> & {
id?: string;
};
type UpdateTimerPayload = Partial<{
organizationName: string | null;
workType: TimerEntry["workType"];
isProject: boolean;
projectName: string | null;
ticketLookupDone: boolean;
activity: string;
zammadArticleBody?: string | null;
billingStatus: TimerEntry["billingStatus"];
phase: TimerPhase;
}>;
type DeleteSessionResult = {
deleted: {
id: string;
@@ -46,6 +67,15 @@ type DeleteSessionResult = {
};
};
export type CreateSessionResult = {
zammadArticle?: {
requested: boolean;
status: "not_requested" | "sent" | "failed";
error: string | null;
articleId: number | null;
};
};
export type ZammadSyncExistingFields = {
title: boolean;
organization: boolean;
@@ -96,6 +126,70 @@ export type ZammadSyncJob = {
finishedAt: string | null;
};
export type TeamspaceProjectIdSyncItem = {
entryId: string;
projectId: string;
ticketNumber: string;
teamspaceProject: string;
previousApiProjectId: string | null;
apiProjectId: string | null;
apiProjectUrl: string | null;
status: "updated" | "current" | "missing" | "ambiguous";
message: string | null;
};
export type TeamspaceProjectIdSyncResult = {
checkedEntries: number;
matched: number;
updated: number;
alreadyCurrent: number;
missing: number;
ambiguous: number;
cleared: number;
remoteProjectsScanned: number;
remoteProjectIds: number;
remoteSubprojectsScanned: number;
subentriesSynced: number;
subentriesRemoved: number;
duplicateRemoteProjectIds: string[];
items: TeamspaceProjectIdSyncItem[];
};
export type TeamspaceProjectSubentrySyncResult = {
projectId: string;
checkedEntries: number;
remoteProjectsScanned: number;
subentriesSynced: number;
subentriesRemoved: number;
};
export type TeamspaceAllSubentrySyncResult = {
checkedProjects: number;
checkedEntries: number;
remoteProjectsScanned: number;
subentriesSynced: number;
subentriesRemoved: number;
};
export type TicketZammadMailSessionItem = {
articleId: number;
ticketNumber: string;
subject: string | null;
activity: string;
createdAt: string;
endedAt: string;
timeUnit: number;
durationSeconds: number;
};
export type TicketZammadMailSessionResult = {
checked: number;
created: number;
skipped: number;
items: TicketZammadMailSessionItem[];
errors: Array<{ articleId: number; error: string }>;
};
export type RestoreValidationResult = {
valid: boolean;
checkedTables: number;
@@ -200,11 +294,21 @@ export function updateCurrentUser(payload: {
currentPassword?: string;
newPassword?: string;
zammadApiToken?: string;
teamspaceDeviceId?: string;
teamspaceDevicePassword?: string;
zammadEmailSessionWebhookEnabled?: boolean;
sessionBillingPromptEnabled?: boolean;
teamspaceTimeTrackingEnabled?: boolean;
zammadSessionNoteDefaultEnabled?: boolean;
zammadTimeUnitRoundingEnabled?: boolean;
zammadTimeUnitRoundingMinutes?: number;
zammadAtossEmailSyncEnabled?: boolean;
zammadArticleTypeId?: number;
projectFollowupEnabled?: boolean;
projectFollowupWeeks?: number;
projectFollowupSecondEnabled?: boolean;
projectFollowupSecondWeeks?: number;
projectFollowupCompleteOnSession?: boolean;
}) {
return request<{ user: AuthUser }>("/api/auth/me", {
method: "PATCH",
@@ -377,6 +481,17 @@ export function saveZammadSettings(payload: { baseUrl: string; apiKey?: string }
});
}
export function getTeamspaceSettings() {
return request<{ settings: { baseUrl: string } }>("/api/admin/teamspace/settings");
}
export function saveTeamspaceSettings(payload: { baseUrl: string }) {
return request<{ settings: { baseUrl: string } }>("/api/admin/teamspace/settings", {
method: "PUT",
body: JSON.stringify(payload)
});
}
export function syncZammadOrganizations(payload: { baseUrl?: string; apiKey?: string }) {
return request<{ synced: number; skipped: number; removed: number; unlinkedTickets: number; unlinkedSessions: number }>("/api/admin/zammad/organizations/sync", {
method: "POST",
@@ -468,6 +583,53 @@ export function applyOwnZammadConsultingTicketsFromPreview(previewJobId: string,
}, onJobStarted);
}
export function previewZammadMailSessions(daysBack: number, perPage: number, onJobStarted?: (job: ZammadSyncJob) => void) {
return runZammadSyncJob("/api/zammad-sync/jobs/mail-sessions", {
dryRun: true,
daysBack,
perPage
}, onJobStarted);
}
export function applyZammadMailSessionsFromPreview(previewJobId: string, articleIds: number[], onJobStarted?: (job: ZammadSyncJob) => void) {
return runZammadSyncJob(`/api/zammad-sync/jobs/${encodeURIComponent(previewJobId)}/apply-mail-sessions`, {
articleIds
}, onJobStarted);
}
export function syncTeamspaceProjectIds() {
return request<{ result: TeamspaceProjectIdSyncResult }>("/api/teamspace/project-ids/sync", {
method: "POST",
timeoutMs: 5 * 60 * 1000
});
}
export function syncProjectTeamspaceSubentries(projectId: string) {
return request<{ result: TeamspaceProjectSubentrySyncResult }>(`/api/projects/${projectId}/teamspace-subentries/sync`, {
method: "POST",
timeoutMs: 5 * 60 * 1000
});
}
export function syncAllTeamspaceSubentries() {
return request<{ result: TeamspaceAllSubentrySyncResult }>("/api/teamspace/subentries/sync", {
method: "POST",
timeoutMs: 5 * 60 * 1000
});
}
export function updateProjectTeamspaceSubentry(
subentryId: string,
payload: {
alias: string;
}
) {
return request<{ subentry: ProjectTeamspaceSubentry }>(`/api/project-teamspace-subentries/${subentryId}`, {
method: "PATCH",
body: JSON.stringify(payload)
});
}
export function getRecurringBillings() {
return request<{ recurringBillings: RecurringBilling[] }>("/api/recurring-billings");
}
@@ -553,6 +715,7 @@ export function createProject(payload: {
teamspaceEntries?: Array<{
id?: string | null;
teamspaceProject?: string | null;
teamspaceDescription?: string | null;
isDefault: boolean;
hourBudgetMinutes?: number | null;
budgetSyncDisabled?: boolean;
@@ -581,6 +744,7 @@ export function updateProject(
teamspaceEntries?: Array<{
id?: string | null;
teamspaceProject?: string | null;
teamspaceDescription?: string | null;
isDefault: boolean;
hourBudgetMinutes?: number | null;
budgetSyncDisabled?: boolean;
@@ -607,6 +771,13 @@ export function updateProjectStatus(projectId: string, closed: boolean) {
});
}
export function acknowledgeProjectReview(projectId: string) {
return request<{ project: { id: string; ticket_id: string; close_review_required: boolean } }>(`/api/projects/${projectId}/review`, {
method: "PATCH",
body: JSON.stringify({ acknowledged: true })
});
}
export function deleteProject(projectId: string) {
return request<{ deleted: { id: string; ticket_id: string } }>(`/api/projects/${projectId}`, {
method: "DELETE"
@@ -682,12 +853,43 @@ export function deleteTodo(todoId: string) {
}
export function createSession(payload: CreateSessionPayload) {
return request("/api/sessions", {
return request<CreateSessionResult>("/api/sessions", {
method: "POST",
body: JSON.stringify(payload)
});
}
export function resendSessionZammadArticle(sessionId: string, payload: { zammadArticleTypeId?: number } = {}) {
return request<CreateSessionResult>(`/api/sessions/${encodeURIComponent(sessionId)}/zammad-article`, {
method: "POST",
body: JSON.stringify(payload)
});
}
export function getActiveTimers() {
return request<{ timers: TimerEntry[] }>("/api/timers");
}
export function createActiveTimer(payload: CreateTimerPayload) {
return request<{ timers: TimerEntry[] }>("/api/timers", {
method: "POST",
body: JSON.stringify(payload)
});
}
export function updateActiveTimer(timerId: string, payload: UpdateTimerPayload) {
return request<{ timers: TimerEntry[] }>(`/api/timers/${encodeURIComponent(timerId)}`, {
method: "PATCH",
body: JSON.stringify(payload)
});
}
export function deleteActiveTimer(timerId: string) {
return request<{ timers: TimerEntry[] }>(`/api/timers/${encodeURIComponent(timerId)}`, {
method: "DELETE"
});
}
export function getZammadSessionArticleQueue() {
return request<{ queue: ZammadSessionArticleQueueItem[] }>("/api/zammad/session-article-queue");
}
@@ -738,6 +940,7 @@ export function updateSessionDetails(
payload: {
organizationId: string;
activity: string;
zammadArticleBody?: string | null;
workType: WorkType;
removeProjectData?: boolean;
projectTeamspaceEntryId?: string | null;
@@ -751,10 +954,18 @@ export function updateSessionDetails(
});
}
export function updateTicketDayBilling(ticketId: string, day: string, billedMinutes: number | null) {
export function updateTicketDayBilling(
ticketId: string,
day: string,
billedMinutes: number | null,
projectTeamspaceEntryId?: string | null
) {
return request(`/api/tickets/${ticketId}/day-billings/${day}`, {
method: "PATCH",
body: JSON.stringify({ billedMinutes })
body: JSON.stringify({
billedMinutes,
projectTeamspaceEntryId: projectTeamspaceEntryId ?? null
})
});
}
@@ -781,8 +992,12 @@ export function getPeriodOverview(type: PeriodType, period: string) {
return request<PeriodOverview>(`/api/periods/${periodPath(type)}/${period}/overview`);
}
export function getStatisticsOverview(month: string) {
return request<StatisticsOverview>(`/api/statistics/months/${month}`);
export function getStatisticsOverview(period: string, type: "month" | "week" = "month") {
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) {
@@ -814,6 +1029,18 @@ export function getTicketDetails(
return request<TicketPeriod>(`/api/tickets/${ticketId}/details${params.size ? `?${params}` : ""}`);
}
export function previewTicketZammadMailSessions(ticketId: string, daysBack: number) {
const params = new URLSearchParams({ daysBack: String(daysBack) });
return request<TicketZammadMailSessionResult>(`/api/tickets/${ticketId}/zammad-mail-sessions?${params}`);
}
export function importTicketZammadMailSessions(ticketId: string, daysBack: number, articleIds: number[]) {
return request<TicketZammadMailSessionResult>(`/api/tickets/${ticketId}/zammad-mail-sessions`, {
method: "POST",
body: JSON.stringify({ daysBack, articleIds })
});
}
export function updateSessionBilling(sessionId: string, billingStatus: BillingStatus) {
return request(`/api/sessions/${sessionId}/billing`, {
method: "PATCH",
@@ -0,0 +1,412 @@
import { Bold, Heading1, Heading2, Heading3, Italic, List, ListOrdered, RemoveFormatting, Strikethrough, Underline } from "lucide-react";
import { type ClipboardEvent, type KeyboardEvent, useEffect, useRef, useState } from "react";
import { toast } from "sonner";
import { cn } from "@/lib/utils";
type RichActivityEditorProps = {
id: string;
value: string;
htmlValue: string;
disabled?: boolean;
placeholder?: string;
onChange: (value: string, htmlValue: string) => void;
};
function escapeHtml(value: string) {
return value
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
function escapeAttribute(value: string) {
return escapeHtml(value).replace(/'/g, "&#39;");
}
function escapeHtmlText(value: string) {
return escapeHtml(value)
.replace(/\t/g, "&nbsp;&nbsp;&nbsp;&nbsp;")
.replace(/ /g, "&nbsp;")
.replace(/\r\n|\r|\n/g, "<br>");
}
function textToEditorHtml(value: string) {
return value
.split(/\r?\n/)
.map((line) => `<div>${line ? escapeHtml(line) : "<br>"}</div>`)
.join("");
}
function fileToDataUrl(file: File) {
return new Promise<string>((resolve, reject) => {
const reader = new FileReader();
reader.addEventListener("load", () => resolve(String(reader.result ?? "")));
reader.addEventListener("error", () => reject(reader.error ?? new Error("Bild konnte nicht gelesen werden")));
reader.readAsDataURL(file);
});
}
function currentSelectionRange() {
const selection = window.getSelection();
return selection && selection.rangeCount > 0 ? selection.getRangeAt(0).cloneRange() : null;
}
function restoreSelectionRange(range: Range | null) {
if (!range) {
return;
}
const selection = window.getSelection();
selection?.removeAllRanges();
selection?.addRange(range);
}
function editorPlainText(element: HTMLElement) {
return element.innerText.replace(/\u00a0/g, " ");
}
function hasInlineImage(element: HTMLElement) {
return Boolean(element.querySelector("img[src^='data:image/']"));
}
function textStyleTagsFromElement(element: HTMLElement) {
const style = element.getAttribute("style")?.toLowerCase() ?? "";
const tags: Array<"b" | "i" | "u" | "strike"> = [];
if (/font-weight\s*:\s*(bold|[7-9]00)\b/.test(style)) {
tags.push("b");
}
if (/font-style\s*:\s*italic\b/.test(style)) {
tags.push("i");
}
if (/text-decoration(?:-line)?\s*:[^;]*underline/.test(style)) {
tags.push("u");
}
if (/text-decoration(?:-line)?\s*:[^;]*(line-through|strike)/.test(style)) {
tags.push("strike");
}
return tags;
}
function wrapHtml(html: string, tags: string[]) {
return tags.reduceRight((current, tag) => `<${tag}>${current}</${tag}>`, html);
}
function serializeEditorChildren(element: Element | DocumentFragment) {
return Array.from(element.childNodes).map(serializeEditorNode).join("");
}
function serializeEditorNode(node: ChildNode): string {
if (node.nodeType === Node.TEXT_NODE) {
return escapeHtmlText(node.textContent ?? "");
}
if (!(node instanceof HTMLElement)) {
return "";
}
const tagName = node.tagName.toLowerCase();
const children = serializeEditorChildren(node);
if (tagName === "br") {
return "<br>";
}
if (tagName === "img") {
const src = node.getAttribute("src") ?? "";
return src.startsWith("data:image/") ? `<img src="${escapeAttribute(src)}" style="max-width:100%;">` : "";
}
if (tagName === "b" || tagName === "strong") {
return `<b>${children}</b>`;
}
if (tagName === "i" || tagName === "em") {
return `<i>${children}</i>`;
}
if (tagName === "u") {
return `<u>${children}</u>`;
}
if (tagName === "s" || tagName === "strike") {
return `<strike>${children}</strike>`;
}
if (tagName === "span" || tagName === "font") {
return wrapHtml(children, textStyleTagsFromElement(node));
}
if (tagName === "div" || tagName === "p" || tagName === "blockquote" || tagName === "pre" || tagName === "code") {
return `<${tagName}>${children || "<br>"}</${tagName}>`;
}
if (tagName === "h1" || tagName === "h2" || tagName === "h3" || tagName === "ul" || tagName === "ol" || tagName === "li") {
return `<${tagName}>${children}</${tagName}>`;
}
return children;
}
function editorHtml(element: HTMLElement) {
return serializeEditorChildren(element);
}
function normalizedEditorHtml(element: HTMLElement) {
const html = editorHtml(element);
return html && (editorPlainText(element).trim() || hasInlineImage(element)) ? html : "";
}
export function readRichActivityEditorValue(id: string) {
const element = typeof document === "undefined" ? null : document.getElementById(id);
if (!(element instanceof HTMLElement)) {
return null;
}
return {
activity: editorPlainText(element),
zammadArticleBody: normalizedEditorHtml(element)
};
}
function editorButtonClass(active = false) {
return cn(
"inline-flex size-8 items-center justify-center rounded-sm text-sm font-semibold text-zinc-100 outline-none transition-colors hover:bg-zinc-700 focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50",
active && "bg-zinc-700"
);
}
export function RichActivityEditor({
id,
value,
htmlValue,
disabled = false,
placeholder = "Tätigkeit beschreiben, Screenshot mit Strg+V einfügen",
onChange
}: RichActivityEditorProps) {
const editorRef = useRef<HTMLDivElement | null>(null);
const lastEmittedTextRef = useRef("");
const lastEmittedHtmlRef = useRef("");
const [selectedImage, setSelectedImage] = useState<HTMLImageElement | null>(null);
useEffect(() => {
const editor = editorRef.current;
if (!editor) {
return;
}
const nextHtml = htmlValue || (value ? textToEditorHtml(value) : "");
const currentHtml = normalizedEditorHtml(editor);
if (htmlValue === lastEmittedHtmlRef.current && value === lastEmittedTextRef.current) {
return;
}
if (!htmlValue && value === lastEmittedTextRef.current && currentHtml === lastEmittedHtmlRef.current) {
return;
}
if (editor.innerHTML !== nextHtml) {
editor.innerHTML = nextHtml;
}
lastEmittedTextRef.current = editorPlainText(editor);
lastEmittedHtmlRef.current = normalizedEditorHtml(editor);
}, [htmlValue, value]);
function clearSelectedImage() {
selectedImage?.classList.remove("ring-2", "ring-ring", "ring-offset-2");
setSelectedImage(null);
}
function emitChange() {
const editor = editorRef.current;
if (!editor) {
return;
}
const text = editorPlainText(editor);
const html = normalizedEditorHtml(editor);
lastEmittedTextRef.current = text;
lastEmittedHtmlRef.current = html;
onChange(text, html);
}
function focusEditor() {
editorRef.current?.focus();
}
function runCommand(command: string, value?: string) {
if (disabled) {
return;
}
focusEditor();
document.execCommand("styleWithCSS", false, "false");
document.execCommand(command, false, value);
emitChange();
}
function selectImage(image: HTMLImageElement) {
clearSelectedImage();
image.classList.add("ring-2", "ring-ring", "ring-offset-2");
setSelectedImage(image);
const range = document.createRange();
range.selectNode(image);
const selection = window.getSelection();
selection?.removeAllRanges();
selection?.addRange(range);
}
function removeSelectedImage() {
const image = selectedImage;
if (!image) {
return;
}
image.remove();
setSelectedImage(null);
emitChange();
}
function handleKeyDown(event: KeyboardEvent<HTMLDivElement>) {
if ((event.key === "Delete" || event.key === "Backspace") && selectedImage) {
event.preventDefault();
removeSelectedImage();
return;
}
if (event.key === "Tab" && !disabled) {
event.preventDefault();
document.execCommand("insertText", false, " ");
emitChange();
}
}
async function handlePaste(event: ClipboardEvent<HTMLDivElement>) {
if (disabled) {
return;
}
const imageFiles = Array.from(event.clipboardData.items)
.filter((item) => item.kind === "file" && item.type.startsWith("image/"))
.map((item) => item.getAsFile())
.filter((file): file is File => Boolean(file));
if (imageFiles.length === 0) {
const text = event.clipboardData.getData("text/plain");
if (text) {
event.preventDefault();
document.execCommand("styleWithCSS", false, "false");
document.execCommand("insertText", false, text);
emitChange();
}
return;
}
event.preventDefault();
clearSelectedImage();
let insertionRange = currentSelectionRange();
const text = event.clipboardData.getData("text/plain");
if (text) {
document.execCommand("styleWithCSS", false, "false");
document.execCommand("insertText", false, `${text}\n`);
insertionRange = currentSelectionRange();
}
try {
for (const file of imageFiles) {
const dataUrl = await fileToDataUrl(file);
restoreSelectionRange(insertionRange);
document.execCommand("insertHTML", false, `<img src="${dataUrl}" style="display:block;max-width:100%;height:auto;margin-top:0.5rem;border-radius:0.375rem;"><br>`);
insertionRange = currentSelectionRange();
}
emitChange();
} catch (error) {
toast.error("Screenshot konnte nicht eingefügt werden", {
description: error instanceof Error ? error.message : "Unbekannter Fehler"
});
}
}
const toolbarDisabled = disabled;
return (
<div className={cn("min-w-0 max-w-full overflow-hidden rounded-lg border border-input bg-transparent", disabled && "opacity-60")}>
<div className="flex min-w-0 flex-wrap items-center gap-1 border-b border-zinc-700 bg-zinc-900 px-2 py-1">
<button type="button" className={editorButtonClass()} disabled={toolbarDisabled} title="Fett" onMouseDown={(event) => event.preventDefault()} onClick={() => runCommand("bold")}>
<Bold className="size-4" />
</button>
<button type="button" className={editorButtonClass()} disabled={toolbarDisabled} title="Kursiv" onMouseDown={(event) => event.preventDefault()} onClick={() => runCommand("italic")}>
<Italic className="size-4" />
</button>
<button type="button" className={editorButtonClass()} disabled={toolbarDisabled} title="Unterstrichen" onMouseDown={(event) => event.preventDefault()} onClick={() => runCommand("underline")}>
<Underline className="size-4" />
</button>
<button type="button" className={editorButtonClass()} disabled={toolbarDisabled} title="Durchgestrichen" onMouseDown={(event) => event.preventDefault()} onClick={() => runCommand("strikeThrough")}>
<Strikethrough className="size-4" />
</button>
<button type="button" className={editorButtonClass()} disabled={toolbarDisabled} title="Formatierung entfernen" onMouseDown={(event) => event.preventDefault()} onClick={() => runCommand("removeFormat")}>
<RemoveFormatting className="size-4" />
</button>
<span className="mx-1 h-6 w-px bg-zinc-700" aria-hidden="true" />
<button type="button" className={editorButtonClass()} disabled={toolbarDisabled} title="Überschrift 1" onMouseDown={(event) => event.preventDefault()} onClick={() => runCommand("formatBlock", "h1")}>
<Heading1 className="size-4" />
</button>
<button type="button" className={editorButtonClass()} disabled={toolbarDisabled} title="Überschrift 2" onMouseDown={(event) => event.preventDefault()} onClick={() => runCommand("formatBlock", "h2")}>
<Heading2 className="size-4" />
</button>
<button type="button" className={editorButtonClass()} disabled={toolbarDisabled} title="Überschrift 3" onMouseDown={(event) => event.preventDefault()} onClick={() => runCommand("formatBlock", "h3")}>
<Heading3 className="size-4" />
</button>
<span className="mx-1 h-6 w-px bg-zinc-700" aria-hidden="true" />
<button type="button" className={editorButtonClass()} disabled={toolbarDisabled} title="Nummerierte Liste" onMouseDown={(event) => event.preventDefault()} onClick={() => runCommand("insertOrderedList")}>
<ListOrdered className="size-4" />
</button>
<button type="button" className={editorButtonClass()} disabled={toolbarDisabled} title="Liste" onMouseDown={(event) => event.preventDefault()} onClick={() => runCommand("insertUnorderedList")}>
<List className="size-4" />
</button>
</div>
<div
id={id}
ref={editorRef}
role="textbox"
aria-multiline="true"
contentEditable={!disabled}
suppressContentEditableWarning
className="min-h-24 w-full min-w-0 overflow-hidden break-words bg-transparent px-2.5 py-2 text-base outline-none transition-colors [overflow-wrap:anywhere] empty:before:text-muted-foreground empty:before:content-[attr(data-placeholder)] focus-visible:ring-3 focus-visible:ring-ring/50 md:text-sm [&_h1]:text-2xl [&_h1]:font-semibold [&_h2]:text-xl [&_h2]:font-semibold [&_h3]:text-lg [&_h3]:font-semibold [&_img]:my-2 [&_img]:max-h-[28rem] [&_img]:max-w-full [&_img]:rounded-md [&_ol]:ml-5 [&_ol]:list-decimal [&_ul]:ml-5 [&_ul]:list-disc"
data-placeholder={placeholder}
onInput={emitChange}
onBlur={() => {
clearSelectedImage();
emitChange();
}}
onClick={(event) => {
const target = event.target;
if (target instanceof HTMLImageElement) {
selectImage(target);
return;
}
clearSelectedImage();
}}
onKeyDown={handleKeyDown}
onPaste={(event) => void handlePaste(event)}
/>
</div>
);
}
+1 -1
View File
@@ -68,7 +68,7 @@ function AccordionContent({
>
<div
className={cn(
"h-(--radix-accordion-content-height) pt-0 pb-2.5 [&_a]:underline [&_a]:underline-offset-3 [&_a]:hover:text-foreground [&_p:not(:last-child)]:mb-4",
"pt-0 pb-2.5 [&_a]:underline [&_a]:underline-offset-3 [&_a]:hover:text-foreground [&_p:not(:last-child)]:mb-4",
className
)}
>
+11
View File
@@ -53,6 +53,17 @@ export function currentMonth() {
return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}`;
}
export function currentWeek() {
const now = new Date();
const date = new Date(Date.UTC(now.getFullYear(), now.getMonth(), now.getDate()));
const weekday = date.getUTCDay() || 7;
date.setUTCDate(date.getUTCDate() + 4 - weekday);
const yearStart = new Date(Date.UTC(date.getUTCFullYear(), 0, 1));
const week = Math.ceil(((date.getTime() - yearStart.getTime()) / 86_400_000 + 1) / 7);
return `${date.getUTCFullYear()}-W${String(week).padStart(2, "0")}`;
}
export function currentDay() {
const now = new Date();
return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}-${String(now.getDate()).padStart(2, "0")}`;
+15 -12
View File
@@ -25,7 +25,7 @@ export const helpSections: HelpSection[] = [
"Eine Session ist immer einem Benutzer zugeordnet. In den normalen Auswertungen sieht jeder Benutzer nur seine eigenen Sessions.",
"Ticketdaten wie Organisation und Art können aus bestehenden Tickets übernommen werden, auch wenn ein anderer Benutzer das Ticket schon bearbeitet hat.",
"Consulting-Projekte können einen oder mehrere Teamspace-Einträge haben. Einer davon ist immer der Standard-Eintrag.",
"Tickets werden nicht abgeschlossen. Sie gruppieren Sessions. Relevant für den Abschluss ist nur, ob alle Sessions eines Monats bewertet sind.",
"Tickets werden nicht abgeschlossen. Sie gruppieren Sessions. Relevant für den Abschluss ist nur, ob alle Sessions eines Monats final bewertet sind.",
"Der Monatsabschluss sperrt den Monat gegen versehentliche Änderungen. Muss doch noch etwas korrigiert werden, kann der Monat wieder geöffnet werden."
]
},
@@ -47,14 +47,14 @@ export const helpSections: HelpSection[] = [
"Hat ein Consulting-Projekt mehrere Teamspace-Einträge, wählst du beim Speichern der Session den passenden Eintrag aus. Bei genau einem Teamspace-Eintrag ordnet TicketTracker automatisch zu.",
"Vergessene Sessions trägst du auf der Seite Session starten im Bereich Session nachtragen nach. Wenn das Ticket bereits in der Ticketliste geöffnet ist, nutzt du in der Ticketdetailansicht ebenfalls Session nachtragen; dann ist die Zuordnung zum Ticket bereits vorbelegt.",
"Unter Tickets wählst du Monat oder Tag, öffnest ein Ticket über Öffnen und siehst dort alle Sessions nach Tag gruppiert.",
"Im Ticket bewertest du jede Session mit Abrechenbar, Abgerechnet oder Nicht abrechenbar. Ein erneuter Klick auf den aktiven Status nimmt die Bewertung zurück.",
"Im Ticket setzt du je Session den Status Abrechenbar, Abgerechnet oder Nicht abrechenbar. Abrechenbar bleibt eine offene Bewertung, bis daraus Abgerechnet oder Nicht abrechenbar wird.",
"Im Ticket steht pro Tag das Feld Teamspace. Dort trägst du die Stunden ein, die du für genau dieses Ticket an genau diesem Tag wirklich im CRM beziehungsweise Teamspace erfasst hast. Nach einer Änderung erscheint der Speichern-Button.",
"Die Teamspace-Zeit ist ein Tageswert pro Ticket und Benutzer. Monats- und Tagesansicht zeigen denselben Wert; er muss also nur einmal gepflegt werden.",
"Am Monatsende öffnest du Monatsabschluss. Dort findest du offene Bewertungen gruppiert nach Ticket, Tage ohne Teamspace-Wert und die Schaltfläche Monat abschließen.",
"Offene Bewertungen sind Pflicht: Öffne das Ticket aus der Liste, bewerte die Sessions und kehre zum Monatsabschluss zurück.",
"Tage ohne Teamspace-Wert sind eine Prüfliste. Wenn die Arbeit wirklich nicht in Teamspace eingetragen werden soll, nutzt du Zur Kenntnis. Mit Zur Kenntnis anzeigen blendest du solche Einträge wieder ein und kannst die Kenntnisnahme zurücknehmen.",
"Kontrolliere vor dem Abschluss die Kennzahl Teamspace im Monatsabschluss. Idealerweise entspricht dieser Wert der Summe, die du im CRM beziehungsweise Teamspace für den Monat eingetragen hast.",
"Wenn alle Sessions bewertet sind und die Teamspace-Kontrolle passt, schließt du den Monat mit Monat abschließen. Danach sind Bewertungen und Änderungen für den Monat geschützt, bis der Monat wieder geöffnet wird."
"Wenn keine offenen Bewertungen mehr vorhanden sind und die Teamspace-Kontrolle passt, schließt du den Monat mit Monat abschließen. Danach sind Bewertungen und Änderungen für den Monat geschützt, bis der Monat wieder geöffnet wird."
],
workflow: [
"Ticketfeld im Kopfbereich oder Session starten öffnen.",
@@ -121,7 +121,7 @@ export const helpSections: HelpSection[] = [
"Wenn du einen Timer auswählst, zeigt die Timerseite dessen Details inklusive gemessener Zeit und Tätigkeitsfeld.",
"Pausierte Timer behalten ihre Tätigkeit. Entfernst du einen Timer, wird auch die noch nicht gespeicherte Tätigkeit verworfen.",
"Beim Klick auf Beenden wird die Stoppuhr sofort angehalten. Die bereits im Timer erfasste Tätigkeit wird beim Speichern an die Session gehängt.",
"Im Abschlussdialog kann die Tätigkeit als interne Zammad-Notiz übertragen werden. Der Article wird intern erstellt und bekommt die getrackte Zeit als time_unit.",
"Im Abschlussdialog kann die Tätigkeit als interne Zammad-Notiz übertragen werden. Der Article wird intern erstellt und bekommt die getrackte Zeit als time_unit sowie den gewählten Zammad-Typ.",
"Wenn Zammad beim Speichern nicht erreichbar ist, bleibt die Session trotzdem gespeichert. Die interne Notiz wird geparkt und kann später im Profil erneut gesendet werden.",
"Existiert das Ticket bereits, übernimmt TicketTracker Organisation und Art.",
"Bei Projekt-Tickets mit mehreren Teamspace-Einträgen zeigt der Beenden-Dialog eine Auswahl. Der Standard-Eintrag ist dort mit Standard gekennzeichnet.",
@@ -146,6 +146,7 @@ export const helpSections: HelpSection[] = [
"Die Organisation muss aus der synchronisierten Organisationsliste gewählt werden. Freitext ist absichtlich gesperrt.",
"Die Tätigkeit ist eine Textarea. Zeilenumbrüche bleiben erhalten und werden in der Ticketdetailansicht wieder ausgegeben.",
"Mit Bewertung --- bleibt die Session offen. Abrechenbar oder Nicht abrechenbar werden direkt an der neuen Session gespeichert.",
"Die interne Zammad-Notiz kann auch beim Nachtragen gesetzt werden. Ist sie aktiv, kann der Zammad-Typ angepasst werden. Bei Zammad-Fehlern wird sie geparkt und kann später im Profil gesendet werden.",
"In der Ticketdetailansicht kann eine Session direkt zum geöffneten Ticket nachgetragen werden. Ticketnummer und Stammdaten sind dort schon vorbelegt beziehungsweise aus dem Ticket abgeleitet.",
"Bei Consulting-Projekten mit mehreren Teamspace-Einträgen ist der passende Teamspace-Eintrag Pflicht. Bei nur einem Eintrag wird er automatisch gesetzt."
]
@@ -218,6 +219,7 @@ export const helpSections: HelpSection[] = [
],
details: [
"Oben steht die Ticketnummer mit Copy-Icon, damit sie schnell in andere Systeme übernommen werden kann.",
"Über Timer starten kann direkt für dieses Ticket ein Timer gestartet oder ein vorhandener Timer aktiviert werden.",
"Die Session-Einträge sind nach Kalendertagen gruppiert. Pro Tagesgruppe steht die Summe der abgerechneten Zeit.",
"Abrechenbare, nicht abrechenbare und offene Zeiten werden neben der abgerechneten Tageszeit separat angezeigt.",
"Bei Projekt-Tickets mit mehreren Teamspace-Einträgen zeigt jede zugeordnete Session den gewählten Teamspace-Eintrag. Nicht zugeordnete Sessions werden hervorgehoben.",
@@ -237,16 +239,16 @@ export const helpSections: HelpSection[] = [
title: "Sessions Bewerten",
description: "Bewertungen bestimmen, ob eine Session offen, abrechenbar, abgerechnet oder bewusst nicht abrechenbar ist.",
quickItems: [
"Jede Session braucht genau eine Bewertung.",
"Jede Session braucht vor dem Monatsabschluss einen finalen Status.",
"Abrechenbar, Abgerechnet und Nicht abrechenbar können durch erneuten Klick zurückgenommen werden.",
"Offene Bewertungen blockieren den Monatsabschluss."
],
details: [
"Abrechenbar bedeutet: Die Session ist bewertet, aber noch nicht als abgerechnet/Teamspace-relevant markiert.",
"Abrechenbar bedeutet: Die Session ist als potenziell abrechenbar markiert, bleibt aber für Abschluss und offene Bewertungen noch offen.",
"Abgerechnet bedeutet: Die Session wurde für die Abrechnung beziehungsweise im CRM berücksichtigt.",
"Nicht abrechenbar bedeutet: Die Session gehört zum Arbeitsnachweis, wird aber nicht abgerechnet.",
"Ein erneuter Klick auf den aktiven Bewertungsbutton entfernt die Bewertung und macht die Session wieder offen.",
"Abrechenbare und nicht abrechenbare Sessions zählen als bewertet. Sie blockieren den Monatsabschluss nicht.",
"Nur Abgerechnet und Nicht abrechenbar schließen die Bewertung ab. Abrechenbar blockiert den Monatsabschluss weiterhin.",
"Wenn ein Monat abgeschlossen ist, sind Bewertungen gesperrt. Öffne den Monat zuerst wieder, wenn du etwas korrigieren musst.",
"Die offenen Bewertungen findest du gebündelt im Monatsabschluss."
]
@@ -343,7 +345,7 @@ export const helpSections: HelpSection[] = [
"Der Monat kann wieder geöffnet werden."
],
details: [
"Ein Monat kann abgeschlossen werden, sobald alle Sessions dieses Monats bewertet sind.",
"Ein Monat kann abgeschlossen werden, sobald keine offenen Bewertungen mehr vorhanden sind.",
"Tage ohne Teamspace-Wert blockieren den Abschluss nicht automatisch. Sie sind eine Prüfliste für CRM-Abgleich und Nacharbeit.",
"Wenn eine Session bewusst nicht in Teamspace eingetragen wird, kann der fehlende Tageswert zur Kenntnis genommen werden.",
"Zur Kenntnis genommene Einträge können über die Checkbox Zur Kenntnis anzeigen wieder eingeblendet werden.",
@@ -439,9 +441,9 @@ export const helpSections: HelpSection[] = [
"Die Klärung erfolgt in der Ticketdetailansicht."
],
details: [
"Offen umfasst alle Sessions des Monats ohne Bewertung.",
"Diese Sessions müssen vor dem Monatsabschluss bewertet werden.",
"Die offene Zeit zeigt, wie viele Minuten noch bewertet werden müssen.",
"Offen umfasst alle Sessions des Monats ohne finalen Status sowie Sessions mit dem Zwischenstatus Abrechenbar.",
"Diese Sessions müssen vor dem Monatsabschluss auf Abgerechnet oder Nicht abrechenbar gesetzt werden.",
"Die offene Zeit zeigt, wie viele Minuten noch final bewertet werden müssen.",
"Der Monatsabschluss listet offene Bewertungen gruppiert nach Ticket, damit du direkt in die passende Ticketdetailansicht springen kannst.",
"Wird eine Bewertung zurückgenommen, steigt die offene Anzahl wieder."
]
@@ -537,7 +539,8 @@ export const helpSections: HelpSection[] = [
"Die Token-Prüfung läuft nur, wenn der Token im Profil geändert oder entfernt wird. Normale Profiländerungen prüfen Zammad nicht erneut.",
"Der persönliche Zammad Token ist vom Admin-Token für den Organisations-Sync getrennt. Er wird für ticketbezogene Zammad-Abgleiche, Consulting-Importe und die Zuordnung zum Zammad-Owner verwendet.",
"Die Bewertungsabfrage beim Timerabschluss kann im Profil ein- oder ausgeschaltet werden. Sie erscheint nur, wenn der Timer noch auf --- steht.",
"Die interne Zammad-Notiz beim Timerabschluss steuert, ob die Checkbox im Abschlussdialog standardmäßig aktiv ist.",
"Die interne Zammad-Notiz beim Timerabschluss steuert, ob die Checkbox im Abschlussdialog standardmäßig aktiv ist. Nur dann werden Standard-Typ und Zeitaufrundung angezeigt.",
"Der Standard-Typ belegt die Zammad-Zeitbuchungskategorie für interne Notizen vor. Es gibt keine leere Auswahl; ohne gespeicherten Profilwert wird Nicht kategorisiert verwendet.",
"Für die an Zammad gesendete time_unit kann optional eine Aufrundung auf 5, 10, 15 oder 30 Minuten aktiviert werden. Ohne Rundung wird die konkrete Zeit mit zwei Nachkommastellen gesendet.",
"Geparkte Zammad-Notizen listet interne Notizen, die wegen eines Zammad-Fehlers noch nicht übertragen wurden. Über Senden werden sie gesammelt nach Zammad gepusht.",
"Im Admin-Profil wird kein persönlicher Zammad-Token angezeigt.",
+97
View File
@@ -0,0 +1,97 @@
Datensatz aus api/v1/ticket_articles/
{
"id": 2,
"ticket_id": 2,
"type_id": 1,
"sender_id": 1,
"from": "",
"to": "",
"cc": null,
"subject": "Test Bestellung",
"reply_to": null,
"message_id": "",
"message_id_md5": "",
"in_reply_to": null,
"content_type": "text/html",
"body": "",
"internal": false,
"preferences": {
"send-auto-response": true,
"is-auto-response": false
},
"updated_by_id": 3,
"created_by_id": 3,
"origin_by_id": null,
"created_at": "2023-01-06T11:17:03.919Z",
"updated_at": "2023-01-06T11:17:03.919Z",
"detected_language": null,
"ai_stored_result_ids": [],
"attachments": [
{
"id": 3,
"store_file_id": 3,
"filename": "message.html",
"size": "5733",
"preferences": {
"content-alternative": true,
"original-format": true,
"Mime-Type": "text/html",
"Charset": "utf-8"
}
}
],
"body_rendering_error": false
}
Datensatz aus api/v1/ticket_articles/{id]}
{
"id": 2,
"ticket_id": 2,
"type_id": 1,
"sender_id": 1,
"from": "",
"to": "",
"cc": null,
"subject": "",
"reply_to": null,
"message_id": "",
"message_id_md5": "a9cc698b80f75fe08ca6d2dc6dec69ae",
"in_reply_to": null,
"content_type": "text/html",
"body": "",
"internal": false,
"preferences": {
"send-auto-response": true,
"is-auto-response": false
},
"updated_by_id": 3,
"created_by_id": 3,
"origin_by_id": null,
"created_at": "2023-01-06T11:17:03.919Z",
"updated_at": "2023-01-06T11:17:03.919Z",
"detected_language": null,
"ai_stored_result_ids": [],
"attachments": [
{
"id": 3,
"store_file_id": 3,
"filename": "message.html",
"size": "5733",
"preferences": {
"content-alternative": true,
"original-format": true,
"Mime-Type": "text/html",
"Charset": "utf-8"
}
}
],
"body_rendering_error": false,
"created_by": "",
"updated_by": "",
"type": "email",
"sender": "Agent",
"ai_stored_results": [],
"time_unit": null
}
+74
View File
@@ -11,6 +11,7 @@ export type TimerEntry = {
projectName?: string | null;
ticketLookupDone?: boolean;
activity: string;
zammadArticleBody?: string;
billingStatus: BillingStatus;
startedAt: number;
pausedTotalMs: number;
@@ -20,6 +21,7 @@ export type TimerEntry = {
const storageKeyPrefix = "tickettracker.timers";
const legacyStorageKey = "tickettracker.activeTimer";
const activityDraftStorageKeyPrefix = "tickettracker.timerActivityDrafts";
export const ticketPattern = /^Ticket#\d{6}$/;
@@ -66,6 +68,7 @@ export function normalizeTimers(timers: TimerEntry[]) {
const normalizedTimer = {
...timer,
activity: typeof timer.activity === "string" ? timer.activity : "",
zammadArticleBody: typeof timer.zammadArticleBody === "string" ? timer.zammadArticleBody : "",
billingStatus: timer.billingStatus === "billed" ? "billable" : timer.billingStatus === "billable" || timer.billingStatus === "non_billable" ? timer.billingStatus : null,
};
@@ -93,6 +96,77 @@ export function storageKeyForUser(userId: string) {
return `${storageKeyPrefix}.${userId}`;
}
function activityDraftStorageKeyForUser(userId: string) {
return `${activityDraftStorageKeyPrefix}.${userId}`;
}
type TimerActivityDraft = {
activity: string;
zammadArticleBody: string;
updatedAt: number;
};
function readTimerActivityDraftMap(userId: string) {
if (typeof window === "undefined") {
return {} as Record<string, TimerActivityDraft>;
}
const raw = sessionStorage.getItem(activityDraftStorageKeyForUser(userId));
if (!raw) {
return {} as Record<string, TimerActivityDraft>;
}
try {
const parsed = JSON.parse(raw) as Record<string, TimerActivityDraft>;
return Object.fromEntries(
Object.entries(parsed).filter(([, draft]) =>
typeof draft.activity === "string" &&
typeof draft.zammadArticleBody === "string" &&
typeof draft.updatedAt === "number"
)
);
} catch {
return {} as Record<string, TimerActivityDraft>;
}
}
function writeTimerActivityDraftMap(userId: string, drafts: Record<string, TimerActivityDraft>) {
if (typeof window === "undefined") {
return;
}
sessionStorage.setItem(activityDraftStorageKeyForUser(userId), JSON.stringify(drafts));
}
export function writeTimerActivityDraft(userId: string, timerId: string, draft: Omit<TimerActivityDraft, "updatedAt">) {
const drafts = readTimerActivityDraftMap(userId);
drafts[timerId] = { ...draft, updatedAt: Date.now() };
writeTimerActivityDraftMap(userId, drafts);
}
export function removeTimerActivityDraft(userId: string, timerId: string) {
const drafts = readTimerActivityDraftMap(userId);
delete drafts[timerId];
writeTimerActivityDraftMap(userId, drafts);
}
export function mergeTimerActivityDrafts(userId: string, timers: TimerEntry[]) {
const drafts = readTimerActivityDraftMap(userId);
const timerIds = new Set(timers.map((timer) => timer.id));
const keptDrafts = Object.fromEntries(Object.entries(drafts).filter(([timerId]) => timerIds.has(timerId)));
if (Object.keys(keptDrafts).length !== Object.keys(drafts).length) {
writeTimerActivityDraftMap(userId, keptDrafts);
}
return timers.map((timer) => {
const draft = keptDrafts[timer.id];
return draft ? { ...timer, activity: draft.activity, zammadArticleBody: draft.zammadArticleBody } : timer;
});
}
export function readStoredTimers(userId: string): TimerEntry[] {
if (typeof window === "undefined") {
return [];
+91 -1
View File
@@ -12,11 +12,21 @@ export type AuthUser = {
active: boolean;
zammad_user_id: string | null;
has_zammad_api_token: boolean;
teamspace_user_id: string | null;
has_teamspace_credentials: boolean;
zammad_email_session_webhook_enabled: boolean;
session_billing_prompt_enabled: boolean;
teamspace_time_tracking_enabled: boolean;
zammad_session_note_default_enabled: boolean;
zammad_time_unit_rounding_enabled: boolean;
zammad_time_unit_rounding_minutes: number;
zammad_atoss_email_sync_enabled: boolean;
zammad_article_type_id: number;
project_followup_enabled: boolean;
project_followup_weeks: number;
project_followup_second_enabled: boolean;
project_followup_second_weeks: number;
project_followup_complete_on_session: boolean;
};
export type DashboardWidgetType = "dayControl" | "today" | "month" | "openRatings" | "projectBudgets" | "todos" | "teamspace" | "favorites" | "divider" | "statistic" | "notes";
@@ -75,6 +85,7 @@ export type ZammadSessionArticleQueueItem = {
zammad_ticket_id: string | null;
activity: string;
time_unit: number;
zammad_article_type_id: number;
attempt_count: number;
last_error: string | null;
last_attempt_at: string | null;
@@ -132,6 +143,11 @@ export type TicketMeta = {
zammad_priority_id?: string | null;
zammad_state_id?: string | null;
zammad_owner_id?: string | null;
zammad_owner_user_id?: string | null;
zammad_owner_display_name?: string | null;
zammad_owner_username?: string | null;
is_foreign_zammad_owner?: boolean;
can_edit_ticket_data?: boolean;
project_id?: string | null;
is_project?: boolean;
project_name?: string | null;
@@ -176,7 +192,12 @@ export type ProjectSummary = {
work_type: WorkType | null;
zammad_priority_id?: string | null;
zammad_state_id?: string | null;
zammad_ticket_id?: string | null;
zammad_owner_id?: string | null;
ticket_zammad_owner_id?: string | null;
zammad_pending_time?: string | null;
zammad_merged_from_ticket_id?: string | null;
zammad_merged_from_ticket_number?: string | null;
teamspace_project: string | null;
description: string;
goals: string;
@@ -200,6 +221,7 @@ export type ProjectSummary = {
teamspace_entry_count?: number;
teamspace_entries?: ProjectTeamspaceEntry[];
unassigned_teamspace_session_count?: number;
unassigned_teamspace_subentry_count?: number;
close_review_required?: boolean;
hidden_for_user?: boolean;
};
@@ -208,6 +230,10 @@ export type ProjectTeamspaceEntry = {
id: string;
project_id?: string;
teamspace_project: string | null;
teamspace_api_project_id?: string | null;
teamspace_api_project_url?: string | null;
teamspace_description?: string | null;
subentries?: ProjectTeamspaceSubentry[];
is_default: boolean;
hour_budget_minutes: number | null;
budget_sync_disabled?: boolean;
@@ -217,6 +243,27 @@ export type ProjectTeamspaceEntry = {
display_order?: number;
};
export type ProjectTeamspaceSubentry = {
id: string;
project_teamspace_entry_id?: string;
teamspace_api_project_id: string;
teamspace_api_project_url: string | null;
teamspace_project_id: string | null;
billing_type: string | null;
name: string | null;
alias: string;
description: string;
progress: number | null;
planned_minutes: number | null;
sum_minutes: number | null;
planned_minutes_billable: number | null;
sum_minutes_billable: number | null;
planned_income: number | string | null;
sum_income: number | string | null;
consumption_minutes?: number;
display_order: number;
};
export type TodoPriority = "low" | "medium" | "high" | "urgent";
export type TodoStatus = "open" | "done";
@@ -311,6 +358,7 @@ export type PeriodOverview = {
export type TicketDayBilling = {
day: string;
project_teamspace_entry_id?: string | null;
billed_minutes: number;
};
@@ -325,6 +373,7 @@ export type SessionEntry = {
organization_name: string | null;
customer_name: string;
activity: string;
zammad_article_body?: string | null;
work_type: WorkType;
project_teamspace_entry_id?: string | null;
project_teamspace_project?: string | null;
@@ -338,6 +387,10 @@ export type SessionEntry = {
recurring_billing_id: string | null;
recurring_billing_slot_id: string | null;
recurring_occurrence_date: string | null;
zammad_source_article_id?: string | null;
zammad_article_status?: "pending" | "sent" | null;
zammad_article_id?: string | null;
zammad_article_type_id?: number;
};
export type TicketPeriod = {
@@ -359,8 +412,16 @@ export type TicketPeriod = {
organization_name: string | null;
customer_name: string | null;
work_type: WorkType | null;
zammad_ticket_id?: string | null;
zammad_group_id?: string | null;
zammad_priority_id?: string | null;
zammad_state_id?: string | null;
zammad_owner_id?: string | null;
zammad_owner_user_id?: string | null;
zammad_owner_display_name?: string | null;
zammad_owner_username?: string | null;
is_foreign_zammad_owner?: boolean;
can_edit_ticket_data?: boolean;
closed_at: string | null;
requires_closure: boolean;
recurring_session_count: number;
@@ -467,8 +528,37 @@ export type StatisticsTeamspaceDay = {
project_teamspace_project: string | null;
};
export type StatisticsProjectFollowup = {
project_id: string;
ticket_id: string;
ticket_number: string;
title: string | null;
organization_name: string | null;
teamspace_project: string | null;
hour_budget_minutes: number | null;
consumption_minutes: number;
remaining_minutes: number | null;
latest_tt_update: string;
last_session_at: string | null;
project_updated_at: string;
open_followup_todo_id: string | null;
followup_state: "open" | "done" | "dismissed" | null;
open_todos: number;
overdue_todos: number;
};
export type StatisticsProjectFollowupOverview = {
enabled: boolean;
weeks: number;
secondEnabled: boolean;
secondWeeks: number;
createdTodos: number;
updatedTodos: number;
projects: StatisticsProjectFollowup[];
};
export type StatisticsOverview = {
periodType: "month";
periodType: "month" | "week";
period: string;
closed: boolean;
closedAt: string | null;
+48 -2
View File
@@ -1,5 +1,5 @@
import { ChangeEvent, FormEvent, useEffect, useState } from "react";
import { CheckCircle2, DatabaseBackup, DatabaseZap, Save, Shield, Upload, UserPlus } from "lucide-react";
import { CheckCircle2, DatabaseBackup, DatabaseZap, Save, Shield, Unplug, Upload, UserPlus } from "lucide-react";
import { toast } from "sonner";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
@@ -14,9 +14,11 @@ import {
createAdminUser,
downloadDatabaseExport,
getAdminUsers,
getTeamspaceSettings,
getZammadSettings,
restoreDatabaseBackup,
saveZammadSettings,
saveTeamspaceSettings,
syncZammadOrganizations,
updateAdminUser,
validateDatabaseBackup,
@@ -54,8 +56,10 @@ export function AdminUsersPage({ currentUser }: { currentUser: AuthUser }) {
const [zammadBaseUrl, setZammadBaseUrl] = useState("");
const [zammadApiKey, setZammadApiKey] = useState("");
const [hasZammadApiKey, setHasZammadApiKey] = useState(false);
const [teamspaceBaseUrl, setTeamspaceBaseUrl] = useState("");
const [syncingOrganizations, setSyncingOrganizations] = useState(false);
const [savingZammadSettings, setSavingZammadSettings] = useState(false);
const [savingTeamspaceSettings, setSavingTeamspaceSettings] = useState(false);
const [exportingDatabase, setExportingDatabase] = useState(false);
const [restoreSql, setRestoreSql] = useState("");
const [restoreValidation, setRestoreValidation] = useState<RestoreValidationResult | null>(null);
@@ -68,12 +72,13 @@ export function AdminUsersPage({ currentUser }: { currentUser: AuthUser }) {
async function load(options: { preserveEdits?: boolean } = {}) {
setLoading(true);
try {
const [usersResult, zammadSettingsResult] = await Promise.all([getAdminUsers(), getZammadSettings()]);
const [usersResult, zammadSettingsResult, teamspaceSettingsResult] = await Promise.all([getAdminUsers(), getZammadSettings(), getTeamspaceSettings()]);
setUsers(usersResult.users);
if (!options.preserveEdits) {
setZammadBaseUrl(zammadSettingsResult.settings.baseUrl);
setHasZammadApiKey(zammadSettingsResult.settings.hasApiKey);
setTeamspaceBaseUrl(teamspaceSettingsResult.settings.baseUrl);
}
setForms((current) =>
@@ -212,6 +217,26 @@ export function AdminUsersPage({ currentUser }: { currentUser: AuthUser }) {
}
}
async function saveTeamspaceAccess() {
setSavingTeamspaceSettings(true);
try {
const result = await saveTeamspaceSettings({
baseUrl: teamspaceBaseUrl
});
setTeamspaceBaseUrl(result.settings.baseUrl);
toast.success("Teamspace-Zugang gespeichert", {
description: "URL ist hinterlegt."
});
} catch (error) {
toast.error("Teamspace-Zugang konnte nicht gespeichert werden", {
description: error instanceof Error ? error.message : "Unbekannter Fehler"
});
} finally {
setSavingTeamspaceSettings(false);
}
}
async function exportDatabase() {
setExportingDatabase(true);
@@ -370,6 +395,27 @@ export function AdminUsersPage({ currentUser }: { currentUser: AuthUser }) {
</Button>
</div>
</form>
<div className="mt-4 border-t pt-4">
<div className="mb-3 flex items-center gap-2">
<Unplug className="size-4 text-muted-foreground" />
<p className="text-sm font-medium">Teamspace API</p>
</div>
<div className="grid gap-3 xl:grid-cols-[minmax(220px,1fr)_auto] xl:items-end">
<div className="space-y-2">
<label className="text-sm font-medium" htmlFor="teamspace-base-url">Teamspace URL</label>
<Input
id="teamspace-base-url"
placeholder="https://app1.teamspace.de"
value={teamspaceBaseUrl}
onChange={(event) => setTeamspaceBaseUrl(event.currentTarget.value)}
/>
</div>
<Button type="button" variant="secondary" disabled={savingTeamspaceSettings} onClick={() => void saveTeamspaceAccess()}>
<Save className="size-4" />
{savingTeamspaceSettings ? "Speichert..." : "URL speichern"}
</Button>
</div>
</div>
</CardContent>
</Card>
+10 -4
View File
@@ -14,9 +14,10 @@ import { HelpLink } from "@/components/HelpLink";
import { MultiFilterDropdown, type MultiFilterOption } from "@/components/MultiFilterDropdown";
import { downloadMonthExport, getPeriodOverview } from "../api";
import { currentDay, currentMonth, formatDateTime, formatMinutes } from "../format";
import type { PeriodOverview, PeriodType, TicketSummary } from "../types";
import type { AuthUser, PeriodOverview, PeriodType, TicketSummary } from "../types";
type AnalysisPageProps = {
currentUser: AuthUser;
onNavigate: (to: string) => void;
};
@@ -327,7 +328,7 @@ function groupTicketSections(tickets: TicketSummary[], groupBy: TicketGroupBy):
});
}
export function AnalysisPage({ onNavigate }: AnalysisPageProps) {
export function AnalysisPage({ currentUser, onNavigate }: AnalysisPageProps) {
const [periodType, setPeriodType] = useState<PeriodType>("month");
const [period, setPeriod] = useState(currentMonth());
const [overview, setOverview] = useState<PeriodOverview | null>(null);
@@ -438,13 +439,18 @@ export function AnalysisPage({ onNavigate }: AnalysisPageProps) {
}
const totals = overview?.totals;
const teamspaceTimeTrackingEnabled = currentUser.teamspace_time_tracking_enabled ?? true;
const periodLabel = periodType === "month" ? "Monat" : "Tag";
const metricCards: Array<{ label: string; value: string | number; detail?: string; icon: LucideIcon }> = [
{ label: "Tickets", value: totals?.tickets ?? 0, icon: Ticket },
{ label: "Sessions", value: totals?.sessions ?? 0, icon: Users },
{ label: "Abgerechnet", value: formatMinutes(totals?.minutes ?? 0), detail: `Teamspace ${formatMinutes(totals?.crmBilledMinutes ?? 0)}`, icon: Clock3 },
{
label: "Abgerechnet",
value: formatMinutes(totals?.minutes ?? 0),
detail: teamspaceTimeTrackingEnabled ? `Teamspace ${formatMinutes(totals?.crmBilledMinutes ?? 0)}` : "Ist aus Teamspace",
icon: Clock3
},
{ label: "Abrechenbar", value: formatMinutes(totals?.billableMinutes ?? 0), icon: UserCheck },
{ label: "Nicht abrechenbar", value: formatMinutes(totals?.nonBillableMinutes ?? 0), icon: UserCheck },
{ label: "Offen", value: totals?.openSessions ?? 0, detail: formatMinutes(totals?.openMinutes ?? 0), icon: UserCheck }
];
const tickets = overview?.tickets ?? [];
+92 -60
View File
@@ -66,9 +66,10 @@ import type {
DashboardFavorite,
DashboardPreference,
DashboardSortDirection,
DashboardWidget,
DashboardWidgetType,
PeriodOverview,
DashboardWidget,
DashboardWidgetType,
AuthUser,
PeriodOverview,
ProjectTeamspaceEntry,
ProjectSummary,
RecurringBilling,
@@ -78,6 +79,7 @@ import type {
} from "../types";
type DashboardPageProps = {
currentUser: AuthUser;
onNavigate: (to: string) => void;
};
@@ -808,6 +810,7 @@ function projectTeamspaceEntries(project: ProjectSummary): ProjectTeamspaceEntry
{
id: `${project.id}-default`,
teamspace_project: project.teamspace_project,
teamspace_description: "",
is_default: true,
hour_budget_minutes: project.hour_budget_minutes,
manual_consumption_minutes: project.manual_consumption_minutes ?? 0,
@@ -818,6 +821,10 @@ function projectTeamspaceEntries(project: ProjectSummary): ProjectTeamspaceEntry
];
}
function projectTeamspaceEntryLabel(entry: ProjectTeamspaceEntry) {
return entry.teamspace_description?.trim() || entry.teamspace_project || (entry.is_default ? "Standard" : "Ohne Teamspace-ID");
}
function budgetedProjectTeamspaceEntries(project: ProjectSummary) {
return projectTeamspaceEntries(project).filter((entry) => Number(entry.hour_budget_minutes ?? 0) > 0);
}
@@ -880,7 +887,7 @@ function sortedFavorites(favorites: DashboardFavorite[], metas: Record<string, T
});
}
export function DashboardPage({ onNavigate }: DashboardPageProps) {
export function DashboardPage({ currentUser, onNavigate }: DashboardPageProps) {
const gridRef = useRef<HTMLDivElement | null>(null);
const [preference, setPreference] = useState<DashboardPreference>(() => normalizePreference({}));
const [loading, setLoading] = useState(true);
@@ -1002,11 +1009,12 @@ export function DashboardPage({ onNavigate }: DashboardPageProps) {
};
}, [favoriteMetas, preference.favorites]);
const maxGridRow = useMemo(
() => Math.max(6, dragPreview ? dragPreview.y + dragPreview.h + 1 : 0, ...preference.widgets.map((widget) => widget.y + widget.h + 1)),
[dragPreview, preference.widgets]
);
const dashboardStats = useMemo(() => {
const maxGridRow = useMemo(
() => Math.max(6, dragPreview ? dragPreview.y + dragPreview.h + 1 : 0, ...preference.widgets.map((widget) => widget.y + widget.h + 1)),
[dragPreview, preference.widgets]
);
const teamspaceTimeTrackingEnabled = currentUser.teamspace_time_tracking_enabled ?? true;
const dashboardStats = useMemo(() => {
const monthTotals = monthOverview?.totals;
const dayTotals = todayOverview?.totals;
const overBudget = projects.filter((project) => project.hour_budget_minutes !== null && project.hour_budget_minutes !== undefined && (project.consumption_minutes ?? 0) > project.hour_budget_minutes);
@@ -1043,17 +1051,18 @@ export function DashboardPage({ onNavigate }: DashboardPageProps) {
dayTotals,
statistics: statisticsOverview,
openRatings: monthOverview?.openSessions ?? [],
todayTickets: todayOverview?.tickets ?? [],
todayOpenRatings: todayOverview?.openSessions ?? [],
dayControlTickets,
fixedBillingTickets,
teamspaceDelta: (monthTotals?.minutes ?? 0) - (monthTotals?.crmBilledMinutes ?? 0),
overBudget,
todayTickets: todayOverview?.tickets ?? [],
todayOpenRatings: todayOverview?.openSessions ?? [],
dayControlTickets,
fixedBillingTickets,
teamspaceTimeTrackingEnabled,
teamspaceDelta: teamspaceTimeTrackingEnabled ? (monthTotals?.minutes ?? 0) - (monthTotals?.crmBilledMinutes ?? 0) : 0,
overBudget,
nearlyFull,
openTodos: todos.filter((todo) => todo.status === "open"),
overdueTodos: todos.filter((todo) => todo.status === "open" && todo.ends_on && todo.ends_on < currentDay())
};
}, [dayControlTickets, monthOverview, projects, recurringBillings, statisticsOverview, todayOverview, todos]);
}, [dayControlTickets, monthOverview, projects, recurringBillings, statisticsOverview, teamspaceTimeTrackingEnabled, todayOverview, todos]);
function updateWidget(widgetId: string, changes: Partial<DashboardWidget>) {
setPreference((current) => ({
@@ -1379,9 +1388,10 @@ function useDashboardStatsPlaceholder() {
monthTotals: null as PeriodOverview["totals"] | null | undefined,
dayTotals: null as PeriodOverview["totals"] | null | undefined,
statistics: null as StatisticsOverview | null,
openRatings: [] as PeriodOverview["openSessions"],
fixedBillingTickets: new Set<string>(),
teamspaceDelta: 0,
openRatings: [] as PeriodOverview["openSessions"],
fixedBillingTickets: new Set<string>(),
teamspaceTimeTrackingEnabled: true,
teamspaceDelta: 0,
overBudget: [] as ProjectSummary[],
nearlyFull: [] as ProjectSummary[],
todayTickets: [] as PeriodOverview["tickets"],
@@ -1554,17 +1564,22 @@ function WidgetContent(props: WidgetContentProps) {
);
}
if (widget.type === "teamspace") {
const delta = stats.teamspaceDelta;
return (
<MetricGrid
items={[
{ label: "TS-Zeit Monat", value: formatMinutes(stats.monthTotals?.crmBilledMinutes ?? 0), icon: LayoutDashboard },
{ label: "Abgerechnet - TS", value: `${delta >= 0 ? "+" : "-"}${formatMinutes(Math.abs(delta))}`, icon: ArrowUp, tone: delta === 0 ? "normal" : delta > 0 ? "warning" : "good" }
]}
/>
);
}
if (widget.type === "teamspace") {
const delta = stats.teamspaceDelta;
return (
<MetricGrid
items={stats.teamspaceTimeTrackingEnabled
? [
{ label: "TS-Zeit Monat", value: formatMinutes(stats.monthTotals?.crmBilledMinutes ?? 0), icon: LayoutDashboard },
{ label: "Abgerechnet - TS", value: `${delta >= 0 ? "+" : "-"}${formatMinutes(Math.abs(delta))}`, icon: ArrowUp, tone: delta === 0 ? "normal" : delta > 0 ? "warning" : "good" }
]
: [
{ label: "TS-Erfassung", value: "Aus", icon: LayoutDashboard, tone: "normal" },
{ label: "Monatsprüfung", value: "Status", icon: CheckCircle2, tone: "good" }
]}
/>
);
}
if (widget.type === "openRatings") {
const filterValues = readFilterValues(widget.config, ["today", "older3", "fixedBilling", "manualBilling"]);
@@ -1632,7 +1647,7 @@ function WidgetContent(props: WidgetContentProps) {
/>
) : null}
<CompactList
empty="Alles bewertet."
empty="Keine offenen Bewertungen."
items={sessions.map((session) => ({
key: session.id,
title: session.ticket_number,
@@ -1679,6 +1694,7 @@ type DayControlListItem = {
function DayControlWidget({ stats, projects, todos, onNavigate }: WidgetContentProps) {
const today = currentDay();
const teamspaceTimeTrackingEnabled = stats.teamspaceTimeTrackingEnabled;
const overdueTodos = stableSort(
todos.filter((todo) => todo.status === "open" && todo.ends_on && todo.ends_on < today),
(left, right) => compareOptionalDate(left.ends_on, right.ends_on, "asc") || compareText(left.title, right.title)
@@ -1696,12 +1712,21 @@ function DayControlWidget({ stats, projects, todos, onNavigate }: WidgetContentP
);
const todayTickets = stableSort(stats.todayTickets, (left, right) => compareText(left.ticket_number, right.ticket_number));
const todayOpenRatings = stableSort(stats.todayOpenRatings, (left, right) => compareOptionalDate(left.started_at, right.started_at, "asc") || compareText(left.ticket_number, right.ticket_number));
const teamspaceReviewTickets = todayTickets.filter((ticket) => ticket.open_count === 0 && teamspaceDeltaMinutes(ticket) > 0);
const afterworkTickets = todayTickets.filter((ticket) => ticket.open_count > 0 || teamspaceDeltaMinutes(ticket) > 0 || (ticket.total_minutes === 0 && (ticket.billable_count > 0 || ticket.non_billable_count > 0)));
const teamspaceReviewTickets = teamspaceTimeTrackingEnabled ? todayTickets.filter((ticket) => ticket.open_count === 0 && teamspaceDeltaMinutes(ticket) > 0) : [];
const afterworkTickets = todayTickets.filter((ticket) =>
ticket.open_count > 0
|| (teamspaceTimeTrackingEnabled && teamspaceDeltaMinutes(ticket) > 0)
|| (ticket.total_minutes === 0 && (ticket.billable_count > 0 || ticket.non_billable_count > 0))
);
const budgetAttention = stableSort(
projects.filter((project) => {
const percent = projectConsumptionPercent(project);
return !project.closed_at && (Boolean(percent && percent >= 90) || Number(project.unassigned_teamspace_session_count ?? 0) > 0 || Boolean(project.close_review_required));
return !project.closed_at && (
Boolean(percent && percent >= 90)
|| Number(project.unassigned_teamspace_session_count ?? 0) > 0
|| Number(project.unassigned_teamspace_subentry_count ?? 0) > 0
|| Boolean(project.close_review_required)
);
}),
(left, right) => (compareNumber(projectConsumptionPercent(right) ?? 0, projectConsumptionPercent(left) ?? 0)) || compareText(left.ticket_number, right.ticket_number)
);
@@ -1722,9 +1747,9 @@ function DayControlWidget({ stats, projects, todos, onNavigate }: WidgetContentP
const checklistItems = [
{
key: "ratings",
label: "Sessions bewertet",
label: "Bewertungen final",
done: todayOpenRatings.length === 0,
action: todayOpenRatings.length === 0 ? "Keine offenen Bewertungen." : `${todayOpenRatings.length} heutige Bewertung(en) setzen.`
action: todayOpenRatings.length === 0 ? "Keine offenen Bewertungen." : `${todayOpenRatings.length} heutige Bewertung(en) finalisieren.`
},
{
key: "todos",
@@ -1743,12 +1768,14 @@ function DayControlWidget({ stats, projects, todos, onNavigate }: WidgetContentP
done: waitingTickets.length === 0,
action: waitingTickets.length === 0 ? "Keine fälligen Wiedervorlagen." : `${waitingTickets.length} Wiedervorlage(n) bearbeiten oder neues Datum setzen.`
},
{
key: "teamspace",
label: "Teamspace geprüft",
done: teamspaceReviewTickets.length === 0,
action: teamspaceReviewTickets.length === 0 ? "Teamspace passt für heute." : `${teamspaceReviewTickets.length} Teamspace-Abgleich(e) korrigieren.`
},
{
key: "teamspace",
label: teamspaceTimeTrackingEnabled ? "Teamspace geprüft" : "TS-Erfassung aus",
done: teamspaceReviewTickets.length === 0,
action: teamspaceTimeTrackingEnabled
? teamspaceReviewTickets.length === 0 ? "Teamspace passt für heute." : `${teamspaceReviewTickets.length} Teamspace-Abgleich(e) korrigieren.`
: "Monatsprüfung läuft über Session-Status."
},
{
key: "budget",
label: "Budget geprüft",
@@ -1786,8 +1813,8 @@ function DayControlWidget({ stats, projects, todos, onNavigate }: WidgetContentP
key: `teamspace-review-${ticket.id}`,
tone: "info" as const,
title: ticket.title || ticket.ticket_number,
detail: `${ticket.ticket_number} · TT ${formatMinutes(ticket.total_minutes)} · Teamspace ${formatMinutes(ticket.crm_billed_minutes ?? 0)}`,
note: dayControlTodaySessionReason(ticket),
detail: `${ticket.ticket_number} · TT ${formatMinutes(ticket.total_minutes)} · Teamspace ${formatMinutes(ticket.crm_billed_minutes ?? 0)}`,
note: dayControlTodaySessionReason(ticket, teamspaceTimeTrackingEnabled),
onClick: () => onNavigate(`/tickets/${ticket.id}?month=${currentMonth()}`)
}))
].slice(0, 8);
@@ -1831,8 +1858,8 @@ function DayControlWidget({ stats, projects, todos, onNavigate }: WidgetContentP
items={afterworkTickets.slice(0, 4).map((ticket) => ({
key: `today-ticket-${ticket.id}`,
title: ticket.title || ticket.ticket_number,
detail: `${ticket.ticket_number} · ${formatMinutes(ticket.total_minutes + ticket.billable_minutes + ticket.non_billable_minutes + ticket.open_minutes)} · ${ticket.session_count} Session(s)`,
note: dayControlTodaySessionReason(ticket),
detail: `${ticket.ticket_number} · ${formatMinutes(ticket.total_minutes + ticket.non_billable_minutes + ticket.open_minutes)} · ${ticket.session_count} Session(s)`,
note: dayControlTodaySessionReason(ticket, teamspaceTimeTrackingEnabled),
onClick: () => onNavigate(`/tickets/${ticket.id}?month=${currentMonth()}`)
}))}
/>
@@ -1854,16 +1881,21 @@ function DayControlWidget({ stats, projects, todos, onNavigate }: WidgetContentP
empty="Keine Budget-Hinweise."
items={budgetAttention.slice(0, 4).map((project) => {
const percent = projectConsumptionPercent(project);
const hasUnassignedSessions = Number(project.unassigned_teamspace_session_count ?? 0) > 0;
const detail = project.close_review_required
? "Abschluss prüfen"
: hasUnassignedSessions
? `${project.unassigned_teamspace_session_count} Session(s) ohne TS-Zuordnung`
: `${percent ?? 0}% Kontingent verbraucht`;
const note = project.close_review_required
? "Abschluss in TT/Teamspace prüfen und ggf. Umbuchungen klären."
: hasUnassignedSessions
? "Sessions einem Teamspace-Projekt zuordnen."
const hasUnassignedSessions = Number(project.unassigned_teamspace_session_count ?? 0) > 0;
const hasUnassignedSubentries = Number(project.unassigned_teamspace_subentry_count ?? 0) > 0;
const detail = project.close_review_required
? project.zammad_merged_from_ticket_id || project.zammad_state_id !== "4" ? "Merge prüfen" : "Abschluss prüfen"
: hasUnassignedSessions
? `${project.unassigned_teamspace_session_count} Session(s) ohne TS-Zuordnung`
: hasUnassignedSubentries
? `${project.unassigned_teamspace_subentry_count} Session(s) ohne Unterposition`
: `${percent ?? 0}% Kontingent verbraucht`;
const note = project.close_review_required
? 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 || hasUnassignedSubentries
? "Sessions über die Session-Bearbeitung zuordnen."
: "Kontingent kritisch: Budget, Verbrauch und weitere Arbeit prüfen.";
return {
@@ -1881,18 +1913,18 @@ function DayControlWidget({ stats, projects, todos, onNavigate }: WidgetContentP
);
}
function dayControlTodaySessionReason(ticket: PeriodOverview["tickets"][number]) {
function dayControlTodaySessionReason(ticket: PeriodOverview["tickets"][number], teamspaceTimeTrackingEnabled = true) {
if (ticket.open_count > 0) {
return `${ticket.open_count} offene Bewertung(en): Bewertungsstatus setzen.`;
}
const crmMinutes = ticket.crm_billed_minutes ?? 0;
const deltaMinutes = teamspaceDeltaMinutes(ticket);
if (ticket.total_minutes > 0 && deltaMinutes > 0) {
if (teamspaceTimeTrackingEnabled && ticket.total_minutes > 0 && deltaMinutes > 0) {
return `Teamspace nachtragen: ${formatMinutes(deltaMinutes)} fehlen.`;
}
if (ticket.total_minutes > 0 && deltaMinutes === 0) {
if (teamspaceTimeTrackingEnabled && ticket.total_minutes > 0 && deltaMinutes === 0) {
return `Teamspace ok: ${formatMinutes(crmMinutes)} erfasst.`;
}
@@ -2348,7 +2380,7 @@ function ProjectBudgetWidget({ widget, editing, projects, stats, onNavigate, onU
<span key={entry.id} className="block">
<span className="flex min-w-0 items-center justify-between gap-2 text-[10px] text-muted-foreground">
<span className="truncate">
{entry.teamspace_project || (entry.is_default ? "Standard" : "Ohne Teamspace-ID")}
{projectTeamspaceEntryLabel(entry)}
{entry.is_default ? " · Standard" : ""}
</span>
<span className="shrink-0">
+93 -27
View File
@@ -22,7 +22,7 @@ import { Input } from "@/components/ui/input";
import { CopyTicketButton } from "@/components/CopyTicketButton";
import { HelpLink } from "@/components/HelpLink";
import { acknowledgeMissingTicketDayBilling, closePeriod, getStatisticsOverview, reopenPeriod } from "../api";
import { currentMonth, formatDate, formatDateTime, formatMinutes } from "../format";
import { currentMonth, currentWeek, formatDate, formatDateTime, formatMinutes } from "../format";
import type { StatisticsCrmDay, StatisticsOpenSession, StatisticsOverview, StatisticsTeamspaceDay } from "../types";
type MonthlyClosePageProps = {
@@ -32,6 +32,17 @@ type MonthlyClosePageProps = {
const trackedTextClass = "text-sky-700 dark:text-sky-300";
const teamspaceTextClass = "text-emerald-700 dark:text-emerald-300";
const showAcknowledgedMissingCrmStorageKey = "tickettracker.monthlyClose.showAcknowledgedMissingCrm";
const monthlyCloseViewModeStorageKey = "tickettracker.monthlyClose.viewMode";
type MonthlyCloseViewMode = "month" | "week";
function readMonthlyCloseViewMode(): MonthlyCloseViewMode {
if (typeof window === "undefined") {
return "month";
}
return window.localStorage.getItem(monthlyCloseViewModeStorageKey) === "week" ? "week" : "month";
}
function formatSignedMinutes(minutes: number) {
if (minutes === 0) {
@@ -63,6 +74,7 @@ type OpenTicketGroup = {
ticketId: string;
ticketNumber: string;
organizationName: string;
month: string;
openCount: number;
totalMinutes: number;
};
@@ -83,6 +95,7 @@ function groupOpenSessions(sessions: StatisticsOpenSession[]) {
ticketId: session.ticket_id,
ticketNumber: session.ticket_number,
organizationName: session.organization_name,
month: session.started_at.slice(0, 7),
openCount: 1,
totalMinutes: session.rounded_minutes
});
@@ -91,7 +104,7 @@ function groupOpenSessions(sessions: StatisticsOpenSession[]) {
return Array.from(groups.values()).sort((left, right) => left.ticketNumber.localeCompare(right.ticketNumber, "de", { numeric: true, sensitivity: "base" }));
}
function OpenTicketItem({ group, month, onNavigate }: { group: OpenTicketGroup; month: string; onNavigate: (to: string) => void }) {
function OpenTicketItem({ group, onNavigate }: { group: OpenTicketGroup; onNavigate: (to: string) => void }) {
return (
<div className="rounded-md border bg-background px-3 py-2">
<div className="flex items-start justify-between gap-3">
@@ -104,7 +117,7 @@ function OpenTicketItem({ group, month, onNavigate }: { group: OpenTicketGroup;
<p className="truncate text-sm text-muted-foreground">{group.organizationName}</p>
<p className={`text-xs font-medium ${trackedTextClass}`}>{formatMinutes(group.totalMinutes)} offen zu bewerten</p>
</div>
<Button className="h-8 shrink-0" variant="secondary" size="sm" onClick={() => onNavigate(`/tickets/${group.ticketId}?month=${month}`)}>
<Button className="h-8 shrink-0" variant="secondary" size="sm" onClick={() => onNavigate(`/tickets/${group.ticketId}?month=${group.month}`)}>
Ticket öffnen
<ExternalLink className="size-4" />
</Button>
@@ -185,7 +198,9 @@ function TeamspaceDayItem({ item, onNavigate }: { item: StatisticsTeamspaceDay;
}
export function MonthlyClosePage({ onNavigate }: MonthlyClosePageProps) {
const [viewMode, setViewMode] = useState<MonthlyCloseViewMode>(() => readMonthlyCloseViewMode());
const [month, setMonth] = useState(currentMonth());
const [week, setWeek] = useState(currentWeek());
const [stats, setStats] = useState<StatisticsOverview | null>(null);
const [loading, setLoading] = useState(false);
const [closing, setClosing] = useState(false);
@@ -194,6 +209,11 @@ export function MonthlyClosePage({ onNavigate }: MonthlyClosePageProps) {
const [showAcknowledgedMissingCrm, setShowAcknowledgedMissingCrm] = useState(false);
const [showAcknowledgedMissingCrmLoaded, setShowAcknowledgedMissingCrmLoaded] = useState(false);
const loadRequestId = useRef(0);
const period = viewMode === "month" ? month : week;
const periodLabel = viewMode === "month" ? "Monat" : "Woche";
const periodAttentionLabel = viewMode === "month" ? "diesen Monat" : "diese Woche";
const periodInLabel = viewMode === "month" ? "diesem Monat" : "dieser Woche";
const isMonthView = viewMode === "month";
async function load() {
const requestId = loadRequestId.current + 1;
@@ -201,14 +221,14 @@ export function MonthlyClosePage({ onNavigate }: MonthlyClosePageProps) {
setLoading(true);
try {
const result = await getStatisticsOverview(month);
const result = await getStatisticsOverview(period, viewMode);
if (requestId === loadRequestId.current) {
setStats(result);
}
} catch (error) {
if (requestId === loadRequestId.current) {
toast.error("Monatsabschluss konnte nicht geladen werden", {
toast.error("Abschlussansicht konnte nicht geladen werden", {
description: error instanceof Error ? error.message : "Unbekannter Fehler"
});
}
@@ -221,7 +241,11 @@ export function MonthlyClosePage({ onNavigate }: MonthlyClosePageProps) {
useEffect(() => {
void load();
}, [month]);
}, [period, viewMode]);
useEffect(() => {
window.localStorage.setItem(monthlyCloseViewModeStorageKey, viewMode);
}, [viewMode]);
useEffect(() => {
setShowAcknowledgedMissingCrm(window.localStorage.getItem(showAcknowledgedMissingCrmStorageKey) === "true");
@@ -241,6 +265,22 @@ export function MonthlyClosePage({ onNavigate }: MonthlyClosePageProps) {
setMonth(`${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}`);
}
function changeWeekBy(delta: number) {
const match = /^(\d{4})-W(\d{2})$/.exec(week);
const year = match ? Number(match[1]) : new Date().getFullYear();
const weekNumber = match ? Number(match[2]) : 1;
const jan4 = new Date(Date.UTC(year, 0, 4));
const jan4Weekday = jan4.getUTCDay() || 7;
const date = new Date(jan4);
date.setUTCDate(jan4.getUTCDate() - jan4Weekday + 1 + (weekNumber - 1 + delta) * 7);
const weekday = date.getUTCDay() || 7;
date.setUTCDate(date.getUTCDate() + 4 - weekday);
const yearStart = new Date(Date.UTC(date.getUTCFullYear(), 0, 1));
const nextWeek = Math.ceil(((date.getTime() - yearStart.getTime()) / 86_400_000 + 1) / 7);
setWeek(`${date.getUTCFullYear()}-W${String(nextWeek).padStart(2, "0")}`);
}
async function finishMonth() {
setClosing(true);
try {
@@ -308,14 +348,14 @@ export function MonthlyClosePage({ onNavigate }: MonthlyClosePageProps) {
const days = showAcknowledgedMissingCrm ? [...missingCrmDays, ...acknowledgedMissingCrmDays] : [...missingCrmDays];
return days.sort((left, right) => left.day.localeCompare(right.day) || left.ticket_number.localeCompare(right.ticket_number, "de", { numeric: true, sensitivity: "base" }));
}, [acknowledgedMissingCrmDays, missingCrmDays, showAcknowledgedMissingCrm]);
const canClose = Boolean(stats && !stats.closed && stats.totals.sessions > 0 && openSessions.length === 0);
const canClose = Boolean(isMonthView && stats && !stats.closed && stats.totals.sessions > 0 && openSessions.length === 0);
const checklist = useMemo(
() => [
{
label: "Offene Bewertungen",
ok: openSessions.length === 0,
value: openSessions.length,
detail: openSessions.length === 0 ? "Alle Sessions sind bewertet." : `${openTicketGroups.length} Ticket(s) bearbeiten`
detail: openSessions.length === 0 ? "Keine offenen Bewertungen." : `${openTicketGroups.length} Ticket(s) bearbeiten`
},
{
label: "Teamspace geprüft",
@@ -324,13 +364,13 @@ export function MonthlyClosePage({ onNavigate }: MonthlyClosePageProps) {
detail: missingCrmDays.length === 0 ? "Keine offenen Teamspace-Prüfpunkte." : `${acknowledgedMissingCrmDays.length} zur Kenntnis genommen`
},
{
label: "Monat abschließbar",
ok: Boolean(stats?.closed || canClose),
value: stats?.closed ? "geschlossen" : canClose ? "bereit" : "offen",
detail: stats?.closed ? "Monat ist gesperrt." : canClose ? "Kann abgeschlossen werden." : "Bewertungen prüfen."
label: isMonthView ? "Monat abschließbar" : "Wochenansicht",
ok: isMonthView ? Boolean(stats?.closed || canClose) : true,
value: isMonthView ? (stats?.closed ? "geschlossen" : canClose ? "bereit" : "offen") : "Ansicht",
detail: isMonthView ? (stats?.closed ? "Monat ist gesperrt." : canClose ? "Kann abgeschlossen werden." : "Bewertungen prüfen.") : "Abschluss bleibt monatlich."
}
],
[acknowledgedMissingCrmDays.length, canClose, openSessions.length, openTicketGroups.length, missingCrmDays.length, stats?.closed]
[acknowledgedMissingCrmDays.length, canClose, isMonthView, openSessions.length, openTicketGroups.length, missingCrmDays.length, stats?.closed]
);
return (
@@ -338,25 +378,46 @@ export function MonthlyClosePage({ onNavigate }: MonthlyClosePageProps) {
<div className="flex flex-col gap-3 xl:flex-row xl:items-end xl:justify-between">
<div>
<h2 className="text-2xl font-semibold tracking-normal">Monatsabschluss</h2>
<p className="text-sm text-muted-foreground">Offene Bewertungen, Teamspace-Abgleich und der eigentliche Monatsabschluss.</p>
<p className="text-sm text-muted-foreground">Offene Bewertungen und Teamspace-Abgleich für {periodAttentionLabel}; der Abschluss selbst bleibt monatlich.</p>
</div>
<div className="grid gap-2 sm:grid-cols-[minmax(220px,300px)_auto] sm:items-end">
<div className="grid gap-2 sm:grid-cols-[auto_minmax(220px,300px)_auto] sm:items-end">
<div className="space-y-1">
<span className="text-xs font-medium text-muted-foreground">Ansicht</span>
<div className="inline-flex h-9 rounded-md border bg-muted/20 p-1">
<Button type="button" size="sm" variant={viewMode === "week" ? "default" : "ghost"} className="h-7 px-3" onClick={() => setViewMode("week")}>
Woche
</Button>
<Button type="button" size="sm" variant={viewMode === "month" ? "default" : "ghost"} className="h-7 px-3" onClick={() => setViewMode("month")}>
Monat
</Button>
</div>
</div>
<div className="space-y-1">
<div className="flex items-center justify-between gap-2">
<label className="text-xs font-medium text-muted-foreground" htmlFor="close-month">Monat</label>
<label className="text-xs font-medium text-muted-foreground" htmlFor="close-period">{periodLabel}</label>
{loading ? <span className="text-xs text-muted-foreground">lädt...</span> : null}
</div>
<div className="grid grid-cols-[2.25rem_minmax(0,1fr)_2.25rem] gap-1">
<Button type="button" size="icon" variant="secondary" className="h-9 w-9" onClick={() => changeMonthBy(-1)} aria-label="Monat zurück">
<Button type="button" size="icon" variant="secondary" className="h-9 w-9" onClick={() => (isMonthView ? changeMonthBy(-1) : changeWeekBy(-1))} aria-label={`${periodLabel} zurück`}>
<ChevronLeft className="size-4" />
</Button>
<Input id="close-month" className="h-9" type="month" value={month} onChange={(event) => setMonth(event.currentTarget.value)} />
<Button type="button" size="icon" variant="secondary" className="h-9 w-9" onClick={() => changeMonthBy(1)} aria-label="Monat vor">
<Input
id="close-period"
className="h-9"
type={isMonthView ? "month" : "week"}
value={period}
onChange={(event) => (isMonthView ? setMonth(event.currentTarget.value) : setWeek(event.currentTarget.value))}
/>
<Button type="button" size="icon" variant="secondary" className="h-9 w-9" onClick={() => (isMonthView ? changeMonthBy(1) : changeWeekBy(1))} aria-label={`${periodLabel} vor`}>
<ChevronRight className="size-4" />
</Button>
</div>
</div>
{stats?.closed ? (
{!isMonthView ? (
<Badge variant="outline" className="h-9 justify-center px-3">
Abschluss nur monatlich
</Badge>
) : stats?.closed ? (
<Button size="sm" variant="secondary" disabled={reopening} onClick={reopenMonth}>
<LockOpen className="size-4" />
{reopening ? "Öffnet..." : "Monat öffnen"}
@@ -370,7 +431,7 @@ export function MonthlyClosePage({ onNavigate }: MonthlyClosePageProps) {
</div>
</div>
{stats?.closed ? (
{isMonthView && stats?.closed ? (
<Alert variant="success" className="flex items-center gap-2 py-3">
<CheckCircle2 className="size-4 shrink-0" />
<span>Dieser Monat wurde am {formatDateTime(stats.closedAt!)} abgeschlossen.</span>
@@ -382,7 +443,12 @@ export function MonthlyClosePage({ onNavigate }: MonthlyClosePageProps) {
<StatusCard label="Teamspace" value={formatMinutes(stats?.totals.crmBilledMinutes ?? 0)} detail={`Differenz ${formatTeamspaceDelta(stats?.totals.crmDeltaMinutes ?? 0)}`} />
<StatusCard label="Offene Bewertungen" value={openSessions.length} detail={`${openTicketGroups.length} Ticket(s)`} tone={openSessions.length > 0 ? "warn" : "ok"} />
<StatusCard label="Ohne Teamspace" value={missingCrmDays.length} detail={`${acknowledgedMissingCrmDays.length} zur Kenntnis genommen`} tone={missingCrmDays.length > 0 ? "warn" : "ok"} />
<StatusCard label="Status" value={stats?.closed ? "geschlossen" : canClose ? "bereit" : "offen"} detail={stats?.closed ? "Monat ist gesperrt" : canClose ? "kann abgeschlossen werden" : "Prüfpunkte bearbeiten"} tone={stats?.closed || canClose ? "ok" : "warn"} />
<StatusCard
label="Status"
value={isMonthView ? (stats?.closed ? "geschlossen" : canClose ? "bereit" : "offen") : "Ansicht"}
detail={isMonthView ? (stats?.closed ? "Monat ist gesperrt" : canClose ? "kann abgeschlossen werden" : "Prüfpunkte bearbeiten") : "Abschluss bleibt monatlich"}
tone={isMonthView ? (stats?.closed || canClose ? "ok" : "warn") : "neutral"}
/>
</div>
<Card>
@@ -413,7 +479,7 @@ export function MonthlyClosePage({ onNavigate }: MonthlyClosePageProps) {
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div>
<CardTitle>Aufräumen</CardTitle>
<CardDescription>Alles, was für diesen Monat noch Aufmerksamkeit braucht.</CardDescription>
<CardDescription>Alles, was für {periodAttentionLabel} noch Aufmerksamkeit braucht.</CardDescription>
</div>
<div className="flex flex-wrap items-center gap-2">
<div className="inline-flex h-8 items-center gap-2 rounded-md border bg-background px-3 text-sm">
@@ -435,7 +501,7 @@ export function MonthlyClosePage({ onNavigate }: MonthlyClosePageProps) {
Offene Bewertungen
</div>
{openTicketGroups.map((group) => (
<OpenTicketItem key={group.ticketId} group={group} month={month} onNavigate={onNavigate} />
<OpenTicketItem key={group.ticketId} group={group} onNavigate={onNavigate} />
))}
{openTicketGroups.length === 0 ? <p className="rounded-md border bg-muted/20 p-3 text-sm text-muted-foreground">Keine offenen Bewertungen.</p> : null}
</section>
@@ -479,7 +545,7 @@ export function MonthlyClosePage({ onNavigate }: MonthlyClosePageProps) {
))}
</div>
) : (
<p className="rounded-md border bg-muted/20 p-3 text-sm text-muted-foreground">Für diesen Monat sind noch keine Teamspace-Werte zu abgerechneten Ticket-Tagen vorhanden.</p>
<p className="rounded-md border bg-muted/20 p-3 text-sm text-muted-foreground">Für {periodAttentionLabel} sind noch keine Teamspace-Werte zu abgerechneten Ticket-Tagen vorhanden.</p>
)}
</AccordionContent>
</AccordionItem>
@@ -490,8 +556,8 @@ export function MonthlyClosePage({ onNavigate }: MonthlyClosePageProps) {
<CardContent className="grid place-items-center gap-2 p-8 text-center">
<Clock3 className="size-8 text-muted-foreground" />
<div>
<p className="font-medium">Keine Sessions in diesem Monat</p>
<p className="text-sm text-muted-foreground">Ein Monatsabschluss ist erst sinnvoll, wenn Sessions vorhanden sind.</p>
<p className="font-medium">Keine Sessions in {periodInLabel}</p>
<p className="text-sm text-muted-foreground">Die Kontrollansicht ist erst sinnvoll, wenn Sessions vorhanden sind.</p>
</div>
</CardContent>
</Card>
+272 -35
View File
@@ -9,6 +9,7 @@ import { HelpLink } from "@/components/HelpLink";
import { getZammadSessionArticleQueue, retryZammadSessionArticleQueue, updateCurrentUser } from "../api";
import { formatDateTime, formatMinutes } from "../format";
import type { AuthUser, ZammadSessionArticleQueueItem } from "../types";
import { zammadArticleTypeFromValue, zammadArticleTypeLabel, zammadArticleTypeOptions, zammadArticleTypeValue, type ZammadArticleTypeId } from "../zammad-article-types";
type ProfilePageProps = {
currentUser: AuthUser;
@@ -23,11 +24,22 @@ export function ProfilePage({ currentUser, onUserUpdated }: ProfilePageProps) {
const [confirmPassword, setConfirmPassword] = useState("");
const [zammadApiToken, setZammadApiToken] = useState("");
const [zammadTokenDirty, setZammadTokenDirty] = useState(false);
const [teamspaceDeviceId, setTeamspaceDeviceId] = useState("");
const [teamspaceDevicePassword, setTeamspaceDevicePassword] = useState("");
const [teamspaceCredentialsDirty, setTeamspaceCredentialsDirty] = useState(false);
const [zammadEmailSessionWebhookEnabled, setZammadEmailSessionWebhookEnabled] = useState(currentUser.zammad_email_session_webhook_enabled);
const [zammadAtossEmailSyncEnabled, setZammadAtossEmailSyncEnabled] = useState(currentUser.zammad_atoss_email_sync_enabled);
const [sessionBillingPromptEnabled, setSessionBillingPromptEnabled] = useState(currentUser.session_billing_prompt_enabled);
const [teamspaceTimeTrackingEnabled, setTeamspaceTimeTrackingEnabled] = useState(currentUser.teamspace_time_tracking_enabled ?? true);
const [zammadSessionNoteDefaultEnabled, setZammadSessionNoteDefaultEnabled] = useState(currentUser.zammad_session_note_default_enabled);
const [zammadTimeUnitRoundingEnabled, setZammadTimeUnitRoundingEnabled] = useState(currentUser.zammad_time_unit_rounding_enabled);
const [zammadTimeUnitRoundingMinutes, setZammadTimeUnitRoundingMinutes] = useState(currentUser.zammad_time_unit_rounding_minutes);
const [zammadArticleTypeId, setZammadArticleTypeId] = useState<ZammadArticleTypeId>(currentUser.zammad_article_type_id ?? 6);
const [projectFollowupEnabled, setProjectFollowupEnabled] = useState(currentUser.project_followup_enabled ?? true);
const [projectFollowupWeeks, setProjectFollowupWeeks] = useState(currentUser.project_followup_weeks ?? 4);
const [projectFollowupSecondEnabled, setProjectFollowupSecondEnabled] = useState(currentUser.project_followup_second_enabled ?? true);
const [projectFollowupSecondWeeks, setProjectFollowupSecondWeeks] = useState(currentUser.project_followup_second_weeks ?? 2);
const [projectFollowupCompleteOnSession, setProjectFollowupCompleteOnSession] = useState(currentUser.project_followup_complete_on_session ?? true);
const [zammadQueue, setZammadQueue] = useState<ZammadSessionArticleQueueItem[]>([]);
const [loadingZammadQueue, setLoadingZammadQueue] = useState(false);
const [retryingZammadQueue, setRetryingZammadQueue] = useState(false);
@@ -38,11 +50,22 @@ export function ProfilePage({ currentUser, onUserUpdated }: ProfilePageProps) {
setDisplayName(currentUser.display_name);
setZammadApiToken("");
setZammadTokenDirty(false);
setTeamspaceDeviceId("");
setTeamspaceDevicePassword("");
setTeamspaceCredentialsDirty(false);
setZammadEmailSessionWebhookEnabled(currentUser.zammad_email_session_webhook_enabled);
setZammadAtossEmailSyncEnabled(currentUser.zammad_atoss_email_sync_enabled);
setSessionBillingPromptEnabled(currentUser.session_billing_prompt_enabled);
setTeamspaceTimeTrackingEnabled(currentUser.teamspace_time_tracking_enabled ?? true);
setZammadSessionNoteDefaultEnabled(currentUser.zammad_session_note_default_enabled);
setZammadTimeUnitRoundingEnabled(currentUser.zammad_time_unit_rounding_enabled);
setZammadTimeUnitRoundingMinutes(currentUser.zammad_time_unit_rounding_minutes);
setZammadArticleTypeId(currentUser.zammad_article_type_id ?? 6);
setProjectFollowupEnabled(currentUser.project_followup_enabled ?? true);
setProjectFollowupWeeks(currentUser.project_followup_weeks ?? 4);
setProjectFollowupSecondEnabled(currentUser.project_followup_second_enabled ?? true);
setProjectFollowupSecondWeeks(currentUser.project_followup_second_weeks ?? 2);
setProjectFollowupCompleteOnSession(currentUser.project_followup_complete_on_session ?? true);
}, [currentUser]);
async function loadZammadQueue() {
@@ -119,14 +142,23 @@ export function ProfilePage({ currentUser, onUserUpdated }: ProfilePageProps) {
currentPassword: wantsPasswordChange ? currentPassword : undefined,
newPassword: wantsPasswordChange ? newPassword : undefined,
...(currentUser.role === "user" && zammadTokenDirty ? { zammadApiToken } : {}),
...(currentUser.role === "user" && teamspaceCredentialsDirty ? { teamspaceDeviceId, teamspaceDevicePassword } : {}),
...(currentUser.role === "user"
? {
zammadEmailSessionWebhookEnabled,
sessionBillingPromptEnabled,
zammadSessionNoteDefaultEnabled,
zammadTimeUnitRoundingEnabled,
zammadTimeUnitRoundingMinutes
}
zammadEmailSessionWebhookEnabled,
zammadAtossEmailSyncEnabled,
sessionBillingPromptEnabled,
teamspaceTimeTrackingEnabled,
zammadSessionNoteDefaultEnabled,
zammadTimeUnitRoundingEnabled,
zammadTimeUnitRoundingMinutes,
zammadArticleTypeId,
projectFollowupEnabled,
projectFollowupWeeks,
projectFollowupSecondEnabled,
projectFollowupSecondWeeks,
projectFollowupCompleteOnSession
}
: {})
};
@@ -138,6 +170,9 @@ export function ProfilePage({ currentUser, onUserUpdated }: ProfilePageProps) {
setConfirmPassword("");
setZammadApiToken("");
setZammadTokenDirty(false);
setTeamspaceDeviceId("");
setTeamspaceDevicePassword("");
setTeamspaceCredentialsDirty(false);
toast.success("Profil gespeichert");
} catch (error) {
toast.error("Profil konnte nicht gespeichert werden", {
@@ -279,6 +314,86 @@ export function ProfilePage({ currentUser, onUserUpdated }: ProfilePageProps) {
) : null}
</div>
</div>
<div className="mt-4 rounded-md border bg-background p-3">
<div className="mb-3 flex items-center gap-2">
<Unplug className="size-4 text-muted-foreground" />
<p className="text-sm font-medium">Teamspace API</p>
</div>
<div className="grid gap-3 md:grid-cols-[minmax(180px,220px)_minmax(220px,1fr)_180px]">
<div className="space-y-2">
<label className="text-sm font-medium" htmlFor="profile-teamspace-device-id">
Geräte-ID
</label>
<Input
id="profile-teamspace-device-id"
value={teamspaceDeviceId}
placeholder={currentUser.has_teamspace_credentials ? "hinterlegt" : "Geräte-ID"}
autoComplete="off"
onChange={(event) => {
setTeamspaceDeviceId(event.currentTarget.value);
setTeamspaceCredentialsDirty(true);
}}
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium" htmlFor="profile-teamspace-device-password">
Gerätepasswort
</label>
<Input
id="profile-teamspace-device-password"
type="password"
value={teamspaceDevicePassword}
placeholder={currentUser.has_teamspace_credentials ? "••••••••••••••••" : "Token aus Teamspace einfügen"}
autoComplete="off"
onChange={(event) => {
setTeamspaceDevicePassword(event.currentTarget.value);
setTeamspaceCredentialsDirty(true);
}}
/>
<p className="text-xs text-muted-foreground">Die Teamspace-ID wird nur geprüft, wenn du neue Zugangsdaten speicherst.</p>
</div>
<div className="space-y-2">
<label className="text-sm font-medium" htmlFor="profile-teamspace-user-id">
Teamspace User-ID
</label>
<Input id="profile-teamspace-user-id" value={currentUser.teamspace_user_id ?? "nicht verknüpft"} readOnly />
{currentUser.has_teamspace_credentials ? (
<Button
type="button"
size="sm"
variant="secondary"
className="w-full"
onClick={() => {
setTeamspaceDeviceId("");
setTeamspaceDevicePassword("");
setTeamspaceCredentialsDirty(true);
}}
>
<X className="size-4" />
Zugang entfernen
</Button>
) : null}
</div>
</div>
</div>
<div className="mt-3 flex items-center justify-between gap-3 rounded-md border bg-background p-3">
<div className="flex items-center gap-2">
<Clock3 className="size-4 text-muted-foreground" />
<div>
<label className="text-sm font-medium" htmlFor="profile-teamspace-time-tracking">
Teamspace-Zeiten in TT erfassen
</label>
<p className="text-xs text-muted-foreground">
Wenn aus, werden keine Teamspace-Tageswerte in TT erfasst; Monatsprüfung und Abschluss laufen nur über Session-Status.
</p>
</div>
</div>
<Switch
id="profile-teamspace-time-tracking"
checked={teamspaceTimeTrackingEnabled}
onCheckedChange={setTeamspaceTimeTrackingEnabled}
/>
</div>
<div className="mt-4 flex items-center justify-between gap-3 rounded-md border bg-background p-3">
<div className="flex items-center gap-2">
<MailPlus className="size-4 text-muted-foreground" />
@@ -295,6 +410,22 @@ export function ProfilePage({ currentUser, onUserUpdated }: ProfilePageProps) {
onCheckedChange={setZammadEmailSessionWebhookEnabled}
/>
</div>
<div className="mt-3 flex items-center justify-between gap-3 rounded-md border bg-background p-3">
<div className="flex items-center gap-2">
<MailPlus className="size-4 text-muted-foreground" />
<div>
<label className="text-sm font-medium" htmlFor="profile-zammad-atoss-email-sync">
ATOSS eMails syncen
</label>
<p className="text-xs text-muted-foreground">Wenn aus, werden eMails an support.atc@atoss.com nicht übernommen.</p>
</div>
</div>
<Switch
id="profile-zammad-atoss-email-sync"
checked={zammadAtossEmailSyncEnabled}
onCheckedChange={setZammadAtossEmailSyncEnabled}
/>
</div>
<div className="mt-3 flex items-center justify-between gap-3 rounded-md border bg-background p-3">
<div className="flex items-center gap-2">
<ClipboardCheck className="size-4 text-muted-foreground" />
@@ -311,6 +442,89 @@ export function ProfilePage({ currentUser, onUserUpdated }: ProfilePageProps) {
onCheckedChange={setSessionBillingPromptEnabled}
/>
</div>
<div className="mt-3 space-y-3 rounded-md border bg-background p-3">
<div className="flex items-center justify-between gap-3">
<div>
<label className="text-sm font-medium" htmlFor="profile-project-followup-enabled">
Automatische Wiedervorlagen
</label>
<p className="text-xs text-muted-foreground">Consulting-Projekte automatisch auf Wiedervorlage setzen.</p>
</div>
<Switch
id="profile-project-followup-enabled"
checked={projectFollowupEnabled}
onCheckedChange={setProjectFollowupEnabled}
/>
</div>
{projectFollowupEnabled ? (
<div className="space-y-3 border-t pt-3">
<div>
<p className="text-sm font-medium">1. Wiedervorlage aktiv</p>
<p className="text-xs text-muted-foreground">Wenn automatische Wiedervorlagen aktiv sind, wird die erste WVL immer geprüft.</p>
</div>
<div className="grid gap-2 sm:max-w-xs">
<label className="text-sm font-medium" htmlFor="profile-project-followup-weeks">
1. Wiedervorlage nach Wochen
</label>
<Input
id="profile-project-followup-weeks"
type="number"
min={1}
max={52}
value={projectFollowupWeeks}
onChange={(event) => setProjectFollowupWeeks(Math.max(1, Math.min(52, Number(event.currentTarget.value) || 1)))}
/>
</div>
<div className="flex items-center justify-between gap-3">
<div>
<label className="text-sm font-medium" htmlFor="profile-project-followup-second-enabled">
2. Wiedervorlage
</label>
<p className="text-xs text-muted-foreground">Nur anlegen, wenn die erste WVL erledigt oder gelöscht wurde und keine Session entstanden ist.</p>
</div>
<Switch
id="profile-project-followup-second-enabled"
checked={projectFollowupSecondEnabled}
onCheckedChange={setProjectFollowupSecondEnabled}
/>
</div>
{projectFollowupSecondEnabled ? (
<div className="grid gap-2 sm:max-w-xs">
<label className="text-sm font-medium" htmlFor="profile-project-followup-second-weeks">
2. Wiedervorlage nach weiteren Wochen
</label>
<Input
id="profile-project-followup-second-weeks"
type="number"
min={1}
max={52}
value={projectFollowupSecondWeeks}
onChange={(event) => setProjectFollowupSecondWeeks(Math.max(1, Math.min(52, Number(event.currentTarget.value) || 1)))}
/>
</div>
) : null}
</div>
) : null}
<div className="flex items-center justify-between gap-3">
<div>
<label className="text-sm font-medium" htmlFor="profile-project-followup-complete-on-session">
Automatische Wiedervorlagen durch Session erledigen
</label>
<p className="text-xs text-muted-foreground">Gilt für beide Zeiträume. Betrifft nur automatisch erzeugte Wiedervorlagen, niemals manuelle ToDos.</p>
</div>
<Switch
id="profile-project-followup-complete-on-session"
checked={projectFollowupCompleteOnSession}
onCheckedChange={setProjectFollowupCompleteOnSession}
/>
</div>
</div>
<div className="mt-3 flex items-center justify-between gap-3 rounded-md border bg-background p-3">
<div className="flex items-center gap-2">
<FileText className="size-4 text-muted-foreground" />
@@ -334,41 +548,63 @@ export function ProfilePage({ currentUser, onUserUpdated }: ProfilePageProps) {
/>
</div>
{zammadSessionNoteDefaultEnabled ? (
<div className="mt-3 rounded-md border bg-background p-3">
<div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-2">
<Clock3 className="size-4 text-muted-foreground" />
<div>
<label className="text-sm font-medium" htmlFor="profile-zammad-time-rounding">
Zammad-Zeit aufrunden
</label>
<p className="text-xs text-muted-foreground">Ohne Rundung wird die konkrete Zeit als Stundenwert mit 2 Nachkommastellen gesendet.</p>
</div>
</div>
<Switch
id="profile-zammad-time-rounding"
checked={zammadTimeUnitRoundingEnabled}
onCheckedChange={setZammadTimeUnitRoundingEnabled}
/>
</div>
{zammadTimeUnitRoundingEnabled ? (
<div className="mt-3 grid gap-2 sm:w-56">
<label className="text-xs font-medium text-muted-foreground" htmlFor="profile-zammad-rounding-minutes">
Rundungsintervall
<div className="mt-3 space-y-3">
<div className="rounded-md border bg-background p-3">
<div className="grid gap-2 sm:max-w-lg">
<label className="text-sm font-medium" htmlFor="profile-zammad-article-type">
Standard-Typ
</label>
<select
id="profile-zammad-rounding-minutes"
id="profile-zammad-article-type"
className="h-9 rounded-md border border-input bg-background px-3 text-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
value={zammadTimeUnitRoundingMinutes}
onChange={(event) => setZammadTimeUnitRoundingMinutes(Number(event.currentTarget.value))}
value={zammadArticleTypeValue(zammadArticleTypeId)}
onChange={(event) => setZammadArticleTypeId(zammadArticleTypeFromValue(event.currentTarget.value))}
>
<option value={5}>5 Minuten</option>
<option value={10}>10 Minuten</option>
<option value={15}>15 Minuten</option>
<option value={30}>30 Minuten</option>
{zammadArticleTypeOptions.map((option) => (
<option key={option.value || "empty"} value={option.value}>
{option.label}
</option>
))}
</select>
<p className="text-xs text-muted-foreground">Wird beim Erstellen interner Zammad-Notizen vorbelegt.</p>
</div>
) : null}
</div>
<div className="rounded-md border bg-background p-3">
<div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-2">
<Clock3 className="size-4 text-muted-foreground" />
<div>
<label className="text-sm font-medium" htmlFor="profile-zammad-time-rounding">
Zammad-Zeit aufrunden
</label>
<p className="text-xs text-muted-foreground">Ohne Rundung wird die konkrete Zeit als Stundenwert mit 2 Nachkommastellen gesendet.</p>
</div>
</div>
<Switch
id="profile-zammad-time-rounding"
checked={zammadTimeUnitRoundingEnabled}
onCheckedChange={setZammadTimeUnitRoundingEnabled}
/>
</div>
{zammadTimeUnitRoundingEnabled ? (
<div className="mt-3 grid gap-2 sm:w-56">
<label className="text-xs font-medium text-muted-foreground" htmlFor="profile-zammad-rounding-minutes">
Rundungsintervall
</label>
<select
id="profile-zammad-rounding-minutes"
className="h-9 rounded-md border border-input bg-background px-3 text-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
value={zammadTimeUnitRoundingMinutes}
onChange={(event) => setZammadTimeUnitRoundingMinutes(Number(event.currentTarget.value))}
>
<option value={5}>5 Minuten</option>
<option value={10}>10 Minuten</option>
<option value={15}>15 Minuten</option>
<option value={30}>30 Minuten</option>
</select>
</div>
) : null}
</div>
</div>
) : null}
<div className="mt-3 rounded-md border bg-background p-3">
@@ -401,6 +637,7 @@ export function ProfilePage({ currentUser, onUserUpdated }: ProfilePageProps) {
<span className="text-muted-foreground">{formatDateTime(item.started_at)}</span>
<span className="text-muted-foreground">{formatMinutes(item.rounded_minutes)}</span>
<span className="text-muted-foreground">time_unit {item.time_unit}</span>
<span className="text-muted-foreground">{zammadArticleTypeLabel(item.zammad_article_type_id)}</span>
</div>
{item.last_error ? <p className="mt-1 line-clamp-2 text-destructive">{item.last_error}</p> : null}
</div>
+338 -91
View File
@@ -1,4 +1,4 @@
import { ArrowDown, ArrowUp, CheckCircle2, Eye, EyeOff, MoreHorizontal, Pencil, Play, Plus, RotateCcw, Save, Search, SlidersHorizontal, Star, Trash2 } from "lucide-react";
import { AlertCircle, ArrowDown, ArrowUp, CheckCircle2, Eye, EyeOff, MoreHorizontal, Pencil, Play, Plus, RotateCcw, Save, Search, SlidersHorizontal, Star, Trash2 } from "lucide-react";
import { FormEvent, useEffect, useMemo, useState } from "react";
import { toast } from "sonner";
@@ -15,11 +15,11 @@ import { HelpLink } from "@/components/HelpLink";
import { MultiFilterDropdown, type MultiFilterOption } from "@/components/MultiFilterDropdown";
import { OrganizationSelect } from "@/components/OrganizationSelect";
import { cn } from "@/lib/utils";
import { addFavoriteTicket, createProject, getProjects, hideProject, lookupTicket, unhideProject, updateProject, updateProjectStatus } from "../api";
import { acknowledgeProjectReview, addFavoriteTicket, createProject, getProjects, hideProject, lookupTicket, unhideProject, updateProject, updateProjectStatus } from "../api";
import { currentMonth, formatMinutes } from "../format";
import { projectHasRecurringBilling } from "../recurring-billings";
import { ticketPattern } from "../timers";
import type { ProjectSummary, ProjectTeamspaceEntry, TicketMeta } from "../types";
import type { AuthUser, ProjectSummary, ProjectTeamspaceEntry, TicketMeta } from "../types";
import {
matchesZammadPriorityFilter,
matchesZammadStateFilter,
@@ -27,6 +27,8 @@ import {
zammadPriorityFilterOptions,
zammadPriorityLabel,
zammadPrioritySortRank,
zammadId,
zammadStateId,
ZammadStateBadge,
zammadStateFilterOptions,
zammadStateLabel,
@@ -34,6 +36,7 @@ import {
} from "../zammad-meta";
type ProjectsPageProps = {
currentUser: AuthUser;
onNavigate: (to: string) => void;
onStartTimer: (ticketNumber: string) => Promise<boolean>;
};
@@ -52,6 +55,7 @@ type ProjectForm = {
type ProjectTeamspaceEntryForm = {
clientId: string;
teamspaceProject: string;
teamspaceDescription: string;
hourBudget: string;
budgetSyncDisabled: boolean;
manualConsumption: string;
@@ -60,6 +64,7 @@ type ProjectTeamspaceEntryForm = {
type ProjectSortBy = "updated" | "ticket" | "title" | "organization" | "status" | "zammadStatus" | "zammadPriority" | "consumption" | "consumptionPercent" | "remaining" | "budget";
type ProjectSortDirection = "asc" | "desc";
type ProjectGroupBy = "none" | "organization" | "status" | "zammadStatus" | "zammadPriority" | "budgetState" | "teamspace";
type ProjectFilterOption = MultiFilterOption;
type ProjectViewSettings = {
@@ -67,6 +72,8 @@ type ProjectViewSettings = {
filterValues: string[];
organizationValues: string[];
standardTeamspaceOnly: boolean;
groupBy: ProjectGroupBy;
groupDirection: ProjectSortDirection;
sortBy: ProjectSortBy;
sortDirection: ProjectSortDirection;
};
@@ -78,6 +85,8 @@ const defaultProjectViewSettings: ProjectViewSettings = {
filterValues: ["open"],
organizationValues: [],
standardTeamspaceOnly: false,
groupBy: "none",
groupDirection: "asc",
sortBy: "updated",
sortDirection: "desc"
};
@@ -97,6 +106,7 @@ const projectStaticFilterOptions: ProjectFilterOption[] = [
const projectStaticFilterValues = new Set(projectStaticFilterOptions.map((option) => option.value));
const projectSortValues: ProjectSortBy[] = ["updated", "ticket", "title", "organization", "status", "zammadStatus", "zammadPriority", "consumption", "consumptionPercent", "remaining", "budget"];
const projectGroupValues: ProjectGroupBy[] = ["none", "organization", "status", "zammadStatus", "zammadPriority", "budgetState", "teamspace"];
const projectSortOptions: Array<{ value: ProjectSortBy; label: string }> = [
{ value: "updated", label: "Aktualisierung" },
@@ -112,6 +122,16 @@ const projectSortOptions: Array<{ value: ProjectSortBy; label: string }> = [
{ value: "budget", label: "Kontingent" }
];
const projectGroupOptions: Array<{ value: ProjectGroupBy; label: string }> = [
{ value: "none", label: "Keine Gruppierung" },
{ value: "organization", label: "Organisation" },
{ value: "status", label: "TT-Status" },
{ value: "zammadStatus", label: "Zammad-Status" },
{ value: "zammadPriority", label: "Zammad-Prio" },
{ value: "budgetState", label: "Kontingentstatus" },
{ value: "teamspace", label: "Standard-Teamspace" }
];
function projectEntryId() {
if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") {
return crypto.randomUUID();
@@ -124,6 +144,7 @@ function emptyProjectTeamspaceEntry(isDefault = false): ProjectTeamspaceEntryFor
return {
clientId: projectEntryId(),
teamspaceProject: "",
teamspaceDescription: "",
hourBudget: "",
budgetSyncDisabled: false,
manualConsumption: "",
@@ -262,6 +283,8 @@ function readStoredProjectViewSettings(): ProjectViewSettings {
filterValues,
organizationValues: legacyProjectOrganizationValues(parsed as Partial<ProjectViewSettings> & Record<string, unknown>),
standardTeamspaceOnly: legacyStandardTeamspaceOnly(parsed as Partial<ProjectViewSettings> & Record<string, unknown>),
groupBy: projectGroupValues.find((value) => value === parsed.groupBy) ?? defaultProjectViewSettings.groupBy,
groupDirection: parsed.groupDirection === "desc" ? "desc" : defaultProjectViewSettings.groupDirection,
sortBy: projectSortValues.find((value) => value === parsed.sortBy) ?? defaultProjectViewSettings.sortBy,
sortDirection: parsed.sortDirection === "asc" ? "asc" : defaultProjectViewSettings.sortDirection
};
@@ -308,6 +331,7 @@ function projectTeamspaceEntries(project: ProjectSummary): ProjectTeamspaceEntry
{
id: `${project.id}-default`,
teamspace_project: project.teamspace_project,
teamspace_description: "",
is_default: true,
hour_budget_minutes: project.hour_budget_minutes,
manual_consumption_minutes: project.manual_consumption_minutes ?? 0,
@@ -318,6 +342,10 @@ function projectTeamspaceEntries(project: ProjectSummary): ProjectTeamspaceEntry
];
}
function projectTeamspaceEntryLabel(entry: { teamspace_project: string | null; teamspace_description?: string | null; is_default: boolean }) {
return entry.teamspace_description?.trim() || entry.teamspace_project || (entry.is_default ? "Standard" : "Ohne Teamspace-ID");
}
function budgetedProjectTeamspaceEntries(project: ProjectSummary) {
return projectTeamspaceEntries(project).filter((entry) => Number(entry.hour_budget_minutes ?? 0) > 0);
}
@@ -384,21 +412,11 @@ function projectOrganizationKey(project: ProjectSummary) {
return project.organization_id ? `id:${project.organization_id}` : `name:${projectOrganizationLabel(project)}`;
}
function projectBudget(project: ProjectSummary, standardTeamspaceOnly = false) {
if (standardTeamspaceOnly) {
return defaultProjectTeamspaceEntry(project)?.hour_budget_minutes ?? null;
}
function projectBudget(project: ProjectSummary, _standardTeamspaceOnly = false) {
return project.hour_budget_minutes ?? null;
}
function projectConsumption(project: ProjectSummary, standardTeamspaceOnly = false) {
if (standardTeamspaceOnly) {
const defaultEntry = defaultProjectTeamspaceEntry(project);
return defaultEntry ? projectEntryConsumption(defaultEntry) : 0;
}
function projectConsumption(project: ProjectSummary, _standardTeamspaceOnly = false) {
return Number(project.consumption_minutes ?? 0);
}
@@ -437,7 +455,7 @@ function projectMatchesQuery(project: ProjectSummary, query: string) {
project.teamspace_project ?? "",
zammadStateLabel(project.zammad_state_id),
zammadPriorityLabel(project.zammad_priority_id),
...projectTeamspaceEntries(project).map((entry) => entry.teamspace_project ?? ""),
...projectTeamspaceEntries(project).flatMap((entry) => [entry.teamspace_project ?? "", entry.teamspace_description ?? ""]),
project.description,
project.goals
];
@@ -579,10 +597,123 @@ function compareProjects(left: ProjectSummary, right: ProjectSummary, sortBy: Pr
return comparison * factor || compareText(left.ticket_number, right.ticket_number);
}
type ProjectGroupMeta = {
key: string;
label: string;
sortLabel: string;
rank: number;
};
type ProjectGroup = ProjectGroupMeta & {
projects: ProjectSummary[];
consumptionMinutes: number;
remainingMinutes: number;
};
function projectBudgetStateGroup(project: ProjectSummary, standardTeamspaceOnly: boolean): ProjectGroupMeta {
const budgetMinutes = projectBudget(project, standardTeamspaceOnly) ?? 0;
const remaining = projectRemaining(project, standardTeamspaceOnly);
if (budgetMinutes <= 0) {
return { key: "budgetState:none", label: "Ohne Kontingent", sortLabel: "Ohne Kontingent", rank: 40 };
}
if (remaining !== null && remaining < 0) {
return { key: "budgetState:over", label: "Überzogen", sortLabel: "Überzogen", rank: 10 };
}
if (remaining === 0) {
return { key: "budgetState:used", label: "Aufgebraucht", sortLabel: "Aufgebraucht", rank: 30 };
}
return { key: "budgetState:remaining", label: "Mit Rest", sortLabel: "Mit Rest", rank: 20 };
}
function projectGroupMeta(project: ProjectSummary, groupBy: ProjectGroupBy, standardTeamspaceOnly: boolean): ProjectGroupMeta {
if (groupBy === "organization") {
const label = projectOrganizationLabel(project);
return { key: `organization:${projectOrganizationKey(project)}`, label, sortLabel: label, rank: 0 };
}
if (groupBy === "status") {
return project.closed_at
? { key: "status:closed", label: "Geschlossen", sortLabel: "Geschlossen", rank: 20 }
: { key: "status:open", label: "Offen", sortLabel: "Offen", rank: 10 };
}
if (groupBy === "zammadStatus") {
const id = zammadStateId(project.zammad_state_id);
const label = id ? zammadStateLabel(id) : "Ohne Zammad-Status";
return { key: `zammadStatus:${id ?? "none"}`, label, sortLabel: label, rank: zammadStateSortRank(id) };
}
if (groupBy === "zammadPriority") {
const id = zammadId(project.zammad_priority_id);
const label = id ? zammadPriorityLabel(id) : "Ohne Zammad-Prio";
return { key: `zammadPriority:${id ?? "none"}`, label, sortLabel: label, rank: zammadPrioritySortRank(id) };
}
if (groupBy === "budgetState") {
return projectBudgetStateGroup(project, standardTeamspaceOnly);
}
if (groupBy === "teamspace") {
const defaultEntry = defaultProjectTeamspaceEntry(project);
const label = defaultEntry ? projectTeamspaceEntryLabel(defaultEntry) : "Ohne Teamspace-ID";
return { key: `teamspace:${label.toLocaleLowerCase("de-DE")}`, label, sortLabel: label, rank: 0 };
}
return { key: "all", label: "Alle Projekte", sortLabel: "Alle Projekte", rank: 0 };
}
function groupProjects(projects: ProjectSummary[], groupBy: ProjectGroupBy, groupDirection: ProjectSortDirection, standardTeamspaceOnly: boolean): ProjectGroup[] {
if (groupBy === "none") {
return [
{
key: "all",
label: "Alle Projekte",
sortLabel: "Alle Projekte",
rank: 0,
projects,
consumptionMinutes: projects.reduce((sum, project) => sum + projectConsumption(project, standardTeamspaceOnly), 0),
remainingMinutes: projects.reduce((sum, project) => sum + Math.max(projectRemaining(project, standardTeamspaceOnly) ?? 0, 0), 0)
}
];
}
const factor = groupDirection === "asc" ? 1 : -1;
const groups = new Map<string, ProjectGroup>();
for (const project of projects) {
const meta = projectGroupMeta(project, groupBy, standardTeamspaceOnly);
const group = groups.get(meta.key);
if (group) {
group.projects.push(project);
group.consumptionMinutes += projectConsumption(project, standardTeamspaceOnly);
group.remainingMinutes += Math.max(projectRemaining(project, standardTeamspaceOnly) ?? 0, 0);
} else {
groups.set(meta.key, {
...meta,
projects: [project],
consumptionMinutes: projectConsumption(project, standardTeamspaceOnly),
remainingMinutes: Math.max(projectRemaining(project, standardTeamspaceOnly) ?? 0, 0)
});
}
}
return Array.from(groups.values()).sort((left, right) => ((left.rank - right.rank) || compareText(left.sortLabel, right.sortLabel)) * factor);
}
function formFromProject(project: ProjectSummary): ProjectForm {
const entries = projectTeamspaceEntries(project).map((entry, index) => ({
clientId: entry.id || projectEntryId(),
teamspaceProject: entry.teamspace_project ?? "",
teamspaceDescription: entry.teamspace_description ?? "",
hourBudget: formatHoursInput(entry.hour_budget_minutes),
budgetSyncDisabled: entry.budget_sync_disabled ?? false,
manualConsumption: formatHoursInput(entry.manual_consumption_minutes),
@@ -606,6 +737,7 @@ type ProjectFormFieldsProps = {
form: ProjectForm;
setForm: (form: ProjectForm) => void;
organizationLocked: boolean;
showManualConsumption?: boolean;
lookupStatus: "idle" | "loading" | "found" | "not-found";
zammadLookupBusy?: boolean;
saving: boolean;
@@ -627,19 +759,19 @@ function normalizeProjectTeamspaceEntryForms(entries: ProjectTeamspaceEntryForm[
function parseProjectTeamspaceEntryForms(entries: ProjectTeamspaceEntryForm[]) {
const normalizedEntries = normalizeProjectTeamspaceEntryForms(entries);
const parsedEntries = normalizedEntries.map((entry) => {
const hourBudgetMinutes = parseHoursInput(entry.hourBudget);
const manualConsumptionMinutes = parseHoursInput(entry.manualConsumption);
if (hourBudgetMinutes === undefined || manualConsumptionMinutes === undefined) {
if (manualConsumptionMinutes === undefined) {
return undefined;
}
return {
id: /^\d+$/.test(entry.clientId) ? entry.clientId : null,
teamspaceProject: entry.teamspaceProject.trim() || null,
teamspaceDescription: entry.teamspaceDescription.trim() || null,
isDefault: entry.isDefault,
hourBudgetMinutes,
budgetSyncDisabled: entry.budgetSyncDisabled,
hourBudgetMinutes: null,
budgetSyncDisabled: false,
manualConsumptionMinutes: manualConsumptionMinutes ?? 0
};
});
@@ -651,6 +783,7 @@ function parseProjectTeamspaceEntryForms(entries: ProjectTeamspaceEntryForm[]) {
return parsedEntries as Array<{
id?: string | null;
teamspaceProject: string | null;
teamspaceDescription: string | null;
isDefault: boolean;
hourBudgetMinutes: number | null;
budgetSyncDisabled: boolean;
@@ -664,10 +797,12 @@ function defaultProjectTeamspacePayload(entries: ReturnType<typeof parseProjectT
function ProjectTeamspaceEntriesFields({
entries,
onChange
onChange,
showManualConsumption = true
}: {
entries: ProjectTeamspaceEntryForm[];
onChange: (entries: ProjectTeamspaceEntryForm[]) => void;
showManualConsumption?: boolean;
}) {
const normalizedEntries = normalizeProjectTeamspaceEntryForms(entries);
@@ -694,7 +829,15 @@ function ProjectTeamspaceEntriesFields({
</div>
<div className="space-y-2">
{normalizedEntries.map((entry, index) => (
<div key={entry.clientId} className="grid gap-2 rounded-lg border bg-muted/10 p-3 lg:grid-cols-[minmax(180px,1fr)_130px_150px_130px_120px_36px] lg:items-end">
<div
key={entry.clientId}
className={cn(
"grid gap-2 rounded-lg border bg-muted/10 p-3 lg:items-end",
showManualConsumption
? "lg:grid-cols-[minmax(180px,1fr)_minmax(160px,0.9fr)_110px_120px_36px]"
: "lg:grid-cols-[minmax(180px,1fr)_minmax(160px,0.9fr)_120px_36px]"
)}
>
<div className="space-y-1.5">
<label className="text-xs font-medium text-muted-foreground" htmlFor={`project-teamspace-${entry.clientId}`}>
Teamspace-Projekt {entry.isDefault ? "(Standard)" : ""}
@@ -706,31 +849,29 @@ function ProjectTeamspaceEntriesFields({
/>
</div>
<div className="space-y-1.5">
<label className="text-xs font-medium text-muted-foreground" htmlFor={`project-budget-${entry.clientId}`}>
Budget h
<label className="text-xs font-medium text-muted-foreground" htmlFor={`project-teamspace-description-${entry.clientId}`}>
Alias
</label>
<Input
id={`project-budget-${entry.clientId}`}
inputMode="decimal"
value={entry.hourBudget}
onChange={(event) => updateEntry(entry.clientId, { hourBudget: event.currentTarget.value, budgetSyncDisabled: true })}
/>
</div>
<label className="flex min-h-9 items-center gap-2 rounded-md border bg-background px-3 text-xs">
<Checkbox checked={entry.budgetSyncDisabled} onCheckedChange={(checked) => updateEntry(entry.clientId, { budgetSyncDisabled: checked === true })} />
<span>Budget schützen</span>
</label>
<div className="space-y-1.5">
<label className="text-xs font-medium text-muted-foreground" htmlFor={`project-manual-${entry.clientId}`}>
Manueller Verbrauch h
</label>
<Input
id={`project-manual-${entry.clientId}`}
inputMode="decimal"
value={entry.manualConsumption}
onChange={(event) => updateEntry(entry.clientId, { manualConsumption: event.currentTarget.value })}
id={`project-teamspace-description-${entry.clientId}`}
value={entry.teamspaceDescription}
placeholder="z.B. Cloud-Umzug"
onChange={(event) => updateEntry(entry.clientId, { teamspaceDescription: event.currentTarget.value })}
/>
</div>
{showManualConsumption ? (
<div className="space-y-1.5">
<label className="text-xs font-medium text-muted-foreground" htmlFor={`project-teamspace-manual-${entry.clientId}`}>
Manuell h
</label>
<Input
id={`project-teamspace-manual-${entry.clientId}`}
inputMode="decimal"
value={entry.manualConsumption}
onChange={(event) => updateEntry(entry.clientId, { manualConsumption: event.currentTarget.value })}
/>
</div>
) : null}
<Button type="button" size="sm" variant={entry.isDefault ? "default" : "secondary"} onClick={() => setDefault(entry.clientId)}>
{entry.isDefault ? "Standard" : "Als Standard"}
</Button>
@@ -741,14 +882,11 @@ function ProjectTeamspaceEntriesFields({
</div>
))}
</div>
<p className="text-xs text-muted-foreground">
Der Standard-Eintrag bekommt die aus Teamspace gebuchten Tageswerte. Manueller Verbrauch zählt nur im Projektverbrauch.
</p>
</div>
);
}
function ProjectFormFields({ form, setForm, organizationLocked, lookupStatus, zammadLookupBusy = false, saving, submitLabel, onZammadLookup, onSubmit }: ProjectFormFieldsProps) {
function ProjectFormFields({ form, setForm, organizationLocked, showManualConsumption = true, lookupStatus, zammadLookupBusy = false, saving, submitLabel, onZammadLookup, onSubmit }: ProjectFormFieldsProps) {
return (
<form className="space-y-4" onSubmit={onSubmit}>
<div className="grid gap-3 lg:grid-cols-[180px_minmax(240px,1fr)_180px]">
@@ -822,7 +960,11 @@ function ProjectFormFields({ form, setForm, organizationLocked, lookupStatus, za
</div>
</div>
<ProjectTeamspaceEntriesFields entries={form.teamspaceEntries} onChange={(teamspaceEntries) => setForm({ ...form, teamspaceEntries })} />
<ProjectTeamspaceEntriesFields
entries={form.teamspaceEntries}
onChange={(teamspaceEntries) => setForm({ ...form, teamspaceEntries })}
showManualConsumption={showManualConsumption}
/>
<div className="space-y-2">
<label className="text-sm font-medium" htmlFor="project-description">
@@ -841,7 +983,7 @@ function ProjectFormFields({ form, setForm, organizationLocked, lookupStatus, za
);
}
export function ProjectsPage({ onNavigate, onStartTimer }: ProjectsPageProps) {
export function ProjectsPage({ currentUser, onNavigate, onStartTimer }: ProjectsPageProps) {
const [projects, setProjects] = useState<ProjectSummary[]>([]);
const [hiddenProjectCount, setHiddenProjectCount] = useState(0);
const [totalProjectCount, setTotalProjectCount] = useState(0);
@@ -858,6 +1000,7 @@ export function ProjectsPage({ onNavigate, onStartTimer }: ProjectsPageProps) {
const [savingProjectId, setSavingProjectId] = useState<string | null>(null);
const [savingProjectStatusId, setSavingProjectStatusId] = useState<string | null>(null);
const [viewSettings, setViewSettings] = useState<ProjectViewSettings>(() => readStoredProjectViewSettings());
const teamspaceTimeTrackingEnabled = currentUser.teamspace_time_tracking_enabled ?? true;
async function load() {
setLoading(true);
@@ -967,12 +1110,18 @@ export function ProjectsPage({ onNavigate, onStartTimer }: ProjectsPageProps) {
() => filteredProjects.reduce((sum, project) => sum + Math.max(projectRemaining(project, viewSettings.standardTeamspaceOnly) ?? 0, 0), 0),
[filteredProjects, viewSettings.standardTeamspaceOnly]
);
const projectGroups = useMemo(
() => groupProjects(filteredProjects, viewSettings.groupBy, viewSettings.groupDirection, viewSettings.standardTeamspaceOnly),
[filteredProjects, viewSettings.groupBy, viewSettings.groupDirection, viewSettings.standardTeamspaceOnly]
);
const closedProjectsLoaded = projectShouldLoadClosed(viewSettings.filterValues);
const activeFilterCount = [
viewSettings.query.trim() !== "",
JSON.stringify(viewSettings.filterValues) !== JSON.stringify(defaultProjectViewSettings.filterValues),
viewSettings.organizationValues.length > 0,
viewSettings.standardTeamspaceOnly,
viewSettings.groupBy !== defaultProjectViewSettings.groupBy,
viewSettings.groupDirection !== defaultProjectViewSettings.groupDirection,
viewSettings.sortBy !== defaultProjectViewSettings.sortBy,
viewSettings.sortDirection !== defaultProjectViewSettings.sortDirection
].filter(Boolean).length;
@@ -1016,6 +1165,7 @@ export function ProjectsPage({ onNavigate, onStartTimer }: ProjectsPageProps) {
? zammadEntries.map((entry, index) => ({
clientId: entry.id || projectEntryId(),
teamspaceProject: entry.teamspace_project ?? "",
teamspaceDescription: entry.teamspace_description ?? "",
hourBudget: formatHoursInput(entry.hour_budget_minutes),
budgetSyncDisabled: entry.budget_sync_disabled ?? false,
manualConsumption: "",
@@ -1028,8 +1178,9 @@ export function ProjectsPage({ onNavigate, onStartTimer }: ProjectsPageProps) {
? {
...entry,
teamspaceProject: entry.teamspaceProject || result.ticket?.project_name || "",
hourBudget: entry.hourBudget || formatHoursInput(result.ticket?.project_hour_budget_minutes ?? null),
budgetSyncDisabled: entry.hourBudget ? entry.budgetSyncDisabled : false
teamspaceDescription: entry.teamspaceDescription,
hourBudget: "",
budgetSyncDisabled: false
}
: entry;
});
@@ -1198,6 +1349,21 @@ export function ProjectsPage({ onNavigate, onStartTimer }: ProjectsPageProps) {
}
}
async function acknowledgeProjectReviewCheck(project: ProjectSummary) {
setSavingProjectStatusId(project.id);
try {
await acknowledgeProjectReview(project.id);
toast.success("Prüfung bestätigt");
await load();
} catch (error) {
toast.error("Prüfung konnte nicht bestätigt werden", {
description: error instanceof Error ? error.message : "Unbekannter Fehler"
});
} finally {
setSavingProjectStatusId(null);
}
}
async function saveProjectFavorite(project: ProjectSummary) {
try {
await addFavoriteTicket(project.ticket_number);
@@ -1286,7 +1452,7 @@ export function ProjectsPage({ onNavigate, onStartTimer }: ProjectsPageProps) {
</Button>
</div>
</CardHeader>
<CardContent className="grid gap-3 px-4 pb-4 md:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-5">
<CardContent className="grid gap-3 px-4 pb-4 md:grid-cols-2 xl:grid-cols-4 2xl:grid-cols-8">
<div className="space-y-1.5">
<label className="text-xs font-medium text-muted-foreground" htmlFor="project-search">
Suche
@@ -1331,6 +1497,44 @@ export function ProjectsPage({ onNavigate, onStartTimer }: ProjectsPageProps) {
<span className="block truncate text-xs">Hauptbalken</span>
</span>
</label>
<div className="space-y-1.5">
<label className="text-xs font-medium text-muted-foreground">Gruppierung</label>
<Select value={viewSettings.groupBy} onValueChange={(value) => setViewSettings((current) => ({ ...current, groupBy: value as ProjectGroupBy }))}>
<SelectTrigger size="sm" className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
{projectGroupOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>{option.label}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<label className="text-xs font-medium text-muted-foreground">Gruppenrichtung</label>
<div className="grid grid-cols-2 rounded-md border bg-background p-0.5">
<Button
type="button"
size="sm"
variant={viewSettings.groupDirection === "asc" ? "secondary" : "ghost"}
className="h-7 px-2"
onClick={() => setViewSettings((current) => ({ ...current, groupDirection: "asc" }))}
>
<ArrowUp className="size-3.5" />
Auf
</Button>
<Button
type="button"
size="sm"
variant={viewSettings.groupDirection === "desc" ? "secondary" : "ghost"}
className="h-7 px-2"
onClick={() => setViewSettings((current) => ({ ...current, groupDirection: "desc" }))}
>
<ArrowDown className="size-3.5" />
Ab
</Button>
</div>
</div>
<div className="space-y-1.5">
<label className="text-xs font-medium text-muted-foreground">Sortierung</label>
<Select value={viewSettings.sortBy} onValueChange={(value) => setViewSettings((current) => ({ ...current, sortBy: value as ProjectSortBy }))}>
@@ -1345,7 +1549,7 @@ export function ProjectsPage({ onNavigate, onStartTimer }: ProjectsPageProps) {
</Select>
</div>
<div className="space-y-1.5">
<label className="text-xs font-medium text-muted-foreground">Richtung</label>
<label className="text-xs font-medium text-muted-foreground">Sortierrichtung</label>
<div className="grid grid-cols-2 rounded-md border bg-background p-0.5">
<Button
type="button"
@@ -1412,6 +1616,7 @@ export function ProjectsPage({ onNavigate, onStartTimer }: ProjectsPageProps) {
form={form}
setForm={setForm}
organizationLocked={organizationLocked}
showManualConsumption={teamspaceTimeTrackingEnabled}
lookupStatus={lookupStatus}
zammadLookupBusy={zammadLookupBusy}
saving={saving}
@@ -1432,18 +1637,31 @@ export function ProjectsPage({ onNavigate, onStartTimer }: ProjectsPageProps) {
{sortHeader("Verbrauch %", "consumptionPercent")}
<span className="text-right">Aktion</span>
</div>
{filteredProjects.map((project) => {
const isEditing = editingProjectId === project.id;
const budgetedTeamspaceEntries = budgetedProjectTeamspaceEntries(project);
const consumptionMinutes = projectConsumption(project, viewSettings.standardTeamspaceOnly);
const budgetMinutes = projectBudget(project, viewSettings.standardTeamspaceOnly);
const remaining = projectRemaining(project, viewSettings.standardTeamspaceOnly);
const usedPercent = projectConsumptionPercent(project, viewSettings.standardTeamspaceOnly);
const showBudgetedTeamspaceEntries = !viewSettings.standardTeamspaceOnly && budgetedTeamspaceEntries.length > 1;
const canEditProject = project.can_edit !== false;
{projectGroups.map((group) => (
<div key={group.key} className={cn(viewSettings.groupBy !== "none" ? "border-b last:border-b-0" : undefined)}>
{viewSettings.groupBy !== "none" ? (
<div className="flex flex-col gap-1 border-b bg-muted/15 px-4 py-2 sm:flex-row sm:items-center sm:justify-between">
<h4 className="min-w-0 truncate text-sm font-medium">{group.label}</h4>
<p className="text-xs text-muted-foreground">
{group.projects.length} Projekt(e) · {formatMinutes(group.consumptionMinutes)} Verbrauch · {formatMinutes(group.remainingMinutes)} Rest offen
</p>
</div>
) : null}
{group.projects.map((project) => {
const isEditing = editingProjectId === project.id;
const budgetedTeamspaceEntries = budgetedProjectTeamspaceEntries(project);
const consumptionMinutes = projectConsumption(project, viewSettings.standardTeamspaceOnly);
const budgetMinutes = projectBudget(project, viewSettings.standardTeamspaceOnly);
const remaining = projectRemaining(project, viewSettings.standardTeamspaceOnly);
const usedPercent = projectConsumptionPercent(project, viewSettings.standardTeamspaceOnly);
const budgetExceeded = budgetMinutes !== null && budgetMinutes > 0 && consumptionMinutes > budgetMinutes;
const showBudgetedTeamspaceEntries = !viewSettings.standardTeamspaceOnly && budgetedTeamspaceEntries.length > 1;
const canEditProject = project.can_edit === true;
const unassignedTeamspaceEntryCount = Number(project.unassigned_teamspace_session_count ?? 0);
const unassignedTeamspaceSubentryCount = 0;
return (
<div key={project.id} className="border-b last:border-b-0">
return (
<div key={project.id} className="border-b last:border-b-0">
<div className="grid gap-3 px-4 py-3 xl:grid-cols-[minmax(220px,1.35fr)_minmax(170px,1fr)_105px_115px_105px_165px_90px_44px] xl:items-center">
<div className="min-w-0">
<button
@@ -1464,7 +1682,8 @@ export function ProjectsPage({ onNavigate, onStartTimer }: ProjectsPageProps) {
{project.closed_at ? <Badge variant="secondary">Geschlossen</Badge> : <Badge variant="success">Offen</Badge>}
{!canEditProject ? <Badge variant="outline">geteilt</Badge> : null}
{project.close_review_required ? <Badge variant="warning">TS/TT prüfen</Badge> : null}
{(project.unassigned_teamspace_session_count ?? 0) > 0 ? <Badge variant="warning">{project.unassigned_teamspace_session_count} ohne TS</Badge> : null}
{unassignedTeamspaceEntryCount > 0 ? <Badge variant="warning">{unassignedTeamspaceEntryCount} ohne TS</Badge> : null}
{unassignedTeamspaceSubentryCount > 0 ? <Badge variant="warning">{unassignedTeamspaceSubentryCount} ohne Pos.</Badge> : null}
</div>
<div className="flex flex-wrap gap-1.5">
<p className="w-full truncate text-sm font-medium xl:hidden">Zammad</p>
@@ -1476,9 +1695,10 @@ export function ProjectsPage({ onNavigate, onStartTimer }: ProjectsPageProps) {
</div>
<div className="min-w-0">
<p className="truncate text-sm font-medium xl:hidden">Verbrauch/Gesamt</p>
<p className={cn("text-sm font-semibold", remaining !== null && remaining < 0 ? "text-destructive" : undefined)}>
{formatDecimalHours(consumptionMinutes)} / {budgetMinutes ? formatDecimalHours(budgetMinutes) : "offen"}
</p>
<p className={cn("inline-flex items-center gap-1 text-sm font-semibold", remaining !== null && remaining < 0 ? "text-destructive" : undefined)}>
{budgetExceeded ? <AlertCircle className="size-3.5 text-amber-500" aria-label="Budgetüberschreitung" /> : null}
{formatDecimalHours(consumptionMinutes)} / {budgetMinutes ? formatDecimalHours(budgetMinutes) : "offen"}
</p>
{showBudgetedTeamspaceEntries ? <p className="text-xs text-muted-foreground">{budgetedTeamspaceEntries.length} Budgets</p> : null}
</div>
<div className="min-w-0 text-sm">
@@ -1545,31 +1765,55 @@ export function ProjectsPage({ onNavigate, onStartTimer }: ProjectsPageProps) {
</div>
</div>
<div className="space-y-2 px-4 pb-3">
{usedPercent !== null ? (
<div className="h-1.5 overflow-hidden rounded-full bg-muted" title={`${usedPercent}% Verbrauch`} aria-label={`${usedPercent}% Verbrauch`}>
<div className={cn("h-full rounded-full", projectConsumptionBarClass(usedPercent))} style={{ width: `${Math.min(100, usedPercent)}%` }} />
</div>
) : (
{usedPercent !== null ? (
<div className="flex items-center gap-1">
<div className="h-1.5 flex-1 overflow-hidden rounded-full bg-muted" title={`${usedPercent}% Verbrauch`} aria-label={`${usedPercent}% Verbrauch`}>
<div className={cn("h-full rounded-full", projectConsumptionBarClass(usedPercent))} style={{ width: `${Math.min(100, usedPercent)}%` }} />
</div>
{budgetExceeded ? <AlertCircle className="size-3.5 shrink-0 text-amber-500" aria-label="Budgetüberschreitung" /> : null}
</div>
) : (
<div className="h-1.5 overflow-hidden rounded-full bg-muted/30">
<div className="h-full rounded-full bg-muted-foreground/10" style={{ width: "0%" }} />
</div>
)}
{(project.unassigned_teamspace_session_count ?? 0) > 0 ? (
{unassignedTeamspaceEntryCount > 0 || unassignedTeamspaceSubentryCount > 0 ? (
<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">
{project.unassigned_teamspace_session_count} Session(s) haben keinen Teamspace-Eintrag.
{[
unassignedTeamspaceEntryCount > 0 ? `${unassignedTeamspaceEntryCount} Session(s) ohne Teamspace-Projekt` : null,
unassignedTeamspaceSubentryCount > 0 ? `${unassignedTeamspaceSubentryCount} Session(s) ohne Unterposition` : null
].filter(Boolean).join(" · ")}
</p>
) : null}
{project.close_review_required ? (
<p className="rounded-md border border-amber-500/30 bg-amber-500/10 px-2 py-1 text-xs text-amber-700 dark:text-amber-300">
Zammad meldet das Consulting-Ticket als geschlossen. Bitte Zeiten in Teamspace und TicketTracker prüfen und das Projekt danach schließen.
</p>
<div className="flex flex-col gap-2 rounded-md border border-amber-500/30 bg-amber-500/10 px-2 py-1 text-xs text-amber-700 dark:text-amber-300 sm:flex-row sm:items-center sm:justify-between">
<span>
{project.zammad_merged_from_ticket_id || project.zammad_state_id !== "4"
? project.zammad_merged_from_ticket_id
? `Dieses Projekt wurde aus ${project.zammad_merged_from_ticket_number ?? `Zammad-ID ${project.zammad_merged_from_ticket_id}`} übernommen. Teamspace-Projekte 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 bleiben lokal erhalten; bitte in Zammad prüfen und nachtragen."
: "Zammad meldet das Consulting-Ticket als geschlossen. Bitte Zeiten in Teamspace und TicketTracker prüfen und das Projekt danach schließen."}
</span>
<Button
type="button"
variant="outline"
size="sm"
className="h-7 shrink-0 px-2 text-xs"
disabled={savingProjectStatusId === project.id || !canEditProject}
onClick={() => void acknowledgeProjectReviewCheck(project)}
>
<CheckCircle2 className="mr-1.5 h-3.5 w-3.5" />
Geprüft
</Button>
</div>
) : null}
{showBudgetedTeamspaceEntries ? (
<div className="grid gap-1.5 md:grid-cols-2">
{budgetedTeamspaceEntries.map((entry) => {
const entryPercent = projectEntryPercent(entry);
const entryConsumption = projectEntryConsumption(entry);
const label = entry.teamspace_project || (entry.is_default ? "Standard" : "Ohne Teamspace-ID");
const entryPercent = projectEntryPercent(entry);
const entryConsumption = projectEntryConsumption(entry);
const label = projectTeamspaceEntryLabel(entry);
const entryBudgetExceeded = Number(entry.hour_budget_minutes ?? 0) > 0 && entryConsumption > Number(entry.hour_budget_minutes ?? 0);
return (
<div key={entry.id} className="min-w-0 rounded-md border bg-background/50 px-2 py-1">
@@ -1577,11 +1821,11 @@ export function ProjectsPage({ onNavigate, onStartTimer }: ProjectsPageProps) {
<span className="truncate text-muted-foreground">
{label}
{entry.is_default ? " · Standard" : ""}
{entry.budget_sync_disabled ? " · Budget geschützt" : ""}
</span>
<span className="shrink-0 font-medium">
{formatMinutes(entryConsumption)} / {entry.hour_budget_minutes ? formatMinutes(entry.hour_budget_minutes) : "offen"}
</span>
<span className="inline-flex shrink-0 items-center gap-1 font-medium">
{entryBudgetExceeded ? <AlertCircle className="size-3.5 text-amber-500" aria-label="Budgetüberschreitung" /> : null}
{formatMinutes(entryConsumption)} / {entry.hour_budget_minutes ? formatMinutes(entry.hour_budget_minutes) : "offen"}
</span>
</div>
<div className="h-1 overflow-hidden rounded-full bg-muted">
<div
@@ -1613,10 +1857,11 @@ export function ProjectsPage({ onNavigate, onStartTimer }: ProjectsPageProps) {
<Input type="date" value={editForm.startsOn} onChange={(event) => setEditForm({ ...editForm, startsOn: event.currentTarget.value })} />
<Input type="date" value={editForm.endsOn} onChange={(event) => setEditForm({ ...editForm, endsOn: event.currentTarget.value })} />
</div>
<ProjectTeamspaceEntriesFields
entries={editForm.teamspaceEntries}
onChange={(teamspaceEntries) => setEditForm({ ...editForm, teamspaceEntries })}
/>
<ProjectTeamspaceEntriesFields
entries={editForm.teamspaceEntries}
onChange={(teamspaceEntries) => setEditForm({ ...editForm, teamspaceEntries })}
showManualConsumption={teamspaceTimeTrackingEnabled}
/>
<Textarea className="min-h-24" value={editForm.description} onChange={(event) => setEditForm({ ...editForm, description: event.currentTarget.value })} />
<div className="flex flex-wrap justify-end gap-2">
<Button
@@ -1640,8 +1885,10 @@ export function ProjectsPage({ onNavigate, onStartTimer }: ProjectsPageProps) {
</div>
) : null}
</div>
);
})}
);
})}
</div>
))}
</Card>
{!loading && filteredProjects.length === 0 ? (
+267 -56
View File
@@ -1,9 +1,11 @@
import {
ChevronLeft,
ChevronRight,
BellPlus,
CircleAlert,
Clock3,
ExternalLink,
ListChecks,
RotateCcw,
Ticket,
TrendingUp,
@@ -20,13 +22,15 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/com
import { Input } from "@/components/ui/input";
import { Pagination, PaginationContent, PaginationItem, PaginationNext, PaginationPrevious } from "@/components/ui/pagination";
import { Progress } from "@/components/ui/progress";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { HelpLink } from "@/components/HelpLink";
import { getStatisticsOverview } from "../api";
import { currentMonth, formatDate, formatMinutes } from "../format";
import type { StatisticsDailyBucket, StatisticsGroup, StatisticsOverview, StatisticsTicket, WorkType } from "../types";
import { getProjectFollowups, getStatisticsOverview } from "../api";
import { currentMonth, formatDate, formatDateTime, formatMinutes } from "../format";
import type { AuthUser, StatisticsDailyBucket, StatisticsGroup, StatisticsOverview, StatisticsProjectFollowup, StatisticsProjectFollowupOverview, StatisticsTicket, WorkType } from "../types";
type StatisticsPageProps = {
currentUser: AuthUser;
onNavigate: (to: string) => void;
};
@@ -106,7 +110,7 @@ function TimeComparison({ trackedMinutes, crmMinutes, sessions }: { trackedMinut
);
}
function ColorLegend() {
function ColorLegend({ showDifference = true }: { showDifference?: boolean }) {
return (
<div className="flex flex-wrap gap-x-4 gap-y-2 rounded-md border bg-muted/20 p-3 text-xs text-muted-foreground">
<span className="inline-flex items-center gap-1.5">
@@ -125,14 +129,18 @@ function ColorLegend() {
<span className="size-2 rounded-sm bg-indigo-500 dark:bg-indigo-400" />
<span className={nonBillableTextClass}>Nicht abrechenbar</span>
</span>
<span className="inline-flex items-center gap-1.5">
<span className="size-2 rounded-full bg-emerald-500 dark:bg-emerald-400" />
<span className="text-emerald-700 dark:text-emerald-300">Differenz plus oder ausgeglichen</span>
</span>
<span className="inline-flex items-center gap-1.5">
<span className="size-2 rounded-full bg-red-500 dark:bg-red-400" />
<span className="text-red-700 dark:text-red-300">Differenz minus</span>
</span>
{showDifference ? (
<>
<span className="inline-flex items-center gap-1.5">
<span className="size-2 rounded-full bg-emerald-500 dark:bg-emerald-400" />
<span className="text-emerald-700 dark:text-emerald-300">Differenz plus oder ausgeglichen</span>
</span>
<span className="inline-flex items-center gap-1.5">
<span className="size-2 rounded-full bg-red-500 dark:bg-red-400" />
<span className="text-red-700 dark:text-red-300">Differenz minus</span>
</span>
</>
) : null}
<span className="inline-flex items-center gap-1.5">
<span className="size-2 rounded-full bg-amber-500 dark:bg-amber-400" />
<span className={openTextClass}>offen</span>
@@ -207,14 +215,14 @@ function buildMonthSeries(month: string, buckets: StatisticsDailyBucket[]) {
});
}
function DailyEffortChart({ month, buckets }: { month: string; buckets: StatisticsDailyBucket[] }) {
function DailyEffortChart({ month, buckets, showTeamspaceLine = true }: { month: string; buckets: StatisticsDailyBucket[]; showTeamspaceLine?: boolean }) {
const series = buildMonthSeries(month, buckets);
const width = 900;
const height = 220;
const padding = { top: 18, right: 20, bottom: 36, left: 64 };
const innerWidth = width - padding.left - padding.right;
const innerHeight = height - padding.top - padding.bottom;
const maxMinutes = niceCeilMinutes(Math.max(...series.map((bucket) => Math.max(bucket.totalMinutes, bucket.crmBilledMinutes)), 1));
const maxMinutes = niceCeilMinutes(Math.max(...series.map((bucket) => Math.max(bucket.totalMinutes, showTeamspaceLine ? bucket.crmBilledMinutes : 0)), 1));
const ticks = Array.from({ length: 5 }).map((_, index) => maxMinutes - (index * maxMinutes) / 4);
const labelStep = Math.max(1, Math.ceil(series.length / 9));
const barWidth = Math.max(5, Math.min(16, innerWidth / Math.max(series.length, 1) / 2));
@@ -228,11 +236,11 @@ function DailyEffortChart({ month, buckets }: { month: string; buckets: Statisti
crmY: padding.top + (1 - bucket.crmBilledMinutes / maxMinutes) * innerHeight
};
});
const crmPath = buildChartPath(points.filter((point) => point.bucket.crmBilledMinutes > 0).map((point) => ({ x: point.x, y: point.crmY })));
const crmPath = showTeamspaceLine ? buildChartPath(points.filter((point) => point.bucket.crmBilledMinutes > 0).map((point) => ({ x: point.x, y: point.crmY }))) : "";
return (
<div className="h-56 w-full overflow-hidden rounded-md border bg-background">
{series.some((bucket) => bucket.totalMinutes > 0 || bucket.crmBilledMinutes > 0) ? (
{series.some((bucket) => bucket.totalMinutes > 0 || (showTeamspaceLine && bucket.crmBilledMinutes > 0)) ? (
<svg viewBox={`0 0 ${width} ${height}`} className="h-full w-full">
{ticks.map((tick) => {
const y = padding.top + (1 - tick / maxMinutes) * innerHeight;
@@ -255,11 +263,11 @@ function DailyEffortChart({ month, buckets }: { month: string; buckets: Statisti
) : null;
})}
{crmPath ? <path d={crmPath} fill="none" stroke="currentColor" strokeWidth="2.5" className={teamspaceStrokeClass} /> : null}
{points
.filter((point) => point.bucket.crmBilledMinutes > 0)
.map((point) => (
<circle key={`${point.bucket.day}-crm`} cx={point.x} cy={point.crmY} r="3" className={`fill-background ${teamspaceStrokeClass}`} stroke="currentColor" strokeWidth="2" />
))}
{showTeamspaceLine ? points
.filter((point) => point.bucket.crmBilledMinutes > 0)
.map((point) => (
<circle key={`${point.bucket.day}-crm`} cx={point.x} cy={point.crmY} r="3" className={`fill-background ${teamspaceStrokeClass}`} stroke="currentColor" strokeWidth="2" />
)) : null}
{points.map((point, index) =>
index % labelStep === 0 || index === points.length - 1 ? (
<text key={`${point.bucket.day}-label`} x={point.x} y={height - 11} textAnchor="middle" className="fill-muted-foreground text-[11px]">
@@ -309,7 +317,17 @@ function MetricCard({
);
}
function OrganizationRow({ organization, maxMinutes, totalMinutes }: { organization: StatisticsGroup; maxMinutes: number; totalMinutes: number }) {
function OrganizationRow({
organization,
maxMinutes,
totalMinutes,
showDifference = true
}: {
organization: StatisticsGroup;
maxMinutes: number;
totalMinutes: number;
showDifference?: boolean;
}) {
const monthlyShare = percentage(organization.total_minutes, totalMinutes);
return (
@@ -324,9 +342,11 @@ function OrganizationRow({ organization, maxMinutes, totalMinutes }: { organizat
<TableCell>
<TimeComparison trackedMinutes={organization.total_minutes} crmMinutes={organization.crm_billed_minutes} />
</TableCell>
<TableCell>
<DeltaBadge minutes={organization.crm_delta_minutes} />
</TableCell>
{showDifference ? (
<TableCell>
<DeltaBadge minutes={organization.crm_delta_minutes} />
</TableCell>
) : null}
<TableCell className="min-w-[150px]">
<Progress value={percentage(organization.total_minutes, maxMinutes)} />
<div className="mt-1 text-xs text-muted-foreground">{monthlyShare}% vom Monat</div>
@@ -335,7 +355,17 @@ function OrganizationRow({ organization, maxMinutes, totalMinutes }: { organizat
);
}
function TicketHotspot({ ticket, month, onNavigate }: { ticket: StatisticsTicket; month: string; onNavigate: (to: string) => void }) {
function TicketHotspot({
ticket,
month,
showDifference = true,
onNavigate
}: {
ticket: StatisticsTicket;
month: string;
showDifference?: boolean;
onNavigate: (to: string) => void;
}) {
return (
<div className="grid gap-2 rounded-md border bg-background p-3 sm:grid-cols-[1fr_auto] sm:items-center">
<div className="min-w-0">
@@ -350,7 +380,7 @@ function TicketHotspot({ ticket, month, onNavigate }: { ticket: StatisticsTicket
<span className="text-muted-foreground">{ticket.billed_sessions} abgerechnet</span>
<span className="text-muted-foreground">{ticket.billable_sessions} abrechenbar</span>
<span>{ticket.active_days} Tag(e)</span>
<span className={deltaTextClass(ticket.crm_delta_minutes)}>{formatTeamspaceDelta(ticket.crm_delta_minutes)}</span>
{showDifference ? <span className={deltaTextClass(ticket.crm_delta_minutes)}>{formatTeamspaceDelta(ticket.crm_delta_minutes)}</span> : null}
</div>
</div>
<Button variant="secondary" size="sm" onClick={() => onNavigate(`/tickets/${ticket.ticket_id}?month=${month}`)}>
@@ -361,12 +391,72 @@ function TicketHotspot({ ticket, month, onNavigate }: { ticket: StatisticsTicket
);
}
export function StatisticsPage({ onNavigate }: StatisticsPageProps) {
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({ currentUser, onNavigate }: StatisticsPageProps) {
const [month, setMonth] = useState(currentMonth());
const [stats, setStats] = useState<StatisticsOverview | null>(null);
const [followups, setFollowups] = useState<StatisticsProjectFollowupOverview | null>(null);
const [loading, setLoading] = useState(false);
const [loadingFollowups, setLoadingFollowups] = useState(false);
const [organizationPage, setOrganizationPage] = useState(1);
const loadRequestId = useRef(0);
const followupRequestId = useRef(0);
async function load() {
const requestId = loadRequestId.current + 1;
@@ -396,6 +486,44 @@ export function StatisticsPage({ onNavigate }: StatisticsPageProps) {
void load();
}, [month]);
async function loadFollowups() {
const requestId = followupRequestId.current + 1;
followupRequestId.current = requestId;
setLoadingFollowups(true);
try {
const result = await getProjectFollowups();
if (requestId === followupRequestId.current) {
setFollowups(result);
if (result.createdTodos > 0 || result.updatedTodos > 0) {
window.dispatchEvent(new Event("tickettracker:todos-changed"));
}
if (result.createdTodos > 0) {
toast.success("Wiedervorlagen erstellt", {
description: `${result.createdTodos} ToDo(s) automatisch angelegt.`
});
}
}
} catch (error) {
if (requestId === followupRequestId.current) {
toast.error("Wiedervorlagen konnten nicht geladen werden", {
description: error instanceof Error ? error.message : "Unbekannter Fehler"
});
}
} finally {
if (requestId === followupRequestId.current) {
setLoadingFollowups(false);
}
}
}
useEffect(() => {
void loadFollowups();
}, []);
useEffect(() => {
function refreshVisible() {
if (document.visibilityState === "visible") {
@@ -422,6 +550,7 @@ export function StatisticsPage({ onNavigate }: StatisticsPageProps) {
}
const totals = stats?.totals;
const teamspaceTimeTrackingEnabled = currentUser.teamspace_time_tracking_enabled ?? true;
const billedSessions = totals?.billedSessions ?? 0;
const billedMinutes = totals?.billedMinutes ?? 0;
const billableSessions = totals?.billableSessions ?? 0;
@@ -430,7 +559,7 @@ export function StatisticsPage({ onNavigate }: StatisticsPageProps) {
const nonBillableMinutes = totals?.nonBillableMinutes ?? 0;
const openSessions = totals?.openSessions ?? 0;
const openMinutes = totals?.openMinutes ?? 0;
const evaluatedSessions = billedSessions + billableSessions + nonBillableSessions;
const evaluatedSessions = billedSessions + nonBillableSessions;
const crmCoverage = percentage(totals?.crmBilledMinutes ?? 0, totals?.minutes ?? 0);
const maxOrganizationMinutes = Math.max(...(stats?.organizations ?? []).map((organization) => organization.total_minutes), 1);
const topOrganization = stats?.organizations[0] ?? null;
@@ -457,7 +586,11 @@ export function StatisticsPage({ onNavigate }: StatisticsPageProps) {
<div className="flex flex-col gap-3 xl:flex-row xl:items-end xl:justify-between">
<div>
<h2 className="text-2xl font-semibold tracking-normal">Statistiken</h2>
<p className="text-sm text-muted-foreground">Monatswerte, Teamspace-Abgleich, Organisationen und offene Nacharbeit.</p>
<p className="text-sm text-muted-foreground">
{teamspaceTimeTrackingEnabled
? "Monatswerte, Teamspace-Abgleich, Organisationen und offene Nacharbeit."
: "Monatswerte, Session-Status, Organisationen und Teamspace-Istwerte."}
</p>
</div>
<div className="grid gap-1 sm:w-[300px]">
<div className="flex items-center justify-between gap-2">
@@ -476,6 +609,15 @@ export function StatisticsPage({ onNavigate }: StatisticsPageProps) {
</div>
</div>
<Tabs defaultValue="overview" className="space-y-5">
<TabsList className="grid h-auto w-full grid-cols-2 gap-1 sm:grid-cols-4">
<TabsTrigger value="overview">Überblick</TabsTrigger>
<TabsTrigger value="groups">Kunden & Typen</TabsTrigger>
<TabsTrigger value="tickets">Tickets</TabsTrigger>
<TabsTrigger value="followups">Wiedervorlagen</TabsTrigger>
</TabsList>
<TabsContent value="overview" className="space-y-5">
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
<MetricCard
label="Abgerechnet"
@@ -497,7 +639,9 @@ export function StatisticsPage({ onNavigate }: StatisticsPageProps) {
<MetricCard
label="Teamspace"
value={formatMinutes(totals?.crmBilledMinutes ?? 0)}
detail={<span className={deltaTextClass(totals?.crmDeltaMinutes ?? 0)}>Differenz {formatTeamspaceDelta(totals?.crmDeltaMinutes ?? 0)}</span>}
detail={teamspaceTimeTrackingEnabled
? <span className={deltaTextClass(totals?.crmDeltaMinutes ?? 0)}>Differenz {formatTeamspaceDelta(totals?.crmDeltaMinutes ?? 0)}</span>
: "Ist-Werte aus Teamspace"}
icon={TrendingUp}
valueClassName={teamspaceTextClass}
helpAnchor="statistik-teamspace"
@@ -523,17 +667,21 @@ export function StatisticsPage({ onNavigate }: StatisticsPageProps) {
<CardHeader className="gap-3 p-4 sm:flex-row sm:items-start sm:justify-between sm:space-y-0">
<div>
<CardTitle>Monatsverlauf und Abrechnungsqualität</CardTitle>
<CardDescription>Abgerechnete Zeit pro Tag, Teamspace-Linie und Bewertungsstand in einem Überblick.</CardDescription>
<CardDescription>
{teamspaceTimeTrackingEnabled
? "Abgerechnete Zeit pro Tag, Teamspace-Linie und Bewertungsstand in einem Überblick."
: "Abgerechnete Zeit pro Tag und Bewertungsstand in einem Überblick."}
</CardDescription>
</div>
<div className="flex items-center gap-2">
<Badge variant="outline">{crmCoverage}% Teamspace-Abdeckung</Badge>
{teamspaceTimeTrackingEnabled ? <Badge variant="outline">{crmCoverage}% Teamspace-Abdeckung</Badge> : <Badge variant="outline">Teamspace als Ist-Quelle</Badge>}
<HelpLink anchor="statistik-verlauf" label="Hilfe zu Monatsverlauf und Abrechnungsqualität" />
</div>
</CardHeader>
<CardContent className="grid gap-4 px-4 pb-4 xl:grid-cols-[1.55fr_1fr]">
<div className="space-y-3">
<DailyEffortChart month={month} buckets={stats?.dailySeries ?? []} />
<ColorLegend />
<DailyEffortChart month={month} buckets={stats?.dailySeries ?? []} showTeamspaceLine={teamspaceTimeTrackingEnabled} />
<ColorLegend showDifference={teamspaceTimeTrackingEnabled} />
{strongestDay ? <p className="text-xs text-muted-foreground">Stärkster abgerechneter Tag: {formatDate(`${strongestDay.day}T00:00:00`)} mit {formatMinutes(strongestDay.total_minutes)}</p> : null}
</div>
@@ -551,17 +699,27 @@ export function StatisticsPage({ onNavigate }: StatisticsPageProps) {
</div>
</div>
<div className="space-y-2 rounded-md border bg-muted/20 p-3">
<div className="flex items-start justify-between gap-3">
<div>
<p className="text-sm font-medium">Teamspace-Abgleich</p>
<p className="text-xs text-muted-foreground">{deltaDescription(totals?.crmDeltaMinutes ?? 0)}</p>
</div>
<DeltaBadge minutes={totals?.crmDeltaMinutes ?? 0} />
</div>
<TimeComparison trackedMinutes={totals?.minutes ?? 0} crmMinutes={totals?.crmBilledMinutes ?? 0} sessions={totals?.billedSessions ?? 0} />
<div className="text-xs text-muted-foreground">Teamspace-Abdeckung: {crmCoverage}% der abgerechneten Zeit</div>
</div>
{teamspaceTimeTrackingEnabled ? (
<div className="space-y-2 rounded-md border bg-muted/20 p-3">
<div className="flex items-start justify-between gap-3">
<div>
<p className="text-sm font-medium">Teamspace-Abgleich</p>
<p className="text-xs text-muted-foreground">{deltaDescription(totals?.crmDeltaMinutes ?? 0)}</p>
</div>
<DeltaBadge minutes={totals?.crmDeltaMinutes ?? 0} />
</div>
<TimeComparison trackedMinutes={totals?.minutes ?? 0} crmMinutes={totals?.crmBilledMinutes ?? 0} sessions={totals?.billedSessions ?? 0} />
<div className="text-xs text-muted-foreground">Teamspace-Abdeckung: {crmCoverage}% der abgerechneten Zeit</div>
</div>
) : (
<div className="space-y-2 rounded-md border bg-muted/20 p-3">
<p className="text-sm font-medium">Teamspace als Ist-Quelle</p>
<p className="text-xs text-muted-foreground">
Für diesen Nutzer werden keine Teamspace-Zeiten in TT erfasst. Die Monatsprüfung bewertet nur den Session-Status.
</p>
<TimeComparison trackedMinutes={totals?.minutes ?? 0} crmMinutes={totals?.crmBilledMinutes ?? 0} sessions={totals?.billedSessions ?? 0} />
</div>
)}
<div className="grid grid-cols-2 gap-2 text-sm">
<div className="rounded-md border bg-muted/20 p-3">
@@ -597,12 +755,19 @@ export function StatisticsPage({ onNavigate }: StatisticsPageProps) {
</CardContent>
</Card>
</TabsContent>
<TabsContent value="groups" className="space-y-5">
<div className="grid gap-3 xl:grid-cols-[1.45fr_0.8fr]">
<Card>
<CardHeader className="gap-3 p-4 sm:flex-row sm:items-start sm:justify-between sm:space-y-0">
<div>
<CardTitle>Statistik pro Organisation</CardTitle>
<CardDescription>Kundenaufwand inklusive Teamspace-Differenz und Anteil an der abgerechneten Monatszeit.</CardDescription>
<CardDescription>
{teamspaceTimeTrackingEnabled
? "Kundenaufwand inklusive Teamspace-Differenz und Anteil an der abgerechneten Monatszeit."
: "Kundenaufwand und Anteil an der abgerechneten Monatszeit."}
</CardDescription>
</div>
<div className="flex items-center gap-2">
<Badge variant="outline">
@@ -619,7 +784,7 @@ export function StatisticsPage({ onNavigate }: StatisticsPageProps) {
<TableHead>Organisation</TableHead>
<TableHead>Abgerechnet</TableHead>
<TableHead>Zeiten</TableHead>
<TableHead>Differenz</TableHead>
{teamspaceTimeTrackingEnabled ? <TableHead>Differenz</TableHead> : null}
<TableHead>Anteil abgerechnet</TableHead>
</TableRow>
</TableHeader>
@@ -627,10 +792,11 @@ export function StatisticsPage({ onNavigate }: StatisticsPageProps) {
{visibleOrganizations.map((organization) => (
<OrganizationRow
key={organization.organization_id ?? organization.organization_name}
organization={organization}
maxMinutes={maxOrganizationMinutes}
totalMinutes={totals?.minutes ?? 0}
/>
organization={organization}
maxMinutes={maxOrganizationMinutes}
totalMinutes={totals?.minutes ?? 0}
showDifference={teamspaceTimeTrackingEnabled}
/>
))}
</TableBody>
</Table>
@@ -643,14 +809,14 @@ export function StatisticsPage({ onNavigate }: StatisticsPageProps) {
<p className="truncate font-medium">{organization.organization_name ?? "Keine Organisation"}</p>
<p className="text-xs text-muted-foreground">{organization.tickets} Ticket(s) · {organization.billed_sessions} abgerechnet · {organization.billable_sessions} abrechenbar</p>
</div>
<DeltaBadge minutes={organization.crm_delta_minutes} />
{teamspaceTimeTrackingEnabled ? <DeltaBadge minutes={organization.crm_delta_minutes} /> : null}
</div>
<Progress value={percentage(organization.total_minutes, maxOrganizationMinutes)} />
<div className="flex flex-wrap gap-x-3 gap-y-1 text-xs">
<span className={trackedTextClass}>Abgerechnet {formatMinutes(organization.total_minutes)}</span>
<span className={teamspaceTextClass}>Teamspace {formatMinutes(organization.crm_billed_minutes)}</span>
<span className="text-muted-foreground">{percentage(organization.total_minutes, totals?.minutes ?? 0)}% vom Monat</span>
<span className={deltaTextClass(organization.crm_delta_minutes)}>{formatTeamspaceDelta(organization.crm_delta_minutes)}</span>
{teamspaceTimeTrackingEnabled ? <span className={deltaTextClass(organization.crm_delta_minutes)}>{formatTeamspaceDelta(organization.crm_delta_minutes)}</span> : null}
</div>
</div>
))}
@@ -712,7 +878,7 @@ export function StatisticsPage({ onNavigate }: StatisticsPageProps) {
<p className="font-semibold">{workTypeLabel(group.work_type)}</p>
<p className="text-xs text-muted-foreground">{group.tickets} Ticket(s), {group.billed_sessions} abgerechnet, {group.billable_sessions} abrechenbar</p>
</div>
<DeltaBadge minutes={group.crm_delta_minutes} />
{teamspaceTimeTrackingEnabled ? <DeltaBadge minutes={group.crm_delta_minutes} /> : null}
</div>
<Progress value={percentage(group.total_minutes, totals?.minutes ?? 0)} />
<div className="flex flex-wrap gap-x-3 gap-y-1 text-xs">
@@ -731,6 +897,9 @@ export function StatisticsPage({ onNavigate }: StatisticsPageProps) {
</Card>
</div>
</TabsContent>
<TabsContent value="tickets" className="space-y-5">
<Card>
<CardHeader className="gap-3 p-4 sm:flex-row sm:items-start sm:justify-between sm:space-y-0">
<div>
@@ -747,7 +916,7 @@ export function StatisticsPage({ onNavigate }: StatisticsPageProps) {
</CardHeader>
<CardContent className="space-y-2 px-4 pb-4">
{(stats?.tickets ?? []).map((ticket) => (
<TicketHotspot key={ticket.ticket_id} ticket={ticket} month={month} onNavigate={onNavigate} />
<TicketHotspot key={ticket.ticket_id} ticket={ticket} month={month} showDifference={teamspaceTimeTrackingEnabled} onNavigate={onNavigate} />
))}
{stats && stats.tickets.length === 0 ? <p className="py-8 text-center text-sm text-muted-foreground">Keine Tickets im gewählten Monat.</p> : null}
</CardContent>
@@ -768,6 +937,48 @@ export function StatisticsPage({ onNavigate }: StatisticsPageProps) {
</CardContent>
</Card>
) : null}
</TabsContent>
<TabsContent value="followups" className="space-y-5">
<Card>
<CardHeader className="gap-3 p-4 sm:flex-row sm:items-start sm:justify-between sm:space-y-0">
<div>
<CardTitle>Automatische Wiedervorlagen</CardTitle>
<CardDescription>
{followups?.enabled === false
? "Automatische Wiedervorlagen sind im Profil deaktiviert."
: followups?.secondEnabled === false
? `Erste Wiedervorlage nach ${followups?.weeks ?? "-"} Woche(n).`
: `Erste Wiedervorlage nach ${followups?.weeks ?? "-"} Woche(n), zweite nach weiteren ${followups?.secondWeeks ?? "-"} Woche(n) ohne Session.`}
</CardDescription>
</div>
<div className="flex items-center gap-2">
<Badge variant="outline">{followups?.projects.length ?? 0} Projekt(e)</Badge>
{loadingFollowups ? <Badge variant="secondary">lädt...</Badge> : null}
<Button type="button" size="sm" variant="secondary" onClick={() => void loadFollowups()} disabled={loadingFollowups}>
<BellPlus className="size-4" />
Prüfen
</Button>
</div>
</CardHeader>
<CardContent className="space-y-3 px-4 pb-4">
{(followups?.projects ?? []).map((project) => (
<ProjectFollowupRow key={project.project_id} project={project} onNavigate={onNavigate} />
))}
{!loadingFollowups && followups && followups.projects.length === 0 ? (
<div className="grid place-items-center gap-2 rounded-md border bg-muted/20 p-8 text-center">
<ListChecks className="size-8 text-muted-foreground" />
<div>
<p className="font-medium">Keine Wiedervorlagen nötig</p>
<p className="text-sm text-muted-foreground">Alle offenen Projektbudgets wurden innerhalb des eingestellten Zeitraums aktualisiert.</p>
</div>
</div>
) : null}
{loadingFollowups && !followups ? <p className="py-8 text-center text-sm text-muted-foreground">Wiedervorlagen werden geprüft...</p> : null}
</CardContent>
</Card>
</TabsContent>
</Tabs>
</div>
);
}
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+57 -34
View File
@@ -21,6 +21,7 @@ type TodosPageProps = {
type TodoSortMode = "custom" | "priority" | "title" | "ticket" | "organization" | "date" | "updated";
type TodoGroupMode = "ticket" | "priority" | "organization" | "status";
type CalendarDayMode = "current";
type TodoPreferences = {
status: TodoStatus | "all";
@@ -277,6 +278,23 @@ function sortTodos(todos: TicketTodo[], sortMode: TodoSortMode) {
return [...todos].sort((left, right) => compareTodos(left, right, sortMode));
}
function uniqueTodos(todos: TicketTodo[]) {
return todos.filter((todo, index, list) => list.findIndex((item) => item.id === todo.id) === index);
}
function sortCurrentTodos(todos: TicketTodo[], today: string) {
return [...todos].sort((left, right) => {
const leftOverdue = isTodoOverdue(left, today);
const rightOverdue = isTodoOverdue(right, today);
if (leftOverdue !== rightOverdue) {
return leftOverdue ? -1 : 1;
}
return priorityOrder[left.priority] - priorityOrder[right.priority] || left.sort_order - right.sort_order || compareText(left.title, right.title);
});
}
function matchesTicketSearch(todo: TicketTodo, search: string) {
const normalizedSearch = normalizeSearch(search);
@@ -400,9 +418,9 @@ export function TodosPage({ onNavigate }: TodosPageProps) {
const [modalEndsOn, setModalEndsOn] = useState("");
const [modalBusy, setModalBusy] = useState(false);
const [selectedCalendarDay, setSelectedCalendarDay] = useState<string | null>(null);
const [selectedCalendarDayMode, setSelectedCalendarDayMode] = useState<"summarized" | "overdue">("summarized");
const [selectedCalendarDayMode, setSelectedCalendarDayMode] = useState<CalendarDayMode>("current");
const [returnCalendarDay, setReturnCalendarDay] = useState<string | null>(null);
const [returnCalendarDayMode, setReturnCalendarDayMode] = useState<"summarized" | "overdue">("summarized");
const [returnCalendarDayMode, setReturnCalendarDayMode] = useState<CalendarDayMode>("current");
async function load() {
setLoading(true);
@@ -422,6 +440,13 @@ export function TodosPage({ onNavigate }: TodosPageProps) {
void load();
}, []);
useEffect(() => {
const onTodosChanged = () => void load();
window.addEventListener("tickettracker:todos-changed", onTodosChanged);
return () => window.removeEventListener("tickettracker:todos-changed", onTodosChanged);
}, []);
useEffect(() => {
window.localStorage.setItem(todoPreferencesKey, JSON.stringify(preferences));
}, [preferences]);
@@ -432,7 +457,7 @@ export function TodosPage({ onNavigate }: TodosPageProps) {
if (selectedTodoId && !selectedTodo) {
setSelectedTodoId(null);
setReturnCalendarDay(null);
setReturnCalendarDayMode("summarized");
setReturnCalendarDayMode("current");
}
}, [selectedTodo, selectedTodoId]);
@@ -473,7 +498,19 @@ export function TodosPage({ onNavigate }: TodosPageProps) {
return entries;
}, [calendarDays, todos]);
const selectedCalendarDayTodos = selectedCalendarDay ? calendarTodosByDay.get(selectedCalendarDay)?.[selectedCalendarDayMode] ?? [] : [];
const selectedCalendarDayTodos = useMemo(() => {
if (!selectedCalendarDay) {
return [];
}
const dayTodos = calendarTodosByDay.get(selectedCalendarDay);
if (!dayTodos) {
return [];
}
return sortCurrentTodos(uniqueTodos([...dayTodos.overdue, ...dayTodos.single, ...dayTodos.summarized]), currentDay());
}, [calendarTodosByDay, selectedCalendarDay]);
const counts = useMemo(
() => ({
open: todos.filter((todo) => todo.status === "open").length,
@@ -531,7 +568,7 @@ export function TodosPage({ onNavigate }: TodosPageProps) {
});
}
function openTodoModal(todo: TicketTodo, returnToDay: string | null = null, returnToDayMode: "summarized" | "overdue" = "summarized") {
function openTodoModal(todo: TicketTodo, returnToDay: string | null = null, returnToDayMode: CalendarDayMode = "current") {
setReturnCalendarDay(returnToDay);
setReturnCalendarDayMode(returnToDayMode);
setSelectedTodoId(todo.id);
@@ -545,7 +582,7 @@ export function TodosPage({ onNavigate }: TodosPageProps) {
function closeTodoModal() {
setSelectedTodoId(null);
setReturnCalendarDay(null);
setReturnCalendarDayMode("summarized");
setReturnCalendarDayMode("current");
}
function backToCalendarDay() {
@@ -557,7 +594,7 @@ export function TodosPage({ onNavigate }: TodosPageProps) {
const mode = returnCalendarDayMode;
setSelectedTodoId(null);
setReturnCalendarDay(null);
setReturnCalendarDayMode("summarized");
setReturnCalendarDayMode("current");
setSelectedCalendarDayMode(mode);
setSelectedCalendarDay(day);
}
@@ -904,6 +941,9 @@ export function TodosPage({ onNavigate }: TodosPageProps) {
const summarizedTodos = dayTodos.summarized;
const overdueTodos = dayTodos.overdue;
const totalTodos = singleTodos.length + summarizedTodos.length + overdueTodos.length;
const currentTodos = sortCurrentTodos(uniqueTodos([...overdueTodos, ...singleTodos, ...summarizedTodos]), currentDay());
const visibleCalendarTodos = currentTodos.slice(0, 2);
const additionalTodosCount = Math.max(0, currentTodos.length - visibleCalendarTodos.length);
return (
<div
@@ -918,7 +958,7 @@ export function TodosPage({ onNavigate }: TodosPageProps) {
</div>
{!day.workingDay ? <div className="text-[10px] leading-tight text-muted-foreground">{day.holidayName ?? "Wochenende"}</div> : null}
<div className="space-y-1">
{singleTodos.slice(0, 3).map((todo) => (
{visibleCalendarTodos.map((todo) => (
<button
key={todo.id}
type="button"
@@ -929,31 +969,18 @@ export function TodosPage({ onNavigate }: TodosPageProps) {
<span className="block truncate text-muted-foreground">{todo.ticket_number ?? "Ticket"}</span>
</button>
))}
{singleTodos.length > 3 ? <span className="block px-1 text-[11px] text-muted-foreground">+{singleTodos.length - 3} weitere fixe</span> : null}
</div>
<div className="mt-auto flex flex-wrap gap-1 pt-1">
{summarizedTodos.length > 0 ? (
<div className="mt-auto pt-1">
{additionalTodosCount > 0 ? (
<button
type="button"
className="min-w-0 flex-1 rounded-md border border-muted-foreground/20 bg-background/60 px-2 py-1 text-left text-[11px] leading-tight text-muted-foreground hover:bg-muted"
className="block w-full rounded-md border border-muted-foreground/20 bg-background/60 px-2 py-1 text-left text-[11px] leading-tight text-muted-foreground hover:bg-muted"
onClick={() => {
setSelectedCalendarDayMode("summarized");
setSelectedCalendarDayMode("current");
setSelectedCalendarDay(day.iso);
}}
>
+{summarizedTodos.length} ToDo{summarizedTodos.length === 1 ? "" : "s"}
</button>
) : null}
{overdueTodos.length > 0 ? (
<button
type="button"
className="shrink-0 rounded-md border border-red-500/50 bg-red-500/10 px-2 py-1 text-left text-[11px] font-semibold leading-tight text-red-200 hover:bg-red-500/20"
onClick={() => {
setSelectedCalendarDayMode("overdue");
setSelectedCalendarDay(day.iso);
}}
>
+{overdueTodos.length} üf
+{additionalTodosCount} weitere ToDo{additionalTodosCount === 1 ? "" : "s"}
</button>
) : null}
</div>
@@ -1073,13 +1100,11 @@ export function TodosPage({ onNavigate }: TodosPageProps) {
<Dialog open={Boolean(selectedCalendarDay)} onOpenChange={(open) => !open && setSelectedCalendarDay(null)}>
<DialogContent className="max-h-[calc(100dvh-2rem)] overflow-y-auto sm:max-w-xl">
<DialogHeader>
<DialogTitle>{selectedCalendarDayMode === "overdue" ? "Überfällige ToDos" : "ToDos am Tag"}</DialogTitle>
<DialogTitle>Aktuelle ToDos</DialogTitle>
<DialogDescription>
{selectedCalendarDay
? `${formatTodoDate(selectedCalendarDay)} · ${selectedCalendarDayTodos.length} ${selectedCalendarDayMode === "overdue" ? "überfällige" : "laufende"} ToDo(s)`
: selectedCalendarDayMode === "overdue"
? "Überfällige ToDos dieses Tages"
: "Laufende ToDos dieses Tages"}
? `${formatTodoDate(selectedCalendarDay)} · ${selectedCalendarDayTodos.length} aktuelle ToDo(s)`
: "Aktuelle ToDos dieses Tages"}
</DialogDescription>
</DialogHeader>
<div className="space-y-2">
@@ -1142,9 +1167,7 @@ export function TodosPage({ onNavigate }: TodosPageProps) {
</div>
))}
{selectedCalendarDayTodos.length === 0 ? (
<p className="rounded-md border bg-muted/20 p-4 text-center text-sm text-muted-foreground">
{selectedCalendarDayMode === "overdue" ? "Keine überfälligen ToDos an diesem Tag." : "Keine laufenden ToDos an diesem Tag."}
</p>
<p className="rounded-md border bg-muted/20 p-4 text-center text-sm text-muted-foreground">Keine aktuellen ToDos an diesem Tag.</p>
) : null}
</div>
</DialogContent>
+603 -66
View File
@@ -1,5 +1,5 @@
import { useEffect, useMemo, useState, useSyncExternalStore } from "react";
import { AlertCircle, CheckCircle2, DatabaseZap, DownloadCloud, RefreshCw } from "lucide-react";
import { AlertCircle, CheckCircle2, DatabaseZap, DownloadCloud, MailPlus, RefreshCw } from "lucide-react";
import { toast } from "sonner";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
@@ -7,14 +7,19 @@ import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Checkbox } from "@/components/ui/checkbox";
import { Input } from "@/components/ui/input";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { cn } from "@/lib/utils";
import {
applyExistingZammadTicketsFromPreview,
applyOwnZammadConsultingTicketsFromPreview,
applyZammadMailSessionsFromPreview,
previewExistingZammadTickets,
previewOwnZammadConsultingTickets,
previewZammadMailSessions,
syncTeamspaceProjectIds,
waitForZammadSyncJob,
type TeamspaceProjectIdSyncResult,
type ZammadSyncExistingFields,
type ZammadSyncChange,
type ZammadSyncJob,
@@ -37,7 +42,7 @@ const defaultFields: ZammadSyncExistingFields = {
organization: true,
type: true,
projectName: true,
budget: true,
budget: false,
overrideBudget: false,
pendingTodo: false
};
@@ -47,7 +52,6 @@ const fieldOptions: Array<{ key: Exclude<keyof ZammadSyncExistingFields, "overri
{ key: "organization", label: "Organisation", description: "Lokale Organisation anhand der Zammad-ID setzen." },
{ key: "type", label: "Typ", description: "Support/Consulting übernehmen, Projekte anlegen oder entfernen." },
{ key: "projectName", label: "Projektbezeichnung", description: "Teamspace-Projekt bei Consulting überschreiben." },
{ key: "budget", label: "Budget", description: "Teamspace-Stundenkontingent überschreiben." },
{ key: "pendingTodo", label: "Termin-ToDo", description: "Aus Wartend/Abschluss mit Datum ein ToDo erzeugen." }
];
@@ -61,10 +65,18 @@ type ZammadSyncPageState = {
importPreviewJobId: string | null;
importRunningJobId: string | null;
importRunningJobKind: RunningJobKind | null;
mailResult: ZammadSyncResult | null;
mailPreviewJobId: string | null;
mailRunningJobId: string | null;
mailRunningJobKind: RunningJobKind | null;
mailDaysBack: number;
mailPerPage: number;
existingSelection: SelectionMap;
importSelection: SelectionMap;
mailSelection: SelectionMap;
syncingExisting: boolean;
importingConsulting: boolean;
syncingMail: boolean;
};
function emptyZammadSyncPageState(): ZammadSyncPageState {
@@ -78,10 +90,18 @@ function emptyZammadSyncPageState(): ZammadSyncPageState {
importPreviewJobId: null,
importRunningJobId: null,
importRunningJobKind: null,
mailResult: null,
mailPreviewJobId: null,
mailRunningJobId: null,
mailRunningJobKind: null,
mailDaysBack: 30,
mailPerPage: 300,
existingSelection: {},
importSelection: {},
mailSelection: {},
syncingExisting: false,
importingConsulting: false
importingConsulting: false,
syncingMail: false
};
}
@@ -162,6 +182,40 @@ function runningJobKind(value: unknown): RunningJobKind | null {
return value === "preview" || value === "apply" ? value : null;
}
function parseMailDaysBack(value: unknown) {
const parsed = Number(value);
return Number.isInteger(parsed) && parsed >= 1 && parsed <= 3650 ? parsed : 30;
}
function berlinDateParts(date: Date) {
const parts = new Intl.DateTimeFormat("en-CA", {
timeZone: "Europe/Berlin",
year: "numeric",
month: "2-digit",
day: "2-digit"
}).formatToParts(date);
const value = (type: string) => Number(parts.find((part) => part.type === type)?.value);
return {
year: value("year"),
month: value("month"),
day: value("day")
};
}
function mailSyncCutoffDate(daysBack: number) {
const current = berlinDateParts(new Date());
const date = new Date(Date.UTC(current.year, current.month - 1, current.day));
date.setUTCDate(date.getUTCDate() - Math.max(0, daysBack - 1));
return date.toISOString().slice(0, 10);
}
function parseMailPerPage(value: unknown) {
const parsed = Number(value);
return Number.isInteger(parsed) && parsed >= 1 && parsed <= 1000 ? parsed : 300;
}
function loadZammadSyncPageState(userId: string): ZammadSyncPageState | null {
const raw = getLocalStorageValue(zammadSyncStorageKey(userId));
@@ -180,6 +234,8 @@ function loadZammadSyncPageState(userId: string): ZammadSyncPageState | null {
const existingRunningJobKind = runningJobKind(parsed.existingRunningJobKind);
const importRunningJobId = nullableString(parsed.importRunningJobId);
const importRunningJobKind = runningJobKind(parsed.importRunningJobKind);
const mailRunningJobId = nullableString(parsed.mailRunningJobId);
const mailRunningJobKind = runningJobKind(parsed.mailRunningJobKind);
return {
fields: syncFields(parsed.fields),
@@ -191,10 +247,18 @@ function loadZammadSyncPageState(userId: string): ZammadSyncPageState | null {
importPreviewJobId: nullableString(parsed.importPreviewJobId),
importRunningJobId,
importRunningJobKind,
mailResult: syncResult(parsed.mailResult),
mailPreviewJobId: nullableString(parsed.mailPreviewJobId),
mailRunningJobId,
mailRunningJobKind,
mailDaysBack: parseMailDaysBack(parsed.mailDaysBack),
mailPerPage: parseMailPerPage(parsed.mailPerPage),
existingSelection: selectionMap(parsed.existingSelection),
importSelection: selectionMap(parsed.importSelection),
mailSelection: selectionMap(parsed.mailSelection),
syncingExisting: Boolean(existingRunningJobId && existingRunningJobKind),
importingConsulting: Boolean(importRunningJobId && importRunningJobKind)
importingConsulting: Boolean(importRunningJobId && importRunningJobKind),
syncingMail: Boolean(mailRunningJobId && mailRunningJobKind)
};
} catch {
return null;
@@ -213,12 +277,17 @@ function selectableItems(result: ZammadSyncResult) {
return result.items.filter((item) => item.action === "update" || item.action === "create");
}
function selectionKey(item: ZammadSyncItem) {
const articleId = item.zammad?.articleId;
return typeof articleId === "number" || typeof articleId === "string" ? `article:${articleId}` : item.ticketNumber;
}
function itemIsSelected(item: ZammadSyncItem, selection: SelectionMap) {
return (item.action === "update" || item.action === "create") && selection[item.ticketNumber] !== false;
return (item.action === "update" || item.action === "create") && selection[selectionKey(item)] !== false;
}
function selectionFromResult(result: ZammadSyncResult): SelectionMap {
return Object.fromEntries(selectableItems(result).map((item) => [item.ticketNumber, true]));
return Object.fromEntries(selectableItems(result).map((item) => [selectionKey(item), true]));
}
function selectedTicketNumbers(result: ZammadSyncResult | null, selection: SelectionMap) {
@@ -231,6 +300,25 @@ function selectedTicketNumbers(result: ZammadSyncResult | null, selection: Selec
.map((item) => item.ticketNumber);
}
function selectedArticleIds(result: ZammadSyncResult | null, selection: SelectionMap) {
if (!result) {
return [];
}
return selectableItems(result)
.filter((item) => itemIsSelected(item, selection))
.map((item) => Number(item.zammad?.articleId))
.filter((articleId) => Number.isInteger(articleId) && articleId > 0);
}
function existingApplyDescription(result: ZammadSyncResult) {
return `${result.updated} aktualisiert, ${result.skipped} ohne Transfer.`;
}
function importApplyDescription(result: ZammadSyncResult) {
return `${result.created} angelegt, ${result.skipped} ohne Transfer.`;
}
function formatMinutes(value: unknown) {
if (typeof value !== "number") {
return formatValue(value);
@@ -243,6 +331,28 @@ function formatMinutes(value: unknown) {
return `${new Intl.NumberFormat("de-DE", { maximumFractionDigits: value < 600 ? 1 : 0 }).format(value / 60)} h`;
}
function formatDurationSeconds(value: unknown) {
const seconds = Number(value);
if (!Number.isFinite(seconds)) {
return formatValue(value);
}
const minutes = Math.round(seconds / 60);
return `${new Intl.NumberFormat("de-DE").format(minutes)} min`;
}
function formatTimeUnit(value: unknown) {
const hours = Number(value);
if (!Number.isFinite(hours)) {
return formatValue(value);
}
const minutes = Math.round(hours * 60);
return `${new Intl.NumberFormat("de-DE", { maximumFractionDigits: 2 }).format(hours)} h / ${new Intl.NumberFormat("de-DE").format(minutes)} min`;
}
function numericValue(value: unknown) {
const number = Number(value);
return Number.isInteger(number) ? number : null;
@@ -316,11 +426,11 @@ function formatValue(value: unknown): string {
}
function formatChangeValue(field: string, value: unknown) {
if (field === "Teamspace-Budget") {
return formatMinutes(value);
if (field === "Dauer") {
return formatDurationSeconds(value);
}
if (field === "Teamspace-Projekte" || field === "Teamspace-Prüfung") {
if (field === "Teamspace-Projekte" || field === "Teamspace-Prüfung" || field === "Merge-Projektdaten") {
return formatTeamspaceProjectList(value) || "-";
}
@@ -380,8 +490,12 @@ function changeAllowedByFields(item: ZammadSyncItem, change: ZammadSyncChange, f
return fields.projectName && consultingProjectCanBeTouched;
}
if (change.field === "Merge-Projektdaten" || change.field === "Merge-Prüfung") {
return true;
}
if (change.field === "Teamspace-Budget") {
return fields.budget && consultingProjectCanBeTouched;
return false;
}
if (change.field === "ToDo") {
@@ -465,6 +579,10 @@ function addCachedTeamspaceProjectChanges(changes: ZammadSyncChange[], localProj
}
}
function mergeReviewMatchesCachedZammad(localTeamspaceProjects: unknown, zammadTeamspaceProjects: unknown) {
return !cachedValueChanged("Teamspace-Projekte", localTeamspaceProjects, zammadTeamspaceProjects);
}
function zammadPendingTodoPreview(zammad: Record<string, unknown>) {
const stateId = numericValue(cachedValue(zammad, "stateId"));
const pendingTime = cachedValue(zammad, "pendingTime");
@@ -538,6 +656,33 @@ function addCachedChange(changes: ZammadSyncChange[], field: string, from: unkno
}
}
const preservedExistingPreviewChangeFields = new Set([
"Fremdes Ticket",
"Fremder Zammad-Owner",
"Owner",
"Ticket-Zuordnung",
"Hinweis",
"Ticketnummer",
"TicketTracker-Zusammenführung"
]);
const informationalExistingPreviewChangeFields = new Set([
"Fremdes Ticket",
"Fremder Zammad-Owner",
"Hinweis"
]);
function preserveExistingPreviewChanges(item: ZammadSyncItem, changes: ZammadSyncChange[], fields: ZammadSyncExistingFields) {
const existingFields = new Set(changes.map((change) => change.field));
const resultChanges = [
...item.changes.filter((change) => preservedExistingPreviewChangeFields.has(change.field) && !existingFields.has(change.field) && changeAllowedByFields(item, change, fields)),
...changes
];
return resultChanges.some((change) => !informationalExistingPreviewChangeFields.has(change.field)) ? resultChanges : [];
}
function changesFromExistingPreviewCache(item: ZammadSyncItem, fields: ZammadSyncExistingFields) {
if (!item.zammad) {
return item.changes.filter((change) => changeAllowedByFields(item, change, fields));
@@ -553,12 +698,16 @@ function changesFromExistingPreviewCache(item: ZammadSyncItem, fields: ZammadSyn
const localWorkType = cachedValue(local, "workType");
const zammadWorkType = cachedValue(zammad, "workType");
const zammadStateId = numericValue(cachedValue(zammad, "stateId"));
const mergeProtected = Boolean(
cachedValue(local, "zammadMergedFromTicketId")
|| cachedValue(zammad, "mergedFromTicketId")
|| (cachedValue(project, "closeReviewRequired") === true && zammadStateId !== 4)
);
const pendingTodoExists = cachedValue(local, "zammadPendingTodoExists");
const localTeamspaceProjects = cachedStringList(project, "teamspaceProjects");
const zammadTeamspaceProjects = cachedStringList(zammad, "teamspaceProjects");
const localTeamspaceProjectList = localTeamspaceProjects.length ? localTeamspaceProjects : [cachedValue(project, "teamspaceProject")].filter((entry): entry is string => typeof entry === "string");
const zammadTeamspaceProjectList = zammadTeamspaceProjects.length ? zammadTeamspaceProjects : splitTeamspaceProjects(cachedValue(zammad, "teamspaceProject"));
const canSyncBudget = fields.budget && (cachedValue(project, "defaultBudgetSyncDisabled") !== true || fields.overrideBudget);
const changes: ZammadSyncChange[] = [];
addCachedChange(changes, "Zammad-Priorität", cachedValue(local, "zammadPriorityId"), cachedValue(zammad, "priorityId"));
@@ -581,18 +730,18 @@ function changesFromExistingPreviewCache(item: ZammadSyncItem, fields: ZammadSyn
addCachedChange(changes, "Organisation", cachedValue(local, "organizationId"), cachedValue(zammad, "organizationId"));
}
if (fields.type) {
if (fields.type && !mergeProtected) {
addCachedChange(changes, "Typ", localWorkType, zammadWorkType);
}
if (fields.type && zammadWorkType === "support" && project) {
if (!mergeProtected && fields.type && zammadWorkType === "support" && project) {
changes.push({ field: "Projekt/Teamspace-Daten", from: "vorhanden", to: "werden gelöscht" });
}
const consultingProjectCanBeTouched = zammadWorkType === "consulting" && (fields.type || localWorkType === "consulting");
const consultingProjectCanBeTouched = !mergeProtected && zammadWorkType === "consulting" && (fields.type || localWorkType === "consulting");
if (consultingProjectCanBeTouched) {
if (fields.type && !project) {
if (fields.type && localWorkType !== "consulting" && !project) {
changes.push({ field: "Projekt", from: null, to: "wird angelegt" });
}
@@ -600,16 +749,33 @@ function changesFromExistingPreviewCache(item: ZammadSyncItem, fields: ZammadSyn
addCachedTeamspaceProjectChanges(changes, localTeamspaceProjectList, zammadTeamspaceProjectList);
}
if (canSyncBudget) {
addCachedChange(changes, "Teamspace-Budget", cachedValue(project, "defaultHourBudgetMinutes") ?? cachedValue(project, "hourBudgetMinutes"), cachedValue(zammad, "teamspaceBudgetMinutes"));
}
if (zammadStateId === 4 && cachedValue(project, "closedAt") === null && cachedValue(project, "closeReviewRequired") !== true) {
changes.push({ field: "TS/TT-Prüfung", from: cachedValue(project, "closeReviewRequired") ?? false, to: true });
}
}
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 {
@@ -725,6 +891,17 @@ function zammadDetails(item: ZammadSyncItem) {
return ["kein Treffer"];
}
if (zammad.articleId) {
return [
`Article ${formatValue(zammad.articleId)}`,
`Ticket-ID ${formatValue(zammad.ticketId)}`,
`Typ eMail`,
`Erstellt ${formatValue(zammad.createdAt)}`,
`time_unit ${formatTimeUnit(zammad.timeUnit)}`,
`Betreff ${formatValue(zammad.subject)}`
];
}
return [
`ID ${formatValue(zammad.ticketId)}`,
`Gruppe ${formatZammadGroup(zammad.groupId)}`,
@@ -735,8 +912,7 @@ function zammadDetails(item: ZammadSyncItem) {
`Owner ${formatValue(zammad.ownerId)}`,
`Org ${formatValue(zammad.organizationId)}`,
`Titel ${formatValue(zammad.title)}`,
`Projekt ${formatValue(formatTeamspaceProjectList(zammad.teamspaceProjects) || zammad.teamspaceProject)}`,
`Budget ${formatMinutes(zammad.teamspaceBudgetMinutes)}`
`Projekt ${formatValue(formatTeamspaceProjectList(zammad.teamspaceProjects) || zammad.teamspaceProject)}`
];
}
@@ -795,18 +971,25 @@ function ResultSummary({
) : null}
<div className="max-h-[520px] overflow-auto rounded-md border">
<Table>
<Table className="w-full table-fixed">
<colgroup>
{selection ? <col className="w-12" /> : null}
<col className="w-40" />
<col className="w-64" />
<col className="w-[34rem]" />
<col className="w-[36rem]" />
</colgroup>
<TableHeader>
<TableRow>
{selection ? (
<TableHead className="w-10">
<TableHead>
<Checkbox checked={allSelected} disabled={selectable.length === 0} onCheckedChange={(checked) => onToggleAll?.(checked === true)} />
</TableHead>
) : null}
<TableHead className="min-w-32">Ticket</TableHead>
<TableHead className="min-w-32">Aktion</TableHead>
<TableHead className="min-w-72">Daten aus Zammad</TableHead>
<TableHead className="min-w-80">Änderungen in TicketTracker</TableHead>
<TableHead>Ticket</TableHead>
<TableHead>Aktion</TableHead>
<TableHead>Daten aus Zammad</TableHead>
<TableHead>Änderungen in TicketTracker</TableHead>
</TableRow>
</TableHeader>
<TableBody>
@@ -823,27 +1006,27 @@ function ResultSummary({
const visibleChanges = visibleTicketTrackerChanges(item);
return (
<TableRow key={`${result.mode}-${item.ticketNumber}-${item.action}`}>
<TableRow key={`${result.mode}-${selectionKey(item)}-${item.action}`}>
{selection ? (
<TableCell>
<Checkbox
checked={itemIsSelected(item, selection)}
disabled={!isSelectable}
onCheckedChange={(checked) => onToggle?.(item.ticketNumber, checked === true)}
onCheckedChange={(checked) => onToggle?.(selectionKey(item), checked === true)}
/>
</TableCell>
) : null}
<TableCell className="font-medium">{item.ticketNumber}</TableCell>
<TableCell className="truncate font-medium" title={item.ticketNumber}>{item.ticketNumber}</TableCell>
<TableCell>
<div className="space-y-1">
<div className="min-w-0 space-y-1">
<Badge variant={action.variant}>{action.label}</Badge>
{item.reason ? <p className="text-xs text-muted-foreground">{item.reason}</p> : null}
{item.reason ? <p className="truncate text-xs text-muted-foreground" title={item.reason}>{item.reason}</p> : null}
</div>
</TableCell>
<TableCell>
<div className="max-w-xl space-y-1 text-xs text-muted-foreground">
<div className="min-w-0 space-y-1 text-xs text-muted-foreground">
{zammadDetails(item).map((detail) => (
<div key={detail} className="break-words">
<div key={detail} className="truncate" title={detail}>
{detail}
</div>
))}
@@ -851,15 +1034,21 @@ function ResultSummary({
</TableCell>
<TableCell>
{visibleChanges.length > 0 ? (
<div className="space-y-1 text-xs">
{visibleChanges.map((change) => (
<div key={`${item.ticketNumber}-${change.field}`} className="break-words">
<span className="font-medium">{change.field}:</span>{" "}
<span className="text-muted-foreground">{formatChangeValue(change.field, change.from)}</span>
<span className="text-muted-foreground"> -&gt; </span>
<span>{formatChangeValue(change.field, change.to)}</span>
</div>
))}
<div className="min-w-0 space-y-1 text-xs">
{visibleChanges.map((change) => {
const fromValue = formatChangeValue(change.field, change.from);
const toValue = formatChangeValue(change.field, change.to);
const title = `${change.field}: ${fromValue} -> ${toValue}`;
return (
<div key={`${item.ticketNumber}-${change.field}`} className="truncate" title={title}>
<span className="font-medium">{change.field}:</span>{" "}
<span className="text-muted-foreground">{fromValue}</span>
<span className="text-muted-foreground"> -&gt; </span>
<span>{toValue}</span>
</div>
);
})}
</div>
) : (
<span className="text-xs text-muted-foreground">Keine Änderung.</span>
@@ -894,16 +1083,27 @@ export function ZammadSyncPage({ currentUser }: ZammadSyncPageProps) {
importResult,
importRunningJobId,
importRunningJobKind,
mailResult,
mailRunningJobId,
mailRunningJobKind,
mailDaysBack,
mailPerPage,
existingSelection,
importSelection,
mailSelection,
syncingExisting,
importingConsulting
importingConsulting,
syncingMail
} = pageState;
const [storageReady, setStorageReady] = useState(false);
const [syncingTeamspaceProjectIds, setSyncingTeamspaceProjectIds] = useState(false);
const [teamspaceProjectIdResult, setTeamspaceProjectIdResult] = useState<TeamspaceProjectIdSyncResult | null>(null);
const displayedExistingResult = useMemo(() => applyFieldsToExistingPreview(existingResult, fields), [existingResult, fields]);
const selectedExistingCount = useMemo(() => selectedTicketNumbers(displayedExistingResult, existingSelection).length, [displayedExistingResult, existingSelection]);
const selectedImportCount = useMemo(() => selectedTicketNumbers(importResult, importSelection).length, [importResult, importSelection]);
const selectedMailCount = useMemo(() => selectedArticleIds(mailResult, mailSelection).length, [mailResult, mailSelection]);
const zammadReady = currentUser.has_zammad_api_token && Boolean(currentUser.zammad_user_id);
const teamspaceReady = currentUser.has_teamspace_credentials && Boolean(currentUser.teamspace_user_id);
useEffect(() => {
setStorageReady(false);
@@ -952,7 +1152,7 @@ export function ZammadSyncPage({ currentUser }: ZammadSyncPageProps) {
}));
} else {
setZammadSyncPageState({
existingResult: result,
existingResult: null,
existingPreviewJobId: null,
existingSelection: {},
existingRunningJobId: null,
@@ -1005,7 +1205,7 @@ export function ZammadSyncPage({ currentUser }: ZammadSyncPageProps) {
}));
} else {
setZammadSyncPageState({
importResult: result,
importResult: null,
importPreviewJobId: null,
importSelection: {},
importRunningJobId: null,
@@ -1034,6 +1234,59 @@ export function ZammadSyncPage({ currentUser }: ZammadSyncPageProps) {
};
}, [importRunningJobId, importRunningJobKind, storageReady]);
useEffect(() => {
if (!storageReady || !mailRunningJobId || !mailRunningJobKind) {
return;
}
let cancelled = false;
void waitForZammadSyncJob(mailRunningJobId)
.then((result) => {
if (cancelled) {
return;
}
if (mailRunningJobKind === "preview") {
setZammadSyncPageState((current) => ({
mailResult: result,
mailPreviewJobId: mailRunningJobId,
mailSelection: Object.keys(current.mailSelection).length ? current.mailSelection : selectionFromResult(result),
mailRunningJobId: null,
mailRunningJobKind: null,
syncingMail: false
}));
} else {
setZammadSyncPageState({
mailResult: null,
mailPreviewJobId: null,
mailSelection: {},
mailRunningJobId: null,
mailRunningJobKind: null,
syncingMail: false
});
}
})
.catch((error) => {
if (cancelled) {
return;
}
setZammadSyncPageState({
mailRunningJobId: null,
mailRunningJobKind: null,
syncingMail: false
});
toast.error("Zammad-Mail-Sync konnte nicht fortgesetzt werden", {
description: error instanceof Error ? error.message : "Unbekannter Fehler"
});
});
return () => {
cancelled = true;
};
}, [mailRunningJobId, mailRunningJobKind, storageReady]);
function setField(key: keyof ZammadSyncExistingFields, checked: boolean) {
setZammadSyncPageState((current) => ({
fields: { ...current.fields, [key]: checked, ...(key === "budget" && !checked ? { overrideBudget: false } : {}) }
@@ -1044,10 +1297,13 @@ export function ZammadSyncPage({ currentUser }: ZammadSyncPageProps) {
setZammadSyncPageState({
syncingExisting: false,
importingConsulting: false,
syncingMail: false,
existingRunningJobId: null,
existingRunningJobKind: null,
importRunningJobId: null,
importRunningJobKind: null
importRunningJobKind: null,
mailRunningJobId: null,
mailRunningJobKind: null
});
toast.info("Laufstatus zurückgesetzt");
}
@@ -1058,7 +1314,14 @@ export function ZammadSyncPage({ currentUser }: ZammadSyncPageProps) {
}
const syncFields = zammadSyncPageState.fields;
setZammadSyncPageState({ syncingExisting: true, existingRunningJobId: null, existingRunningJobKind: null });
setZammadSyncPageState({
existingResult: null,
existingPreviewJobId: null,
existingSelection: {},
syncingExisting: true,
existingRunningJobId: null,
existingRunningJobKind: null
});
try {
const { jobId, result } = await previewExistingZammadTickets(syncFields, (job: ZammadSyncJob) => {
setZammadSyncPageState({ syncingExisting: true, existingRunningJobId: job.id, existingRunningJobKind: "preview" });
@@ -1107,14 +1370,14 @@ export function ZammadSyncPage({ currentUser }: ZammadSyncPageProps) {
setZammadSyncPageState({ syncingExisting: true, existingRunningJobId: job.id, existingRunningJobKind: "apply" });
});
setZammadSyncPageState({
existingResult: result,
existingResult: null,
existingPreviewJobId: null,
existingSelection: {},
existingRunningJobId: null,
existingRunningJobKind: null
});
toast.success("Auswahl synchronisiert", {
description: `${result.updated} aktualisiert, ${result.skipped} ohne Transfer.`
description: existingApplyDescription(result)
});
} catch (error) {
toast.error("Zammad-Sync fehlgeschlagen", {
@@ -1130,7 +1393,14 @@ export function ZammadSyncPage({ currentUser }: ZammadSyncPageProps) {
return;
}
setZammadSyncPageState({ importingConsulting: true, importRunningJobId: null, importRunningJobKind: null });
setZammadSyncPageState({
importResult: null,
importPreviewJobId: null,
importSelection: {},
importingConsulting: true,
importRunningJobId: null,
importRunningJobKind: null
});
try {
const { jobId, result } = await previewOwnZammadConsultingTickets((job: ZammadSyncJob) => {
setZammadSyncPageState({ importingConsulting: true, importRunningJobId: job.id, importRunningJobKind: "preview" });
@@ -1177,14 +1447,14 @@ export function ZammadSyncPage({ currentUser }: ZammadSyncPageProps) {
setZammadSyncPageState({ importingConsulting: true, importRunningJobId: job.id, importRunningJobKind: "apply" });
});
setZammadSyncPageState({
importResult: result,
importResult: null,
importPreviewJobId: null,
importSelection: {},
importRunningJobId: null,
importRunningJobKind: null
});
toast.success("Auswahl importiert", {
description: `${result.created} angelegt, ${result.skipped} ohne Transfer.`
description: importApplyDescription(result)
});
} catch (error) {
toast.error("Zammad-Import fehlgeschlagen", {
@@ -1195,6 +1465,109 @@ export function ZammadSyncPage({ currentUser }: ZammadSyncPageProps) {
}
}
async function previewMailSync() {
if (zammadSyncPageState.syncingMail) {
return;
}
const daysBack = parseMailDaysBack(zammadSyncPageState.mailDaysBack);
const perPage = parseMailPerPage(zammadSyncPageState.mailPerPage);
setZammadSyncPageState({
mailResult: null,
mailPreviewJobId: null,
mailSelection: {},
mailDaysBack: daysBack,
mailPerPage: perPage,
syncingMail: true,
mailRunningJobId: null,
mailRunningJobKind: null
});
try {
const { jobId, result } = await previewZammadMailSessions(daysBack, perPage, (job: ZammadSyncJob) => {
setZammadSyncPageState({ syncingMail: true, mailRunningJobId: job.id, mailRunningJobKind: "preview" });
});
setZammadSyncPageState({
mailResult: result,
mailPreviewJobId: jobId,
mailSelection: selectionFromResult(result),
mailRunningJobId: null,
mailRunningJobKind: null
});
toast.success("Mail-Vorschau erstellt", {
description: `${result.created} Mail-Session(s) können angelegt werden.`
});
} catch (error) {
toast.error("Mail-Vorschau fehlgeschlagen", {
description: error instanceof Error ? error.message : "Unbekannter Fehler"
});
} finally {
setZammadSyncPageState({ syncingMail: false, mailRunningJobId: null, mailRunningJobKind: null });
}
}
async function applyMailSync() {
if (zammadSyncPageState.syncingMail) {
return;
}
const articleIds = selectedArticleIds(zammadSyncPageState.mailResult, zammadSyncPageState.mailSelection);
if (articleIds.length === 0) {
toast.info("Keine Mail-Sessions ausgewählt");
return;
}
if (!zammadSyncPageState.mailPreviewJobId) {
toast.error("Keine Mail-Vorschau im Zwischenspeicher");
return;
}
setZammadSyncPageState({ syncingMail: true, mailRunningJobId: null, mailRunningJobKind: null });
try {
const { result } = await applyZammadMailSessionsFromPreview(zammadSyncPageState.mailPreviewJobId, articleIds, (job: ZammadSyncJob) => {
setZammadSyncPageState({ syncingMail: true, mailRunningJobId: job.id, mailRunningJobKind: "apply" });
});
setZammadSyncPageState({
mailResult: null,
mailPreviewJobId: null,
mailSelection: {},
mailRunningJobId: null,
mailRunningJobKind: null
});
toast.success("Mail-Sessions importiert", {
description: `${result.created} angelegt, ${result.skipped} ohne Transfer.`
});
} catch (error) {
toast.error("Mail-Sync fehlgeschlagen", {
description: error instanceof Error ? error.message : "Unbekannter Fehler"
});
} finally {
setZammadSyncPageState({ syncingMail: false, mailRunningJobId: null, mailRunningJobKind: null });
}
}
async function runTeamspaceProjectIdSync() {
if (syncingTeamspaceProjectIds) {
return;
}
setSyncingTeamspaceProjectIds(true);
try {
const { result } = await syncTeamspaceProjectIds();
setTeamspaceProjectIdResult(result);
toast.success("Teamspace-Projekt-IDs synchronisiert", {
description: `${result.updated} aktualisiert, ${result.alreadyCurrent} bereits aktuell, ${result.subentriesSynced} Unterposition(en), ${result.missing + result.ambiguous} ohne eindeutige Zuordnung.`
});
} catch (error) {
toast.error("Teamspace-Projekt-IDs konnten nicht synchronisiert werden", {
description: error instanceof Error ? error.message : "Unbekannter Fehler"
});
} finally {
setSyncingTeamspaceProjectIds(false);
}
}
function toggleExisting(ticketNumber: string, checked: boolean) {
setZammadSyncPageState((current) => ({
existingSelection: { ...current.existingSelection, [ticketNumber]: checked }
@@ -1209,7 +1582,7 @@ export function ZammadSyncPage({ currentUser }: ZammadSyncPageProps) {
}
setZammadSyncPageState({
existingSelection: Object.fromEntries(selectableItems(currentResult).map((item) => [item.ticketNumber, checked]))
existingSelection: Object.fromEntries(selectableItems(currentResult).map((item) => [selectionKey(item), checked]))
});
}
@@ -1225,7 +1598,23 @@ export function ZammadSyncPage({ currentUser }: ZammadSyncPageProps) {
}
setZammadSyncPageState({
importSelection: Object.fromEntries(selectableItems(zammadSyncPageState.importResult).map((item) => [item.ticketNumber, checked]))
importSelection: Object.fromEntries(selectableItems(zammadSyncPageState.importResult).map((item) => [selectionKey(item), checked]))
});
}
function toggleMail(key: string, checked: boolean) {
setZammadSyncPageState((current) => ({
mailSelection: { ...current.mailSelection, [key]: checked }
}));
}
function toggleMailAll(checked: boolean) {
if (!zammadSyncPageState.mailResult) {
return;
}
setZammadSyncPageState({
mailSelection: Object.fromEntries(selectableItems(zammadSyncPageState.mailResult).map((item) => [selectionKey(item), checked]))
});
}
@@ -1244,6 +1633,105 @@ export function ZammadSyncPage({ currentUser }: ZammadSyncPageProps) {
</Alert>
) : null}
<Card>
<CardHeader className="p-4">
<div className="flex items-start justify-between gap-3">
<div>
<CardTitle className="flex items-center gap-2">
<DatabaseZap className="size-5 text-muted-foreground" />
Teamspace-Projekt-IDs
</CardTitle>
<CardDescription>Offene Consulting-Projekte gegen die Teamspace-API auflösen und API-ID, Gesamtbudget sowie Unterpositionen aus Teamspace speichern.</CardDescription>
</div>
<Badge variant={teamspaceReady ? "success" : "secondary"}>{teamspaceReady ? "bereit" : "nicht bereit"}</Badge>
</div>
</CardHeader>
<CardContent className="space-y-3 px-4 pb-4">
{!teamspaceReady ? (
<Alert variant="warning">
<AlertCircle className="size-4" />
<AlertTitle>Teamspace-Zugang fehlt</AlertTitle>
<AlertDescription>Bitte im Profil Geräte-ID und Gerätepasswort speichern, damit die Teamspace User-ID bekannt ist.</AlertDescription>
</Alert>
) : null}
<div className="flex justify-end">
<Button type="button" disabled={!teamspaceReady || syncingTeamspaceProjectIds} onClick={() => void runTeamspaceProjectIdSync()}>
<RefreshCw className="size-4" />
{syncingTeamspaceProjectIds ? "Synchronisiert..." : "Teamspace-IDs, Budgets und Positionen synchronisieren"}
</Button>
</div>
{teamspaceProjectIdResult ? (
<div className="space-y-3">
<div className="grid gap-2 sm:grid-cols-3 xl:grid-cols-9">
{[
["TT-Einträge", teamspaceProjectIdResult.checkedEntries],
["TS gelesen", teamspaceProjectIdResult.remoteProjectsScanned],
["TS Positionen gelesen", teamspaceProjectIdResult.remoteSubprojectsScanned],
["Treffer", teamspaceProjectIdResult.matched],
["IDs aktualisiert", teamspaceProjectIdResult.updated],
["Aktuell", teamspaceProjectIdResult.alreadyCurrent],
["Positionen", teamspaceProjectIdResult.subentriesSynced],
["Entfernt", teamspaceProjectIdResult.subentriesRemoved],
["Offen", teamspaceProjectIdResult.missing + teamspaceProjectIdResult.ambiguous]
].map(([label, value]) => (
<div key={label} className="rounded-md border bg-muted/20 px-3 py-2">
<span className="block text-xs text-muted-foreground">{label}</span>
<span className="text-lg font-semibold">{value}</span>
</div>
))}
</div>
{teamspaceProjectIdResult.duplicateRemoteProjectIds.length > 0 ? (
<Alert variant="warning">
<AlertCircle className="size-4" />
<AlertTitle>Nicht eindeutige Teamspace projectIds</AlertTitle>
<AlertDescription>{teamspaceProjectIdResult.duplicateRemoteProjectIds.slice(0, 8).join(", ")}</AlertDescription>
</Alert>
) : null}
<div className="max-h-80 overflow-auto rounded-md border">
<Table className="w-full table-fixed">
<colgroup>
<col className="w-36" />
<col className="w-44" />
<col className="w-28" />
<col className="w-32" />
<col />
</colgroup>
<TableHeader>
<TableRow>
<TableHead>Ticket</TableHead>
<TableHead>Teamspace-ID</TableHead>
<TableHead>Status</TableHead>
<TableHead>API-ID</TableHead>
<TableHead>Hinweis</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{teamspaceProjectIdResult.items.slice(0, 100).map((item) => (
<TableRow key={item.entryId}>
<TableCell className="truncate font-medium" title={item.ticketNumber}>{item.ticketNumber}</TableCell>
<TableCell className="truncate" title={item.teamspaceProject}>{item.teamspaceProject}</TableCell>
<TableCell>
<Badge variant={item.status === "updated" ? "success" : item.status === "current" ? "info" : "warning"}>
{item.status === "updated" ? "Aktualisiert" : item.status === "current" ? "Aktuell" : item.status === "missing" ? "Fehlt" : "Mehrdeutig"}
</Badge>
</TableCell>
<TableCell className="truncate" title={item.apiProjectId ?? ""}>{item.apiProjectId ?? "-"}</TableCell>
<TableCell className="truncate text-xs text-muted-foreground" title={item.message ?? item.apiProjectUrl ?? ""}>
{item.message ?? item.apiProjectUrl ?? "-"}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
{teamspaceProjectIdResult.items.length > 100 ? (
<p className="text-xs text-muted-foreground">Weitere {teamspaceProjectIdResult.items.length - 100} Einträge wurden synchronisiert und sind hier nicht gelistet.</p>
) : null}
</div>
) : null}
</CardContent>
</Card>
<Card>
<CardHeader className="p-4">
<div className="flex items-start justify-between gap-3">
@@ -1269,15 +1757,6 @@ export function ZammadSyncPage({ currentUser }: ZammadSyncPageProps) {
</label>
))}
</div>
{fields.budget ? (
<label className={cn("flex min-w-0 gap-3 rounded-md border p-3", fields.overrideBudget ? "bg-muted/30" : "bg-background")}>
<Checkbox checked={fields.overrideBudget} onCheckedChange={(checked) => setField("overrideBudget", checked === true)} />
<span className="min-w-0">
<span className="block text-sm font-medium">Budget-Schutz im Sync ignorieren</span>
<span className="block text-xs text-muted-foreground">Aus: nur ungeschützte Budgets aktualisieren. An: Zammad überschreibt auch geschützte Budgets.</span>
</span>
</label>
) : null}
<div className="flex flex-wrap justify-end gap-2">
{syncingExisting ? (
<Button type="button" variant="outline" onClick={resetRunningState}>
@@ -1340,6 +1819,64 @@ export function ZammadSyncPage({ currentUser }: ZammadSyncPageProps) {
) : null}
</CardContent>
</Card>
<Card>
<CardHeader className="p-4">
<CardTitle className="flex items-center gap-2">
<MailPlus className="size-5 text-muted-foreground" />
Mail-Sync
</CardTitle>
<CardDescription>Legt fest, wie weit und mit welcher Seitengröße Mail-Session-Importe suchen.</CardDescription>
</CardHeader>
<CardContent className="space-y-4 px-4 pb-4">
<div className="flex flex-wrap items-end gap-3">
<label className="grid w-48 gap-1 text-sm font-medium">
Tage rückwirkend
<Input
type="number"
min={1}
max={3650}
value={mailDaysBack}
onChange={(event) => setZammadSyncPageState({ mailDaysBack: parseMailDaysBack(event.currentTarget.value) })}
/>
</label>
<label className="grid w-48 gap-1 text-sm font-medium">
Abgleich ab
<Input type="date" value={mailSyncCutoffDate(mailDaysBack)} readOnly tabIndex={-1} />
</label>
<label className="grid w-48 gap-1 text-sm font-medium">
PER_PAGE
<Input
type="number"
min={1}
max={1000}
value={mailPerPage}
onChange={(event) => setZammadSyncPageState({ mailPerPage: parseMailPerPage(event.currentTarget.value) })}
/>
</label>
</div>
<div className="flex flex-wrap justify-end gap-2">
{syncingMail ? (
<Button type="button" variant="outline" onClick={resetRunningState}>
Laufstatus zurücksetzen
</Button>
) : null}
{mailResult?.mode === "preview" ? (
<Button type="button" disabled={!zammadReady || syncingMail || selectedMailCount === 0} onClick={() => void applyMailSync()}>
<CheckCircle2 className="size-4" />
{syncingMail ? "Importiert..." : `Auswahl übernehmen (${selectedMailCount})`}
</Button>
) : null}
<Button type="button" variant={mailResult?.mode === "preview" ? "outline" : "default"} disabled={!zammadReady || syncingMail} onClick={() => void previewMailSync()}>
<MailPlus className="size-4" />
{syncingMail ? "Bereitet vor..." : "Vorschau erstellen"}
</Button>
</div>
{mailResult ? (
<ResultSummary result={mailResult} selection={mailResult.mode === "preview" ? mailSelection : undefined} onToggle={toggleMail} onToggleAll={toggleMailAll} />
) : null}
</CardContent>
</Card>
</div>
);
}
+24
View File
@@ -0,0 +1,24 @@
export const zammadArticleTypeOptions = [
{ value: "4", label: "Dienstleistung" },
{ value: "1", label: "Koordination, Kommunikation und Dokumentation" },
{ value: "6", label: "Nicht kategorisiert" },
{ value: "2", label: "Schulungen, Workshops und Meetings" },
{ value: "5", label: "Support" },
{ value: "3", label: "Vor- und Nacharbeiten" }
] as const;
export type ZammadArticleTypeId = number;
export function zammadArticleTypeValue(typeId: ZammadArticleTypeId | null | undefined) {
return String(typeId ?? 6);
}
export function zammadArticleTypeFromValue(value: string): ZammadArticleTypeId {
return Number(value || 6);
}
export function zammadArticleTypeLabel(typeId: ZammadArticleTypeId | null | undefined) {
const value = zammadArticleTypeValue(typeId);
return zammadArticleTypeOptions.find((option) => option.value === value)?.label ?? "Nicht kategorisiert";
}
Executable
+91
View File
@@ -0,0 +1,91 @@
#!/usr/bin/env bash
# Holt alle Zammad-Articles der letzten X Tage, gefiltert nach type_id und created_by_id.
#
# Aufruf: ZAMMAD_TOKEN=xxx ./zammad_articles.sh [TAGE] [CREATED_BY_ID] [TYPE_ID]
# Beispiel: ZAMMAD_TOKEN=xxx ./zammad_articles.sh 2 1764 1 > mails.json
#
# Voraussetzungen: curl, jq, API-Token mit Admin-Rechten (für /ticket_articles)
set -euo pipefail
BASE_URL="${ZAMMAD_URL:-https://aikava.zammad.com}"
TOKEN="${ZAMMAD_TOKEN:?Bitte ZAMMAD_TOKEN setzen}"
DAYS="${1:-2}"
CREATED_BY="${2:-1764}"
TYPE_ID="${3:-1}"
PER_PAGE=1000
MAX_PAGE=1000000 # Schutz gegen Endlosschleifen
# Call-Zähler über eine Temp-Datei, da fetch_page oft in $(...) läuft
CALLS_FILE=$(mktemp)
trap 'rm -f "$CALLS_FILE"' EXIT
calls() { wc -l < "$CALLS_FILE" | tr -d ' '; }
# Stichtag in UTC (GNU date oder BSD/macOS date)
# CALENDAR=1: ab lokaler Mitternacht vor (TAGE-1) Tagen, also TAGE=2 -> gestern 00:00 bis jetzt
# sonst: rollierend, jetzt minus TAGE*24h
if date -u -d "now" >/dev/null 2>&1; then
if [ "${CALENDAR:-0}" = "1" ]; then
TS=$(date -d "$((DAYS - 1)) days ago 00:00" +%s)
else
TS=$(date -d "${DAYS} days ago" +%s)
fi
CUTOFF=$(date -u -d "@${TS}" +%Y-%m-%dT%H:%M:%S)
else
if [ "${CALENDAR:-0}" = "1" ]; then
TS=$(date -v-"$((DAYS - 1))"d -v0H -v0M -v0S +%s)
else
TS=$(date -v-"${DAYS}"d +%s)
fi
CUTOFF=$(date -u -r "${TS}" +%Y-%m-%dT%H:%M:%S)
fi
log() { echo "$*" >&2; }
fetch_page() {
echo "$1" >> "$CALLS_FILE"
curl -sf -H "Authorization: Token token=${TOKEN}" \
"${BASE_URL}/api/v1/ticket_articles?page=$1&per_page=${PER_PAGE}"
}
page_len() { fetch_page "$1" | jq 'length'; }
log "Stichtag: ${CUTOFF}Z | created_by_id=${CREATED_BY} | type_id=${TYPE_ID}"
# 1) Letzte nicht-leere Seite per Binärsuche finden
if [ "$(page_len 1)" -eq 0 ]; then
log "Keine Articles gefunden. (API-Calls: $(calls))"
echo "[]"
exit 0
fi
lo=1; hi=2
while [ "$hi" -le "$MAX_PAGE" ] && [ "$(page_len "$hi")" -gt 0 ]; do
lo=$hi; hi=$((hi * 2))
done
while [ $((hi - lo)) -gt 1 ]; do
mid=$(( (lo + hi) / 2 ))
if [ "$(page_len "$mid")" -gt 0 ]; then lo=$mid; else hi=$mid; fi
done
SEARCH_CALLS=$(calls)
log "Letzte Seite: ${lo} (Binärsuche: ${SEARCH_CALLS} Calls)"
# 2) Rückwärts lesen bis zum Stichtag und filtern
results="[]"
p=$lo
while [ "$p" -ge 1 ]; do
batch=$(fetch_page "$p")
matches=$(jq --arg c "$CUTOFF" --argjson u "$CREATED_BY" --argjson t "$TYPE_ID" \
'[.[] | select(.created_at >= $c and .type_id == $t and .created_by_id == $u)]' <<<"$batch")
results=$(jq -s 'add' <(echo "$results") <(echo "$matches"))
oldest=$(jq -r '.[0].created_at // empty' <<<"$batch")
log "Seite ${p}: $(jq 'length' <<<"$matches") Treffer (ältester Article: ${oldest})"
[[ -z "$oldest" || "$oldest" < "$CUTOFF" ]] && break
p=$((p - 1))
done
TOTAL_CALLS=$(calls)
log "Gesamt: $(jq 'length' <<<"$results") Articles"
log "API-Calls: ${TOTAL_CALLS} (Binärsuche: ${SEARCH_CALLS}, Rückwärts lesen: $((TOTAL_CALLS - SEARCH_CALLS)))"
jq 'sort_by(.id)' <<<"$results"
+37
View File
@@ -0,0 +1,37 @@
[
{
"id": 66368,
"ticket_id": 3122,
"type_id": 1,
"sender_id": 1,
"from": "XX",
"to": "XX",
"cc": "XX",
"subject": "XX",
"reply_to": null,
"message_id": "XX",
"message_id_md5": "XX",
"in_reply_to": "XX",
"content_type": "text/html",
"body": "",
"internal": false,
"preferences": {
"subtype": "reply",
"email_address_id": 7,
"delivery_retry": 1,
"delivery_channel_id": 24,
"delivery_status_message": null,
"delivery_status": "success",
"delivery_status_date": "2026-09-25T10:55:51.214Z"
},
"updated_by_id": 77,
"created_by_id": 77,
"origin_by_id": null,
"created_at": "2026-09-25T10:55:48.201Z",
"updated_at": "2026-09-25T10:55:51.215Z",
"detected_language": null,
"ai_stored_result_ids": [],
"attachments": [],
"body_rendering_error": false
}
]
+59
View File
@@ -0,0 +1,59 @@
#!/usr/bin/env bash
set -euo pipefail
if [[ -f ".env_dev" ]]; then
set -a
source .env_dev
set +a
fi
APP_PORT="${APP_PORT:-3911}"
BACKEND_PORT="${BACKEND_PORT:-3002}"
API_PROXY_TARGET="${API_PROXY_TARGET:-http://127.0.0.1:${BACKEND_PORT}}"
export APP_PORT BACKEND_PORT API_PROXY_TARGET
export NEXT_TELEMETRY_DISABLED=1
kill_port_processes() {
local port="$1"
local pids=""
if command -v lsof >/dev/null 2>&1; then
pids="${pids}"$'\n'"$(lsof -tiTCP:"${port}" -sTCP:LISTEN 2>/dev/null || true)"
fi
if command -v fuser >/dev/null 2>&1; then
pids="${pids}"$'\n'"$(fuser -n tcp "${port}" 2>/dev/null | tr ' ' '\n' || true)"
fi
if command -v ss >/dev/null 2>&1; then
pids="${pids}"$'\n'"$(ss -ltnp "sport = :${port}" 2>/dev/null | grep -o 'pid=[0-9]*' | cut -d= -f2 || true)"
fi
pids="$(printf '%s\n' "${pids}" | sed '/^$/d' | sort -u)"
if [[ -z "${pids}" ]]; then
echo "Port ${port} ist frei."
return
fi
echo "Beende Prozess(e) auf Port ${port}: ${pids//$'\n'/ }"
kill ${pids} 2>/dev/null || true
sleep 1
local remaining=""
for pid in ${pids}; do
if kill -0 "${pid}" 2>/dev/null; then
remaining="${remaining} ${pid}"
fi
done
if [[ -n "${remaining}" ]]; then
echo "Erzwinge Stop für:${remaining}"
kill -9 ${remaining} 2>/dev/null || true
fi
}
kill_port_processes "${BACKEND_PORT}"
if [[ "${APP_PORT}" != "${BACKEND_PORT}" ]]; then
kill_port_processes "${APP_PORT}"
fi
+65 -5
View File
@@ -13,24 +13,84 @@ API_PROXY_TARGET="${API_PROXY_TARGET:-http://127.0.0.1:${BACKEND_PORT}}"
export APP_PORT BACKEND_PORT API_PROXY_TARGET
export NEXT_TELEMETRY_DISABLED=1
kill_port_processes() {
local port="$1"
local pids=""
if command -v lsof >/dev/null 2>&1; then
pids="${pids}"$'\n'"$(lsof -tiTCP:"${port}" -sTCP:LISTEN 2>/dev/null || true)"
fi
if command -v fuser >/dev/null 2>&1; then
pids="${pids}"$'\n'"$(fuser -n tcp "${port}" 2>/dev/null | tr ' ' '\n' || true)"
fi
if command -v ss >/dev/null 2>&1; then
pids="${pids}"$'\n'"$(ss -ltnp "sport = :${port}" 2>/dev/null | grep -o 'pid=[0-9]*' | cut -d= -f2 || true)"
fi
pids="$(printf '%s\n' "${pids}" | sed '/^$/d' | sort -u)"
if [[ -z "${pids}" ]]; then
echo "Port ${port} ist frei."
return
fi
echo "Beende Prozess(e) auf Port ${port}: ${pids//$'\n'/ }"
kill ${pids} 2>/dev/null || true
sleep 1
local remaining=""
for pid in ${pids}; do
if kill -0 "${pid}" 2>/dev/null; then
remaining="${remaining} ${pid}"
fi
done
if [[ -n "${remaining}" ]]; then
echo "Erzwinge Stop für:${remaining}"
kill -9 ${remaining} 2>/dev/null || true
fi
}
kill_port_processes "${BACKEND_PORT}"
if [[ "${APP_PORT}" != "${BACKEND_PORT}" ]]; then
kill_port_processes "${APP_PORT}"
fi
if [[ -z "${DATABASE_URL:-}" ]]; then
echo "DATABASE_URL fehlt. Beispiel:"
echo " export DATABASE_URL='postgresql://user:pass@localhost:5432/tickettracker'"
exit 1
fi
if [[ -z "${SECRET_ENCRYPTION_KEY:-}" && -z "${TICKETTRACKER_SECRET_KEY:-}" ]]; then
export SECRET_ENCRYPTION_KEY="${DATABASE_URL}"
fi
DATABASE_HOST="$(node -e 'const url = new URL(process.env.DATABASE_URL); process.stdout.write(url.hostname)' 2>/dev/null || true)"
DATABASE_PORT="$(node -e 'const url = new URL(process.env.DATABASE_URL); process.stdout.write(url.port || "5432")' 2>/dev/null || echo 5432)"
POSTGRES_CONTAINER="${POSTGRES_CONTAINER:-tickettracker-postgres}"
if [[ "${DATABASE_HOST}" == "postgres" || "${DATABASE_HOST}" == "postgres-tickettracker" ]]; then
if ! getent hosts "${DATABASE_HOST}" >/dev/null 2>&1 && command -v docker >/dev/null 2>&1 && docker inspect "${POSTGRES_CONTAINER}" >/dev/null 2>&1; then
POSTGRES_CONTAINER_IP="$(docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' "${POSTGRES_CONTAINER}")"
if command -v docker >/dev/null 2>&1 && docker inspect "${POSTGRES_CONTAINER}" >/dev/null 2>&1; then
POSTGRES_CONTAINER_IP="$(docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' "${POSTGRES_CONTAINER}")"
if [[ -n "${POSTGRES_CONTAINER_IP}" ]]; then
if [[ -n "${POSTGRES_CONTAINER_IP}" ]]; then
SHOULD_USE_CONTAINER_IP=false
if [[ "${DATABASE_HOST}" == "postgres" || "${DATABASE_HOST}" == "postgres-tickettracker" ]]; then
if ! getent hosts "${DATABASE_HOST}" >/dev/null 2>&1; then
SHOULD_USE_CONTAINER_IP=true
fi
elif [[ "${DATABASE_HOST}" =~ ^172\. ]] && [[ "${DATABASE_HOST}" != "${POSTGRES_CONTAINER_IP}" ]]; then
SHOULD_USE_CONTAINER_IP=true
fi
if [[ "${SHOULD_USE_CONTAINER_IP}" == "true" ]]; then
export DATABASE_URL="$(
DATABASE_HOST="${POSTGRES_CONTAINER_IP}" node -e 'const url = new URL(process.env.DATABASE_URL); url.hostname = process.env.DATABASE_HOST; process.stdout.write(url.toString())'
)"
echo "Nutze Docker-Postgres ${POSTGRES_CONTAINER} unter ${POSTGRES_CONTAINER_IP}:5432 für den lokalen Start."
echo "Nutze Docker-Postgres ${POSTGRES_CONTAINER} unter ${POSTGRES_CONTAINER_IP}:${DATABASE_PORT} für den lokalen Start."
fi
fi
fi
+48
View File
@@ -0,0 +1,48 @@
{
"_url": "https://app1.teamspace.de/api/project;limit=1000;depth=2;sort=projectId?parent=32700722&isActive=true&isTemplate=false",
"_links": [
{
"caption": "Self",
"href": "https://app1.teamspace.de/api/project;limit=1000;depth=2;sort=projectId?parent=32700722&isActive=true&isTemplate=false",
"rel": "self"
},
{
"caption": "|< firstPage",
"href": "https://app1.teamspace.de/api/project;offset=0;limit=1000;sort=projectId?parent=32700722&isActive=true&isTemplate=false",
"rel": "first",
"title": "First page of projects of this CollectionResource"
},
{
"caption": ">| lastPage",
"href": "https://app1.teamspace.de/api/project;offset=0;limit=1000;sort=projectId?parent=32700722&isActive=true&isTemplate=false",
"rel": "last",
"title": "Last page of projects of this CollectionResource"
}
],
"_etag": {
"value": "2cdf79c35612fb82487eed3923af2e43",
"weak": false
},
"size": 5,
"offset": 0,
"limit": 100,
"items": [
{
"data": {
"contractPositions": [
{
"billingType": {
"caption": "Fix price",
"href": "https://app1.teamspace.de/api/enum/financepositionbillingtype/INVOICEABLE_FIX",
"value": "INVOICEABLE_FIX",
"optionsUrl": "https://app1.teamspace.de/api/enum/financepositionbillingtype"
}
}
]
}
}
]
}