This commit is contained in:
michael committed 2026-10-08 09:03:15 +02:00
1 parent fc0dfd0556
commit 649dd6f028
20 files changed
+3721 -984

No files matched your search

+9
View File
@@ -17,8 +17,11 @@ export type AuthUser = {
active: boolean; active: boolean;
zammad_user_id: string | null; zammad_user_id: string | null;
has_zammad_api_token: boolean; has_zammad_api_token: boolean;
teamspace_user_id: string | null;
has_teamspace_credentials: boolean;
zammad_email_session_webhook_enabled: boolean; zammad_email_session_webhook_enabled: boolean;
session_billing_prompt_enabled: boolean; session_billing_prompt_enabled: boolean;
teamspace_time_tracking_enabled: boolean;
zammad_session_note_default_enabled: boolean; zammad_session_note_default_enabled: boolean;
zammad_time_unit_rounding_enabled: boolean; zammad_time_unit_rounding_enabled: boolean;
zammad_time_unit_rounding_minutes: number; zammad_time_unit_rounding_minutes: number;
@@ -90,8 +93,11 @@ function publicUser(user: AuthUser) {
active: user.active, active: user.active,
zammad_user_id: user.zammad_user_id ?? null, zammad_user_id: user.zammad_user_id ?? null,
has_zammad_api_token: Boolean(user.has_zammad_api_token), 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), zammad_email_session_webhook_enabled: Boolean(user.zammad_email_session_webhook_enabled),
session_billing_prompt_enabled: Boolean(user.session_billing_prompt_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_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_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),
@@ -159,8 +165,11 @@ export async function requireAuth(req: Request, res: Response, next: NextFunctio
u.active, u.active,
u.zammad_user_id::text AS zammad_user_id, u.zammad_user_id::text AS zammad_user_id,
(u.zammad_api_token IS NOT NULL) AS has_zammad_api_token, (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.zammad_email_session_webhook_enabled,
u.session_billing_prompt_enabled, u.session_billing_prompt_enabled,
u.teamspace_time_tracking_enabled,
u.zammad_session_note_default_enabled, u.zammad_session_note_default_enabled,
u.zammad_time_unit_rounding_enabled, u.zammad_time_unit_rounding_enabled,
u.zammad_time_unit_rounding_minutes, u.zammad_time_unit_rounding_minutes,
+2082 -383
View File
File diff suppressed because it is too large. Load diff
+199 -15
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 zammadApiKeyResult = await query<{ value: string }>("SELECT value FROM app_settings WHERE key = $1;", [zammadApiKeySettingKey]);
const zammadApiKey = zammadApiKeyResult.rows[0]?.value ?? null; const zammadApiKey = zammadApiKeyResult.rows[0]?.value ?? null;
@@ -86,8 +113,12 @@ 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_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 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 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 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_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_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_time_unit_rounding_minutes INTEGER NOT NULL DEFAULT 15;");
@@ -98,6 +129,7 @@ export async function migrate() {
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_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_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 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("UPDATE users SET zammad_article_type_id = 6 WHERE zammad_article_type_id IS NULL;");
await query("ALTER TABLE users ALTER COLUMN zammad_article_type_id SET DEFAULT 6;"); await query("ALTER TABLE users ALTER COLUMN zammad_article_type_id SET DEFAULT 6;");
await query("ALTER TABLE users ALTER COLUMN zammad_article_type_id SET NOT NULL;"); await query("ALTER TABLE users ALTER COLUMN zammad_article_type_id SET NOT NULL;");
@@ -717,10 +749,11 @@ export async function migrate() {
CONSTRAINT ticket_day_billings_minutes_non_negative CHECK (billed_minutes >= 0) 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 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("ALTER TABLE ticket_day_billings DROP CONSTRAINT IF EXISTS ticket_day_billings_pkey;");
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("DROP INDEX IF EXISTS idx_ticket_day_billings_ticket_user_day_default;");
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("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(` await query(`
CREATE TABLE IF NOT EXISTS ticket_day_billing_acknowledgements ( CREATE TABLE IF NOT EXISTS ticket_day_billing_acknowledgements (
@@ -829,8 +862,84 @@ 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 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 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 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_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(` await query(`
DO $$ DO $$
BEGIN BEGIN
@@ -843,15 +952,18 @@ export async function migrate() {
DROP CONSTRAINT ticket_day_billings_project_teamspace_entry_fkey; DROP CONSTRAINT ticket_day_billings_project_teamspace_entry_fkey;
END IF; END IF;
IF NOT EXISTS ( IF NOT EXISTS (
SELECT 1 FROM pg_constraint WHERE conname = 'ticket_day_billings_project_teamspace_entry_fkey' SELECT 1 FROM pg_constraint WHERE conname = 'ticket_day_billings_project_teamspace_entry_fkey'
) THEN ) THEN
ALTER TABLE ticket_day_billings ALTER TABLE ticket_day_billings
ADD CONSTRAINT ticket_day_billings_project_teamspace_entry_fkey ADD CONSTRAINT ticket_day_billings_project_teamspace_entry_fkey
FOREIGN KEY (project_teamspace_entry_id) REFERENCES project_teamspace_entries(id) ON DELETE SET NULL; FOREIGN KEY (project_teamspace_entry_id) REFERENCES project_teamspace_entries(id) ON DELETE SET NULL;
END IF; END IF;
END $$;
`); ALTER TABLE ticket_day_billings
DROP CONSTRAINT IF EXISTS ticket_day_billings_project_teamspace_subentry_fkey;
END $$;
`);
await query(` await query(`
CREATE TABLE IF NOT EXISTS hidden_user_projects ( CREATE TABLE IF NOT EXISTS hidden_user_projects (
user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE, user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
@@ -873,6 +985,78 @@ export async function migrate() {
END IF; END IF;
END $$; 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(` await query(`
UPDATE tickets t UPDATE tickets t
@@ -1025,8 +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 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_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_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_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_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_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);"); await query("CREATE INDEX IF NOT EXISTS idx_projects_user_updated ON projects(user_id, updated_at DESC);");
+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
+4 -4
View File
@@ -1628,11 +1628,11 @@ export function App() {
currentUser={currentUser} currentUser={currentUser}
/> />
) : null} ) : null}
{!isAdmin && route.page === "dashboard" ? <DashboardPage onNavigate={navigate} /> : null} {!isAdmin && route.page === "dashboard" ? <DashboardPage currentUser={currentUser} onNavigate={navigate} /> : null}
{!isAdmin && route.page === "analysis" ? <AnalysisPage 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 === "monthly-close" ? <MonthlyClosePage onNavigate={navigate} /> : null}
{!isAdmin && route.page === "statistics" ? <StatisticsPage onNavigate={navigate} /> : null} {!isAdmin && route.page === "statistics" ? <StatisticsPage currentUser={currentUser} onNavigate={navigate} /> : null}
{!isAdmin && route.page === "projects" ? <ProjectsPage onNavigate={navigate} onStartTimer={startQuickTimer} /> : null} {!isAdmin && route.page === "projects" ? <ProjectsPage currentUser={currentUser} onNavigate={navigate} onStartTimer={startQuickTimer} /> : null}
{!isAdmin && route.page === "todos" ? <TodosPage onNavigate={navigate} /> : null} {!isAdmin && route.page === "todos" ? <TodosPage onNavigate={navigate} /> : null}
{!isAdmin && route.page === "recurring" ? <RecurringBillingsPage /> : null} {!isAdmin && route.page === "recurring" ? <RecurringBillingsPage /> : null}
{!isAdmin && route.page === "zammad-sync" ? <ZammadSyncPage currentUser={currentUser} /> : null} {!isAdmin && route.page === "zammad-sync" ? <ZammadSyncPage currentUser={currentUser} /> : null}
+103 -2
View File
@@ -7,6 +7,7 @@ import type {
Organization, Organization,
PeriodOverview, PeriodOverview,
PeriodType, PeriodType,
ProjectTeamspaceSubentry,
ProjectSummary, ProjectSummary,
RecurringBilling, RecurringBilling,
StatisticsOverview, StatisticsOverview,
@@ -125,6 +126,51 @@ export type ZammadSyncJob = {
finishedAt: string | null; 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 = { export type TicketZammadMailSessionItem = {
articleId: number; articleId: number;
ticketNumber: string; ticketNumber: string;
@@ -248,8 +294,11 @@ export function updateCurrentUser(payload: {
currentPassword?: string; currentPassword?: string;
newPassword?: string; newPassword?: string;
zammadApiToken?: string; zammadApiToken?: string;
teamspaceDeviceId?: string;
teamspaceDevicePassword?: string;
zammadEmailSessionWebhookEnabled?: boolean; zammadEmailSessionWebhookEnabled?: boolean;
sessionBillingPromptEnabled?: boolean; sessionBillingPromptEnabled?: boolean;
teamspaceTimeTrackingEnabled?: boolean;
zammadSessionNoteDefaultEnabled?: boolean; zammadSessionNoteDefaultEnabled?: boolean;
zammadTimeUnitRoundingEnabled?: boolean; zammadTimeUnitRoundingEnabled?: boolean;
zammadTimeUnitRoundingMinutes?: number; zammadTimeUnitRoundingMinutes?: number;
@@ -432,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 }) { 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", { return request<{ synced: number; skipped: number; removed: number; unlinkedTickets: number; unlinkedSessions: number }>("/api/admin/zammad/organizations/sync", {
method: "POST", method: "POST",
@@ -537,6 +597,39 @@ export function applyZammadMailSessionsFromPreview(previewJobId: string, article
}, onJobStarted); }, 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() { export function getRecurringBillings() {
return request<{ recurringBillings: RecurringBilling[] }>("/api/recurring-billings"); return request<{ recurringBillings: RecurringBilling[] }>("/api/recurring-billings");
} }
@@ -861,10 +954,18 @@ export function updateSessionDetails(
}); });
} }
export function updateTicketDayBilling(ticketId: string, day: string, billedMinutes: number | null, projectTeamspaceEntryId?: string | null) { export function updateTicketDayBilling(
ticketId: string,
day: string,
billedMinutes: number | null,
projectTeamspaceEntryId?: string | null
) {
return request(`/api/tickets/${ticketId}/day-billings/${day}`, { return request(`/api/tickets/${ticketId}/day-billings/${day}`, {
method: "PATCH", method: "PATCH",
body: JSON.stringify({ billedMinutes, projectTeamspaceEntryId: projectTeamspaceEntryId ?? null }) body: JSON.stringify({
billedMinutes,
projectTeamspaceEntryId: projectTeamspaceEntryId ?? null
})
}); });
} }
+1 -1
View File
@@ -68,7 +68,7 @@ function AccordionContent({
> >
<div <div
className={cn( 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 className
)} )}
> >
+28
View File
@@ -12,8 +12,11 @@ export type AuthUser = {
active: boolean; active: boolean;
zammad_user_id: string | null; zammad_user_id: string | null;
has_zammad_api_token: boolean; has_zammad_api_token: boolean;
teamspace_user_id: string | null;
has_teamspace_credentials: boolean;
zammad_email_session_webhook_enabled: boolean; zammad_email_session_webhook_enabled: boolean;
session_billing_prompt_enabled: boolean; session_billing_prompt_enabled: boolean;
teamspace_time_tracking_enabled: boolean;
zammad_session_note_default_enabled: boolean; zammad_session_note_default_enabled: boolean;
zammad_time_unit_rounding_enabled: boolean; zammad_time_unit_rounding_enabled: boolean;
zammad_time_unit_rounding_minutes: number; zammad_time_unit_rounding_minutes: number;
@@ -218,6 +221,7 @@ export type ProjectSummary = {
teamspace_entry_count?: number; teamspace_entry_count?: number;
teamspace_entries?: ProjectTeamspaceEntry[]; teamspace_entries?: ProjectTeamspaceEntry[];
unassigned_teamspace_session_count?: number; unassigned_teamspace_session_count?: number;
unassigned_teamspace_subentry_count?: number;
close_review_required?: boolean; close_review_required?: boolean;
hidden_for_user?: boolean; hidden_for_user?: boolean;
}; };
@@ -226,7 +230,10 @@ export type ProjectTeamspaceEntry = {
id: string; id: string;
project_id?: string; project_id?: string;
teamspace_project: string | null; teamspace_project: string | null;
teamspace_api_project_id?: string | null;
teamspace_api_project_url?: string | null;
teamspace_description?: string | null; teamspace_description?: string | null;
subentries?: ProjectTeamspaceSubentry[];
is_default: boolean; is_default: boolean;
hour_budget_minutes: number | null; hour_budget_minutes: number | null;
budget_sync_disabled?: boolean; budget_sync_disabled?: boolean;
@@ -236,6 +243,27 @@ export type ProjectTeamspaceEntry = {
display_order?: number; 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 TodoPriority = "low" | "medium" | "high" | "urgent";
export type TodoStatus = "open" | "done"; export type TodoStatus = "open" | "done";
+48 -2
View File
@@ -1,5 +1,5 @@
import { ChangeEvent, FormEvent, useEffect, useState } from "react"; 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 { toast } from "sonner";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
@@ -14,9 +14,11 @@ import {
createAdminUser, createAdminUser,
downloadDatabaseExport, downloadDatabaseExport,
getAdminUsers, getAdminUsers,
getTeamspaceSettings,
getZammadSettings, getZammadSettings,
restoreDatabaseBackup, restoreDatabaseBackup,
saveZammadSettings, saveZammadSettings,
saveTeamspaceSettings,
syncZammadOrganizations, syncZammadOrganizations,
updateAdminUser, updateAdminUser,
validateDatabaseBackup, validateDatabaseBackup,
@@ -54,8 +56,10 @@ export function AdminUsersPage({ currentUser }: { currentUser: AuthUser }) {
const [zammadBaseUrl, setZammadBaseUrl] = useState(""); const [zammadBaseUrl, setZammadBaseUrl] = useState("");
const [zammadApiKey, setZammadApiKey] = useState(""); const [zammadApiKey, setZammadApiKey] = useState("");
const [hasZammadApiKey, setHasZammadApiKey] = useState(false); const [hasZammadApiKey, setHasZammadApiKey] = useState(false);
const [teamspaceBaseUrl, setTeamspaceBaseUrl] = useState("");
const [syncingOrganizations, setSyncingOrganizations] = useState(false); const [syncingOrganizations, setSyncingOrganizations] = useState(false);
const [savingZammadSettings, setSavingZammadSettings] = useState(false); const [savingZammadSettings, setSavingZammadSettings] = useState(false);
const [savingTeamspaceSettings, setSavingTeamspaceSettings] = useState(false);
const [exportingDatabase, setExportingDatabase] = useState(false); const [exportingDatabase, setExportingDatabase] = useState(false);
const [restoreSql, setRestoreSql] = useState(""); const [restoreSql, setRestoreSql] = useState("");
const [restoreValidation, setRestoreValidation] = useState<RestoreValidationResult | null>(null); const [restoreValidation, setRestoreValidation] = useState<RestoreValidationResult | null>(null);
@@ -68,12 +72,13 @@ export function AdminUsersPage({ currentUser }: { currentUser: AuthUser }) {
async function load(options: { preserveEdits?: boolean } = {}) { async function load(options: { preserveEdits?: boolean } = {}) {
setLoading(true); setLoading(true);
try { try {
const [usersResult, zammadSettingsResult] = await Promise.all([getAdminUsers(), getZammadSettings()]); const [usersResult, zammadSettingsResult, teamspaceSettingsResult] = await Promise.all([getAdminUsers(), getZammadSettings(), getTeamspaceSettings()]);
setUsers(usersResult.users); setUsers(usersResult.users);
if (!options.preserveEdits) { if (!options.preserveEdits) {
setZammadBaseUrl(zammadSettingsResult.settings.baseUrl); setZammadBaseUrl(zammadSettingsResult.settings.baseUrl);
setHasZammadApiKey(zammadSettingsResult.settings.hasApiKey); setHasZammadApiKey(zammadSettingsResult.settings.hasApiKey);
setTeamspaceBaseUrl(teamspaceSettingsResult.settings.baseUrl);
} }
setForms((current) => 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() { async function exportDatabase() {
setExportingDatabase(true); setExportingDatabase(true);
@@ -370,6 +395,27 @@ export function AdminUsersPage({ currentUser }: { currentUser: AuthUser }) {
</Button> </Button>
</div> </div>
</form> </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> </CardContent>
</Card> </Card>
+10 -3
View File
@@ -14,9 +14,10 @@ import { HelpLink } from "@/components/HelpLink";
import { MultiFilterDropdown, type MultiFilterOption } from "@/components/MultiFilterDropdown"; import { MultiFilterDropdown, type MultiFilterOption } from "@/components/MultiFilterDropdown";
import { downloadMonthExport, getPeriodOverview } from "../api"; import { downloadMonthExport, getPeriodOverview } from "../api";
import { currentDay, currentMonth, formatDateTime, formatMinutes } from "../format"; import { currentDay, currentMonth, formatDateTime, formatMinutes } from "../format";
import type { PeriodOverview, PeriodType, TicketSummary } from "../types"; import type { AuthUser, PeriodOverview, PeriodType, TicketSummary } from "../types";
type AnalysisPageProps = { type AnalysisPageProps = {
currentUser: AuthUser;
onNavigate: (to: string) => void; 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 [periodType, setPeriodType] = useState<PeriodType>("month");
const [period, setPeriod] = useState(currentMonth()); const [period, setPeriod] = useState(currentMonth());
const [overview, setOverview] = useState<PeriodOverview | null>(null); const [overview, setOverview] = useState<PeriodOverview | null>(null);
@@ -438,11 +439,17 @@ export function AnalysisPage({ onNavigate }: AnalysisPageProps) {
} }
const totals = overview?.totals; const totals = overview?.totals;
const teamspaceTimeTrackingEnabled = currentUser.teamspace_time_tracking_enabled ?? true;
const periodLabel = periodType === "month" ? "Monat" : "Tag"; const periodLabel = periodType === "month" ? "Monat" : "Tag";
const metricCards: Array<{ label: string; value: string | number; detail?: string; icon: LucideIcon }> = [ const metricCards: Array<{ label: string; value: string | number; detail?: string; icon: LucideIcon }> = [
{ label: "Tickets", value: totals?.tickets ?? 0, icon: Ticket }, { label: "Tickets", value: totals?.tickets ?? 0, icon: Ticket },
{ label: "Sessions", value: totals?.sessions ?? 0, icon: Users }, { 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: "Abrechenbar", value: formatMinutes(totals?.billableMinutes ?? 0), icon: UserCheck },
{ label: "Offen", value: totals?.openSessions ?? 0, detail: formatMinutes(totals?.openMinutes ?? 0), icon: UserCheck } { label: "Offen", value: totals?.openSessions ?? 0, detail: formatMinutes(totals?.openMinutes ?? 0), icon: UserCheck }
]; ];
+73 -48
View File
@@ -66,9 +66,10 @@ import type {
DashboardFavorite, DashboardFavorite,
DashboardPreference, DashboardPreference,
DashboardSortDirection, DashboardSortDirection,
DashboardWidget, DashboardWidget,
DashboardWidgetType, DashboardWidgetType,
PeriodOverview, AuthUser,
PeriodOverview,
ProjectTeamspaceEntry, ProjectTeamspaceEntry,
ProjectSummary, ProjectSummary,
RecurringBilling, RecurringBilling,
@@ -78,6 +79,7 @@ import type {
} from "../types"; } from "../types";
type DashboardPageProps = { type DashboardPageProps = {
currentUser: AuthUser;
onNavigate: (to: string) => void; onNavigate: (to: string) => void;
}; };
@@ -885,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 gridRef = useRef<HTMLDivElement | null>(null);
const [preference, setPreference] = useState<DashboardPreference>(() => normalizePreference({})); const [preference, setPreference] = useState<DashboardPreference>(() => normalizePreference({}));
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
@@ -1007,11 +1009,12 @@ export function DashboardPage({ onNavigate }: DashboardPageProps) {
}; };
}, [favoriteMetas, preference.favorites]); }, [favoriteMetas, preference.favorites]);
const maxGridRow = useMemo( const maxGridRow = useMemo(
() => Math.max(6, dragPreview ? dragPreview.y + dragPreview.h + 1 : 0, ...preference.widgets.map((widget) => widget.y + widget.h + 1)), () => Math.max(6, dragPreview ? dragPreview.y + dragPreview.h + 1 : 0, ...preference.widgets.map((widget) => widget.y + widget.h + 1)),
[dragPreview, preference.widgets] [dragPreview, preference.widgets]
); );
const dashboardStats = useMemo(() => { const teamspaceTimeTrackingEnabled = currentUser.teamspace_time_tracking_enabled ?? true;
const dashboardStats = useMemo(() => {
const monthTotals = monthOverview?.totals; const monthTotals = monthOverview?.totals;
const dayTotals = todayOverview?.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); const overBudget = projects.filter((project) => project.hour_budget_minutes !== null && project.hour_budget_minutes !== undefined && (project.consumption_minutes ?? 0) > project.hour_budget_minutes);
@@ -1048,17 +1051,18 @@ export function DashboardPage({ onNavigate }: DashboardPageProps) {
dayTotals, dayTotals,
statistics: statisticsOverview, statistics: statisticsOverview,
openRatings: monthOverview?.openSessions ?? [], openRatings: monthOverview?.openSessions ?? [],
todayTickets: todayOverview?.tickets ?? [], todayTickets: todayOverview?.tickets ?? [],
todayOpenRatings: todayOverview?.openSessions ?? [], todayOpenRatings: todayOverview?.openSessions ?? [],
dayControlTickets, dayControlTickets,
fixedBillingTickets, fixedBillingTickets,
teamspaceDelta: (monthTotals?.minutes ?? 0) - (monthTotals?.crmBilledMinutes ?? 0), teamspaceTimeTrackingEnabled,
overBudget, teamspaceDelta: teamspaceTimeTrackingEnabled ? (monthTotals?.minutes ?? 0) - (monthTotals?.crmBilledMinutes ?? 0) : 0,
overBudget,
nearlyFull, nearlyFull,
openTodos: todos.filter((todo) => todo.status === "open"), openTodos: todos.filter((todo) => todo.status === "open"),
overdueTodos: todos.filter((todo) => todo.status === "open" && todo.ends_on && todo.ends_on < currentDay()) overdueTodos: todos.filter((todo) => todo.status === "open" && todo.ends_on && todo.ends_on < currentDay())
}; };
}, [dayControlTickets, monthOverview, projects, recurringBillings, statisticsOverview, todayOverview, todos]); }, [dayControlTickets, monthOverview, projects, recurringBillings, statisticsOverview, teamspaceTimeTrackingEnabled, todayOverview, todos]);
function updateWidget(widgetId: string, changes: Partial<DashboardWidget>) { function updateWidget(widgetId: string, changes: Partial<DashboardWidget>) {
setPreference((current) => ({ setPreference((current) => ({
@@ -1384,9 +1388,10 @@ function useDashboardStatsPlaceholder() {
monthTotals: null as PeriodOverview["totals"] | null | undefined, monthTotals: null as PeriodOverview["totals"] | null | undefined,
dayTotals: null as PeriodOverview["totals"] | null | undefined, dayTotals: null as PeriodOverview["totals"] | null | undefined,
statistics: null as StatisticsOverview | null, statistics: null as StatisticsOverview | null,
openRatings: [] as PeriodOverview["openSessions"], openRatings: [] as PeriodOverview["openSessions"],
fixedBillingTickets: new Set<string>(), fixedBillingTickets: new Set<string>(),
teamspaceDelta: 0, teamspaceTimeTrackingEnabled: true,
teamspaceDelta: 0,
overBudget: [] as ProjectSummary[], overBudget: [] as ProjectSummary[],
nearlyFull: [] as ProjectSummary[], nearlyFull: [] as ProjectSummary[],
todayTickets: [] as PeriodOverview["tickets"], todayTickets: [] as PeriodOverview["tickets"],
@@ -1559,17 +1564,22 @@ function WidgetContent(props: WidgetContentProps) {
); );
} }
if (widget.type === "teamspace") { if (widget.type === "teamspace") {
const delta = stats.teamspaceDelta; const delta = stats.teamspaceDelta;
return ( return (
<MetricGrid <MetricGrid
items={[ 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-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") { if (widget.type === "openRatings") {
const filterValues = readFilterValues(widget.config, ["today", "older3", "fixedBilling", "manualBilling"]); const filterValues = readFilterValues(widget.config, ["today", "older3", "fixedBilling", "manualBilling"]);
@@ -1684,6 +1694,7 @@ type DayControlListItem = {
function DayControlWidget({ stats, projects, todos, onNavigate }: WidgetContentProps) { function DayControlWidget({ stats, projects, todos, onNavigate }: WidgetContentProps) {
const today = currentDay(); const today = currentDay();
const teamspaceTimeTrackingEnabled = stats.teamspaceTimeTrackingEnabled;
const overdueTodos = stableSort( const overdueTodos = stableSort(
todos.filter((todo) => todo.status === "open" && todo.ends_on && todo.ends_on < today), 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) (left, right) => compareOptionalDate(left.ends_on, right.ends_on, "asc") || compareText(left.title, right.title)
@@ -1701,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 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 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 teamspaceReviewTickets = teamspaceTimeTrackingEnabled ? 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 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( const budgetAttention = stableSort(
projects.filter((project) => { projects.filter((project) => {
const percent = projectConsumptionPercent(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) (left, right) => (compareNumber(projectConsumptionPercent(right) ?? 0, projectConsumptionPercent(left) ?? 0)) || compareText(left.ticket_number, right.ticket_number)
); );
@@ -1748,12 +1768,14 @@ function DayControlWidget({ stats, projects, todos, onNavigate }: WidgetContentP
done: waitingTickets.length === 0, done: waitingTickets.length === 0,
action: waitingTickets.length === 0 ? "Keine fälligen Wiedervorlagen." : `${waitingTickets.length} Wiedervorlage(n) bearbeiten oder neues Datum setzen.` action: waitingTickets.length === 0 ? "Keine fälligen Wiedervorlagen." : `${waitingTickets.length} Wiedervorlage(n) bearbeiten oder neues Datum setzen.`
}, },
{ {
key: "teamspace", key: "teamspace",
label: "Teamspace geprüft", label: teamspaceTimeTrackingEnabled ? "Teamspace geprüft" : "TS-Erfassung aus",
done: teamspaceReviewTickets.length === 0, done: teamspaceReviewTickets.length === 0,
action: teamspaceReviewTickets.length === 0 ? "Teamspace passt für heute." : `${teamspaceReviewTickets.length} Teamspace-Abgleich(e) korrigieren.` 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", key: "budget",
label: "Budget geprüft", label: "Budget geprüft",
@@ -1791,8 +1813,8 @@ function DayControlWidget({ stats, projects, todos, onNavigate }: WidgetContentP
key: `teamspace-review-${ticket.id}`, key: `teamspace-review-${ticket.id}`,
tone: "info" as const, tone: "info" as const,
title: ticket.title || ticket.ticket_number, title: ticket.title || ticket.ticket_number,
detail: `${ticket.ticket_number} · TT ${formatMinutes(ticket.total_minutes)} · Teamspace ${formatMinutes(ticket.crm_billed_minutes ?? 0)}`, detail: `${ticket.ticket_number} · TT ${formatMinutes(ticket.total_minutes)} · Teamspace ${formatMinutes(ticket.crm_billed_minutes ?? 0)}`,
note: dayControlTodaySessionReason(ticket), note: dayControlTodaySessionReason(ticket, teamspaceTimeTrackingEnabled),
onClick: () => onNavigate(`/tickets/${ticket.id}?month=${currentMonth()}`) onClick: () => onNavigate(`/tickets/${ticket.id}?month=${currentMonth()}`)
})) }))
].slice(0, 8); ].slice(0, 8);
@@ -1836,8 +1858,8 @@ function DayControlWidget({ stats, projects, todos, onNavigate }: WidgetContentP
items={afterworkTickets.slice(0, 4).map((ticket) => ({ items={afterworkTickets.slice(0, 4).map((ticket) => ({
key: `today-ticket-${ticket.id}`, key: `today-ticket-${ticket.id}`,
title: ticket.title || ticket.ticket_number, title: ticket.title || ticket.ticket_number,
detail: `${ticket.ticket_number} · ${formatMinutes(ticket.total_minutes + ticket.non_billable_minutes + ticket.open_minutes)} · ${ticket.session_count} Session(s)`, detail: `${ticket.ticket_number} · ${formatMinutes(ticket.total_minutes + ticket.non_billable_minutes + ticket.open_minutes)} · ${ticket.session_count} Session(s)`,
note: dayControlTodaySessionReason(ticket), note: dayControlTodaySessionReason(ticket, teamspaceTimeTrackingEnabled),
onClick: () => onNavigate(`/tickets/${ticket.id}?month=${currentMonth()}`) onClick: () => onNavigate(`/tickets/${ticket.id}?month=${currentMonth()}`)
}))} }))}
/> />
@@ -1860,17 +1882,20 @@ function DayControlWidget({ stats, projects, todos, onNavigate }: WidgetContentP
items={budgetAttention.slice(0, 4).map((project) => { items={budgetAttention.slice(0, 4).map((project) => {
const percent = projectConsumptionPercent(project); const percent = projectConsumptionPercent(project);
const hasUnassignedSessions = Number(project.unassigned_teamspace_session_count ?? 0) > 0; const hasUnassignedSessions = Number(project.unassigned_teamspace_session_count ?? 0) > 0;
const hasUnassignedSubentries = Number(project.unassigned_teamspace_subentry_count ?? 0) > 0;
const detail = project.close_review_required const detail = project.close_review_required
? project.zammad_merged_from_ticket_id || project.zammad_state_id !== "4" ? "Merge prüfen" : "Abschluss prüfen" ? project.zammad_merged_from_ticket_id || project.zammad_state_id !== "4" ? "Merge prüfen" : "Abschluss prüfen"
: hasUnassignedSessions : hasUnassignedSessions
? `${project.unassigned_teamspace_session_count} Session(s) ohne TS-Zuordnung` ? `${project.unassigned_teamspace_session_count} Session(s) ohne TS-Zuordnung`
: hasUnassignedSubentries
? `${project.unassigned_teamspace_subentry_count} Session(s) ohne Unterposition`
: `${percent ?? 0}% Kontingent verbraucht`; : `${percent ?? 0}% Kontingent verbraucht`;
const note = project.close_review_required const note = project.close_review_required
? project.zammad_merged_from_ticket_id || project.zammad_state_id !== "4" ? project.zammad_merged_from_ticket_id || project.zammad_state_id !== "4"
? "Lokale Projektdaten aus dem alten Ticket prüfen und in Zammad nachtragen." ? "Lokale Projektdaten aus dem alten Ticket prüfen und in Zammad nachtragen."
: "Abschluss in TT/Teamspace prüfen und ggf. Umbuchungen klären." : "Abschluss in TT/Teamspace prüfen und ggf. Umbuchungen klären."
: hasUnassignedSessions : hasUnassignedSessions || hasUnassignedSubentries
? "Sessions einem Teamspace-Projekt zuordnen." ? "Sessions über die Session-Bearbeitung zuordnen."
: "Kontingent kritisch: Budget, Verbrauch und weitere Arbeit prüfen."; : "Kontingent kritisch: Budget, Verbrauch und weitere Arbeit prüfen.";
return { return {
@@ -1888,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) { if (ticket.open_count > 0) {
return `${ticket.open_count} offene Bewertung(en): Bewertungsstatus setzen.`; return `${ticket.open_count} offene Bewertung(en): Bewertungsstatus setzen.`;
} }
const crmMinutes = ticket.crm_billed_minutes ?? 0; const crmMinutes = ticket.crm_billed_minutes ?? 0;
const deltaMinutes = teamspaceDeltaMinutes(ticket); 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.`; 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.`; return `Teamspace ok: ${formatMinutes(crmMinutes)} erfasst.`;
} }
+93
View File
@@ -24,9 +24,13 @@ export function ProfilePage({ currentUser, onUserUpdated }: ProfilePageProps) {
const [confirmPassword, setConfirmPassword] = useState(""); const [confirmPassword, setConfirmPassword] = useState("");
const [zammadApiToken, setZammadApiToken] = useState(""); const [zammadApiToken, setZammadApiToken] = useState("");
const [zammadTokenDirty, setZammadTokenDirty] = useState(false); 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 [zammadEmailSessionWebhookEnabled, setZammadEmailSessionWebhookEnabled] = useState(currentUser.zammad_email_session_webhook_enabled);
const [zammadAtossEmailSyncEnabled, setZammadAtossEmailSyncEnabled] = useState(currentUser.zammad_atoss_email_sync_enabled); const [zammadAtossEmailSyncEnabled, setZammadAtossEmailSyncEnabled] = useState(currentUser.zammad_atoss_email_sync_enabled);
const [sessionBillingPromptEnabled, setSessionBillingPromptEnabled] = useState(currentUser.session_billing_prompt_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 [zammadSessionNoteDefaultEnabled, setZammadSessionNoteDefaultEnabled] = useState(currentUser.zammad_session_note_default_enabled);
const [zammadTimeUnitRoundingEnabled, setZammadTimeUnitRoundingEnabled] = useState(currentUser.zammad_time_unit_rounding_enabled); const [zammadTimeUnitRoundingEnabled, setZammadTimeUnitRoundingEnabled] = useState(currentUser.zammad_time_unit_rounding_enabled);
const [zammadTimeUnitRoundingMinutes, setZammadTimeUnitRoundingMinutes] = useState(currentUser.zammad_time_unit_rounding_minutes); const [zammadTimeUnitRoundingMinutes, setZammadTimeUnitRoundingMinutes] = useState(currentUser.zammad_time_unit_rounding_minutes);
@@ -46,9 +50,13 @@ export function ProfilePage({ currentUser, onUserUpdated }: ProfilePageProps) {
setDisplayName(currentUser.display_name); setDisplayName(currentUser.display_name);
setZammadApiToken(""); setZammadApiToken("");
setZammadTokenDirty(false); setZammadTokenDirty(false);
setTeamspaceDeviceId("");
setTeamspaceDevicePassword("");
setTeamspaceCredentialsDirty(false);
setZammadEmailSessionWebhookEnabled(currentUser.zammad_email_session_webhook_enabled); setZammadEmailSessionWebhookEnabled(currentUser.zammad_email_session_webhook_enabled);
setZammadAtossEmailSyncEnabled(currentUser.zammad_atoss_email_sync_enabled); setZammadAtossEmailSyncEnabled(currentUser.zammad_atoss_email_sync_enabled);
setSessionBillingPromptEnabled(currentUser.session_billing_prompt_enabled); setSessionBillingPromptEnabled(currentUser.session_billing_prompt_enabled);
setTeamspaceTimeTrackingEnabled(currentUser.teamspace_time_tracking_enabled ?? true);
setZammadSessionNoteDefaultEnabled(currentUser.zammad_session_note_default_enabled); setZammadSessionNoteDefaultEnabled(currentUser.zammad_session_note_default_enabled);
setZammadTimeUnitRoundingEnabled(currentUser.zammad_time_unit_rounding_enabled); setZammadTimeUnitRoundingEnabled(currentUser.zammad_time_unit_rounding_enabled);
setZammadTimeUnitRoundingMinutes(currentUser.zammad_time_unit_rounding_minutes); setZammadTimeUnitRoundingMinutes(currentUser.zammad_time_unit_rounding_minutes);
@@ -134,11 +142,13 @@ export function ProfilePage({ currentUser, onUserUpdated }: ProfilePageProps) {
currentPassword: wantsPasswordChange ? currentPassword : undefined, currentPassword: wantsPasswordChange ? currentPassword : undefined,
newPassword: wantsPasswordChange ? newPassword : undefined, newPassword: wantsPasswordChange ? newPassword : undefined,
...(currentUser.role === "user" && zammadTokenDirty ? { zammadApiToken } : {}), ...(currentUser.role === "user" && zammadTokenDirty ? { zammadApiToken } : {}),
...(currentUser.role === "user" && teamspaceCredentialsDirty ? { teamspaceDeviceId, teamspaceDevicePassword } : {}),
...(currentUser.role === "user" ...(currentUser.role === "user"
? { ? {
zammadEmailSessionWebhookEnabled, zammadEmailSessionWebhookEnabled,
zammadAtossEmailSyncEnabled, zammadAtossEmailSyncEnabled,
sessionBillingPromptEnabled, sessionBillingPromptEnabled,
teamspaceTimeTrackingEnabled,
zammadSessionNoteDefaultEnabled, zammadSessionNoteDefaultEnabled,
zammadTimeUnitRoundingEnabled, zammadTimeUnitRoundingEnabled,
zammadTimeUnitRoundingMinutes, zammadTimeUnitRoundingMinutes,
@@ -160,6 +170,9 @@ export function ProfilePage({ currentUser, onUserUpdated }: ProfilePageProps) {
setConfirmPassword(""); setConfirmPassword("");
setZammadApiToken(""); setZammadApiToken("");
setZammadTokenDirty(false); setZammadTokenDirty(false);
setTeamspaceDeviceId("");
setTeamspaceDevicePassword("");
setTeamspaceCredentialsDirty(false);
toast.success("Profil gespeichert"); toast.success("Profil gespeichert");
} catch (error) { } catch (error) {
toast.error("Profil konnte nicht gespeichert werden", { toast.error("Profil konnte nicht gespeichert werden", {
@@ -301,6 +314,86 @@ export function ProfilePage({ currentUser, onUserUpdated }: ProfilePageProps) {
) : null} ) : null}
</div> </div>
</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="mt-4 flex items-center justify-between gap-3 rounded-md border bg-background p-3">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<MailPlus className="size-4 text-muted-foreground" /> <MailPlus className="size-4 text-muted-foreground" />
+82 -78
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 { FormEvent, useEffect, useMemo, useState } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
@@ -19,7 +19,7 @@ import { acknowledgeProjectReview, addFavoriteTicket, createProject, getProjects
import { currentMonth, formatMinutes } from "../format"; import { currentMonth, formatMinutes } from "../format";
import { projectHasRecurringBilling } from "../recurring-billings"; import { projectHasRecurringBilling } from "../recurring-billings";
import { ticketPattern } from "../timers"; import { ticketPattern } from "../timers";
import type { ProjectSummary, ProjectTeamspaceEntry, TicketMeta } from "../types"; import type { AuthUser, ProjectSummary, ProjectTeamspaceEntry, TicketMeta } from "../types";
import { import {
matchesZammadPriorityFilter, matchesZammadPriorityFilter,
matchesZammadStateFilter, matchesZammadStateFilter,
@@ -36,6 +36,7 @@ import {
} from "../zammad-meta"; } from "../zammad-meta";
type ProjectsPageProps = { type ProjectsPageProps = {
currentUser: AuthUser;
onNavigate: (to: string) => void; onNavigate: (to: string) => void;
onStartTimer: (ticketNumber: string) => Promise<boolean>; onStartTimer: (ticketNumber: string) => Promise<boolean>;
}; };
@@ -411,21 +412,11 @@ function projectOrganizationKey(project: ProjectSummary) {
return project.organization_id ? `id:${project.organization_id}` : `name:${projectOrganizationLabel(project)}`; return project.organization_id ? `id:${project.organization_id}` : `name:${projectOrganizationLabel(project)}`;
} }
function projectBudget(project: ProjectSummary, standardTeamspaceOnly = false) { function projectBudget(project: ProjectSummary, _standardTeamspaceOnly = false) {
if (standardTeamspaceOnly) {
return defaultProjectTeamspaceEntry(project)?.hour_budget_minutes ?? null;
}
return project.hour_budget_minutes ?? null; return project.hour_budget_minutes ?? null;
} }
function projectConsumption(project: ProjectSummary, standardTeamspaceOnly = false) { function projectConsumption(project: ProjectSummary, _standardTeamspaceOnly = false) {
if (standardTeamspaceOnly) {
const defaultEntry = defaultProjectTeamspaceEntry(project);
return defaultEntry ? projectEntryConsumption(defaultEntry) : 0;
}
return Number(project.consumption_minutes ?? 0); return Number(project.consumption_minutes ?? 0);
} }
@@ -746,6 +737,7 @@ type ProjectFormFieldsProps = {
form: ProjectForm; form: ProjectForm;
setForm: (form: ProjectForm) => void; setForm: (form: ProjectForm) => void;
organizationLocked: boolean; organizationLocked: boolean;
showManualConsumption?: boolean;
lookupStatus: "idle" | "loading" | "found" | "not-found"; lookupStatus: "idle" | "loading" | "found" | "not-found";
zammadLookupBusy?: boolean; zammadLookupBusy?: boolean;
saving: boolean; saving: boolean;
@@ -767,10 +759,9 @@ function normalizeProjectTeamspaceEntryForms(entries: ProjectTeamspaceEntryForm[
function parseProjectTeamspaceEntryForms(entries: ProjectTeamspaceEntryForm[]) { function parseProjectTeamspaceEntryForms(entries: ProjectTeamspaceEntryForm[]) {
const normalizedEntries = normalizeProjectTeamspaceEntryForms(entries); const normalizedEntries = normalizeProjectTeamspaceEntryForms(entries);
const parsedEntries = normalizedEntries.map((entry) => { const parsedEntries = normalizedEntries.map((entry) => {
const hourBudgetMinutes = parseHoursInput(entry.hourBudget);
const manualConsumptionMinutes = parseHoursInput(entry.manualConsumption); const manualConsumptionMinutes = parseHoursInput(entry.manualConsumption);
if (hourBudgetMinutes === undefined || manualConsumptionMinutes === undefined) { if (manualConsumptionMinutes === undefined) {
return undefined; return undefined;
} }
@@ -779,8 +770,8 @@ function parseProjectTeamspaceEntryForms(entries: ProjectTeamspaceEntryForm[]) {
teamspaceProject: entry.teamspaceProject.trim() || null, teamspaceProject: entry.teamspaceProject.trim() || null,
teamspaceDescription: entry.teamspaceDescription.trim() || null, teamspaceDescription: entry.teamspaceDescription.trim() || null,
isDefault: entry.isDefault, isDefault: entry.isDefault,
hourBudgetMinutes, hourBudgetMinutes: null,
budgetSyncDisabled: entry.budgetSyncDisabled, budgetSyncDisabled: false,
manualConsumptionMinutes: manualConsumptionMinutes ?? 0 manualConsumptionMinutes: manualConsumptionMinutes ?? 0
}; };
}); });
@@ -806,10 +797,12 @@ function defaultProjectTeamspacePayload(entries: ReturnType<typeof parseProjectT
function ProjectTeamspaceEntriesFields({ function ProjectTeamspaceEntriesFields({
entries, entries,
onChange onChange,
showManualConsumption = true
}: { }: {
entries: ProjectTeamspaceEntryForm[]; entries: ProjectTeamspaceEntryForm[];
onChange: (entries: ProjectTeamspaceEntryForm[]) => void; onChange: (entries: ProjectTeamspaceEntryForm[]) => void;
showManualConsumption?: boolean;
}) { }) {
const normalizedEntries = normalizeProjectTeamspaceEntryForms(entries); const normalizedEntries = normalizeProjectTeamspaceEntryForms(entries);
@@ -836,7 +829,15 @@ function ProjectTeamspaceEntriesFields({
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
{normalizedEntries.map((entry, index) => ( {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)_minmax(160px,0.9fr)_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"> <div className="space-y-1.5">
<label className="text-xs font-medium text-muted-foreground" htmlFor={`project-teamspace-${entry.clientId}`}> <label className="text-xs font-medium text-muted-foreground" htmlFor={`project-teamspace-${entry.clientId}`}>
Teamspace-Projekt {entry.isDefault ? "(Standard)" : ""} Teamspace-Projekt {entry.isDefault ? "(Standard)" : ""}
@@ -858,32 +859,19 @@ function ProjectTeamspaceEntriesFields({
onChange={(event) => updateEntry(entry.clientId, { teamspaceDescription: event.currentTarget.value })} onChange={(event) => updateEntry(entry.clientId, { teamspaceDescription: event.currentTarget.value })}
/> />
</div> </div>
<div className="space-y-1.5"> {showManualConsumption ? (
<label className="text-xs font-medium text-muted-foreground" htmlFor={`project-budget-${entry.clientId}`}> <div className="space-y-1.5">
Budget h <label className="text-xs font-medium text-muted-foreground" htmlFor={`project-teamspace-manual-${entry.clientId}`}>
</label> Manuell h
<Input </label>
id={`project-budget-${entry.clientId}`} <Input
inputMode="decimal" id={`project-teamspace-manual-${entry.clientId}`}
value={entry.hourBudget} inputMode="decimal"
onChange={(event) => updateEntry(entry.clientId, { hourBudget: event.currentTarget.value, budgetSyncDisabled: true })} value={entry.manualConsumption}
/> onChange={(event) => updateEntry(entry.clientId, { manualConsumption: event.currentTarget.value })}
</div> />
<label className="flex min-h-9 items-center gap-2 rounded-md border bg-background px-3 text-xs"> </div>
<Checkbox checked={entry.budgetSyncDisabled} onCheckedChange={(checked) => updateEntry(entry.clientId, { budgetSyncDisabled: checked === true })} /> ) : null}
<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 })}
/>
</div>
<Button type="button" size="sm" variant={entry.isDefault ? "default" : "secondary"} onClick={() => setDefault(entry.clientId)}> <Button type="button" size="sm" variant={entry.isDefault ? "default" : "secondary"} onClick={() => setDefault(entry.clientId)}>
{entry.isDefault ? "Standard" : "Als Standard"} {entry.isDefault ? "Standard" : "Als Standard"}
</Button> </Button>
@@ -894,14 +882,11 @@ function ProjectTeamspaceEntriesFields({
</div> </div>
))} ))}
</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> </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 ( return (
<form className="space-y-4" onSubmit={onSubmit}> <form className="space-y-4" onSubmit={onSubmit}>
<div className="grid gap-3 lg:grid-cols-[180px_minmax(240px,1fr)_180px]"> <div className="grid gap-3 lg:grid-cols-[180px_minmax(240px,1fr)_180px]">
@@ -975,7 +960,11 @@ function ProjectFormFields({ form, setForm, organizationLocked, lookupStatus, za
</div> </div>
</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"> <div className="space-y-2">
<label className="text-sm font-medium" htmlFor="project-description"> <label className="text-sm font-medium" htmlFor="project-description">
@@ -994,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 [projects, setProjects] = useState<ProjectSummary[]>([]);
const [hiddenProjectCount, setHiddenProjectCount] = useState(0); const [hiddenProjectCount, setHiddenProjectCount] = useState(0);
const [totalProjectCount, setTotalProjectCount] = useState(0); const [totalProjectCount, setTotalProjectCount] = useState(0);
@@ -1011,6 +1000,7 @@ export function ProjectsPage({ onNavigate, onStartTimer }: ProjectsPageProps) {
const [savingProjectId, setSavingProjectId] = useState<string | null>(null); const [savingProjectId, setSavingProjectId] = useState<string | null>(null);
const [savingProjectStatusId, setSavingProjectStatusId] = useState<string | null>(null); const [savingProjectStatusId, setSavingProjectStatusId] = useState<string | null>(null);
const [viewSettings, setViewSettings] = useState<ProjectViewSettings>(() => readStoredProjectViewSettings()); const [viewSettings, setViewSettings] = useState<ProjectViewSettings>(() => readStoredProjectViewSettings());
const teamspaceTimeTrackingEnabled = currentUser.teamspace_time_tracking_enabled ?? true;
async function load() { async function load() {
setLoading(true); setLoading(true);
@@ -1189,8 +1179,8 @@ export function ProjectsPage({ onNavigate, onStartTimer }: ProjectsPageProps) {
...entry, ...entry,
teamspaceProject: entry.teamspaceProject || result.ticket?.project_name || "", teamspaceProject: entry.teamspaceProject || result.ticket?.project_name || "",
teamspaceDescription: entry.teamspaceDescription, teamspaceDescription: entry.teamspaceDescription,
hourBudget: entry.hourBudget || formatHoursInput(result.ticket?.project_hour_budget_minutes ?? null), hourBudget: "",
budgetSyncDisabled: entry.hourBudget ? entry.budgetSyncDisabled : false budgetSyncDisabled: false
} }
: entry; : entry;
}); });
@@ -1626,6 +1616,7 @@ export function ProjectsPage({ onNavigate, onStartTimer }: ProjectsPageProps) {
form={form} form={form}
setForm={setForm} setForm={setForm}
organizationLocked={organizationLocked} organizationLocked={organizationLocked}
showManualConsumption={teamspaceTimeTrackingEnabled}
lookupStatus={lookupStatus} lookupStatus={lookupStatus}
zammadLookupBusy={zammadLookupBusy} zammadLookupBusy={zammadLookupBusy}
saving={saving} saving={saving}
@@ -1663,8 +1654,11 @@ export function ProjectsPage({ onNavigate, onStartTimer }: ProjectsPageProps) {
const budgetMinutes = projectBudget(project, viewSettings.standardTeamspaceOnly); const budgetMinutes = projectBudget(project, viewSettings.standardTeamspaceOnly);
const remaining = projectRemaining(project, viewSettings.standardTeamspaceOnly); const remaining = projectRemaining(project, viewSettings.standardTeamspaceOnly);
const usedPercent = projectConsumptionPercent(project, viewSettings.standardTeamspaceOnly); const usedPercent = projectConsumptionPercent(project, viewSettings.standardTeamspaceOnly);
const budgetExceeded = budgetMinutes !== null && budgetMinutes > 0 && consumptionMinutes > budgetMinutes;
const showBudgetedTeamspaceEntries = !viewSettings.standardTeamspaceOnly && budgetedTeamspaceEntries.length > 1; const showBudgetedTeamspaceEntries = !viewSettings.standardTeamspaceOnly && budgetedTeamspaceEntries.length > 1;
const canEditProject = project.can_edit === true; const canEditProject = project.can_edit === true;
const unassignedTeamspaceEntryCount = Number(project.unassigned_teamspace_session_count ?? 0);
const unassignedTeamspaceSubentryCount = 0;
return ( return (
<div key={project.id} className="border-b last:border-b-0"> <div key={project.id} className="border-b last:border-b-0">
@@ -1688,7 +1682,8 @@ export function ProjectsPage({ onNavigate, onStartTimer }: ProjectsPageProps) {
{project.closed_at ? <Badge variant="secondary">Geschlossen</Badge> : <Badge variant="success">Offen</Badge>} {project.closed_at ? <Badge variant="secondary">Geschlossen</Badge> : <Badge variant="success">Offen</Badge>}
{!canEditProject ? <Badge variant="outline">geteilt</Badge> : null} {!canEditProject ? <Badge variant="outline">geteilt</Badge> : null}
{project.close_review_required ? <Badge variant="warning">TS/TT prüfen</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>
<div className="flex flex-wrap gap-1.5"> <div className="flex flex-wrap gap-1.5">
<p className="w-full truncate text-sm font-medium xl:hidden">Zammad</p> <p className="w-full truncate text-sm font-medium xl:hidden">Zammad</p>
@@ -1700,9 +1695,10 @@ export function ProjectsPage({ onNavigate, onStartTimer }: ProjectsPageProps) {
</div> </div>
<div className="min-w-0"> <div className="min-w-0">
<p className="truncate text-sm font-medium xl:hidden">Verbrauch/Gesamt</p> <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)}> <p className={cn("inline-flex items-center gap-1 text-sm font-semibold", remaining !== null && remaining < 0 ? "text-destructive" : undefined)}>
{formatDecimalHours(consumptionMinutes)} / {budgetMinutes ? formatDecimalHours(budgetMinutes) : "offen"} {budgetExceeded ? <AlertCircle className="size-3.5 text-amber-500" aria-label="Budgetüberschreitung" /> : null}
</p> {formatDecimalHours(consumptionMinutes)} / {budgetMinutes ? formatDecimalHours(budgetMinutes) : "offen"}
</p>
{showBudgetedTeamspaceEntries ? <p className="text-xs text-muted-foreground">{budgetedTeamspaceEntries.length} Budgets</p> : null} {showBudgetedTeamspaceEntries ? <p className="text-xs text-muted-foreground">{budgetedTeamspaceEntries.length} Budgets</p> : null}
</div> </div>
<div className="min-w-0 text-sm"> <div className="min-w-0 text-sm">
@@ -1769,18 +1765,24 @@ export function ProjectsPage({ onNavigate, onStartTimer }: ProjectsPageProps) {
</div> </div>
</div> </div>
<div className="space-y-2 px-4 pb-3"> <div className="space-y-2 px-4 pb-3">
{usedPercent !== null ? ( {usedPercent !== null ? (
<div className="h-1.5 overflow-hidden rounded-full bg-muted" title={`${usedPercent}% Verbrauch`} aria-label={`${usedPercent}% Verbrauch`}> <div className="flex items-center gap-1">
<div className={cn("h-full rounded-full", projectConsumptionBarClass(usedPercent))} style={{ width: `${Math.min(100, usedPercent)}%` }} /> <div className="h-1.5 flex-1 overflow-hidden rounded-full bg-muted" title={`${usedPercent}% Verbrauch`} aria-label={`${usedPercent}% Verbrauch`}>
</div> <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-1.5 overflow-hidden rounded-full bg-muted/30">
<div className="h-full rounded-full bg-muted-foreground/10" style={{ width: "0%" }} /> <div className="h-full rounded-full bg-muted-foreground/10" style={{ width: "0%" }} />
</div> </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"> <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> </p>
) : null} ) : null}
{project.close_review_required ? ( {project.close_review_required ? (
@@ -1788,8 +1790,8 @@ export function ProjectsPage({ onNavigate, onStartTimer }: ProjectsPageProps) {
<span> <span>
{project.zammad_merged_from_ticket_id || project.zammad_state_id !== "4" {project.zammad_merged_from_ticket_id || project.zammad_state_id !== "4"
? project.zammad_merged_from_ticket_id ? project.zammad_merged_from_ticket_id
? `Dieses Projekt wurde aus ${project.zammad_merged_from_ticket_number ?? `Zammad-ID ${project.zammad_merged_from_ticket_id}`} übernommen. Teamspace-Projekte und Budget bleiben lokal erhalten; bitte in Zammad prüfen und nachtragen.` ? `Dieses Projekt 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 und Budget bleiben lokal erhalten; bitte in Zammad prüfen und nachtragen." : "Dieses Projekt steht zur TS/TT-Prüfung, obwohl das Zammad-Ticket offen ist. Teamspace-Projekte 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."} : "Zammad meldet das Consulting-Ticket als geschlossen. Bitte Zeiten in Teamspace und TicketTracker prüfen und das Projekt danach schließen."}
</span> </span>
<Button <Button
@@ -1808,9 +1810,10 @@ export function ProjectsPage({ onNavigate, onStartTimer }: ProjectsPageProps) {
{showBudgetedTeamspaceEntries ? ( {showBudgetedTeamspaceEntries ? (
<div className="grid gap-1.5 md:grid-cols-2"> <div className="grid gap-1.5 md:grid-cols-2">
{budgetedTeamspaceEntries.map((entry) => { {budgetedTeamspaceEntries.map((entry) => {
const entryPercent = projectEntryPercent(entry); const entryPercent = projectEntryPercent(entry);
const entryConsumption = projectEntryConsumption(entry); const entryConsumption = projectEntryConsumption(entry);
const label = projectTeamspaceEntryLabel(entry); const label = projectTeamspaceEntryLabel(entry);
const entryBudgetExceeded = Number(entry.hour_budget_minutes ?? 0) > 0 && entryConsumption > Number(entry.hour_budget_minutes ?? 0);
return ( return (
<div key={entry.id} className="min-w-0 rounded-md border bg-background/50 px-2 py-1"> <div key={entry.id} className="min-w-0 rounded-md border bg-background/50 px-2 py-1">
@@ -1818,11 +1821,11 @@ export function ProjectsPage({ onNavigate, onStartTimer }: ProjectsPageProps) {
<span className="truncate text-muted-foreground"> <span className="truncate text-muted-foreground">
{label} {label}
{entry.is_default ? " · Standard" : ""} {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>
<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>
<div className="h-1 overflow-hidden rounded-full bg-muted"> <div className="h-1 overflow-hidden rounded-full bg-muted">
<div <div
@@ -1854,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.startsOn} onChange={(event) => setEditForm({ ...editForm, startsOn: event.currentTarget.value })} />
<Input type="date" value={editForm.endsOn} onChange={(event) => setEditForm({ ...editForm, endsOn: event.currentTarget.value })} /> <Input type="date" value={editForm.endsOn} onChange={(event) => setEditForm({ ...editForm, endsOn: event.currentTarget.value })} />
</div> </div>
<ProjectTeamspaceEntriesFields <ProjectTeamspaceEntriesFields
entries={editForm.teamspaceEntries} entries={editForm.teamspaceEntries}
onChange={(teamspaceEntries) => setEditForm({ ...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 })} /> <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"> <div className="flex flex-wrap justify-end gap-2">
<Button <Button
+106 -53
View File
@@ -27,9 +27,10 @@ import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@
import { HelpLink } from "@/components/HelpLink"; import { HelpLink } from "@/components/HelpLink";
import { getProjectFollowups, getStatisticsOverview } from "../api"; import { getProjectFollowups, getStatisticsOverview } from "../api";
import { currentMonth, formatDate, formatDateTime, formatMinutes } from "../format"; import { currentMonth, formatDate, formatDateTime, formatMinutes } from "../format";
import type { StatisticsDailyBucket, StatisticsGroup, StatisticsOverview, StatisticsProjectFollowup, StatisticsProjectFollowupOverview, StatisticsTicket, WorkType } from "../types"; import type { AuthUser, StatisticsDailyBucket, StatisticsGroup, StatisticsOverview, StatisticsProjectFollowup, StatisticsProjectFollowupOverview, StatisticsTicket, WorkType } from "../types";
type StatisticsPageProps = { type StatisticsPageProps = {
currentUser: AuthUser;
onNavigate: (to: string) => void; onNavigate: (to: string) => void;
}; };
@@ -109,7 +110,7 @@ function TimeComparison({ trackedMinutes, crmMinutes, sessions }: { trackedMinut
); );
} }
function ColorLegend() { function ColorLegend({ showDifference = true }: { showDifference?: boolean }) {
return ( 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"> <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"> <span className="inline-flex items-center gap-1.5">
@@ -128,14 +129,18 @@ function ColorLegend() {
<span className="size-2 rounded-sm bg-indigo-500 dark:bg-indigo-400" /> <span className="size-2 rounded-sm bg-indigo-500 dark:bg-indigo-400" />
<span className={nonBillableTextClass}>Nicht abrechenbar</span> <span className={nonBillableTextClass}>Nicht abrechenbar</span>
</span> </span>
<span className="inline-flex items-center gap-1.5"> {showDifference ? (
<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 className="inline-flex items-center gap-1.5">
</span> <span className="size-2 rounded-full bg-emerald-500 dark:bg-emerald-400" />
<span className="inline-flex items-center gap-1.5"> <span className="text-emerald-700 dark:text-emerald-300">Differenz plus oder ausgeglichen</span>
<span className="size-2 rounded-full bg-red-500 dark:bg-red-400" /> </span>
<span className="text-red-700 dark:text-red-300">Differenz minus</span> <span className="inline-flex items-center gap-1.5">
</span> <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="inline-flex items-center gap-1.5">
<span className="size-2 rounded-full bg-amber-500 dark:bg-amber-400" /> <span className="size-2 rounded-full bg-amber-500 dark:bg-amber-400" />
<span className={openTextClass}>offen</span> <span className={openTextClass}>offen</span>
@@ -210,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 series = buildMonthSeries(month, buckets);
const width = 900; const width = 900;
const height = 220; const height = 220;
const padding = { top: 18, right: 20, bottom: 36, left: 64 }; const padding = { top: 18, right: 20, bottom: 36, left: 64 };
const innerWidth = width - padding.left - padding.right; const innerWidth = width - padding.left - padding.right;
const innerHeight = height - padding.top - padding.bottom; 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 ticks = Array.from({ length: 5 }).map((_, index) => maxMinutes - (index * maxMinutes) / 4);
const labelStep = Math.max(1, Math.ceil(series.length / 9)); 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)); const barWidth = Math.max(5, Math.min(16, innerWidth / Math.max(series.length, 1) / 2));
@@ -231,11 +236,11 @@ function DailyEffortChart({ month, buckets }: { month: string; buckets: Statisti
crmY: padding.top + (1 - bucket.crmBilledMinutes / maxMinutes) * innerHeight 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 ( return (
<div className="h-56 w-full overflow-hidden rounded-md border bg-background"> <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"> <svg viewBox={`0 0 ${width} ${height}`} className="h-full w-full">
{ticks.map((tick) => { {ticks.map((tick) => {
const y = padding.top + (1 - tick / maxMinutes) * innerHeight; const y = padding.top + (1 - tick / maxMinutes) * innerHeight;
@@ -258,11 +263,11 @@ function DailyEffortChart({ month, buckets }: { month: string; buckets: Statisti
) : null; ) : null;
})} })}
{crmPath ? <path d={crmPath} fill="none" stroke="currentColor" strokeWidth="2.5" className={teamspaceStrokeClass} /> : null} {crmPath ? <path d={crmPath} fill="none" stroke="currentColor" strokeWidth="2.5" className={teamspaceStrokeClass} /> : null}
{points {showTeamspaceLine ? points
.filter((point) => point.bucket.crmBilledMinutes > 0) .filter((point) => point.bucket.crmBilledMinutes > 0)
.map((point) => ( .map((point) => (
<circle key={`${point.bucket.day}-crm`} cx={point.x} cy={point.crmY} r="3" className={`fill-background ${teamspaceStrokeClass}`} stroke="currentColor" strokeWidth="2" /> <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) => {points.map((point, index) =>
index % labelStep === 0 || index === points.length - 1 ? ( 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]"> <text key={`${point.bucket.day}-label`} x={point.x} y={height - 11} textAnchor="middle" className="fill-muted-foreground text-[11px]">
@@ -312,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); const monthlyShare = percentage(organization.total_minutes, totalMinutes);
return ( return (
@@ -327,9 +342,11 @@ function OrganizationRow({ organization, maxMinutes, totalMinutes }: { organizat
<TableCell> <TableCell>
<TimeComparison trackedMinutes={organization.total_minutes} crmMinutes={organization.crm_billed_minutes} /> <TimeComparison trackedMinutes={organization.total_minutes} crmMinutes={organization.crm_billed_minutes} />
</TableCell> </TableCell>
<TableCell> {showDifference ? (
<DeltaBadge minutes={organization.crm_delta_minutes} /> <TableCell>
</TableCell> <DeltaBadge minutes={organization.crm_delta_minutes} />
</TableCell>
) : null}
<TableCell className="min-w-[150px]"> <TableCell className="min-w-[150px]">
<Progress value={percentage(organization.total_minutes, maxMinutes)} /> <Progress value={percentage(organization.total_minutes, maxMinutes)} />
<div className="mt-1 text-xs text-muted-foreground">{monthlyShare}% vom Monat</div> <div className="mt-1 text-xs text-muted-foreground">{monthlyShare}% vom Monat</div>
@@ -338,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 ( return (
<div className="grid gap-2 rounded-md border bg-background p-3 sm:grid-cols-[1fr_auto] sm:items-center"> <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"> <div className="min-w-0">
@@ -353,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.billed_sessions} abgerechnet</span>
<span className="text-muted-foreground">{ticket.billable_sessions} abrechenbar</span> <span className="text-muted-foreground">{ticket.billable_sessions} abrechenbar</span>
<span>{ticket.active_days} Tag(e)</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>
</div> </div>
<Button variant="secondary" size="sm" onClick={() => onNavigate(`/tickets/${ticket.ticket_id}?month=${month}`)}> <Button variant="secondary" size="sm" onClick={() => onNavigate(`/tickets/${ticket.ticket_id}?month=${month}`)}>
@@ -421,7 +448,7 @@ function ProjectFollowupRow({ project, onNavigate }: { project: StatisticsProjec
); );
} }
export function StatisticsPage({ onNavigate }: StatisticsPageProps) { export function StatisticsPage({ currentUser, onNavigate }: StatisticsPageProps) {
const [month, setMonth] = useState(currentMonth()); const [month, setMonth] = useState(currentMonth());
const [stats, setStats] = useState<StatisticsOverview | null>(null); const [stats, setStats] = useState<StatisticsOverview | null>(null);
const [followups, setFollowups] = useState<StatisticsProjectFollowupOverview | null>(null); const [followups, setFollowups] = useState<StatisticsProjectFollowupOverview | null>(null);
@@ -523,6 +550,7 @@ export function StatisticsPage({ onNavigate }: StatisticsPageProps) {
} }
const totals = stats?.totals; const totals = stats?.totals;
const teamspaceTimeTrackingEnabled = currentUser.teamspace_time_tracking_enabled ?? true;
const billedSessions = totals?.billedSessions ?? 0; const billedSessions = totals?.billedSessions ?? 0;
const billedMinutes = totals?.billedMinutes ?? 0; const billedMinutes = totals?.billedMinutes ?? 0;
const billableSessions = totals?.billableSessions ?? 0; const billableSessions = totals?.billableSessions ?? 0;
@@ -558,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 className="flex flex-col gap-3 xl:flex-row xl:items-end xl:justify-between">
<div> <div>
<h2 className="text-2xl font-semibold tracking-normal">Statistiken</h2> <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>
<div className="grid gap-1 sm:w-[300px]"> <div className="grid gap-1 sm:w-[300px]">
<div className="flex items-center justify-between gap-2"> <div className="flex items-center justify-between gap-2">
@@ -607,7 +639,9 @@ export function StatisticsPage({ onNavigate }: StatisticsPageProps) {
<MetricCard <MetricCard
label="Teamspace" label="Teamspace"
value={formatMinutes(totals?.crmBilledMinutes ?? 0)} 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} icon={TrendingUp}
valueClassName={teamspaceTextClass} valueClassName={teamspaceTextClass}
helpAnchor="statistik-teamspace" helpAnchor="statistik-teamspace"
@@ -633,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"> <CardHeader className="gap-3 p-4 sm:flex-row sm:items-start sm:justify-between sm:space-y-0">
<div> <div>
<CardTitle>Monatsverlauf und Abrechnungsqualität</CardTitle> <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>
<div className="flex items-center gap-2"> <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" /> <HelpLink anchor="statistik-verlauf" label="Hilfe zu Monatsverlauf und Abrechnungsqualität" />
</div> </div>
</CardHeader> </CardHeader>
<CardContent className="grid gap-4 px-4 pb-4 xl:grid-cols-[1.55fr_1fr]"> <CardContent className="grid gap-4 px-4 pb-4 xl:grid-cols-[1.55fr_1fr]">
<div className="space-y-3"> <div className="space-y-3">
<DailyEffortChart month={month} buckets={stats?.dailySeries ?? []} /> <DailyEffortChart month={month} buckets={stats?.dailySeries ?? []} showTeamspaceLine={teamspaceTimeTrackingEnabled} />
<ColorLegend /> <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} {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> </div>
@@ -661,17 +699,27 @@ export function StatisticsPage({ onNavigate }: StatisticsPageProps) {
</div> </div>
</div> </div>
<div className="space-y-2 rounded-md border bg-muted/20 p-3"> {teamspaceTimeTrackingEnabled ? (
<div className="flex items-start justify-between gap-3"> <div className="space-y-2 rounded-md border bg-muted/20 p-3">
<div> <div className="flex items-start justify-between gap-3">
<p className="text-sm font-medium">Teamspace-Abgleich</p> <div>
<p className="text-xs text-muted-foreground">{deltaDescription(totals?.crmDeltaMinutes ?? 0)}</p> <p className="text-sm font-medium">Teamspace-Abgleich</p>
</div> <p className="text-xs text-muted-foreground">{deltaDescription(totals?.crmDeltaMinutes ?? 0)}</p>
<DeltaBadge minutes={totals?.crmDeltaMinutes ?? 0} /> </div>
</div> <DeltaBadge minutes={totals?.crmDeltaMinutes ?? 0} />
<TimeComparison trackedMinutes={totals?.minutes ?? 0} crmMinutes={totals?.crmBilledMinutes ?? 0} sessions={totals?.billedSessions ?? 0} /> </div>
<div className="text-xs text-muted-foreground">Teamspace-Abdeckung: {crmCoverage}% der abgerechneten Zeit</div> <TimeComparison trackedMinutes={totals?.minutes ?? 0} crmMinutes={totals?.crmBilledMinutes ?? 0} sessions={totals?.billedSessions ?? 0} />
</div> <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="grid grid-cols-2 gap-2 text-sm">
<div className="rounded-md border bg-muted/20 p-3"> <div className="rounded-md border bg-muted/20 p-3">
@@ -715,7 +763,11 @@ export function StatisticsPage({ onNavigate }: StatisticsPageProps) {
<CardHeader className="gap-3 p-4 sm:flex-row sm:items-start sm:justify-between sm:space-y-0"> <CardHeader className="gap-3 p-4 sm:flex-row sm:items-start sm:justify-between sm:space-y-0">
<div> <div>
<CardTitle>Statistik pro Organisation</CardTitle> <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>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Badge variant="outline"> <Badge variant="outline">
@@ -732,7 +784,7 @@ export function StatisticsPage({ onNavigate }: StatisticsPageProps) {
<TableHead>Organisation</TableHead> <TableHead>Organisation</TableHead>
<TableHead>Abgerechnet</TableHead> <TableHead>Abgerechnet</TableHead>
<TableHead>Zeiten</TableHead> <TableHead>Zeiten</TableHead>
<TableHead>Differenz</TableHead> {teamspaceTimeTrackingEnabled ? <TableHead>Differenz</TableHead> : null}
<TableHead>Anteil abgerechnet</TableHead> <TableHead>Anteil abgerechnet</TableHead>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
@@ -740,10 +792,11 @@ export function StatisticsPage({ onNavigate }: StatisticsPageProps) {
{visibleOrganizations.map((organization) => ( {visibleOrganizations.map((organization) => (
<OrganizationRow <OrganizationRow
key={organization.organization_id ?? organization.organization_name} key={organization.organization_id ?? organization.organization_name}
organization={organization} organization={organization}
maxMinutes={maxOrganizationMinutes} maxMinutes={maxOrganizationMinutes}
totalMinutes={totals?.minutes ?? 0} totalMinutes={totals?.minutes ?? 0}
/> showDifference={teamspaceTimeTrackingEnabled}
/>
))} ))}
</TableBody> </TableBody>
</Table> </Table>
@@ -756,14 +809,14 @@ export function StatisticsPage({ onNavigate }: StatisticsPageProps) {
<p className="truncate font-medium">{organization.organization_name ?? "Keine Organisation"}</p> <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> <p className="text-xs text-muted-foreground">{organization.tickets} Ticket(s) · {organization.billed_sessions} abgerechnet · {organization.billable_sessions} abrechenbar</p>
</div> </div>
<DeltaBadge minutes={organization.crm_delta_minutes} /> {teamspaceTimeTrackingEnabled ? <DeltaBadge minutes={organization.crm_delta_minutes} /> : null}
</div> </div>
<Progress value={percentage(organization.total_minutes, maxOrganizationMinutes)} /> <Progress value={percentage(organization.total_minutes, maxOrganizationMinutes)} />
<div className="flex flex-wrap gap-x-3 gap-y-1 text-xs"> <div className="flex flex-wrap gap-x-3 gap-y-1 text-xs">
<span className={trackedTextClass}>Abgerechnet {formatMinutes(organization.total_minutes)}</span> <span className={trackedTextClass}>Abgerechnet {formatMinutes(organization.total_minutes)}</span>
<span className={teamspaceTextClass}>Teamspace {formatMinutes(organization.crm_billed_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="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>
</div> </div>
))} ))}
@@ -825,7 +878,7 @@ export function StatisticsPage({ onNavigate }: StatisticsPageProps) {
<p className="font-semibold">{workTypeLabel(group.work_type)}</p> <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> <p className="text-xs text-muted-foreground">{group.tickets} Ticket(s), {group.billed_sessions} abgerechnet, {group.billable_sessions} abrechenbar</p>
</div> </div>
<DeltaBadge minutes={group.crm_delta_minutes} /> {teamspaceTimeTrackingEnabled ? <DeltaBadge minutes={group.crm_delta_minutes} /> : null}
</div> </div>
<Progress value={percentage(group.total_minutes, totals?.minutes ?? 0)} /> <Progress value={percentage(group.total_minutes, totals?.minutes ?? 0)} />
<div className="flex flex-wrap gap-x-3 gap-y-1 text-xs"> <div className="flex flex-wrap gap-x-3 gap-y-1 text-xs">
@@ -863,7 +916,7 @@ export function StatisticsPage({ onNavigate }: StatisticsPageProps) {
</CardHeader> </CardHeader>
<CardContent className="space-y-2 px-4 pb-4"> <CardContent className="space-y-2 px-4 pb-4">
{(stats?.tickets ?? []).map((ticket) => ( {(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} {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> </CardContent>
File diff suppressed because it is too large. Load diff
+8 -4
View File
@@ -321,6 +321,8 @@ export function TimerPage({ timers, setTimers, selectedTimerId, setSelectedTimer
const runningTimer = timers.find((timer) => timer.phase === "running") ?? null; const runningTimer = timers.find((timer) => timer.phase === "running") ?? null;
const selectedManualTeamspaceEntry = ticketTeamspaceEntries(manualLookupTicket).find((entry) => entry.id === manualSession.projectTeamspaceEntryId) ?? null; const selectedManualTeamspaceEntry = ticketTeamspaceEntries(manualLookupTicket).find((entry) => entry.id === manualSession.projectTeamspaceEntryId) ?? null;
const selectedFinishedTeamspaceEntry = ticketTeamspaceEntries(finished?.ticket).find((entry) => entry.id === projectTeamspaceEntryId) ?? null; const selectedFinishedTeamspaceEntry = ticketTeamspaceEntries(finished?.ticket).find((entry) => entry.id === projectTeamspaceEntryId) ?? null;
const manualRequiresTeamspaceEntryForForm = requiresTeamspaceEntry(manualLookupTicket);
const finishedRequiresTeamspaceEntryForForm = requiresTeamspaceEntry(finished?.ticket);
useEffect(() => { useEffect(() => {
let cancelled = false; let cancelled = false;
@@ -825,7 +827,9 @@ export function TimerPage({ timers, setTimers, selectedTimerId, setSelectedTimer
return; return;
} }
if (requiresTeamspaceEntry(manualLookupTicket) && !manualSession.projectTeamspaceEntryId) { const manualRequiresTeamspaceEntry = requiresTeamspaceEntry(manualLookupTicket);
if (manualRequiresTeamspaceEntry && !manualSession.projectTeamspaceEntryId) {
toast.error("Teamspace-Eintrag wählen", { toast.error("Teamspace-Eintrag wählen", {
description: "Dieses Projekt hat mehrere Teamspace-Einträge." description: "Dieses Projekt hat mehrere Teamspace-Einträge."
}); });
@@ -864,7 +868,7 @@ export function TimerPage({ timers, setTimers, selectedTimerId, setSelectedTimer
endedAt: endedAt.toISOString(), endedAt: endedAt.toISOString(),
durationSeconds, durationSeconds,
zammadLookup: Boolean(manualLookupTicket?.zammad_ticket_id), zammadLookup: Boolean(manualLookupTicket?.zammad_ticket_id),
projectTeamspaceEntryId: requiresTeamspaceEntry(manualLookupTicket) ? manualSession.projectTeamspaceEntryId : null, projectTeamspaceEntryId: manualRequiresTeamspaceEntry ? manualSession.projectTeamspaceEntryId : null,
billingStatus: manualSession.billingStatus, billingStatus: manualSession.billingStatus,
createZammadArticle: manualSession.createZammadArticle && (activityValue.activity.trim().length > 0 || Boolean(activityValue.zammadArticleBody)), createZammadArticle: manualSession.createZammadArticle && (activityValue.activity.trim().length > 0 || Boolean(activityValue.zammadArticleBody)),
zammadArticleTypeId: manualSession.zammadArticleTypeId zammadArticleTypeId: manualSession.zammadArticleTypeId
@@ -1117,7 +1121,7 @@ export function TimerPage({ timers, setTimers, selectedTimerId, setSelectedTimer
</div> </div>
</div> </div>
{requiresTeamspaceEntry(manualLookupTicket) ? ( {manualRequiresTeamspaceEntryForForm ? (
<div className="space-y-2"> <div className="space-y-2">
<label className="text-sm font-medium" htmlFor="manual-teamspace-entry">Teamspace-Eintrag</label> <label className="text-sm font-medium" htmlFor="manual-teamspace-entry">Teamspace-Eintrag</label>
<select <select
@@ -1379,7 +1383,7 @@ export function TimerPage({ timers, setTimers, selectedTimerId, setSelectedTimer
</select> </select>
</div> </div>
)} )}
{requiresTeamspaceEntry(finished?.ticket) ? ( {finishedRequiresTeamspaceEntryForForm ? (
<div className="space-y-2"> <div className="space-y-2">
<label className="text-sm font-medium" htmlFor="finished-teamspace-entry">Teamspace-Eintrag</label> <label className="text-sm font-medium" htmlFor="finished-teamspace-entry">Teamspace-Eintrag</label>
<select <select
+129 -23
View File
@@ -17,7 +17,9 @@ import {
previewExistingZammadTickets, previewExistingZammadTickets,
previewOwnZammadConsultingTickets, previewOwnZammadConsultingTickets,
previewZammadMailSessions, previewZammadMailSessions,
syncTeamspaceProjectIds,
waitForZammadSyncJob, waitForZammadSyncJob,
type TeamspaceProjectIdSyncResult,
type ZammadSyncExistingFields, type ZammadSyncExistingFields,
type ZammadSyncChange, type ZammadSyncChange,
type ZammadSyncJob, type ZammadSyncJob,
@@ -40,7 +42,7 @@ const defaultFields: ZammadSyncExistingFields = {
organization: true, organization: true,
type: true, type: true,
projectName: true, projectName: true,
budget: true, budget: false,
overrideBudget: false, overrideBudget: false,
pendingTodo: false pendingTodo: false
}; };
@@ -50,7 +52,6 @@ const fieldOptions: Array<{ key: Exclude<keyof ZammadSyncExistingFields, "overri
{ key: "organization", label: "Organisation", description: "Lokale Organisation anhand der Zammad-ID setzen." }, { 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: "type", label: "Typ", description: "Support/Consulting übernehmen, Projekte anlegen oder entfernen." },
{ key: "projectName", label: "Projektbezeichnung", description: "Teamspace-Projekt bei Consulting überschreiben." }, { 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." } { key: "pendingTodo", label: "Termin-ToDo", description: "Aus Wartend/Abschluss mit Datum ein ToDo erzeugen." }
]; ];
@@ -429,10 +430,6 @@ function formatChangeValue(field: string, value: unknown) {
return formatDurationSeconds(value); return formatDurationSeconds(value);
} }
if (field === "Teamspace-Budget") {
return formatMinutes(value);
}
if (field === "Teamspace-Projekte" || field === "Teamspace-Prüfung" || field === "Merge-Projektdaten") { if (field === "Teamspace-Projekte" || field === "Teamspace-Prüfung" || field === "Merge-Projektdaten") {
return formatTeamspaceProjectList(value) || "-"; return formatTeamspaceProjectList(value) || "-";
} }
@@ -498,7 +495,7 @@ function changeAllowedByFields(item: ZammadSyncItem, change: ZammadSyncChange, f
} }
if (change.field === "Teamspace-Budget") { if (change.field === "Teamspace-Budget") {
return fields.budget && consultingProjectCanBeTouched; return false;
} }
if (change.field === "ToDo") { if (change.field === "ToDo") {
@@ -711,7 +708,6 @@ function changesFromExistingPreviewCache(item: ZammadSyncItem, fields: ZammadSyn
const zammadTeamspaceProjects = cachedStringList(zammad, "teamspaceProjects"); const zammadTeamspaceProjects = cachedStringList(zammad, "teamspaceProjects");
const localTeamspaceProjectList = localTeamspaceProjects.length ? localTeamspaceProjects : [cachedValue(project, "teamspaceProject")].filter((entry): entry is string => typeof entry === "string"); 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 zammadTeamspaceProjectList = zammadTeamspaceProjects.length ? zammadTeamspaceProjects : splitTeamspaceProjects(cachedValue(zammad, "teamspaceProject"));
const canSyncBudget = fields.budget && (cachedValue(project, "defaultBudgetSyncDisabled") !== true || fields.overrideBudget);
const changes: ZammadSyncChange[] = []; const changes: ZammadSyncChange[] = [];
addCachedChange(changes, "Zammad-Priorität", cachedValue(local, "zammadPriorityId"), cachedValue(zammad, "priorityId")); addCachedChange(changes, "Zammad-Priorität", cachedValue(local, "zammadPriorityId"), cachedValue(zammad, "priorityId"));
@@ -753,10 +749,6 @@ function changesFromExistingPreviewCache(item: ZammadSyncItem, fields: ZammadSyn
addCachedTeamspaceProjectChanges(changes, localTeamspaceProjectList, zammadTeamspaceProjectList); 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) { 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 }); changes.push({ field: "TS/TT-Prüfung", from: cachedValue(project, "closeReviewRequired") ?? false, to: true });
} }
@@ -920,8 +912,7 @@ function zammadDetails(item: ZammadSyncItem) {
`Owner ${formatValue(zammad.ownerId)}`, `Owner ${formatValue(zammad.ownerId)}`,
`Org ${formatValue(zammad.organizationId)}`, `Org ${formatValue(zammad.organizationId)}`,
`Titel ${formatValue(zammad.title)}`, `Titel ${formatValue(zammad.title)}`,
`Projekt ${formatValue(formatTeamspaceProjectList(zammad.teamspaceProjects) || zammad.teamspaceProject)}`, `Projekt ${formatValue(formatTeamspaceProjectList(zammad.teamspaceProjects) || zammad.teamspaceProject)}`
`Budget ${formatMinutes(zammad.teamspaceBudgetMinutes)}`
]; ];
} }
@@ -1105,11 +1096,14 @@ export function ZammadSyncPage({ currentUser }: ZammadSyncPageProps) {
syncingMail syncingMail
} = pageState; } = pageState;
const [storageReady, setStorageReady] = useState(false); 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 displayedExistingResult = useMemo(() => applyFieldsToExistingPreview(existingResult, fields), [existingResult, fields]);
const selectedExistingCount = useMemo(() => selectedTicketNumbers(displayedExistingResult, existingSelection).length, [displayedExistingResult, existingSelection]); const selectedExistingCount = useMemo(() => selectedTicketNumbers(displayedExistingResult, existingSelection).length, [displayedExistingResult, existingSelection]);
const selectedImportCount = useMemo(() => selectedTicketNumbers(importResult, importSelection).length, [importResult, importSelection]); const selectedImportCount = useMemo(() => selectedTicketNumbers(importResult, importSelection).length, [importResult, importSelection]);
const selectedMailCount = useMemo(() => selectedArticleIds(mailResult, mailSelection).length, [mailResult, mailSelection]); const selectedMailCount = useMemo(() => selectedArticleIds(mailResult, mailSelection).length, [mailResult, mailSelection]);
const zammadReady = currentUser.has_zammad_api_token && Boolean(currentUser.zammad_user_id); const zammadReady = currentUser.has_zammad_api_token && Boolean(currentUser.zammad_user_id);
const teamspaceReady = currentUser.has_teamspace_credentials && Boolean(currentUser.teamspace_user_id);
useEffect(() => { useEffect(() => {
setStorageReady(false); setStorageReady(false);
@@ -1552,6 +1546,28 @@ export function ZammadSyncPage({ currentUser }: ZammadSyncPageProps) {
} }
} }
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) { function toggleExisting(ticketNumber: string, checked: boolean) {
setZammadSyncPageState((current) => ({ setZammadSyncPageState((current) => ({
existingSelection: { ...current.existingSelection, [ticketNumber]: checked } existingSelection: { ...current.existingSelection, [ticketNumber]: checked }
@@ -1617,6 +1633,105 @@ export function ZammadSyncPage({ currentUser }: ZammadSyncPageProps) {
</Alert> </Alert>
) : null} ) : 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> <Card>
<CardHeader className="p-4"> <CardHeader className="p-4">
<div className="flex items-start justify-between gap-3"> <div className="flex items-start justify-between gap-3">
@@ -1642,15 +1757,6 @@ export function ZammadSyncPage({ currentUser }: ZammadSyncPageProps) {
</label> </label>
))} ))}
</div> </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"> <div className="flex flex-wrap justify-end gap-2">
{syncingExisting ? ( {syncingExisting ? (
<Button type="button" variant="outline" onClick={resetRunningState}> <Button type="button" variant="outline" onClick={resetRunningState}>
View File
Whitespace-only changes.
-211
View File
@@ -1,211 +0,0 @@
[
{
"id": 66333,
"ticket_id": 6910,
"type_id": 1,
"sender_id": 1,
"from": "\"Michael Böhmländer via Zammad Helpdesk Support\" <consulting@aikava.de>",
"to": "tobias.ritter@primed-halberstadt.de",
"cc": "",
"subject": "Primed Halberstadt Medizintechnik GmbH / WF + neuer Standort Tschechien / 17459",
"reply_to": null,
"message_id": "<20260925061659.6910.ebeeffdd-6e76-46c8-a41e-36c38f4a3f5d@aikava.zammad.com>",
"message_id_md5": "fa072c137010607ae161cc50262d5fa5",
"in_reply_to": "<FR0P281MB2831BA848411868C36FA216BB9812@FR0P281MB2831.DEUP281.PROD.OUTLOOK.COM>",
"content_type": "text/html",
"body": "Hallo Herr Ritter,<div><br></div><div>ich bin bisher noch nicht zum testen gekommen. Ich habe mich eingeloggt und den QR Code gescannt. Beim anschließenden Login mit</div><div><br></div><div>Username</div><div>PasswordTOTP (so wie es in der Seite steht; also Passwort direkt gefolgt vom Token aus der App)</div><div><br></div><div>erhalte ich aber Login failed. Habe ich etwas falsch gemacht?<br><br><div data-signature=\"true\" data-signature-id=\"3\"><div><b>Michael Böhmländer</b></div><div>Senior Software Consultant<br>Geplante Abwesenheiten:<br>\n</div><div><br></div><div><b>AIKAVA GmbH</b></div><div>Galgenfeld 7 | 93413 Cham</div><div><br></div><div>T: +49 9971 88999 40</div><div><a href=\"mailto:consulting.atoss@aikava.de\" title=\"mailto:consulting.atoss@aikava.de\">consulting@aikava.de</a></div><div>\n<a href=\"https://aikava.de/\" rel=\"nofollow noreferrer noopener\" target=\"_blank\">aikava.de</a><br>\n</div><div><br></div><div><p>Amtsgericht Regensburg HRB 19554<br></p></div><div>Geschäftsführung Marco Christener</div><div><b><br></b></div><div><i>This e-mail may contain confidential and/or privileged information. If you are not the intended recipient or have received this e-mail in error please notify the sender immediately and delete this e-mail.</i></div></div><div><br><br></div><div><blockquote type=\"cite\">Am Donnerstag, 24. September 2026 um 13:00:24, schrieb Tobias Ritter:<br><br><div>\n<p>Sehr geehrter Hr. Böhmländer,</p><p>&nbsp;</p><p>hat die Anmeldung VPN und ATOSS geklappt ?</p><p>&nbsp;</p><p>Sonst hätten Sie sich sicher gemeldet.</p><p>&nbsp;</p><p>Ich habe ein paar Infos in die Cloud getan.</p><p>&nbsp;</p><p>Bitte entnehmen Sie</p><p>&nbsp;</p><p>KeyUserEinstellgn Checkliste - Primed Halberstadt Medizintechnik GmbH Projekt 20260924</p><p>&nbsp;</p><div>\n<div>\n<div>\n<p>Mit freundlichen Grüßen / Kind regards</p><p>Tobias Ritter</p><p>Betriebsleiter</p><p><span>B: +49 3941- 668 710 | <a href=\"mailto:tobias.ritter@primed-halberstadt.de\">tobias.ritter@primed-halberstadt.de</a></span></p><p>PRIMED Halberstadt Medizintechnik GmbH | <a href=\"http://www.primed-halberstadt.de/\" title=\"www.primed-halberstadt.de\" rel=\"nofollow noreferrer noopener\" target=\"_blank\">www.primed-halberstadt.de</a></p></div></div></div><p>&nbsp;</p><div>\n<div>\n<span class=\"js-signatureMarker\"></span><p><b>Von:</b><span> Ritter, Tobias <br>\n<b>Gesendet:</b> Mittwoch, 23. September 2026 16:07<br>\n<b>An:</b> 'Michael Böhmländer via Zammad Helpdesk Support' &lt;consulting@aikava.de&gt;<br>\n<b>Betreff:</b> AW: [Ticket#946896] Primed Halberstadt Medizintechnik GmbH / WF + neuer Standort Tschechien / 17459</span></p></div></div><p>&nbsp;</p><p>Hallo Hr. Böhmländer,</p><p>&nbsp;</p><p>danke für die Nachfrage.</p><p>Ich komme mit der Zusammenstellung der Informationen nicht so voran, wie ich mir das wünschte. Sorry.</p><p>Aber ein wenig habe ich vorbereiten können.</p><p>&nbsp;</p><p>Zugang:</p><p>Bitte einen VPN-Tunnel via Sophos Connect Client aufbauen:</p><p>Dazu melden Sie sich zuerst am Gateway / VPN-Portal an, um das Zertifikat und den Client herunter zu laden.</p><p>Außerdem werden Sie aufgefordert, einen OTP-Token als 2FA einzurichten (Authenticator..).</p><p>&nbsp;</p><p>Die Zugangsdaten erhalten Sie separat.</p><p>&nbsp;</p><p>Wenn diese Anmeldung erfolgreich war und der Tunnel steht, ist es so konfiguriert, dass Sie auf den ATOSS-Dienst via Browser Port 8080 zugreifen können.</p><p>&nbsp;</p><p>Weiter Infos erhalten Sie separat.</p><p>&nbsp;</p><p>Der Link zum VPN-Gateway ist:</p><p>&nbsp;</p><p>Bitte im VPN Portal: <a href=\"https://gw-primed.primed-halberstadt.de:4442/\" rel=\"nofollow noreferrer noopener\" title=\"https://gw-primed.primed-halberstadt.de:4442/\" target=\"_blank\"> https://deu01.safelinks.protection.outlook.com/?url=https%3A%2F%2Fgw-primed.primed-halberstadt.de%3A4442%2F&amp;data=05%7C02%7CTobias.Ritter%40primed-halberstadt.de%7C79dd9da709a6417f9efa08df1486fb3a%7Ccdf1a6302e144edca1dfd90a5e006008%7C0%7C0%7C639252242147688576%7CUnknown%7CTWFpbGZsb3d8eyJFbXB0eU1hcGkiOnRydWUsIlYiOiIwLjAuMDAwMCIsIlAiOiJXaW4zMiIsIkFOIjoiTWFpbCIsIldUIjoyfQ%3D%3D%7C0%7C%7C%7C&amp;sdata=CAsI%2FABdwPu9EtaSgccfmUe3RLF6zyCL4ociily5oQo%3D&amp;reserved=0</a> den Token einrichten. Mit einen Software-Authenticator (bsp: Google Authenticator, Microsoft Authenticator, etc. -&gt; also Handy geht auch.)</p><p>&nbsp;</p><p>Bitte testen Sie dies. Für Rückfragen stehe ich zur Verfügung.</p><p>&nbsp;</p><p>Ein Hinweis: Alle Änderungen im ATOSS möchte ich begleiten. Bitte sprechen Sie sie mit mir ab.</p><p>&nbsp;</p><p>Mit freundlichen Grüßen / Kind regards</p><p>Tobias Ritter</p><p>Betriebsleiter</p><p><span>B: +49 3941- 668 710 | <a href=\"mailto:tobias.ritter@primed-halberstadt.de\">tobias.ritter@primed-halberstadt.de</a></span></p><p>PRIMED Halberstadt Medizintechnik GmbH | <a href=\"http://www.primed-halberstadt.de/\" title=\"www.primed-halberstadt.de\" rel=\"nofollow noreferLine truncated
"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-25T06:17:03.054Z"
},
"updated_by_id": 1764,
"created_by_id": 1764,
"origin_by_id": null,
"created_at": "2026-09-25T06:16:58.989Z",
"updated_at": "2026-09-25T06:17:03.056Z",
"detected_language": null,
"ai_stored_result_ids": [],
"attachments": [],
"body_rendering_error": false
},
{
"id": 66336,
"ticket_id": 6916,
"type_id": 1,
"sender_id": 1,
"from": "\"Michael Böhmländer via Zammad Helpdesk Support\" <support@aikava.de>",
"to": "kirchhof@hwk-berlin.de",
"cc": "",
"subject": "Verarbeitungsanpassung",
"reply_to": null,
"message_id": "<20260925062752.6916.7844e20a-2b4e-4bd5-819e-91042a70d7bb@aikava.zammad.com>",
"message_id_md5": "abf1595d00cb49f18dd00cff2f45446e",
"in_reply_to": "<VI0PR09MB8591D4B56733592E7AF7FAE988812@VI0PR09MB8591.eurprd09.prod.outlook.com>",
"content_type": "text/html",
"body": "Hallo Frau Kirchhof,<div><br></div><div>ich habe an den Verarbeitungen nichts gemacht. Das einzige was ich gemacht habe ist, dass ich die (inzwischen nachgetragene Gehenstempelung) testweise am 28.06.2026 entfernt habe und eine Neuberechnung durchgeführt habe. Hier habe ich festgestellt, dass alles so funktioniert wie es soll.</div><div><br></div><div>Den Screenshot, den Sie sehen, wo das Datum der 22.9. ist, ist aus meinem lokalen Testsystem (Mitarbeiter value 2 value 1). Das ist kein Bild aus Ihrem System.</div><div><br></div><div>Viele Grüße<br><br><div data-signature=\"true\" data-signature-id=\"2\"><div>\n<div><b>Michael Böhmländer</b></div>\n<div>Senior Software Consultant<br>Geplante Abwesenheiten:</div>\n</div><div><br></div><div><b>AIKAVA GmbH</b></div><div>Galgenfeld 7 | 93413 Cham</div><div><b><br></b></div><div>T: +49 9971 88999 40</div><div>\n<a href=\"mailto:support.atoss@aikava.de\" title=\"mailto:support.atoss@aikava.de\">support@aikava.de</a><br>\n</div><div>\n<a href=\"https://aikava.de/\" rel=\"nofollow noreferrer noopener\" target=\"_blank\">aikava.de</a><br>\n</div><div><br></div><div><p>Amtsgericht Regensburg HRB 19554<br></p></div><div>Geschäftsführung Marco Christener</div><div><i><br></i></div><div><i>This e-mail may contain confidential and/or privileged information. If you are not the intended recipient or have received this e-mail in error please notify the sender immediately and delete this e-mail.</i></div></div><div><br><br></div><div><blockquote type=\"cite\">Am Donnerstag, 24. September 2026 um 12:35:48, schrieb Christiane Kirchhof:<br><br><div>\n<p>Ein Nachtrag zu meiner Mail von eben.</p><p>Da hier bei Testen und Nachstellen eine rote Fehlermeldung erzeugt wird, fragen wir uns, ob Sie die Regel ( s. Ihre untere Mail) schon ausgeschaltet haben?</p><p>Grundsätzlich sind wir auch weiterhin für eine Fehlermeldung, haben aber Bedenken, dass das Entfernen dieses Regel dann wieder Folgefehler nach sich zieht.</p><p>Leider hatten wir in der Vergangenheit oft sehr unschöne Erfahrungen gemacht…</p><p>&nbsp;</p><p>Oder haben Sie gar nichts gemacht und es funktioniert jetzt dennoch?</p><p>&nbsp;</p><table cellspacing=\"0\" cellpadding=\"0\" style=\"width: 660px;\"><tbody><tr><td valign=\"top\" style=\"width: 660px; font-size: 10pt;\"><span><br>Mit freundlichen Grüßen</span></td></tr><tr style=\"font-size: 11pt;\"><td valign=\"top\" style=\"width: 660px; font-size: 11pt; color: rgb(7, 48, 112);\"><span style=\"color: rgb(7, 48, 112);\"><span style=\" color: rgb(35, 255, 0);\"><span style=\"color: rgb(7, 48, 112);\"><span style=\"color: rgb(7, 48, 112);\">Christiane </span></span></span><span style=\"background-color: rgb(255, 255, 255); color: rgb(35, 255, 0);\"><span style=\"color: rgb(7, 48, 112);\"><b>Kirchhof</b><br></span></span></span></td></tr><tr style=\"font-size: 11pt;\"><td valign=\"top\" style=\"width: 660px; padding: 5px 0px 0px;\">Personal</td></tr><tr style=\"font-size: 11pt;\"><td valign=\"top\" style=\"width: 660px; font-size: 11pt; color: rgb(0, 0, 0); padding: 5px 0px 0px;\">Handwerkskammer Berlin<br>Blücherstraße 68, 10961 Berlin<br>Tel: <a href=\"tel:+49 30 25903 305\">+49 30 25903 305</a> | <span>Fax: <a href=\"tel:+49 30 25903 322\">+49 30 25903 322</a></span><br></td></tr><tr><td valign=\"top\" style=\"width: 660px; font-size: 10pt; color: rgb(0, 0, 0); padding: 5px 0px 0px;\"></td></tr></tbody></table><p><br></p><table style=\"width: 200px;\" cellspacing=\"0\" cellpadding=\"0\"><tbody><tr><td valign=\"bottom\" style=\"width: 40px; font-size: 10pt; text-align: center; padding: 10px 0px;\"><a href=\"https://www.hwk-berlin.de\" rel=\"nofollow noreferrer noopener\" title=\"https://www.hwk-berlin.de\" target=\"_blank\"><img src=\"/api/v1/ticket_attachment/6916/66336/227357?view=inline\" style=\"max-width: 100%;\"></a> </td><td valign=\"middle\" style=\"width: 40px; font-size: 10pt; text-align: center;\"> <a href=\"https://www.facebook.com/hwkberlin/?locale=de_DE\" rel=\"nofollow noreferrer noopener\" title=\"https://www.facebook.com/hwkberlin/?locale=de_DE\" target=\"_blank\"><img src=\"/api/v1/ticket_attachment/6916/66336/227358?view=inline\" style=\"max-width: 100%;\"></a></td><td valign=\"middle\" style=\"width: 40px; font-size: 10pt; text-align: center;\"> <a href=\"https://www.instagram.com/handwerkskammer_berlin/\" rel=\"nofollow noreferrer noopener\" title=\"https://www.instagram.com/handwerkskammer_berlin/\" target=\"_blank\"><img src=\"/api/v1/ticket_attachment/6916/66336/227359?view=inline\" style=\"max-width: 100%;\"></a></td><td valign=\"middle\" style=\"width: 40px; font-size: 10pt; text-align: center;\"><a href=\"https://de.linkedin.com/company/handwerkskammer-berlin\" rel=\"nofollow noreferrer noopener\" title=\"https://de.linkedin.com/company/handwerkskammer-berlin\" target=\"_blank\"><img src=\"/api/v1/ticket_attachment/6916/66336/227360?view=inline\" style=\"max-width: 100%;\"></a> </td><td valign=\"middle\" style=\"wiLine truncated
"internal": false,
"preferences": {
"subtype": "reply",
"email_address_id": 6,
"delivery_retry": 1,
"delivery_channel_id": 23,
"delivery_status_message": null,
"delivery_status": "success",
"delivery_status_date": "2026-09-25T06:27:56.971Z"
},
"updated_by_id": 1764,
"created_by_id": 1764,
"origin_by_id": null,
"created_at": "2026-09-25T06:27:52.681Z",
"updated_at": "2026-09-25T06:27:56.972Z",
"detected_language": null,
"ai_stored_result_ids": [],
"attachments": [],
"body_rendering_error": false
},
{
"id": 66337,
"ticket_id": 6973,
"type_id": 1,
"sender_id": 1,
"from": "\"Michael Böhmländer via Zammad Helpdesk Support\" <consulting@aikava.de>",
"to": "sophie.ochsenbein@pierre-fabre.com",
"cc": "",
"subject": "Teilzeitfrei neu konfigurieren",
"reply_to": null,
"message_id": "<20260925063108.6973.491aa02d-8d2c-4528-9a07-10b362e2f542@aikava.zammad.com>",
"message_id_md5": "9a1b9f2c0384d6e7c0b5a9b1ca2ae9e3",
"in_reply_to": "",
"content_type": "text/html",
"body": "Hallo Frau Ochsenbein,<div><br></div><div>ich hatte hier vor einiger Zeit einen Call mit Frau Medina. Leider ist sie aktuell nicht erreichbar und ich würde gerne wissen, wie es um das untenstehende Angebot steht.</div><div><br></div><div>Vielen Dank und beste Grüße<br><br><div data-signature=\"true\" data-signature-id=\"3\"><div><b>Michael Böhmländer</b></div><div>Senior Software Consultant<br>Geplante Abwesenheiten:<br>\n</div><div><br></div><div><b>AIKAVA GmbH</b></div><div>Galgenfeld 7 | 93413 Cham</div><div><br></div><div>T: +49 9971 88999 40</div><div><a href=\"mailto:consulting.atoss@aikava.de\" title=\"mailto:consulting.atoss@aikava.de\">consulting@aikava.de</a></div><div>\n<a href=\"https://aikava.de/\" rel=\"nofollow noreferrer noopener\" target=\"_blank\">aikava.de</a><br>\n</div><div><br></div><div><p>Amtsgericht Regensburg HRB 19554<br></p></div><div>Geschäftsführung Marco Christener</div><div><b><br></b></div><div><i>This e-mail may contain confidential and/or privileged information. If you are not the intended recipient or have received this e-mail in error please notify the sender immediately and delete this e-mail.</i></div></div><br><div>---Anfang der weitergeleiteten Nachricht:---<br><br></div><div><blockquote type=\"cite\"><div>Betreff: Teilzeitfrei neu konfigurieren<br>Datum: 24.09.2026 08:28<br>Von: \"Michael Böhmländer via Zammad Helpdesk Support\" &lt;consulting@aikava.de&gt;<br>An: Medina Adriana &lt;adriana.medina.ext@pierre-fabre.com&gt;<br><br></div>Hallo Frau Adriana,<div><br></div><div>leider habe ich bisher keine Rückmeldung bzgl. meiner Aufwandseinschätzung erhalten. Konnten Sie das Angebot bereits sichten und möchten Sie es umsetzen?</div><div><br></div><div>Viele Grüße<br><br><div data-signature=\"true\" data-signature-id=\"3\"><div><b>Michael Böhmländer</b></div><div>Senior Software Consultant<br>Geplante Abwesenheiten:<br>\n</div><div><br></div><div><b>AIKAVA GmbH</b></div><div>Galgenfeld 7 | 93413 Cham</div><div><br></div><div>T: +49 9971 88999 40</div><div><a href=\"mailto:consulting.atoss@aikava.de\" title=\"mailto:consulting.atoss@aikava.de\">consulting@aikava.de</a></div><div>\n<a href=\"https://aikava.de/\" rel=\"nofollow noreferrer noopener\" target=\"_blank\">aikava.de</a><br>\n</div><div><br></div><div><p>Amtsgericht Regensburg HRB 19554<br></p></div><div>Geschäftsführung Marco Christener</div><div><b><br></b></div><div><i>This e-mail may contain confidential and/or privileged information. If you are not the intended recipient or have received this e-mail in error please notify the sender immediately and delete this e-mail.</i></div></div><div><br><br></div><div><blockquote type=\"cite\">Am Mittwoch, 16. September 2026 um 08:12:55, schrieb Michael Böhmländer via Zammad Helpdesk Support:<br><br><div></div><div>Hallo Frau Adriana,</div><div><br></div>vielen Dank für Ihre Anfrage. Nach Prüfung Ihrer Anforderungen möchten wir Sie darauf hinweisen, dass deren Umsetzung voraussichtlich einen Aufwand von etwa 6&nbsp; Stunden erfordert und nur im Rahmen einer kostenpflichtigen Dienstleistung möglich ist.<div><br></div><div>Gerne lassen wir Ihnen ein unverbindliches Angebot zukommen. Da es sich um eine Dienstleistung handelt, für die weniger als 8 Stunden benötigt werden, reicht Ihre Ihre schriftliche Freigabe aus. Die aktuellen Stundensätze entnehmen Sie bitte nachfolgender Tabelle:</div><div><br></div><div><img style=\"width: 1000px; max-width: 100%;\" src=\"/api/v1/ticket_attachment/6973/66337/227371?view=inline\"></div><div><br></div><div>Im vorliegenden Fall gilt der Stundensatz \"Senior Consultant\".</div><div><br></div><div>5,5 h</div><div>- Tagesart, berechneten Wert und freien Saldo anlegen</div><div>- bisherige Verarbeitungsregeln in MAG 40001 beenden</div><div>- neue Verarbeitungsregel konfigurieren</div><div>- Workshop zur Handhabung</div><div><br></div><div>0,5 h</div><div>- Dokumentation</div><div>- Kommunikation Mail/Telefon<br></div><div><br></div>Viele Grüße<br><br><div data-signature=\"true\" data-signature-id=\"3\"><div><b>Michael Böhmländer</b></div><div>Senior Software Consultant<br>Geplante Abwesenheiten:<br>\n</div><div><br></div><div><b>AIKAVA GmbH</b></div><div>Galgenfeld 7 | 93413 Cham</div><div><br></div><div>T: +49 9971 88999 40</div><div><a href=\"mailto:consulting.atoss@aikava.de\" title=\"mailto:consulting.atoss@aikava.de\">consulting@aikava.de</a></div><div>\n<a href=\"https://aikava.de/\" rel=\"nofollow noreferrer noopener\" target=\"_blank\">aikava.de</a><br>\n</div><div><br></div><div><p>Amtsgericht Regensburg HRB 19554<br></p></div><div>Geschäftsführung Marco Christener</div><div><b><br></b></div><div><i>This e-mail may contain confidential and/or privileged information. If you are not the intended recipient or have received this e-mail in error please notify the sender immediately and delete this e-mail.</i></div></div><br></blockquote></div><div><br></div></div></blockquote></div><div><br>Line truncated
"internal": false,
"preferences": {
"subtype": "forward",
"email_address_id": 7,
"delivery_retry": 1,
"delivery_channel_id": 24,
"delivery_status_message": null,
"delivery_status": "success",
"delivery_status_date": "2026-09-25T06:31:11.458Z"
},
"updated_by_id": 1764,
"created_by_id": 1764,
"origin_by_id": null,
"created_at": "2026-09-25T06:31:08.180Z",
"updated_at": "2026-09-25T06:31:11.459Z",
"detected_language": null,
"ai_stored_result_ids": [],
"attachments": [],
"body_rendering_error": false
},
{
"id": 66342,
"ticket_id": 6910,
"type_id": 1,
"sender_id": 1,
"from": "\"Michael Böhmländer via Zammad Helpdesk Support\" <consulting@aikava.de>",
"to": "support@mdlink.de",
"cc": "tobias.ritter@primed-halberstadt.de, ralph.huxol@tw-stassfurt.de",
"subject": "[Ticket#202609161013] VPN Login",
"reply_to": null,
"message_id": "<20260925072842.6910.ded4c986-2030-4cc5-b4e0-08646cc6d72b@aikava.zammad.com>",
"message_id_md5": "5e46a2fb0a92173d5dc54e9bf27c96b2",
"in_reply_to": "<1790320644.705583.864936388@otter.in.mdlink.de>",
"content_type": "text/html",
"body": "Hallo Herr Braun,<div><br></div><div>vielen Dank, jetzt hat es funktioniert.</div><div><br></div><div>Viele Grüße<br><br><div data-signature=\"true\" data-signature-id=\"3\"><div><b>Michael Böhmländer</b></div><div>Senior Software Consultant<br>Geplante Abwesenheiten:<br>\n</div><div><br></div><div><b>AIKAVA GmbH</b></div><div>Galgenfeld 7 | 93413 Cham</div><div><br></div><div>T: +49 9971 88999 40</div><div><a href=\"mailto:consulting.atoss@aikava.de\" title=\"mailto:consulting.atoss@aikava.de\">consulting@aikava.de</a></div><div>\n<a href=\"https://aikava.de/\" rel=\"nofollow noreferrer noopener\" target=\"_blank\">aikava.de</a><br>\n</div><div><br></div><div><p>Amtsgericht Regensburg HRB 19554<br></p></div><div>Geschäftsführung Marco Christener</div><div><b><br></b></div><div><i>This e-mail may contain confidential and/or privileged information. If you are not the intended recipient or have received this e-mail in error please notify the sender immediately and delete this e-mail.</i></div></div><div><br><br></div><div><blockquote type=\"cite\">Am Freitag, 25. September 2026 um 09:18:17, schrieb MDlink online service center GmbH:<br><br><div>Sehr geehrter Herr Böhmländer,</div><div><br></div><div>scheinbar wurde der Token vom MFA nicht bestätigt.</div><div>Vorsichtshalber habe ich Ihren bereits hinterlegten MFA-Token gelöscht.</div><div><br></div><div>Bitte melden Sie sich nochmal im VPN-User Portal an, mit:</div><div>- Username</div><div>- Passwort</div><div><br></div><div>Es wird nun ein neuer QR-Code angezeigt. Den alten bitte aus Ihren Authenticator löschen und diesen neuen Importieren.</div><div><br></div><div>Dann unmittelbar im VPN-Portal einloggen mit:</div><div>- Username</div><div>- PasswortTOTP</div><div><br></div><div>Erst dann wird der Token bestätigt.</div><div>Die Sophos hat die \"Eigenart\", dass die Tokens, die nicht durch ein direktes Logins bestätigt werden. Immer wieder neu generiert werden.</div><div><br></div><div>Also wenn Sie zwischen durch, irgendwie auf der Seite neu anmelden (ohne TOTP). Wird der Token neu generiert.</div><div>Erst mit einen gültigen Login mit PasswortTOTP wird der Token final gespeichert.</div><div><br></div><div>Ich sehe in der Firewall nur: \"Fehlerhaftes Passwort\". Das kann auch die oben genannte \"Eigenart\" sein.</div><div><br></div><div>Sofern Sie trotzdem noch dieses Probleme haben, können Sie gerne uns anrufen.</div><div><br></div><div>Mit freundlichen Grüßen</div><div>--</div><div><br></div><div>Urs Braun</div><div><br></div><div>MDlink online service center GmbH&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; mail: <a href=\"mailto:support@mdlink.de\" title=\"mailto:support@mdlink.de\">support@mdlink.de</a></div><div>Lorenzweg 42 Haus 3&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; phone:&nbsp;&nbsp;+49 391 25568 0</div><div>Magdeburg&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; fax:&nbsp;&nbsp;&nbsp;&nbsp;+49 391 25568 99</div><div>39124&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; <a href=\"http://www.mdlink.de\" title=\"http://www.mdlink.de\" rel=\"nofollow noreferrer noopener\" target=\"_blank\">http://www.mdlink.de</a></div><div>Germany&nbsp;&nbsp;</div><div><br></div><div>Amtsgericht Stendal HRB 106650</div><div>Geschäftsführer: Olaf Müller</div><div>UST-ID: DE163829671</div><br></blockquote></div><div><br></div></div>",
"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-25T07:28:46.835Z"
},
"updated_by_id": 1764,
"created_by_id": 1764,
"origin_by_id": null,
"created_at": "2026-09-25T07:28:42.824Z",
"updated_at": "2026-09-25T07:28:46.836Z",
"detected_language": null,
"ai_stored_result_ids": [],
"attachments": [],
"body_rendering_error": false
},
{
"id": 66347,
"ticket_id": 4086,
"type_id": 1,
"sender_id": 1,
"from": "\"Michael Böhmländer via Zammad Helpdesk Support\" <consulting@aikava.de>",
"to": "johannes.fleck@schilling-holz.de",
"cc": "",
"subject": "Holzwerk Schilling // Neuinstallation ATOSS Time Control",
"reply_to": null,
"message_id": "<20260925075412.4086.0c72179c-4b24-41b0-b49f-9bc8d12a5ad9@aikava.zammad.com>",
"message_id_md5": "a9d395a7ce897a845b62ac47669d4479",
"in_reply_to": "<FR7P281MB65338CD0F117F888996AB7C9CB822@FR7P281MB6533.DEUP281.PROD.OUTLOOK.COM>",
"content_type": "text/html",
"body": "Hallo Herr Fleck,<div><br></div><div>die Software Eclipse ist meines Wissens nach kein Teil von Atoss selbst. Es handelt sich, normalerweise, um eine sogenannte IDE zur Softwareentwicklung für JAVA. Warum genau die Software hier installiert ist, weiß ich leider nicht. Eventuell wurde diese von CompData bei der Installation benötigt. Bitte fragen Sie hier die Kollegen von CompData, ob sie etwas wissen.</div><div><br></div><div>Viele Grüße<br><br><div data-signature=\"true\" data-signature-id=\"3\"><div><b>Michael Böhmländer</b></div><div>Senior Software Consultant<br>Geplante Abwesenheiten:<br>\n</div><div><br></div><div><b>AIKAVA GmbH</b></div><div>Galgenfeld 7 | 93413 Cham</div><div><br></div><div>T: +49 9971 88999 40</div><div><a href=\"mailto:consulting.atoss@aikava.de\" title=\"mailto:consulting.atoss@aikava.de\">consulting@aikava.de</a></div><div>\n<a href=\"https://aikava.de/\" rel=\"nofollow noreferrer noopener\" target=\"_blank\">aikava.de</a><br>\n</div><div><br></div><div><p>Amtsgericht Regensburg HRB 19554<br></p></div><div>Geschäftsführung Marco Christener</div><div><b><br></b></div><div><i>This e-mail may contain confidential and/or privileged information. If you are not the intended recipient or have received this e-mail in error please notify the sender immediately and delete this e-mail.</i></div></div><div><br><br></div><div><blockquote type=\"cite\">Am Mittwoch, 23. September 2026 um 16:03:16, schrieb Johannes Fleck:<br><br><div>\n<p>Hallo Herr Böhmländer,</p><p>&nbsp;</p><p><span>vielleicht können Sie Frau Hopp direkt kontaktieren (<a href=\"mailto:birgit.hopp@schilling-holz.de\">birgit.hopp@schilling-holz.de</a>).</span></p><p>&nbsp;</p><p>Wegen der Fahrer würden wir es so machen, wie unten geschrieben. Falls ich da noch was brauche, melde ich mich.</p><p>&nbsp;</p><p>Ich habe noch etwas anderes. Es läuft wohl auf dem Server eine Software namens Eclipse. Hier gibt es laut Systemanalyse mehrere Schwachstellen. Kann man die Software updaten?</p><p>&nbsp;</p><div>\n<div>\n<p><span style=\"color:#201f1e;\"> </span></p><p><span style=\"color:#201f1e;\"><br>\n<br>\n</span></p><p><span style=\"color:#201f1e;\">Mit freundlichen Grüßen</span></p><p><span style=\"color:#201f1e;\"> </span></p><p><span style=\"color:#201f1e;\">i.A. Johannes Fleck</span></p><p><span style=\"color:#201f1e;\"> </span></p><p><b><span style=\"color:#201f1e;\">Holzwerk Schilling GmbH &amp; Co. KG</span></b><span style=\"color:#201f1e;\"></span></p><p><span style=\"color:#201f1e;\">Klosterstraße 49</span></p><p><span style=\"color:#201f1e;\">D- 88430 Rot an der Rot</span></p><p><span style=\"color:#201f1e;\"> </span></p><p><span style=\"color:#201f1e;\">Tel: +49 8395 9366-23</span></p><p><span style=\"color:#201f1e;\">Fax: +49 8395 9366-20</span></p><p><span style=\"color:#201f1e;\">E-Mail: </span><a href=\"mailto:johannes.fleck@schilling-holz.de\">johannes.fleck@schilling-holz.de</a></p><p><span style=\"color:#201f1e;\">Internet: </span><a href=\"http://www.schilling-holz.de/\" rel=\"nofollow noreferrer noopener\" target=\"_blank\">www.schilling-holz.de</a></p><p><span style=\"color:#201f1e;\">USt.-IdNr.: DE815534186</span></p><p><span style=\"color:#201f1e;\">Amtsgericht Ulm HRA 724237</span></p><p><span style=\"color:#201f1e;\"> </span></p><p><span style=\"color:#201f1e;\">Persönlich haftende Gesellschafterin:</span></p><p><span style=\"color:#201f1e;\">Holzwerk Schilling Verwaltungs GmbH</span></p><p><span style=\"color:#201f1e;\">Sitz: Rot an der Rot</span></p><p><span style=\"color:#201f1e;\">Amtsgericht Ulm HRB 731409</span></p><p><span style=\"color:#201f1e;\">Geschäftsführer: Johannes S. Schilling</span></p></div></div><p>&nbsp;</p><div>\n<div>\n<span class=\"js-signatureMarker\"></span><p><b>Von:</b><span> Michael Böhmländer via Zammad Helpdesk Support &lt;consulting@aikava.de&gt; <br>\n<b>Gesendet:</b> Dienstag, 22. </span><span lang=\"EN-GB\">September 2026 10:58<br>\n<b>An:</b> Johannes Fleck &lt;johannes.fleck@schilling-holz.de&gt;<br>\n<b>Betreff:</b> RE: [Ticket#944079] Holzwerk Schilling // Neuinstallation ATOSS Time Control</span></p></div></div><p><span lang=\"EN-GB\"> </span></p><p>Hallo Herr Fleck,</p><div>\n<p>&nbsp;</p></div><div>\n<p>leider hat sich Frau Hopp bisher noch nicht bei mir gemeldet. Könnten Sie hier bitte noch einmal nachhaken?</p></div><div>\n<p>&nbsp;</p></div><div>\n<p>Bezüglich der Fahrer: Sie starten im Oktober einfach mal und geben mir dann Rückmeldung, falls noch Anpassungen notwendig sind, oder?</p></div><div>\n<p>&nbsp;</p></div><div>\n<p>Viele Grüße</p><div>\n<div>\n<p><b>Michael Böhmländer</b></p></div><div>\n<p><span>Senior Software Consultant<br> Geplante Abwesenheiten:</span></p></div><div>\n<p>&nbsp;</p></div><div>\n<p><b>AIKAVA GmbH</b></p></div><div>\n<p>Galgenfeld 7 | 93413 Cham</p></div><div>\n<p>&nbsp;</p></div><div>\n<p>T: +49 9971 88999 40</p></div><div>\n<p><a href=\"mailto:consulting.atoss@aikava.de\" title=\"mailto:consuLine truncated
"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-25T07:54:14.523Z"
},
"updated_by_id": 1764,
"created_by_id": 1764,
"origin_by_id": null,
"created_at": "2026-09-25T07:54:11.948Z",
"updated_at": "2026-09-25T07:54:14.524Z",
"detected_language": null,
"ai_stored_result_ids": [],
"attachments": [],
"body_rendering_error": false
},
{
"id": 66350,
"ticket_id": 4086,
"type_id": 1,
"sender_id": 1,
"from": "\"Michael Böhmländer via Zammad Helpdesk Support\" <consulting@aikava.de>",
"to": "birgit.hopp@schilling-holz.de",
"cc": "",
"subject": "Schnittstelle",
"reply_to": null,
"message_id": "<20260925081606.4086.cdbe02f0-4053-4ac9-a255-21cfdc926460@aikava.zammad.com>",
"message_id_md5": "2bc1e94239dd8a7315ba47fa61668419",
"in_reply_to": "",
"content_type": "text/html",
"body": "Hallo Frau Hopp,<div><br></div><div>ich bin aktuell mit Herrn Fleck in Kontakt bezüglich der Abschlussarbeiten für TimeControl. Er meinte, Sie würden sich bzgl. der DATEV-Übergabe bei mir melden. Ich würde gerne wissen, wie es mit der Übergabe aussieht? Funktioniert das schon, wird es bereits genutzt oder sind hier noch Anpassungen notwendig?</div><div><br></div><div>Viele Grüße<br><br><div data-signature=\"true\" data-signature-id=\"3\"><div><b>Michael Böhmländer</b></div><div>Senior Software Consultant<br>Geplante Abwesenheiten:<br>\n</div><div><br></div><div><b>AIKAVA GmbH</b></div><div>Galgenfeld 7 | 93413 Cham</div><div><br></div><div>T: +49 9971 88999 40</div><div><a href=\"mailto:consulting.atoss@aikava.de\" title=\"mailto:consulting.atoss@aikava.de\">consulting@aikava.de</a></div><div>\n<a href=\"https://aikava.de/\" rel=\"nofollow noreferrer noopener\" target=\"_blank\">aikava.de</a><br>\n</div><div><br></div><div><p>Amtsgericht Regensburg HRB 19554<br></p></div><div>Geschäftsführung Marco Christener</div><div><b><br></b></div><div><i>This e-mail may contain confidential and/or privileged information. If you are not the intended recipient or have received this e-mail in error please notify the sender immediately and delete this e-mail.</i></div></div></div>",
"internal": false,
"preferences": {
"email_address_id": 7,
"delivery_retry": 1,
"delivery_channel_id": 24,
"delivery_status_message": null,
"delivery_status": "success",
"delivery_status_date": "2026-09-25T08:16:09.314Z"
},
"updated_by_id": 1764,
"created_by_id": 1764,
"origin_by_id": null,
"created_at": "2026-09-25T08:16:06.517Z",
"updated_at": "2026-09-25T08:16:09.315Z",
"detected_language": null,
"ai_stored_result_ids": [],
"attachments": [],
"body_rendering_error": false
}
]
+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"
}
}
]
}
}
]
}