1.1.0-rc1

This commit is contained in:
michael committed 2026-10-13 10:43:53 +02:00
1 parent 649dd6f028
commit 45ff46b7eb
26 files changed
+4700 -1056

No files matched your search

+5
View File
@@ -15,7 +15,12 @@ export const databaseExportTables = [
"organizations", "organizations",
"tickets", "tickets",
"projects", "projects",
"teamspace_project_resources",
"project_teamspace_entries", "project_teamspace_entries",
"teamspace_project_exclusions",
"teamspace_monthly_user_actuals",
"project_teamspace_monthly_actuals",
"project_teamspace_subentries",
"hidden_user_projects", "hidden_user_projects",
"ticket_todos", "ticket_todos",
"recurring_billings", "recurring_billings",
+2334 -44
View File
File diff suppressed because it is too large. Load diff
+564
View File
@@ -182,6 +182,41 @@ export async function migrate() {
); );
`); `);
await query(`
CREATE TABLE IF NOT EXISTS teamspace_project_exclusions (
teamspace_project TEXT PRIMARY KEY,
teamspace_name TEXT,
customer_name TEXT,
note TEXT NOT NULL DEFAULT '',
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
CONSTRAINT teamspace_project_exclusions_project_not_blank CHECK (btrim(teamspace_project) <> '')
);
`);
await query("ALTER TABLE teamspace_project_exclusions ADD COLUMN IF NOT EXISTS teamspace_name TEXT;");
await query("ALTER TABLE teamspace_project_exclusions ADD COLUMN IF NOT EXISTS customer_name TEXT;");
await query("CREATE UNIQUE INDEX IF NOT EXISTS idx_teamspace_project_exclusions_lower ON teamspace_project_exclusions (lower(btrim(teamspace_project)));");
await query(`
CREATE TABLE IF NOT EXISTS teamspace_project_resources (
id BIGSERIAL PRIMARY KEY,
teamspace_project TEXT NOT NULL,
teamspace_api_project_id BIGINT,
teamspace_api_project_url TEXT,
hour_budget_minutes INTEGER,
budget_sync_disabled BOOLEAN NOT NULL DEFAULT false,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
CONSTRAINT teamspace_project_resources_project_not_blank CHECK (btrim(teamspace_project) <> ''),
CONSTRAINT teamspace_project_resources_budget_non_negative CHECK (hour_budget_minutes IS NULL OR hour_budget_minutes >= 0)
);
`);
await query("ALTER TABLE teamspace_project_resources ADD COLUMN IF NOT EXISTS teamspace_api_project_id BIGINT;");
await query("ALTER TABLE teamspace_project_resources ADD COLUMN IF NOT EXISTS teamspace_api_project_url TEXT;");
await query("ALTER TABLE teamspace_project_resources ADD COLUMN IF NOT EXISTS hour_budget_minutes INTEGER;");
await query("ALTER TABLE teamspace_project_resources ADD COLUMN IF NOT EXISTS budget_sync_disabled BOOLEAN NOT NULL DEFAULT false;");
await query("CREATE UNIQUE INDEX IF NOT EXISTS idx_teamspace_project_resources_lower ON teamspace_project_resources (lower(btrim(teamspace_project)));");
await encryptStoredApiSecrets(); await encryptStoredApiSecrets();
await query(` await query(`
@@ -864,9 +899,154 @@ export async function migrate() {
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_id BIGINT;");
await query("ALTER TABLE project_teamspace_entries ADD COLUMN IF NOT EXISTS teamspace_api_project_url TEXT;"); await query("ALTER TABLE project_teamspace_entries ADD COLUMN IF NOT EXISTS teamspace_api_project_url TEXT;");
await query("ALTER TABLE project_teamspace_entries ADD COLUMN IF NOT EXISTS teamspace_project_resource_id BIGINT REFERENCES teamspace_project_resources(id) ON DELETE SET NULL;");
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 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 INDEX IF NOT EXISTS idx_project_teamspace_entries_resource ON project_teamspace_entries(teamspace_project_resource_id) WHERE teamspace_project_resource_id IS NOT NULL;");
await query(`
INSERT INTO teamspace_project_resources (
teamspace_project,
teamspace_api_project_id,
teamspace_api_project_url,
hour_budget_minutes,
budget_sync_disabled,
updated_at
)
SELECT
canonical.teamspace_project,
canonical.teamspace_api_project_id,
canonical.teamspace_api_project_url,
canonical.hour_budget_minutes,
canonical.budget_sync_disabled,
now()
FROM (
SELECT DISTINCT ON (lower(btrim(teamspace_project)))
btrim(teamspace_project) AS teamspace_project,
teamspace_api_project_id,
teamspace_api_project_url,
hour_budget_minutes,
COALESCE(budget_sync_disabled, false) AS budget_sync_disabled
FROM project_teamspace_entries
WHERE teamspace_project IS NOT NULL
AND btrim(teamspace_project) <> ''
AND NOT EXISTS (
SELECT 1
FROM teamspace_project_exclusions tpe
WHERE lower(btrim(tpe.teamspace_project)) = lower(btrim(project_teamspace_entries.teamspace_project))
)
ORDER BY
lower(btrim(teamspace_project)),
(teamspace_api_project_id IS NOT NULL) DESC,
(teamspace_api_project_url IS NOT NULL) DESC,
(hour_budget_minutes IS NOT NULL) DESC,
updated_at DESC,
id DESC
) canonical
WHERE NOT EXISTS (
SELECT 1
FROM teamspace_project_resources existing
WHERE lower(btrim(existing.teamspace_project)) = lower(btrim(canonical.teamspace_project))
);
`);
await query(`
UPDATE project_teamspace_entries pte
SET teamspace_project_resource_id = resource.id,
updated_at = CASE
WHEN pte.teamspace_project_resource_id IS DISTINCT FROM resource.id THEN now()
ELSE pte.updated_at
END
FROM teamspace_project_resources resource
WHERE pte.teamspace_project IS NOT NULL
AND btrim(pte.teamspace_project) <> ''
AND NOT EXISTS (
SELECT 1
FROM teamspace_project_exclusions tpe
WHERE lower(btrim(tpe.teamspace_project)) = lower(btrim(pte.teamspace_project))
)
AND lower(btrim(resource.teamspace_project)) = lower(btrim(pte.teamspace_project))
AND pte.teamspace_project_resource_id IS DISTINCT FROM resource.id;
`);
await query(`
UPDATE project_teamspace_entries pte
SET teamspace_project_resource_id = NULL,
updated_at = now()
WHERE EXISTS (
SELECT 1
FROM teamspace_project_exclusions tpe
WHERE lower(btrim(tpe.teamspace_project)) = lower(btrim(pte.teamspace_project))
)
AND pte.teamspace_project_resource_id IS NOT NULL;
`);
await query(`
DELETE FROM teamspace_project_resources resource
WHERE EXISTS (
SELECT 1
FROM teamspace_project_exclusions tpe
WHERE lower(btrim(tpe.teamspace_project)) = lower(btrim(resource.teamspace_project))
);
`);
await query(`
UPDATE project_teamspace_entries pte
SET teamspace_api_project_id = COALESCE(resource.teamspace_api_project_id, pte.teamspace_api_project_id),
teamspace_api_project_url = COALESCE(resource.teamspace_api_project_url, pte.teamspace_api_project_url),
hour_budget_minutes = COALESCE(resource.hour_budget_minutes, pte.hour_budget_minutes),
budget_sync_disabled = resource.budget_sync_disabled,
updated_at = CASE
WHEN pte.teamspace_api_project_id IS DISTINCT FROM COALESCE(resource.teamspace_api_project_id, pte.teamspace_api_project_id)
OR pte.teamspace_api_project_url IS DISTINCT FROM COALESCE(resource.teamspace_api_project_url, pte.teamspace_api_project_url)
OR pte.hour_budget_minutes IS DISTINCT FROM COALESCE(resource.hour_budget_minutes, pte.hour_budget_minutes)
OR pte.budget_sync_disabled IS DISTINCT FROM resource.budget_sync_disabled
THEN now()
ELSE pte.updated_at
END
FROM teamspace_project_resources resource
WHERE pte.teamspace_project_resource_id = resource.id
AND pte.teamspace_project IS NOT NULL
AND btrim(pte.teamspace_project) <> ''
AND NOT EXISTS (
SELECT 1
FROM teamspace_project_exclusions tpe
WHERE lower(btrim(tpe.teamspace_project)) = lower(btrim(pte.teamspace_project))
);
`);
await query(`
CREATE TABLE IF NOT EXISTS project_teamspace_monthly_actuals (
user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
project_teamspace_entry_id BIGINT NOT NULL REFERENCES project_teamspace_entries(id) ON DELETE CASCADE,
month DATE NOT NULL,
teamspace_minutes INTEGER NOT NULL DEFAULT 0,
teamspace_billable_minutes INTEGER NOT NULL DEFAULT 0,
teamspace_time_entry_count INTEGER NOT NULL DEFAULT 0,
positions JSONB NOT NULL DEFAULT '[]'::jsonb,
synced_at TIMESTAMPTZ NOT NULL DEFAULT now(),
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (user_id, project_teamspace_entry_id, month),
CONSTRAINT project_teamspace_monthly_actuals_minutes_nonnegative CHECK (teamspace_minutes >= 0),
CONSTRAINT project_teamspace_monthly_actuals_billable_nonnegative CHECK (teamspace_billable_minutes >= 0),
CONSTRAINT project_teamspace_monthly_actuals_count_nonnegative CHECK (teamspace_time_entry_count >= 0)
);
`);
await query("ALTER TABLE project_teamspace_monthly_actuals ADD COLUMN IF NOT EXISTS positions JSONB NOT NULL DEFAULT '[]'::jsonb;");
await query("CREATE INDEX IF NOT EXISTS idx_project_teamspace_monthly_actuals_entry_month ON project_teamspace_monthly_actuals(project_teamspace_entry_id, month);");
await query(`
CREATE TABLE IF NOT EXISTS teamspace_monthly_user_actuals (
user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
month DATE NOT NULL,
teamspace_minutes INTEGER NOT NULL DEFAULT 0,
teamspace_billable_minutes INTEGER NOT NULL DEFAULT 0,
teamspace_time_entry_count INTEGER NOT NULL DEFAULT 0,
positions JSONB NOT NULL DEFAULT '[]'::jsonb,
synced_at TIMESTAMPTZ NOT NULL DEFAULT now(),
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (user_id, month),
CONSTRAINT teamspace_monthly_user_actuals_minutes_nonnegative CHECK (teamspace_minutes >= 0),
CONSTRAINT teamspace_monthly_user_actuals_billable_nonnegative CHECK (teamspace_billable_minutes >= 0),
CONSTRAINT teamspace_monthly_user_actuals_count_nonnegative CHECK (teamspace_time_entry_count >= 0)
);
`);
await query(` await query(`
CREATE TABLE IF NOT EXISTS project_teamspace_subentries ( CREATE TABLE IF NOT EXISTS project_teamspace_subentries (
id BIGSERIAL PRIMARY KEY, id BIGSERIAL PRIMARY KEY,
@@ -940,6 +1120,390 @@ export async function migrate() {
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_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 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("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(`
WITH duplicate_keys AS (
SELECT lower(btrim(teamspace_project)) AS teamspace_key
FROM project_teamspace_entries
WHERE teamspace_project IS NOT NULL
AND btrim(teamspace_project) <> ''
AND NOT EXISTS (
SELECT 1
FROM teamspace_project_exclusions tpe
WHERE lower(btrim(tpe.teamspace_project)) = lower(btrim(project_teamspace_entries.teamspace_project))
)
GROUP BY lower(btrim(teamspace_project))
HAVING COUNT(*) > 1
),
canonical AS (
SELECT DISTINCT ON (lower(btrim(pte.teamspace_project)))
lower(btrim(pte.teamspace_project)) AS teamspace_key,
pte.teamspace_api_project_id,
pte.teamspace_api_project_url,
pte.hour_budget_minutes,
COALESCE(pte.budget_sync_disabled, false) AS budget_sync_disabled
FROM project_teamspace_entries pte
JOIN duplicate_keys dk ON dk.teamspace_key = lower(btrim(pte.teamspace_project))
ORDER BY
lower(btrim(pte.teamspace_project)),
(pte.teamspace_api_project_id IS NOT NULL) DESC,
(pte.teamspace_api_project_url IS NOT NULL) DESC,
(pte.hour_budget_minutes IS NOT NULL) DESC,
pte.updated_at DESC,
pte.id DESC
)
UPDATE project_teamspace_entries target
SET teamspace_api_project_id = COALESCE(canonical.teamspace_api_project_id, target.teamspace_api_project_id),
teamspace_api_project_url = COALESCE(canonical.teamspace_api_project_url, target.teamspace_api_project_url),
hour_budget_minutes = COALESCE(canonical.hour_budget_minutes, target.hour_budget_minutes),
budget_sync_disabled = canonical.budget_sync_disabled,
updated_at = CASE
WHEN target.teamspace_api_project_id IS DISTINCT FROM COALESCE(canonical.teamspace_api_project_id, target.teamspace_api_project_id)
OR target.teamspace_api_project_url IS DISTINCT FROM COALESCE(canonical.teamspace_api_project_url, target.teamspace_api_project_url)
OR target.hour_budget_minutes IS DISTINCT FROM COALESCE(canonical.hour_budget_minutes, target.hour_budget_minutes)
OR target.budget_sync_disabled IS DISTINCT FROM canonical.budget_sync_disabled
THEN now()
ELSE target.updated_at
END
FROM canonical
WHERE target.teamspace_project IS NOT NULL
AND btrim(target.teamspace_project) <> ''
AND lower(btrim(target.teamspace_project)) = canonical.teamspace_key;
`);
await query(`
WITH duplicate_keys AS (
SELECT lower(btrim(teamspace_project)) AS teamspace_key
FROM project_teamspace_entries
WHERE teamspace_project IS NOT NULL
AND btrim(teamspace_project) <> ''
AND NOT EXISTS (
SELECT 1
FROM teamspace_project_exclusions tpe
WHERE lower(btrim(tpe.teamspace_project)) = lower(btrim(project_teamspace_entries.teamspace_project))
)
GROUP BY lower(btrim(teamspace_project))
HAVING COUNT(*) > 1
),
entry_keys AS (
SELECT
pte.id AS entry_id,
p.user_id,
lower(btrim(pte.teamspace_project)) AS teamspace_key,
pte.teamspace_api_project_id,
pte.updated_at
FROM project_teamspace_entries pte
JOIN projects p ON p.id = pte.project_id
JOIN duplicate_keys dk ON dk.teamspace_key = lower(btrim(pte.teamspace_project))
WHERE p.user_id IS NOT NULL
),
canonical_entries AS (
SELECT DISTINCT ON (entry_keys.user_id, entry_keys.teamspace_key)
entry_keys.user_id,
entry_keys.teamspace_key,
entry_keys.entry_id
FROM entry_keys
ORDER BY
entry_keys.user_id,
entry_keys.teamspace_key,
EXISTS (
SELECT 1
FROM project_teamspace_subentries pts
WHERE pts.user_id = entry_keys.user_id
AND pts.project_teamspace_entry_id = entry_keys.entry_id
) DESC,
(entry_keys.teamspace_api_project_id IS NOT NULL) DESC,
entry_keys.updated_at DESC,
entry_keys.entry_id DESC
),
targets AS (
SELECT
entry_keys.entry_id AS target_entry_id,
entry_keys.user_id,
canonical_entries.entry_id AS source_entry_id
FROM entry_keys
JOIN canonical_entries
ON canonical_entries.user_id = entry_keys.user_id
AND canonical_entries.teamspace_key = entry_keys.teamspace_key
WHERE entry_keys.entry_id <> canonical_entries.entry_id
),
deleted AS (
DELETE FROM project_teamspace_subentries pts
USING targets
WHERE pts.user_id = targets.user_id
AND pts.project_teamspace_entry_id = targets.target_entry_id
)
INSERT INTO project_teamspace_subentries (
user_id,
project_teamspace_entry_id,
teamspace_api_project_id,
teamspace_api_project_url,
teamspace_project_id,
billing_type,
name,
alias,
description,
progress,
planned_minutes,
sum_minutes,
planned_minutes_billable,
sum_minutes_billable,
planned_income,
sum_income,
display_order,
synced_at,
updated_at
)
SELECT
targets.user_id,
targets.target_entry_id,
source.teamspace_api_project_id,
source.teamspace_api_project_url,
source.teamspace_project_id,
source.billing_type,
source.name,
source.alias,
source.description,
source.progress,
source.planned_minutes,
source.sum_minutes,
source.planned_minutes_billable,
source.sum_minutes_billable,
source.planned_income,
source.sum_income,
source.display_order,
source.synced_at,
now()
FROM targets
JOIN project_teamspace_subentries source
ON source.user_id = targets.user_id
AND source.project_teamspace_entry_id = targets.source_entry_id
ON CONFLICT (user_id, project_teamspace_entry_id, teamspace_api_project_id)
DO UPDATE SET
teamspace_api_project_url = EXCLUDED.teamspace_api_project_url,
teamspace_project_id = EXCLUDED.teamspace_project_id,
billing_type = EXCLUDED.billing_type,
name = EXCLUDED.name,
alias = EXCLUDED.alias,
description = EXCLUDED.description,
progress = EXCLUDED.progress,
planned_minutes = EXCLUDED.planned_minutes,
sum_minutes = EXCLUDED.sum_minutes,
planned_minutes_billable = EXCLUDED.planned_minutes_billable,
sum_minutes_billable = EXCLUDED.sum_minutes_billable,
planned_income = EXCLUDED.planned_income,
sum_income = EXCLUDED.sum_income,
display_order = EXCLUDED.display_order,
synced_at = EXCLUDED.synced_at,
updated_at = now();
`);
await query(`
WITH shared_resources AS (
SELECT pte.teamspace_project_resource_id AS resource_id
FROM project_teamspace_entries pte
WHERE pte.teamspace_project_resource_id IS NOT NULL
AND pte.teamspace_project IS NOT NULL
AND btrim(pte.teamspace_project) <> ''
AND NOT EXISTS (
SELECT 1
FROM teamspace_project_exclusions tpe
WHERE lower(btrim(tpe.teamspace_project)) = lower(btrim(pte.teamspace_project))
)
GROUP BY pte.teamspace_project_resource_id
HAVING COUNT(*) > 1
),
resource_entries AS (
SELECT
pte.id AS entry_id,
pte.teamspace_project_resource_id AS resource_id,
p.user_id,
p.ticket_id,
pte.is_default,
pte.updated_at
FROM project_teamspace_entries pte
JOIN projects p ON p.id = pte.project_id
JOIN shared_resources sr ON sr.resource_id = pte.teamspace_project_resource_id
WHERE p.user_id IS NOT NULL
),
canonical_entries AS (
SELECT DISTINCT ON (resource_id, user_id)
resource_id,
user_id,
entry_id
FROM resource_entries
ORDER BY
resource_id,
user_id,
EXISTS (
SELECT 1
FROM project_teamspace_subentries pts
WHERE pts.user_id = resource_entries.user_id
AND pts.project_teamspace_entry_id = resource_entries.entry_id
) DESC,
updated_at DESC,
entry_id DESC
),
resource_consumption AS (
SELECT
ce.resource_id,
ce.user_id,
COALESCE(SUM(
CASE
WHEN pts.billing_type IS DISTINCT FROM 'INVOICEABLE_FIX'
THEN COALESCE(pts.sum_minutes_billable, pts.sum_minutes, 0)
ELSE 0
END
), 0)::int AS shared_consumption_minutes
FROM canonical_entries ce
JOIN project_teamspace_subentries pts
ON pts.project_teamspace_entry_id = ce.entry_id
AND pts.user_id = ce.user_id
GROUP BY ce.resource_id, ce.user_id
),
local_day_values AS (
SELECT
re.entry_id,
COALESCE(SUM(tdb.billed_minutes), 0)::int AS local_day_minutes
FROM resource_entries re
LEFT JOIN ticket_day_billings tdb
ON tdb.ticket_id = re.ticket_id
AND tdb.user_id = re.user_id
AND (
tdb.project_teamspace_entry_id = re.entry_id
OR (re.is_default AND tdb.project_teamspace_entry_id IS NULL)
)
GROUP BY re.entry_id
)
UPDATE project_teamspace_entries pte
SET manual_consumption_minutes = GREATEST(resource_consumption.shared_consumption_minutes - local_day_values.local_day_minutes, 0),
updated_at = CASE
WHEN pte.manual_consumption_minutes IS DISTINCT FROM GREATEST(resource_consumption.shared_consumption_minutes - local_day_values.local_day_minutes, 0)
THEN now()
ELSE pte.updated_at
END
FROM resource_entries re
JOIN resource_consumption
ON resource_consumption.resource_id = re.resource_id
AND resource_consumption.user_id = re.user_id
JOIN local_day_values ON local_day_values.entry_id = re.entry_id
WHERE pte.id = re.entry_id
AND resource_consumption.shared_consumption_minutes > 0;
`);
await query(`
WITH duplicate_keys AS (
SELECT lower(btrim(teamspace_project)) AS teamspace_key
FROM project_teamspace_entries
WHERE teamspace_project IS NOT NULL
AND btrim(teamspace_project) <> ''
AND NOT EXISTS (
SELECT 1
FROM teamspace_project_exclusions tpe
WHERE lower(btrim(tpe.teamspace_project)) = lower(btrim(project_teamspace_entries.teamspace_project))
)
GROUP BY lower(btrim(teamspace_project))
HAVING COUNT(*) > 1
),
entry_keys AS (
SELECT
pte.id AS entry_id,
p.user_id,
lower(btrim(pte.teamspace_project)) AS teamspace_key
FROM project_teamspace_entries pte
JOIN projects p ON p.id = pte.project_id
JOIN duplicate_keys dk ON dk.teamspace_key = lower(btrim(pte.teamspace_project))
WHERE p.user_id IS NOT NULL
),
canonical_actuals AS (
SELECT DISTINCT ON (ptma.user_id, entry_keys.teamspace_key, ptma.month)
ptma.user_id,
entry_keys.teamspace_key,
ptma.month,
ptma.teamspace_minutes,
ptma.teamspace_billable_minutes,
ptma.teamspace_time_entry_count,
ptma.positions,
ptma.synced_at
FROM project_teamspace_monthly_actuals ptma
JOIN entry_keys ON entry_keys.entry_id = ptma.project_teamspace_entry_id
ORDER BY
ptma.user_id,
entry_keys.teamspace_key,
ptma.month,
ptma.synced_at DESC,
ptma.updated_at DESC,
ptma.project_teamspace_entry_id DESC
)
INSERT INTO project_teamspace_monthly_actuals (
user_id,
project_teamspace_entry_id,
month,
teamspace_minutes,
teamspace_billable_minutes,
teamspace_time_entry_count,
positions,
synced_at,
updated_at
)
SELECT
canonical_actuals.user_id,
entry_keys.entry_id,
canonical_actuals.month,
canonical_actuals.teamspace_minutes,
canonical_actuals.teamspace_billable_minutes,
canonical_actuals.teamspace_time_entry_count,
canonical_actuals.positions,
canonical_actuals.synced_at,
now()
FROM canonical_actuals
JOIN entry_keys
ON entry_keys.user_id = canonical_actuals.user_id
AND entry_keys.teamspace_key = canonical_actuals.teamspace_key
ON CONFLICT (user_id, project_teamspace_entry_id, month)
DO UPDATE SET
teamspace_minutes = EXCLUDED.teamspace_minutes,
teamspace_billable_minutes = EXCLUDED.teamspace_billable_minutes,
teamspace_time_entry_count = EXCLUDED.teamspace_time_entry_count,
positions = EXCLUDED.positions,
synced_at = EXCLUDED.synced_at,
updated_at = now();
`);
await query(`
WITH duplicate_keys AS (
SELECT lower(btrim(teamspace_project)) AS teamspace_key
FROM project_teamspace_entries
WHERE teamspace_project IS NOT NULL
AND btrim(teamspace_project) <> ''
AND NOT EXISTS (
SELECT 1
FROM teamspace_project_exclusions tpe
WHERE lower(btrim(tpe.teamspace_project)) = lower(btrim(project_teamspace_entries.teamspace_project))
)
GROUP BY lower(btrim(teamspace_project))
HAVING COUNT(*) > 1
),
affected_projects AS (
SELECT DISTINCT pte.project_id
FROM project_teamspace_entries pte
JOIN duplicate_keys dk ON dk.teamspace_key = lower(btrim(pte.teamspace_project))
),
entry_budget AS (
SELECT
pte.project_id,
COALESCE(SUM(pte.hour_budget_minutes) FILTER (WHERE pte.hour_budget_minutes IS NOT NULL), NULL)::int AS hour_budget_minutes,
COALESCE(SUM(pte.manual_consumption_minutes), 0)::int AS manual_consumption_minutes
FROM project_teamspace_entries pte
JOIN affected_projects affected ON affected.project_id = pte.project_id
GROUP BY pte.project_id
)
UPDATE projects p
SET hour_budget_minutes = entry_budget.hour_budget_minutes,
manual_consumption_minutes = entry_budget.manual_consumption_minutes,
updated_at = now()
FROM entry_budget
WHERE p.id = entry_budget.project_id
AND (
p.hour_budget_minutes IS DISTINCT FROM entry_budget.hour_budget_minutes
OR p.manual_consumption_minutes IS DISTINCT FROM entry_budget.manual_consumption_minutes
);
`);
await query(` await query(`
DO $$ DO $$
BEGIN BEGIN
-1
View File
@@ -1 +0,0 @@
{"_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
@@ -0,0 +1,89 @@
# TicketTracker
Stand: Oktober 2026
TicketTracker ist ein Arbeits-, Projekt- und Abrechnungscockpit für ticketbasierte Arbeit. Das System ergänzt Zammad und Teamspace: Zammad bleibt das Ticketsystem, Teamspace bleibt die Quelle für Projektbudgets und gebuchte Projektwerte, TicketTracker führt Erfassung, Bewertung, ToDos, Projektüberwachung und Monatsabschluss zusammen.
Der Kernnutzen: Jeder Benutzer sieht, was er gearbeitet hat, was noch bewertet werden muss, welche Projektbudgets kritisch sind, welche Teamspace-Daten fehlen oder abweichen und wann ein Monat sauber abgeschlossen werden kann.
## Zielgruppe und Einsatz
TicketTracker ist für Teams oder Einzelpersonen gedacht, die viel mit Tickets arbeiten und ihre Zeiten zuverlässig nachvollziehen müssen.
Typische Einsatzfälle:
- Support- und Consulting-Zeiten per Timer erfassen
- Sessions nachträglich ergänzen
- Arbeit am Ticket dokumentieren
- Sessions als offen, abrechenbar, abgerechnet oder nicht abrechenbar bewerten
- Teamspace-Tageswerte für abrechnungsrelevante Projektzeiten pflegen oder Teamspace als Ist-Quelle nutzen
- Monatsabschlüsse kontrolliert vorbereiten
- Consulting-Projekte mit Teamspace-Budgets, Reststunden und Unterpositionen überwachen
- ToDos und Wiedervorlagen direkt am Ticket oder Projekt organisieren
- wiederkehrende Leistungen automatisch erzeugen lassen
- interne Zammad-Notizen aus TicketTracker heraus erzeugen
## Grundprinzip
TicketTracker arbeitet auf drei Ebenen:
1. **Session-Zeit**
Die tatsächlich gemessene oder manuell erfasste Arbeitszeit.
2. **Teamspace-/Projekt-Ist**
Je nach Profilmodus entweder in TicketTracker gepflegte Teamspace-Tageswerte oder direkt aus Teamspace synchronisierte Ist-Werte.
3. **Monatsabschluss**
Der kontrollierte Abschluss eines Monats, sobald alle Sessions final bewertet und die benötigten Prüfpunkte erledigt sind.
Wichtig: Tickets werden nicht abgeschlossen. Tickets dienen als Arbeits- und Auswertungsrahmen. Abgeschlossen wird der Monat.
## Die wichtigsten Bereiche
- **Timer und Sessions:** Zeiten erfassen, nachtragen, bewerten und dokumentieren.
- **Tickets:** Sessions pro Ticket, Tag und Monat prüfen.
- **Projekte:** Consulting-Projekte, Teamspace-Projekte, Budgets, Reststunden und Unterpositionen überwachen.
- **Teamspace:** Projekt-IDs, Budgets, Unterpositionen, Monatszeiten und Istwerte synchronisieren.
- **Zammad:** Organisationen, Ticketstammdaten, Status, Priorität und interne Notizen anbinden.
- **ToDos:** Aufgaben, Termine, Wiedervorlagen und Projektfollow-ups verwalten.
- **Dashboard:** Tagessteuerung für offene Bewertungen, ToDos, Teamspace-Nacharbeit und Projektwarnungen.
- **Statistiken:** Monatswerte, Organisationen, Tickets, Projektbudgets und Teamspace-Abgleich auswerten.
- **Monatsabschluss:** Einen Monat kontrolliert schließen oder wieder öffnen.
## Unterseiten für Confluence
Diese Dokumentation ist für den Import als Confluence-Seitenbaum vorbereitet:
1. `01-zeiterfassung-sessions-monatsabschluss.md`
2. `02-projekte-budgets-teamspace.md`
3. `03-zammad-sync-und-integration.md`
4. `04-dashboard-statistiken-und-widgets.md`
5. `05-todos-wiedervorlagen-fixe-abrechnungen.md`
6. `06-profil-admin-und-betrieb.md`
## Typischer Tagesablauf
1. Ticketnummer eingeben und Timer starten.
2. Tätigkeit dokumentieren.
3. Timer beenden und Session speichern.
4. Bei Bedarf Session nachtragen.
5. Sessions im Ticket bewerten.
6. Wenn Teamspace-Erfassung in TT aktiv ist: Teamspace-Tageswerte pro Projekt/Tag pflegen.
7. ToDos und Wiedervorlagen bearbeiten.
8. Dashboard prüfen, ob offene Bewertungen oder Teamspace-Nacharbeiten vorhanden sind.
## Typischer Monatsablauf
1. Monat in Tickets, Statistiken oder Monatsabschluss auswählen.
2. Offene Sessions prüfen.
3. Sessions final als **Abgerechnet** oder **Nicht abrechenbar** bewerten.
4. **Abrechenbar**-Zwischenstände klären, weil sie den Abschluss blockieren.
5. Bei aktivierter Teamspace-Erfassung: Teamspace-Tageswerte und Differenzen prüfen.
6. Im Zammad-Sync bei Bedarf den Teamspace-Monatsabgleich ausführen, um echte TS-Monatszeiten pro Projekt gegen TT zu prüfen.
7. Bei deaktivierter Teamspace-Erfassung: Teamspace-Differenzen entfallen; nur Session-Status ist relevant.
8. ToDos, Wiedervorlagen und Projektwarnungen prüfen.
9. Monat abschließen.
TicketTracker ist damit das Kontrollzentrum zwischen täglicher Ticketarbeit, Zammad-Dokumentation, Teamspace-Projekten und sauberer Abrechnung.
Die zentrale Frage bleibt:
**Ist alles vollständig, bewertet, dokumentiert und abrechnungsbereit?**
@@ -0,0 +1,96 @@
# Zeiterfassung, Sessions und Monatsabschluss
Diese Seite beschreibt die tägliche Erfassung, Bewertung und Monatsprüfung in TicketTracker.
## Timer
Benutzer starten einen Timer über eine Ticketnummer. Es können mehrere Timer vorbereitet sein, aber immer nur einer läuft aktiv. Wird ein anderer Timer gestartet, pausiert TicketTracker die übrigen Timer automatisch.
Beim Beenden werden Tätigkeit, Zeit, Ticket, Organisation, Art und optional Bewertung gespeichert. Wenn die Zammad-Notiz im Profil aktiv ist, kann TicketTracker beim Speichern einen internen Zammad-Article erzeugen.
## Sessions nachtragen
Vergessene Arbeit kann manuell als Session nachgetragen werden. Dabei werden erfasst:
- Datum
- Start und Ende
- Ticket
- Organisation
- Art, zum Beispiel Support oder Consulting
- Tätigkeit
- Bewertung
- optional interne Zammad-Notiz
Nachgetragene Sessions laufen genauso in Statistiken, Ticketansichten und Monatsabschluss ein wie per Timer erzeugte Sessions.
## Session-Bewertungen
Jede Session kann einen dieser Zustände haben:
- **Offen**
- **Abrechenbar**
- **Abgerechnet**
- **Nicht abrechenbar**
Nur **Abgerechnet** und **Nicht abrechenbar** gelten als final. **Abrechenbar** ist ein Zwischenstatus und blockiert weiterhin den Monatsabschluss.
Ein erneuter Klick auf den bereits gesetzten Status nimmt die Bewertung wieder zurück und macht die Session offen.
## Ticketdetailansicht
In der Ticketdetailansicht werden Sessions nach Tagen gruppiert. Dort werden Sessions bewertet, bearbeitet, gelöscht und aufgeklappt.
Pro Tag sieht man unter anderem:
- Summe abgerechneter Session-Zeit
- Summe abrechenbarer Session-Zeit
- Summe nicht abrechenbarer Session-Zeit
- offene Session-Zeit
- Teamspace-Tageswert, falls die Teamspace-Erfassung in TT aktiv ist
Die Tageswerte sind pro Benutzer und Projekt/Teamspace-Eintrag gespeichert. Sie sind nicht an einzelne Sessions gebunden.
## Teamspace-Tageswerte
Wenn im Profil **Teamspace-Zeiten in TT erfassen** aktiv ist, kann der Benutzer in der Ticketdetailansicht Teamspace-Tageswerte pflegen.
Die Eingabe erfolgt in Stunden, zum Beispiel:
- `1`
- `1,5`
- `2.25`
- `0,75`
Der Wert wird nicht während der Eingabe gespeichert. Sobald sich der Wert ändert, erscheint ein Speichern-Button. Ein leeres Feld entfernt den gespeicherten Teamspace-Wert für diesen Tag.
Wenn mehrere Teamspace-Projekte an einem Ticket hängen, wird der Tageswert pro Teamspace-Projekt-Eintrag geführt.
Wenn die Teamspace-Erfassung im Profil deaktiviert ist, sind diese Eingaben ausgeblendet und auch serverseitig gesperrt.
## Monatsabschluss
Der Monatsabschluss ist die zentrale Kontrollseite für einen Benutzer und Monat.
Sie zeigt unter anderem:
- offene Bewertungen
- Sessions mit Zwischenstatus **Abrechenbar**
- fehlende oder abweichende Teamspace-Werte, wenn Teamspace-Erfassung in TT aktiv ist
- Prüfpunkte vor dem Abschluss
- Möglichkeit, den Monat zu schließen oder wieder zu öffnen
Ein abgeschlossener Monat ist gegen versehentliche Änderungen geschützt. Wird eine Session in einem abgeschlossenen Zeitraum geändert, muss der Zeitraum vorher wieder geöffnet werden.
## Regeln bei deaktivierter Teamspace-Erfassung
Der Profil-Schalter **Teamspace-Zeiten in TT erfassen** ist userbezogen.
Wenn er ausgeschaltet ist:
- Teamspace-Tageswerte können nicht in TicketTracker gepflegt werden.
- Manuelle Projektstunden sind nicht editierbar.
- Monatsprüfung und Widgets prüfen nur Session-Status.
- Teamspace-Differenzen werden nicht angezeigt.
- Projekt-Istwerte kommen aus Teamspace.
Das ist für Benutzer gedacht, die keine Teamspace-Zeiten in TicketTracker nachpflegen, sondern Teamspace als führende Ist-Quelle verwenden.
@@ -0,0 +1,150 @@
# Projekte, Budgets und Teamspace
Diese Seite beschreibt die aktuelle Projekt- und Teamspace-Logik in TicketTracker.
## Projekte
Projekte erweitern normale Tickets um Consulting-Projektinformationen.
Ein Projekt enthält unter anderem:
- Ticketnummer
- Titel und Organisation
- Teamspace-Projekt-Einträge
- Beschreibung und Ziele
- Zeitraum
- Projektstatus offen oder geschlossen
- ToDos
- Teamspace-Budgets und Verbrauchswerte
- Teamspace-Unterpositionen
Projekt-Tickets sind Consulting-Tickets. Wird ein Projekt gelöscht, bleiben Ticket, Sessions und ToDos erhalten. Gelöscht werden nur die Projektinformationen.
## Teamspace als Budgetquelle
Teamspace ist die fachliche Quelle für Projektbudgets.
Aktuelle Budgetregeln:
- Teamspace ist die Source of Truth für Projektbudgets.
- Zammad-Budgets werden nicht mehr als Budgetquelle verwendet.
- Das Gesamtbudget eines Projekts kommt aus Teamspace.
- Wenn mehrere Teamspace-Projekte an einem Ticket hängen, wird das Gesamtbudget aus diesen Teamspace-Projekten zusammengesetzt.
- Unterpositionen zeigen ihre eigenen Teamspace-Budgets und Teamspace-Istwerte.
- Stückpositionen werden nicht als Stundenbudget in die Stundenbetrachtung eingerechnet.
## Verbrauchslogik
Der Projektverbrauch hängt vom Profil-Schalter **Teamspace-Zeiten in TT erfassen** ab.
Wenn der Schalter aktiv ist:
- Projekt-Ist kommt aus Teamspace-Tageswerten, die in TicketTracker gepflegt werden.
- Zusätzlich können manuelle Projektstunden erfasst werden.
- Progress Bars, Reststunden und Budgetwarnungen verwenden diese Werte.
Wenn der Schalter deaktiviert ist:
- Projekt-Ist kommt aus Teamspace.
- Manuelle Projektstunden sind ausgeblendet und serverseitig gesperrt.
- Teamspace-Tageswerte können nicht in TicketTracker erfasst werden.
- Statistiken und Widgets zeigen keine TT/Teamspace-Differenzprüfung für diesen Benutzer.
## Mehrere Teamspace-Projekte pro Ticket
Ein Ticket kann mehrere Teamspace-Projekt-IDs haben. TicketTracker speichert dafür mehrere Teamspace-Einträge am Projekt.
Beispiel:
- `EP-2025-Comp-009`
- `EP-2025-Comp-025`
Einer der Einträge ist der Standard. Sessions können einem Teamspace-Projekt-Eintrag zugeordnet werden. Diese Zuordnung erfolgt auf Ebene des Teamspace-Projekts, nicht auf Ebene einzelner Unterpositionen.
## Gemeinsames Teamspace-Budget über mehrere Tickets
Mehrere unterschiedliche Ticketnummern können dieselbe Teamspace-Projekt-ID verwenden. TicketTracker behandelt diese Teamspace-Nummer dann als gemeinsame Budgetquelle.
Dabei gilt:
- Es gibt keine automatische Aufteilung auf die Ticketnummern.
- Teamspace-API-ID, Teamspace-API-URL, Budget, Unterpositionen und Teamspace-Monatswerte werden für dieselbe Teamspace-Nummer konsolidiert.
- Der Teamspace-Sync zieht gleiche Teamspace-Nummern nach jedem Lauf wieder auf denselben Datenstand.
- Die Migration konsolidiert bereits vorhandene doppelte Teamspace-Nummern beim Start.
- Ticket-spezifische Felder wie Standard-Eintrag, Reihenfolge, Session-Zuordnung und manuelle Projektstunden bleiben am jeweiligen Ticket.
## Teamspace-Projekt-ID-Sync
Der Sync auf der Zammad-Sync-Seite löst offene Consulting-Projekte gegen die Teamspace-API auf.
Gespeichert werden unter anderem:
- Teamspace-API-ID des Projekts
- Teamspace-API-URL
- Teamspace-Gesamtbudget
- Teamspace-Unterpositionen
- Teamspace-Budgets und Istwerte der Unterpositionen
- Stückpositionen und deren Erfüllungsstand
- echte Teamspace-Monatszeiten aus `/api/time` für den gewählten Monat
Die fachliche Teamspace-ID aus Zammad oder Projektinformationen, zum Beispiel `EP-2026-A-035`, ist nicht identisch mit der API-ID für Einzelabfragen. TicketTracker speichert deshalb die interne Teamspace-API-ID, sobald sie gefunden wurde.
## Teamspace-Monatsabgleich
Der Teamspace-Projekt-ID-Sync auf der Zammad-Sync-Seite enthält zusätzlich einen Monatsabgleich. Der Benutzer wählt einen Monat aus, TicketTracker ruft für diesen Zeitraum die Teamspace-Zeitbuchungen des angemeldeten Teamspace-Benutzers ab und speichert einen Snapshot pro Teamspace-Projekt-Eintrag.
Technische Grundlage:
- API-Endpoint: `/api/time`
- Filter: `worker={Teamspace User-ID}` und `date=YYYY-MM-01..YYYY-MM-letzterTag`
- gezählt werden Einträge mit `draftStatus.value = NORMAL`
- relevante Minuten sind `amountBillable`
- `amount` wird als Gesamtzeit mitgeführt, ist aber nicht die Vergleichsgröße
Die Monatswerte werden pro Benutzer, Teamspace-Projekt-Eintrag und Monat gespeichert. Dadurch bleiben drei Dinge getrennt:
- TT-Tageswerte, die in TicketTracker gepflegt werden
- echte Teamspace-Monatszeiten aus Teamspace
- Teamspace-Budget- und Unterpositionswerte aus dem Projektsync
Im Ergebnis des Syncs sieht man pro Teamspace-Projekt:
- TT-Monatswert
- TS-Monatswert
- Differenz
- Anzahl der gefundenen TS-Zeitbuchungen
- betroffene Haupt- oder Unterpositionen mit TS-Minuten
Wenn Teamspace-Zeiten auf Unterpositionen gebucht wurden, werden diese ebenfalls dem passenden Teamspace-Projekt-Eintrag zugeordnet. TicketTracker mappt dafür nicht nur die Hauptprojekt-API-ID, sondern auch die synchronisierten Unterpositions-API-IDs. Die Positionsaufschlüsselung wird im Monats-Snapshot gespeichert und kann dadurch später in der Monatsübersicht angezeigt werden.
## Unterpositionen
Unterpositionen werden aus Teamspace synchronisiert und im Projektdetail angezeigt. Sie dienen als Teamspace-Anzeige, um Budget, Verbrauch und freie Ressourcen pro Teamspace-Position sichtbar zu machen.
Wichtig:
- Zeiten werden nicht auf Unterpositionen in TicketTracker erfasst.
- Unterpositionen bekommen ihre Budgets und Istwerte aus Teamspace.
- Teamspace-Descriptions werden dekodiert und formatiert ausgegeben.
- Unterpositionen können einen Alias in TicketTracker bekommen.
- Akkordions der Unterpositionsgruppen merken sich den letzten offenen oder geschlossenen Zustand pro Benutzer und Projekt.
- Bei Teamspace-Strukturen mit Überpositionen werden nur echte bebuchbare Unterpositionen angezeigt, nicht reine Summenpositionen.
- Positionsnummern werden lesbar dargestellt, zum Beispiel `1` statt `0001` oder `1.1` statt `0001.0001`.
## Stückpositionen
Teamspace-Positionen mit `billingType = INVOICEABLE_FIX` sind Stück- oder Fixpositionen.
Für diese Positionen gelten andere Regeln:
- Stundenwerte werden nicht als Stundenbudget interpretiert.
- TicketTracker zeigt sie als Stückpositionen an.
- Der Erfüllungsstand wird über `plannedIncome` gegen `sumIncome` geprüft.
- Ist das Soll erreicht, wird die Position als abgerechnet beziehungsweise erfüllt markiert.
- Überschreitungen werden mit einem Hinweis-Icon angezeigt.
## Projektansichten
Die Projektliste zeigt Gesamtbudget, Verbrauch, Reststunden und Projektwarnungen. Bei mehreren Teamspace-Projekten können Detailbalken pro Teamspace-Projekt angezeigt oder ausgeblendet werden. Der Hauptbalken zeigt weiterhin das Gesamtbudget aller Teamspace-Projekte.
Im Projektdetail stehen Projektinformationen und ToDos getrennt von den Teamspace-Unterpositionen. Die Unterpositionen liegen in einer eigenen breiten Card unterhalb der Projekt-/ToDo-Informationen.
@@ -0,0 +1,82 @@
# Zammad Sync und Integration
TicketTracker nutzt Zammad, ohne Zammad zu ersetzen. Zammad bleibt das führende Ticketsystem für Ticketstammdaten, Organisationen, Status und Prioritäten.
## Persönlicher Zammad-Token
Jeder Benutzer kann im Profil einen persönlichen Zammad API-Token speichern. Beim Speichern ruft TicketTracker Zammad ab, prüft den Token und speichert die Zammad User-ID.
Der Token wird verschlüsselt in der Datenbank gespeichert.
Der persönliche Token wird unter anderem für benutzerbezogene Zammad-Funktionen verwendet, zum Beispiel interne Notizen oder personenbezogene Synchronisierungen.
## Admin-Zammad-Zugang
Im Adminbereich wird der Zammad-Zugang für systemweite Synchronisierungen gepflegt.
Der Admin kann Organisationen aus Zammad synchronisieren. Dabei werden lokale Organisationen angelegt, aktualisiert oder entfernt beziehungsweise von alten Verknüpfungen gelöst.
## Ticketdaten aus Zammad
TicketTracker kann Ticketdaten aus Zammad übernehmen:
- Ticketnummer
- Titel
- Organisation
- Work-Type, zum Beispiel Support oder Consulting
- Status
- Priorität
- Owner
- Teamspace-Projektinformationen
Zammad-verwaltete Ticketstammdaten bleiben geschützt. Ticketnummer, Titel, Art und Organisation können dann in TicketTracker nicht beliebig überschrieben werden.
## Consulting-Projekte aus Zammad
Beim Zammad-Sync oder beim Anlegen eines Projekts können Consulting-Projektinformationen übernommen werden.
Dazu gehören insbesondere:
- Teamspace-Projekt-ID oder mehrere Teamspace-Projekt-IDs
- Zammad-Owner
- Projektstatus-Hinweise, zum Beispiel Review bei Abschlussstatus
Die Teamspace-Projekt-ID aus Zammad ist die fachliche ID, zum Beispiel `EP-2026-A-035`. Für Teamspace-API-Abfragen speichert TicketTracker zusätzlich die interne Teamspace-API-ID.
## Teamspace-Projekt-ID-Sync auf der Zammad-Sync-Seite
Auf der Zammad-Sync-Seite gibt es den Sync für Teamspace-Projekt-IDs, Budgets und Positionen.
Voraussetzungen:
- Admin hat die Teamspace-Base-URL gespeichert.
- Benutzer hat im Profil Teamspace-Geraete-ID und Geraetepasswort gespeichert.
- Die Teamspace User-ID ist bekannt.
- Das Projekt hat eine Teamspace-Projekt-ID.
Der Sync speichert:
- Teamspace-API-ID
- Teamspace-API-URL
- Gesamtbudget
- Unterpositionen
- Budgets und Istwerte der Unterpositionen
- Stückpositionen
## Interne Zammad-Notizen
Beim Timerabschluss und beim manuellen Nachtragen kann TicketTracker einen internen Zammad-Article erzeugen.
Der Article enthält die Tätigkeit und optional den `time_unit`-Wert. Im Profil kann eingestellt werden:
- ob die interne Notiz standardmäßig aktiv ist
- welcher Zammad-Zeitbuchungstyp vorausgewählt wird
- ob `time_unit` auf 5, 10, 15 oder 30 Minuten aufgerundet wird
Wenn Zammad nicht erreichbar ist oder die Notiz fehlschlägt, speichert TicketTracker die Session trotzdem. Die fehlgeschlagene Notiz wird geparkt und kann später im Profil erneut gesendet werden.
## eMail-/Article-Import
TicketTracker kann Zammad-Articles prüfen und daraus Sessions vorbereiten. Das ist besonders hilfreich, wenn Arbeit per eMail-Import ins Ticket kam.
Solche Sessions können nachträglich geprüft, zugeordnet und bewertet werden. Wenn ein importierter Eintrag keinem Teamspace-Projekt zugeordnet ist, kann das im Projektdetail sichtbar werden.
@@ -0,0 +1,95 @@
# Dashboard, Statistiken und Widgets
Diese Seite beschreibt die Kontroll- und Auswertungsflächen in TicketTracker.
## Dashboard
Das Dashboard ist das Tagescockpit. Es zeigt, ob heute oder in der aktuellen Arbeitslage noch etwas offen ist.
Typische Hinweise:
- offene Session-Bewertungen
- Sessions im Zwischenstatus **Abrechenbar**
- heutige oder überfällige ToDos
- fällige Wiedervorlagen
- fehlende Teamspace-Tageswerte, wenn Teamspace-Erfassung in TT aktiv ist
- Projektbudget-Hinweise
- Nacharbeit für heutige Tickets
Widgets können je nach Konfiguration angeordnet und genutzt werden. Favoriten und persönliche Notizen unterstützen die tägliche Arbeit.
## Teamspace-Verhalten im Dashboard
Der Profil-Schalter **Teamspace-Zeiten in TT erfassen** beeinflusst die Dashboard-Logik.
Wenn aktiv:
- Dashboard kann fehlende Teamspace-Tageswerte anzeigen.
- Nacharbeitslisten können Teamspace-Differenzen berücksichtigen.
- Projektverbrauch basiert auf TT-Teamspace-Tageswerten plus manuellen Projektstunden.
Wenn deaktiviert:
- Teamspace-Erfassung wird als ausgeschaltet angezeigt.
- Monatsprüfung läuft statusbasiert.
- Teamspace-Differenzhinweise werden ausgeblendet.
- Projekt-Istwerte kommen aus Teamspace.
## Statistiken
Die Statistikseite betrachtet einen Monat.
Sie zeigt unter anderem:
- abgerechnete Zeit
- abrechenbare Zwischenstände
- nicht abrechenbare Zeit
- offene Sessions
- Organisationen mit Aufwand
- Tickets mit Aufwand
- Projektbudget-Hinweise
- manuelle und fixe Sessions
- Teamspace-Abgleich, wenn Teamspace-Erfassung in TT aktiv ist
- Teamspace als Ist-Quelle, wenn die Erfassung in TT deaktiviert ist
## Abgerechnet, abrechenbar und offen
Für statistische Auswertungen gilt:
- **Abgerechnet** zählt in die abgerechnete Session-Zeit.
- **Abrechenbar** ist ein Zwischenstatus und bleibt offen für den Monatsabschluss.
- **Nicht abrechenbar** ist final, zählt aber nicht als abgerechnete Zeit.
- **Offen** blockiert den Monatsabschluss.
## Teamspace-Abgleich in Statistiken
Wenn Teamspace-Erfassung in TT aktiv ist, vergleicht TicketTracker:
`Teamspace-Zeit minus abgerechnete Session-Zeit`
Bedeutung:
- Positive Differenz: In Teamspace steht mehr Zeit als abgerechnet wurde.
- Negative Differenz: In Teamspace steht weniger Zeit als abgerechnet wurde.
- Null: Teamspace und abgerechnete Session-Zeit sind ausgeglichen.
Wenn Teamspace-Erfassung deaktiviert ist, werden Differenzspalten, Differenzbadges und Teamspace-Linien ausgeblendet oder angepasst. Die Statistik zeigt dann Teamspace als Ist-Quelle und prüft primär die Session-Status.
## Teamspace-Monatsabgleich aus dem Sync
Zusätzlich zum Tageswert-Abgleich kann der Zammad-Sync echte Teamspace-Monatszeiten abrufen. Diese Daten kommen aus Teamspace `/api/time` und verwenden `amountBillable` der Projektzeitbuchungen.
Der Sync speichert die Werte als Monats-Snapshot pro Benutzer und Teamspace-Projekt-Eintrag. Dadurch wird sichtbar, ob der in TicketTracker gepflegte Monatswert zu den echten Teamspace-Buchungen passt und bei welchem Projekt die Abweichung liegt.
Der Monatsabgleich ist besonders hilfreich, wenn mehrere Teamspace-Projekte an einem Ticket hängen oder wenn Teamspace-Zeiten direkt auf Unterpositionen gebucht wurden. Diese Unterpositionszeiten werden dem passenden Teamspace-Projekt-Eintrag zugerechnet.
In der Monatsübersicht wird der gespeicherte Snapshot direkt am Ticket ausgegeben: TS-Monatswert, Differenz und die betroffenen Haupt- oder Unterpositionen. So ist nicht nur sichtbar, dass eine Abweichung existiert, sondern auch, auf welcher Teamspace-Position die Zeit gebucht wurde.
## Projektbudget-Hinweise
Projektbudget-Hinweise basieren auf den aktuellen Projektregeln:
- Budget kommt aus Teamspace.
- Ist kommt entweder aus TT-Teamspace-Tageswerten plus manuellen Projektstunden oder aus Teamspace, abhängig vom Profil-Schalter.
- Überschreitungen werden als Warnung angezeigt.
- Stückpositionen werden gesondert bewertet und nicht als Stundenbudget behandelt.
@@ -0,0 +1,79 @@
# ToDos, Wiedervorlagen und fixe Abrechnungen
Diese Seite beschreibt die Aufgabenlogik und automatisch erzeugte Sessions.
## ToDos
ToDos können direkt an Tickets oder Projekte gehängt werden.
Ein ToDo enthält:
- Aufgabe
- Priorität
- Status offen oder erledigt
- optional Umsetzung von
- optional Umsetzung bis
- optionale Beschreibung
ToDos können bearbeitet, erledigt, wieder geöffnet oder gelöscht werden.
## ToDo-Seite
Die zentrale ToDo-Seite bietet:
- heutige ToDos
- offene, erledigte oder alle ToDos
- Suche nach Ticketnummer
- Sortierung
- Gruppierung
- freie Reihenfolge per Drag and Drop
- Kalenderansicht
Überfällige ToDos werden hervorgehoben. ToDos mit Zeitraum zählen an jedem Arbeitstag im Zeitraum. Ein-Tages-ToDos erscheinen einzeln am jeweiligen Tag.
## Wiedervorlagen
Für Consulting-Projekte können automatische Wiedervorlagen entstehen, wenn länger keine Aktivität im TicketTracker passiert ist.
Mögliche Regeln:
- erste Wiedervorlage nach einer definierten Anzahl Wochen ohne Aktivität
- optionale zweite Wiedervorlage nach weiterem Stillstand
- automatische Erledigung, wenn später wieder eine Session auf dem Projekt erfasst wird
Die Einstellungen dafür liegen im Profil.
## Zammad-Termine
Aus Zammad-Tickets im Status **Wartend** oder **Abschluss** mit Datum kann TicketTracker Termin-ToDos erzeugen.
So werden Zammad-Termine direkt im TicketTracker-Dashboard, in der ToDo-Seite und im Kalender sichtbar.
## Fixe Abrechnungen
Regelmäßige Leistungen können als fixe Abrechnung angelegt werden.
Beispiele:
- jeden Donnerstag 4 Stunden
- bestimmte Wochentage mit festen Zeiten
- alle 2 Wochen eine Leistung
TicketTracker erzeugt daraus automatisch Sessions.
## Feiertage und Ausnahmen
Bayerische Feiertage werden berücksichtigt. Für Feiertage werden keine fixen Sessions erzeugt.
Einzelne automatisch erzeugte Sessions können gelöscht werden, zum Beispiel wegen Urlaub, Krankheit oder Verschiebung. Diese Löschung wird als Ausnahme gespeichert, damit die Session beim nächsten Aufruf nicht automatisch wieder erscheint.
Wenn eine wiederkehrende Leistung an einem anderen Tag erbracht wurde, kann die erzeugte Session bearbeitet oder verschoben werden.
## Bewertung fixer Sessions
Fix erzeugte Sessions verhalten sich im Abschluss wie normale Sessions:
- Sie müssen bewertet werden.
- **Abrechenbar** bleibt ein Zwischenstatus.
- **Abgerechnet** und **Nicht abrechenbar** sind final.
- Offene oder abrechenbare Sessions blockieren den Monatsabschluss.
@@ -0,0 +1,107 @@
# Profil, Adminbereich und Betrieb
Diese Seite beschreibt persönliche Einstellungen, Adminfunktionen und betriebliche Hinweise.
## Profil
Jeder Benutzer pflegt im Profil seine persönlichen Einstellungen:
- Benutzername und Anzeigename
- Passwort
- persönlicher Zammad API-Token
- Teamspace-Geraete-ID und Geraetepasswort
- angezeigte Zammad User-ID
- angezeigte Teamspace User-ID
- Bewertungsabfrage beim Timerabschluss
- Teamspace-Zeiten in TT erfassen
- interne Zammad-Notiz beim Timerabschluss
- Standard-Typ für interne Zammad-Notizen
- optionale Zammad-Zeitaufrundung für `time_unit`
- ATOSS/eMail-Sync-Optionen, sofern aktiviert
- Projekt-Wiedervorlagen
Passwortänderungen erfordern das aktuelle Passwort. Bleiben die Passwortfelder leer, werden nur die übrigen Profildaten gespeichert.
## Zammad-Zugang im Profil
Normale Benutzer können im Profil einen persönlichen Zammad API-Token hinterlegen. Beim Speichern eines neuen Tokens ruft TicketTracker Zammad ab und speichert die User-ID, die Zammad für diesen Token zurückliefert.
Der Token wird verschlüsselt gespeichert. Die User-ID wird danach read-only im Profil angezeigt.
## Teamspace-Zugang im Profil
Jeder Benutzer kann seine Teamspace-Geraete-ID und sein Geraetepasswort speichern.
Beim Speichern werden die Zugangsdaten gegen Teamspace geprüft. Wenn die Prüfung erfolgreich ist, speichert TicketTracker die Teamspace User-ID und zeigt sie im Profil an.
Geraete-ID und Passwort werden verschlüsselt in der Datenbank gespeichert.
## Teamspace-Zeiten in TT erfassen
Der Schalter **Teamspace-Zeiten in TT erfassen** ist userbezogen und steuert, ob ein Benutzer Teamspace-Tageswerte in TicketTracker pflegt.
**An:**
- Teamspace-Tageswerte können in TicketTracker erfasst werden.
- Manuelle Projektstunden sind editierbar.
- Projekt-Ist kommt aus TT-Teamspace-Tageswerten plus manuellen Projektstunden.
- Statistiken und Monatsprüfung vergleichen TT gegen Teamspace-Werte.
**Aus:**
- Teamspace-Tageswerte können nicht erfasst werden.
- Manuelle Projektstunden sind ausgeblendet und serverseitig gesperrt.
- Projekt-Ist kommt aus Teamspace.
- Monatsprüfung und Widgets prüfen nur Session-Status.
- Teamspace-Differenzen werden ausgeblendet.
Der Schalter ist bewusst userbezogen. Zwei Benutzer können am gleichen Projekt arbeiten und trotzdem unterschiedliche Erfassungs- und Prüflogiken nutzen.
## Adminbereich
Der feste Admin ist für Systemverwaltung vorgesehen. Er kann:
- Benutzer anlegen und bearbeiten
- Passwörter für Benutzer setzen
- Zammad-Adminzugang für Organisationssync pflegen
- Teamspace-Base-URL speichern
- Organisationen aus Zammad synchronisieren
- SQL-Backups herunterladen
- Restore-fähige SQL-Backups wieder einspielen
Der Admin sieht keine Timer, keine persönlichen Auswertungen, keine Statistiken, keine fixen Abrechnungen und keinen Monatsabschluss. Diese Bereiche sind für normale Benutzer vorgesehen.
## Teamspace-Base-URL
Die Teamspace-Base-URL wird im Adminbereich gespeichert. Ohne diese URL können Benutzer ihre Teamspace-Zugangsdaten nicht sinnvoll für API-Syncs nutzen.
Die persönlichen Teamspace-Zugangsdaten liegen dagegen im Benutzerprofil.
## Backup und Restore
Der Adminbereich kann SQL-Backups erzeugen und wieder einspielen.
Der Export enthält alle Tabellen, die für den Systemstand notwendig sind, unter anderem:
- Benutzer
- Einstellungen
- Organisationen
- Tickets
- Sessions
- Teamspace-Werte
- Projektinformationen
- Unterpositionen
- fixe Abrechnungen
- Ausnahmen
- Monatsabschlüsse
Ein Restore ersetzt den aktuellen Datenbestand durch den Stand des Backups. Vor dem Restore wird die Struktur geprüft. Nach dem Restore ist eine neue Anmeldung mit den Zugangsdaten aus dem Backup notwendig.
## Technischer Stack
- Frontend: Next.js, React, shadcn/ui, Tailwind CSS
- Backend: Node.js, Express, TypeScript
- Datenbank: PostgreSQL
- Deployment: Docker-fähig
Secrets wie Zammad-Token und Teamspace-Zugangsdaten werden verschlüsselt gespeichert.
+48 -12
View File
@@ -136,6 +136,10 @@ function pathWithoutSearch(value: string) {
return value.split("?")[0]; return value.split("?")[0];
} }
function openTicketKey(ticketId: string) {
return `ticket:${ticketId}`;
}
function readOpenTickets(userId: string): OpenTicketNavItem[] { function readOpenTickets(userId: string): OpenTicketNavItem[] {
try { try {
const raw = localStorage.getItem(openTicketsStorageKey(userId)); const raw = localStorage.getItem(openTicketsStorageKey(userId));
@@ -145,11 +149,15 @@ function readOpenTickets(userId: string): OpenTicketNavItem[] {
} }
const parsed = JSON.parse(raw) as Partial<OpenTicketNavItem>[]; const parsed = JSON.parse(raw) as Partial<OpenTicketNavItem>[];
const tickets: OpenTicketNavItem[] = [];
return parsed for (const item of parsed) {
.filter((item) => item.key && item.ticketId && item.ticketNumber && item.path) if (!item.ticketId || !item.ticketNumber || !item.path) {
.map((item) => ({ continue;
key: String(item.key), }
const ticket: OpenTicketNavItem = {
key: openTicketKey(String(item.ticketId)),
ticketId: String(item.ticketId), ticketId: String(item.ticketId),
ticketNumber: String(item.ticketNumber), ticketNumber: String(item.ticketNumber),
periodType: item.periodType === "day" ? "day" : item.periodType === "month" ? "month" : "all", periodType: item.periodType === "day" ? "day" : item.periodType === "month" ? "month" : "all",
@@ -157,7 +165,17 @@ function readOpenTickets(userId: string): OpenTicketNavItem[] {
path: String(item.path), path: String(item.path),
returnPath: typeof item.returnPath === "string" ? item.returnPath : undefined, returnPath: typeof item.returnPath === "string" ? item.returnPath : undefined,
dirty: false dirty: false
})); };
const existingIndex = tickets.findIndex((existing) => existing.ticketId === ticket.ticketId);
if (existingIndex >= 0) {
tickets[existingIndex] = ticket;
} else {
tickets.push(ticket);
}
}
return tickets.slice(-12);
} catch { } catch {
return []; return [];
} }
@@ -1245,13 +1263,31 @@ export function App() {
const updateOpenTicket = useCallback((ticket: OpenTicketNavItem) => { const updateOpenTicket = useCallback((ticket: OpenTicketNavItem) => {
setOpenTickets((current) => { setOpenTickets((current) => {
const existingIndex = current.findIndex((item) => item.key === ticket.key); const normalizedTicket = {
...ticket,
key: openTicketKey(ticket.ticketId)
};
const existingIndex = current.findIndex((item) => item.ticketId === normalizedTicket.ticketId || item.key === normalizedTicket.key);
if (existingIndex >= 0) { if (existingIndex >= 0) {
return current.map((item, index) => (index === existingIndex ? ticket : item)); return current
.map((item, index) => {
if (item.ticketId === normalizedTicket.ticketId || item.key === normalizedTicket.key) {
return index === existingIndex
? {
...item,
...normalizedTicket,
returnPath: normalizedTicket.returnPath ?? item.returnPath
}
: null;
} }
return [...current, ticket].slice(-12); return item;
})
.filter((item): item is OpenTicketNavItem => item !== null);
}
return [...current, normalizedTicket].slice(-12);
}); });
}, []); }, []);
@@ -1313,7 +1349,7 @@ export function App() {
return; return;
} }
setOpenTickets((items) => items.filter((item) => item.key !== ticket.key)); setOpenTickets((items) => items.filter((item) => item.ticketId !== ticket.ticketId && item.key !== ticket.key));
if (route.page === "ticket" && route.ticketId === ticket.ticketId) { if (route.page === "ticket" && route.ticketId === ticket.ticketId) {
const returnPath = current.returnPath && pathWithoutSearch(current.returnPath) !== pathWithoutSearch(ticket.path) ? current.returnPath : "/analysis"; const returnPath = current.returnPath && pathWithoutSearch(current.returnPath) !== pathWithoutSearch(ticket.path) ? current.returnPath : "/analysis";
@@ -1472,7 +1508,7 @@ export function App() {
<div ref={openTicketsListRef} className="max-h-[clamp(8rem,calc(100svh-31rem),24rem)] overflow-y-auto overscroll-contain pr-1 [scrollbar-gutter:stable] [scrollbar-width:thin]"> <div ref={openTicketsListRef} className="max-h-[clamp(8rem,calc(100svh-31rem),24rem)] overflow-y-auto overscroll-contain pr-1 [scrollbar-gutter:stable] [scrollbar-width:thin]">
<SidebarMenu> <SidebarMenu>
{openTickets.map((ticket) => { {openTickets.map((ticket) => {
const isActive = pathWithoutSearch(path) === pathWithoutSearch(ticket.path); const isActive = route.page === "ticket" && route.ticketId === ticket.ticketId;
return ( return (
<SidebarMenuItem key={ticket.key}> <SidebarMenuItem key={ticket.key}>
@@ -1531,7 +1567,7 @@ export function App() {
<SidebarMenuItem> <SidebarMenuItem>
<SidebarMenuButton tooltip="Worklog"> <SidebarMenuButton tooltip="Worklog">
<FolderGit /> <FolderGit />
<span className="text-xs text-muted-foreground">v1.0.11-rc1</span> <span className="text-xs text-muted-foreground">v1.1.0-rc1</span>
</SidebarMenuButton> </SidebarMenuButton>
</SidebarMenuItem> </SidebarMenuItem>
</SidebarMenu> </SidebarMenu>
@@ -1630,7 +1666,7 @@ export function App() {
) : null} ) : null}
{!isAdmin && route.page === "dashboard" ? <DashboardPage currentUser={currentUser} onNavigate={navigate} /> : null} {!isAdmin && route.page === "dashboard" ? <DashboardPage currentUser={currentUser} onNavigate={navigate} /> : null}
{!isAdmin && route.page === "analysis" ? <AnalysisPage currentUser={currentUser} onNavigate={navigate} /> : null} {!isAdmin && route.page === "analysis" ? <AnalysisPage currentUser={currentUser} onNavigate={navigate} /> : null}
{!isAdmin && route.page === "monthly-close" ? <MonthlyClosePage onNavigate={navigate} /> : null} {!isAdmin && route.page === "monthly-close" ? <MonthlyClosePage currentUser={currentUser} onNavigate={navigate} /> : null}
{!isAdmin && route.page === "statistics" ? <StatisticsPage currentUser={currentUser} onNavigate={navigate} /> : null} {!isAdmin && route.page === "statistics" ? <StatisticsPage currentUser={currentUser} onNavigate={navigate} /> : null}
{!isAdmin && route.page === "projects" ? <ProjectsPage currentUser={currentUser} onNavigate={navigate} onStartTimer={startQuickTimer} /> : null} {!isAdmin && route.page === "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}
+83 -10
View File
@@ -134,11 +134,35 @@ export type TeamspaceProjectIdSyncItem = {
previousApiProjectId: string | null; previousApiProjectId: string | null;
apiProjectId: string | null; apiProjectId: string | null;
apiProjectUrl: string | null; apiProjectUrl: string | null;
status: "updated" | "current" | "missing" | "ambiguous"; ttMonthBillableMinutes: number;
teamspaceMonthMinutes: number;
teamspaceMonthBillableMinutes: number;
teamspaceMonthEntryCount: number;
teamspaceMonthDeltaMinutes: number;
teamspaceMonthPositions: TeamspaceProjectIdSyncPosition[];
status: "updated" | "current" | "missing" | "ambiguous" | "excluded";
message: string | null; message: string | null;
}; };
export type TeamspaceProjectIdSyncPosition = {
apiProjectId: string;
projectId: string | null;
label: string;
isSubentry: boolean;
billingType?: string | null;
minutes: number;
billableMinutes: number;
entryCount: number;
days?: Array<{
day: string;
minutes: number;
billableMinutes: number;
entryCount: number;
}>;
};
export type TeamspaceProjectIdSyncResult = { export type TeamspaceProjectIdSyncResult = {
month: string;
checkedEntries: number; checkedEntries: number;
matched: number; matched: number;
updated: number; updated: number;
@@ -151,6 +175,10 @@ export type TeamspaceProjectIdSyncResult = {
remoteSubprojectsScanned: number; remoteSubprojectsScanned: number;
subentriesSynced: number; subentriesSynced: number;
subentriesRemoved: number; subentriesRemoved: number;
teamspaceTimeEntriesScanned: number;
teamspaceTimeEntriesMapped: number;
teamspaceTimeBillableMinutes: number;
teamspaceTimeDeltaMinutes: number;
duplicateRemoteProjectIds: string[]; duplicateRemoteProjectIds: string[];
items: TeamspaceProjectIdSyncItem[]; items: TeamspaceProjectIdSyncItem[];
}; };
@@ -161,6 +189,10 @@ export type TeamspaceProjectSubentrySyncResult = {
remoteProjectsScanned: number; remoteProjectsScanned: number;
subentriesSynced: number; subentriesSynced: number;
subentriesRemoved: number; subentriesRemoved: number;
monthsSynced: string[];
teamspaceTimeEntriesScanned: number;
teamspaceTimeEntriesMapped: number;
teamspaceTimeBillableMinutes: number;
}; };
export type TeamspaceAllSubentrySyncResult = { export type TeamspaceAllSubentrySyncResult = {
@@ -482,16 +514,34 @@ export function saveZammadSettings(payload: { baseUrl: string; apiKey?: string }
} }
export function getTeamspaceSettings() { export function getTeamspaceSettings() {
return request<{ settings: { baseUrl: string } }>("/api/admin/teamspace/settings"); return request<{ settings: { baseUrl: string; credentialsUserId: string | null } }>("/api/admin/teamspace/settings");
} }
export function saveTeamspaceSettings(payload: { baseUrl: string }) { export function saveTeamspaceSettings(payload: { baseUrl: string; credentialsUserId?: string | null }) {
return request<{ settings: { baseUrl: string } }>("/api/admin/teamspace/settings", { return request<{ settings: { baseUrl: string; credentialsUserId: string | null } }>("/api/admin/teamspace/settings", {
method: "PUT", method: "PUT",
body: JSON.stringify(payload) body: JSON.stringify(payload)
}); });
} }
export type TeamspaceProjectExclusion = {
teamspace_project: string;
teamspace_name: string | null;
customer_name: string | null;
note: string;
};
export function getTeamspaceProjectExclusions() {
return request<{ exclusions: TeamspaceProjectExclusion[] }>("/api/admin/teamspace/project-exclusions");
}
export function saveTeamspaceProjectExclusions(projects: string[]) {
return request<{ exclusions: TeamspaceProjectExclusion[] }>("/api/admin/teamspace/project-exclusions", {
method: "PUT",
body: JSON.stringify({ projects })
});
}
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",
@@ -538,25 +588,46 @@ async function runZammadSyncJob(url: string, body: Record<string, unknown>, onJo
return { jobId: job.id, result }; return { jobId: job.id, result };
} }
export async function syncExistingZammadTickets(fields: ZammadSyncExistingFields, options: { dryRun?: boolean; ticketNumbers?: string[] } = {}) { export async function syncExistingZammadTickets(fields: ZammadSyncExistingFields, options: { dryRun?: boolean; ticketNumbers?: string[] } = {}, onJobStarted?: (job: ZammadSyncJob) => void) {
const { result } = await runZammadSyncJob("/api/zammad-sync/jobs/existing", { const { result } = await runZammadSyncJob("/api/zammad-sync/jobs/existing", {
fields, fields,
dryRun: options.dryRun ?? true, dryRun: options.dryRun ?? true,
ticketNumbers: options.ticketNumbers ticketNumbers: options.ticketNumbers
}); }, onJobStarted);
return result; return result;
} }
export async function importOwnZammadConsultingTickets(options: { dryRun?: boolean; ticketNumbers?: string[] } = {}) { export async function importOwnZammadConsultingTickets(options: { dryRun?: boolean; ticketNumbers?: string[] } = {}, onJobStarted?: (job: ZammadSyncJob) => void) {
const { result } = await runZammadSyncJob("/api/zammad-sync/jobs/consulting-import", { const { result } = await runZammadSyncJob("/api/zammad-sync/jobs/consulting-import", {
dryRun: options.dryRun ?? true, dryRun: options.dryRun ?? true,
ticketNumbers: options.ticketNumbers ticketNumbers: options.ticketNumbers
}); }, onJobStarted);
return result; return result;
} }
export function syncExistingZammadTicketsDirect(fields: ZammadSyncExistingFields, onJobStarted?: (job: ZammadSyncJob) => void) {
return runZammadSyncJob("/api/zammad-sync/jobs/existing", {
fields,
dryRun: false
}, onJobStarted);
}
export function importOwnZammadConsultingTicketsDirect(onJobStarted?: (job: ZammadSyncJob) => void) {
return runZammadSyncJob("/api/zammad-sync/jobs/consulting-import", {
dryRun: false
}, onJobStarted);
}
export function syncZammadMailSessionsDirect(daysBack: number, perPage: number, onJobStarted?: (job: ZammadSyncJob) => void) {
return runZammadSyncJob("/api/zammad-sync/jobs/mail-sessions", {
dryRun: false,
daysBack,
perPage
}, onJobStarted);
}
export function previewExistingZammadTickets(fields: ZammadSyncExistingFields, onJobStarted?: (job: ZammadSyncJob) => void) { export function previewExistingZammadTickets(fields: ZammadSyncExistingFields, onJobStarted?: (job: ZammadSyncJob) => void) {
return runZammadSyncJob("/api/zammad-sync/jobs/existing", { return runZammadSyncJob("/api/zammad-sync/jobs/existing", {
fields, fields,
@@ -597,16 +668,18 @@ export function applyZammadMailSessionsFromPreview(previewJobId: string, article
}, onJobStarted); }, onJobStarted);
} }
export function syncTeamspaceProjectIds() { export function syncTeamspaceProjectIds(month?: string) {
return request<{ result: TeamspaceProjectIdSyncResult }>("/api/teamspace/project-ids/sync", { return request<{ result: TeamspaceProjectIdSyncResult }>("/api/teamspace/project-ids/sync", {
method: "POST", method: "POST",
body: JSON.stringify(month ? { month } : {}),
timeoutMs: 5 * 60 * 1000 timeoutMs: 5 * 60 * 1000
}); });
} }
export function syncProjectTeamspaceSubentries(projectId: string) { export function syncProjectTeamspaceSubentries(projectId: string, months?: string[]) {
return request<{ result: TeamspaceProjectSubentrySyncResult }>(`/api/projects/${projectId}/teamspace-subentries/sync`, { return request<{ result: TeamspaceProjectSubentrySyncResult }>(`/api/projects/${projectId}/teamspace-subentries/sync`, {
method: "POST", method: "POST",
body: JSON.stringify(months?.length ? { months } : {}),
timeoutMs: 5 * 60 * 1000 timeoutMs: 5 * 60 * 1000
}); });
} }
+6 -1
View File
@@ -54,7 +54,7 @@
--font-sans: var(--font-sans); --font-sans: var(--font-sans);
--breakpoint-xxl: 108.125rem; --breakpoint-xxl: 108.125rem;
--breakpoint-details: 85rem; --breakpoint-details: 85rem;
--text-xxs: 0.65rem;
} }
/* Default theme styles (used when no data-theme-preset is set or when 'default' is selected). /* Default theme styles (used when no data-theme-preset is set or when 'default' is selected).
@@ -166,6 +166,11 @@ These serve as the fallback; there is no separate default.css file. */
} }
@layer utilities { @layer utilities {
.text-xxs {
font-size: var(--text-xxs);
line-height: 0.65rem;
}
[data-theme-preset]:not([data-theme-preset="default"]) .shadow-2xs { [data-theme-preset]:not([data-theme-preset="default"]) .shadow-2xs {
box-shadow: var(--shadow-2xs); box-shadow: var(--shadow-2xs);
} }
+59
View File
@@ -204,6 +204,7 @@ export type ProjectSummary = {
starts_on: string | null; starts_on: string | null;
ends_on: string | null; ends_on: string | null;
hour_budget_minutes: number | null; hour_budget_minutes: number | null;
teamspace_budget_excluded?: boolean;
manual_consumption_minutes: number; manual_consumption_minutes: number;
teamspace_consumption_minutes?: number; teamspace_consumption_minutes?: number;
consumption_minutes?: number; consumption_minutes?: number;
@@ -236,6 +237,7 @@ export type ProjectTeamspaceEntry = {
subentries?: ProjectTeamspaceSubentry[]; subentries?: ProjectTeamspaceSubentry[];
is_default: boolean; is_default: boolean;
hour_budget_minutes: number | null; hour_budget_minutes: number | null;
teamspace_budget_excluded?: boolean;
budget_sync_disabled?: boolean; budget_sync_disabled?: boolean;
manual_consumption_minutes: number; manual_consumption_minutes: number;
teamspace_consumption_minutes?: number; teamspace_consumption_minutes?: number;
@@ -311,6 +313,40 @@ export type TicketSummary = {
requires_closure: boolean; requires_closure: boolean;
closed: boolean; closed: boolean;
closed_at: string | null; closed_at: string | null;
teamspace_month_billable_minutes?: number;
teamspace_month_delta_minutes?: number;
teamspace_month_actuals?: TeamspaceMonthActual[];
};
export type TeamspaceMonthActual = {
entry_id: string;
teamspace_project: string | null;
teamspace_description: string | null;
teamspace_api_project_id: string | null;
teamspace_minutes: number;
teamspace_billable_minutes: number;
teamspace_time_entry_count: number;
delta_minutes: number;
positions: TeamspaceMonthActualPosition[];
};
export type TeamspaceMonthActualPosition = {
apiProjectId: string;
projectId: string | null;
label: string;
isSubentry: boolean;
billingType?: string | null;
minutes: number;
billableMinutes: number;
entryCount: number;
days?: TeamspaceMonthActualPositionDay[];
};
export type TeamspaceMonthActualPositionDay = {
day: string;
minutes: number;
billableMinutes: number;
entryCount: number;
}; };
export type OpenSession = { export type OpenSession = {
@@ -432,6 +468,9 @@ export type TicketPeriod = {
dayBillings: TicketDayBilling[]; dayBillings: TicketDayBilling[];
project?: ProjectSummary | null; project?: ProjectSummary | null;
todos?: TicketTodo[]; todos?: TicketTodo[];
teamspaceMonthActuals?: TeamspaceMonthActual[];
teamspaceMonthBillableMinutes?: number;
teamspaceMonthDeltaMinutes?: number;
canClose: boolean; canClose: boolean;
openCount: number; openCount: number;
ticketOpenCount: number; ticketOpenCount: number;
@@ -472,6 +511,9 @@ export type StatisticsGroup = {
open_sessions: number; open_sessions: number;
crm_billed_minutes: number; crm_billed_minutes: number;
crm_delta_minutes: number; crm_delta_minutes: number;
teamspace_month_billable_minutes?: number;
teamspace_month_delta_minutes?: number;
teamspace_month_actuals?: TeamspaceMonthActual[];
}; };
export type StatisticsTicket = { export type StatisticsTicket = {
@@ -492,6 +534,9 @@ export type StatisticsTicket = {
open_sessions: number; open_sessions: number;
crm_billed_minutes: number; crm_billed_minutes: number;
crm_delta_minutes: number; crm_delta_minutes: number;
teamspace_month_billable_minutes?: number;
teamspace_month_delta_minutes?: number;
teamspace_month_actuals?: TeamspaceMonthActual[];
}; };
export type StatisticsOpenSession = { export type StatisticsOpenSession = {
@@ -528,6 +573,16 @@ export type StatisticsTeamspaceDay = {
project_teamspace_project: string | null; project_teamspace_project: string | null;
}; };
export type StatisticsTeamspaceMonthMismatch = {
ticket_id: string;
ticket_number: string;
organization_name: string;
crm_billed_minutes: number;
teamspace_month_billable_minutes: number;
teamspace_month_delta_minutes: number;
teamspace_month_actuals: TeamspaceMonthActual[];
};
export type StatisticsProjectFollowup = { export type StatisticsProjectFollowup = {
project_id: string; project_id: string;
ticket_id: string; ticket_id: string;
@@ -536,6 +591,7 @@ export type StatisticsProjectFollowup = {
organization_name: string | null; organization_name: string | null;
teamspace_project: string | null; teamspace_project: string | null;
hour_budget_minutes: number | null; hour_budget_minutes: number | null;
teamspace_budget_excluded?: boolean;
consumption_minutes: number; consumption_minutes: number;
remaining_minutes: number | null; remaining_minutes: number | null;
latest_tt_update: string; latest_tt_update: string;
@@ -582,6 +638,8 @@ export type StatisticsOverview = {
activeDays: number; activeDays: number;
crmBilledMinutes: number; crmBilledMinutes: number;
crmDeltaMinutes: number; crmDeltaMinutes: number;
teamspaceApiBillableMinutes?: number;
teamspaceApiDeltaMinutes?: number;
}; };
dailySeries: StatisticsDailyBucket[]; dailySeries: StatisticsDailyBucket[];
organizations: StatisticsGroup[]; organizations: StatisticsGroup[];
@@ -592,6 +650,7 @@ export type StatisticsOverview = {
missingCrmDays: StatisticsCrmDay[]; missingCrmDays: StatisticsCrmDay[];
acknowledgedMissingCrmDays: StatisticsCrmDay[]; acknowledgedMissingCrmDays: StatisticsCrmDay[];
crmMismatches: StatisticsCrmDay[]; crmMismatches: StatisticsCrmDay[];
teamspaceMonthMismatches: StatisticsTeamspaceMonthMismatch[];
}; };
teamspaceDays: StatisticsTeamspaceDay[]; teamspaceDays: StatisticsTeamspaceDay[];
}; };
+142 -5
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, Unplug, Upload, UserPlus } from "lucide-react"; import { CheckCircle2, DatabaseBackup, DatabaseZap, Plus, Save, Shield, Trash2, 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";
@@ -15,13 +15,16 @@ import {
downloadDatabaseExport, downloadDatabaseExport,
getAdminUsers, getAdminUsers,
getTeamspaceSettings, getTeamspaceSettings,
getTeamspaceProjectExclusions,
getZammadSettings, getZammadSettings,
restoreDatabaseBackup, restoreDatabaseBackup,
saveZammadSettings, saveZammadSettings,
saveTeamspaceSettings, saveTeamspaceSettings,
saveTeamspaceProjectExclusions,
syncZammadOrganizations, syncZammadOrganizations,
updateAdminUser, updateAdminUser,
validateDatabaseBackup, validateDatabaseBackup,
type TeamspaceProjectExclusion,
type RestoreValidationResult type RestoreValidationResult
} from "../api"; } from "../api";
import type { AdminUser, AuthUser } from "../types"; import type { AdminUser, AuthUser } from "../types";
@@ -57,9 +60,13 @@ export function AdminUsersPage({ currentUser }: { currentUser: AuthUser }) {
const [zammadApiKey, setZammadApiKey] = useState(""); const [zammadApiKey, setZammadApiKey] = useState("");
const [hasZammadApiKey, setHasZammadApiKey] = useState(false); const [hasZammadApiKey, setHasZammadApiKey] = useState(false);
const [teamspaceBaseUrl, setTeamspaceBaseUrl] = useState(""); const [teamspaceBaseUrl, setTeamspaceBaseUrl] = useState("");
const [teamspaceCredentialsUserId, setTeamspaceCredentialsUserId] = useState("");
const [teamspaceProjectExclusions, setTeamspaceProjectExclusions] = useState<TeamspaceProjectExclusion[]>([]);
const [newTeamspaceProjectExclusion, setNewTeamspaceProjectExclusion] = 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 [savingTeamspaceSettings, setSavingTeamspaceSettings] = useState(false);
const [savingTeamspaceProjectExclusions, setSavingTeamspaceProjectExclusions] = 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);
@@ -72,13 +79,20 @@ 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, teamspaceSettingsResult] = await Promise.all([getAdminUsers(), getZammadSettings(), getTeamspaceSettings()]); const [usersResult, zammadSettingsResult, teamspaceSettingsResult, teamspaceProjectExclusionsResult] = await Promise.all([
getAdminUsers(),
getZammadSettings(),
getTeamspaceSettings(),
getTeamspaceProjectExclusions()
]);
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); setTeamspaceBaseUrl(teamspaceSettingsResult.settings.baseUrl);
setTeamspaceCredentialsUserId(teamspaceSettingsResult.settings.credentialsUserId ?? "");
setTeamspaceProjectExclusions(teamspaceProjectExclusionsResult.exclusions);
} }
setForms((current) => setForms((current) =>
@@ -222,9 +236,11 @@ export function AdminUsersPage({ currentUser }: { currentUser: AuthUser }) {
try { try {
const result = await saveTeamspaceSettings({ const result = await saveTeamspaceSettings({
baseUrl: teamspaceBaseUrl baseUrl: teamspaceBaseUrl,
credentialsUserId: teamspaceCredentialsUserId || null
}); });
setTeamspaceBaseUrl(result.settings.baseUrl); setTeamspaceBaseUrl(result.settings.baseUrl);
setTeamspaceCredentialsUserId(result.settings.credentialsUserId ?? "");
toast.success("Teamspace-Zugang gespeichert", { toast.success("Teamspace-Zugang gespeichert", {
description: "URL ist hinterlegt." description: "URL ist hinterlegt."
}); });
@@ -237,6 +253,56 @@ export function AdminUsersPage({ currentUser }: { currentUser: AuthUser }) {
} }
} }
function addTeamspaceExclusion() {
const project = newTeamspaceProjectExclusion.trim();
if (!project) {
return;
}
const exists = teamspaceProjectExclusions.some((entry) => entry.teamspace_project.toLocaleLowerCase("de") === project.toLocaleLowerCase("de"));
if (exists) {
toast.info("Teamspace-Projekt ist bereits in der Liste");
setNewTeamspaceProjectExclusion("");
return;
}
setTeamspaceProjectExclusions((current) => [
...current,
{
teamspace_project: project,
teamspace_name: null,
customer_name: null,
note: ""
}
]);
setNewTeamspaceProjectExclusion("");
}
function removeTeamspaceExclusion(project: string) {
setTeamspaceProjectExclusions((current) => current.filter((entry) => entry.teamspace_project !== project));
}
async function saveTeamspaceExclusions() {
setSavingTeamspaceProjectExclusions(true);
try {
const projects = teamspaceProjectExclusions.map((entry) => entry.teamspace_project.trim()).filter(Boolean);
const result = await saveTeamspaceProjectExclusions(projects);
setTeamspaceProjectExclusions(result.exclusions);
toast.success("Teamspace-Ausnahmen gespeichert", {
description: `${result.exclusions.length} Projektnummer(n) geprüft und gespeichert.`
});
} catch (error) {
toast.error("Teamspace-Ausnahmen konnten nicht gespeichert werden", {
description: error instanceof Error ? error.message : "Unbekannter Fehler"
});
} finally {
setSavingTeamspaceProjectExclusions(false);
}
}
async function exportDatabase() { async function exportDatabase() {
setExportingDatabase(true); setExportingDatabase(true);
@@ -400,7 +466,7 @@ export function AdminUsersPage({ currentUser }: { currentUser: AuthUser }) {
<Unplug className="size-4 text-muted-foreground" /> <Unplug className="size-4 text-muted-foreground" />
<p className="text-sm font-medium">Teamspace API</p> <p className="text-sm font-medium">Teamspace API</p>
</div> </div>
<div className="grid gap-3 xl:grid-cols-[minmax(220px,1fr)_auto] xl:items-end"> <div className="grid gap-3 xl:grid-cols-[minmax(220px,1fr)_minmax(220px,1fr)_auto] xl:items-end">
<div className="space-y-2"> <div className="space-y-2">
<label className="text-sm font-medium" htmlFor="teamspace-base-url">Teamspace URL</label> <label className="text-sm font-medium" htmlFor="teamspace-base-url">Teamspace URL</label>
<Input <Input
@@ -410,9 +476,80 @@ export function AdminUsersPage({ currentUser }: { currentUser: AuthUser }) {
onChange={(event) => setTeamspaceBaseUrl(event.currentTarget.value)} onChange={(event) => setTeamspaceBaseUrl(event.currentTarget.value)}
/> />
</div> </div>
<div className="space-y-2">
<label className="text-sm font-medium" htmlFor="teamspace-credentials-user">Credentials für Adminabfragen</label>
<select
id="teamspace-credentials-user"
className="h-10 w-full rounded-md border border-input bg-background px-3 text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring"
value={teamspaceCredentialsUserId}
onChange={(event) => setTeamspaceCredentialsUserId(event.currentTarget.value)}
>
<option value="">Bitte wählen</option>
{users.filter((user) => user.active && user.has_teamspace_credentials).map((user) => (
<option key={user.id} value={user.id}>
{user.display_name} ({user.username})
</option>
))}
</select>
<p className="text-xs text-muted-foreground">Dieser Benutzer wird für Admin-Checks gegen Teamspace verwendet.</p>
</div>
<Button type="button" variant="secondary" disabled={savingTeamspaceSettings} onClick={() => void saveTeamspaceAccess()}> <Button type="button" variant="secondary" disabled={savingTeamspaceSettings} onClick={() => void saveTeamspaceAccess()}>
<Save className="size-4" /> <Save className="size-4" />
{savingTeamspaceSettings ? "Speichert..." : "URL speichern"} {savingTeamspaceSettings ? "Speichert..." : "Einstellungen speichern"}
</Button>
</div>
<div className="mt-4 space-y-3 rounded-md border bg-muted/20 p-3">
<div>
<p className="text-sm font-medium">TS-Projekte ohne Differenz-/Budgetprüfung</p>
<p className="text-xs text-muted-foreground">
Sync und Gesamtverbrauch laufen weiter, aber TS/TT-Differenzen, Budgetbalken und Budgetverbrauch werden für diese TS-Projekte ausgeblendet. Beim Speichern werden Name und Kunde aus Teamspace geladen.
</p>
</div>
<div className="flex flex-col gap-2 sm:flex-row">
<Input
id="teamspace-project-exclusion-new"
placeholder="BP-2026-A-023"
value={newTeamspaceProjectExclusion}
onChange={(event) => setNewTeamspaceProjectExclusion(event.currentTarget.value)}
onKeyDown={(event) => {
if (event.key === "Enter") {
event.preventDefault();
addTeamspaceExclusion();
}
}}
/>
<Button type="button" variant="secondary" onClick={addTeamspaceExclusion}>
<Plus className="size-4" />
Hinzufügen
</Button>
</div>
<div className="overflow-hidden rounded-md border bg-background">
{teamspaceProjectExclusions.length === 0 ? (
<p className="px-3 py-4 text-sm text-muted-foreground">Noch keine Teamspace-Projekte ausgenommen.</p>
) : (
<div className="divide-y">
{teamspaceProjectExclusions.map((entry) => (
<div key={entry.teamspace_project} className="grid gap-2 px-3 py-2 sm:grid-cols-[140px_minmax(0,1fr)_minmax(0,1fr)_auto] sm:items-center">
<div className="font-mono text-sm font-semibold">{entry.teamspace_project}</div>
<div className="min-w-0">
<p className="truncate text-sm">{entry.teamspace_name ?? "Name wird beim Speichern aus Teamspace geladen"}</p>
<p className="text-xs text-muted-foreground sm:hidden">{entry.customer_name ?? "Kunde wird beim Speichern aus Teamspace geladen"}</p>
</div>
<div className="hidden min-w-0 text-sm text-muted-foreground sm:block">
<span className="truncate">{entry.customer_name ?? "Kunde wird beim Speichern aus Teamspace geladen"}</span>
</div>
<Button type="button" variant="ghost" size="icon" className="justify-self-start sm:justify-self-end" onClick={() => removeTeamspaceExclusion(entry.teamspace_project)}>
<Trash2 className="size-4" />
<span className="sr-only">Teamspace-Projekt entfernen</span>
</Button>
</div>
))}
</div>
)}
</div>
<Button type="button" variant="secondary" disabled={savingTeamspaceProjectExclusions} onClick={() => void saveTeamspaceExclusions()}>
<Save className="size-4" />
{savingTeamspaceProjectExclusions ? "Speichert..." : "Ausnahmen speichern"}
</Button> </Button>
</div> </div>
</div> </div>
+45 -1
View File
@@ -1,4 +1,4 @@
import { ArrowDown, ArrowUp, ChevronLeft, ChevronRight, Clock3, Download, ExternalLink, RotateCcw, SlidersHorizontal, Ticket, UserCheck, Users } from "lucide-react"; import { AlertCircle, ArrowDown, ArrowUp, ChevronLeft, ChevronRight, Clock3, Download, ExternalLink, RotateCcw, SlidersHorizontal, Ticket, UserCheck, Users } from "lucide-react";
import type { LucideIcon } from "lucide-react"; import type { LucideIcon } from "lucide-react";
import { Fragment, useEffect, useMemo, useRef, useState } from "react"; import { Fragment, useEffect, useMemo, useRef, useState } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
@@ -168,6 +168,41 @@ function TicketStatus({ ticket }: { ticket: TicketSummary }) {
return <Badge variant="success">bewertet</Badge>; return <Badge variant="success">bewertet</Badge>;
} }
function formatSignedMinutes(minutes: number) {
if (minutes === 0) {
return formatMinutes(0);
}
return `${minutes > 0 ? "+" : "-"}${formatMinutes(Math.abs(minutes))}`;
}
function teamspaceMonthDeltaMinutes(ticket: TicketSummary) {
const actuals = ticket.teamspace_month_actuals ?? [];
if (actuals.length === 0) {
return 0;
}
const tsMinutes = ticket.teamspace_month_billable_minutes ?? actuals.reduce((sum, actual) => sum + actual.teamspace_billable_minutes, 0);
return ticket.teamspace_month_delta_minutes ?? tsMinutes - ticket.crm_billed_minutes;
}
function TeamspaceDeviationIcon({ ticket }: { ticket: TicketSummary }) {
const deltaMinutes = teamspaceMonthDeltaMinutes(ticket);
if (deltaMinutes === 0) {
return <span className="inline-block size-4" aria-hidden="true" />;
}
const title = deltaMinutes > 0 ? `${formatMinutes(deltaMinutes)} fehlt in TT` : `${formatMinutes(Math.abs(deltaMinutes))} fehlt in TS`;
return (
<span title={`${formatSignedMinutes(deltaMinutes)} · ${title}`} aria-label={`Teamspace-Abweichung: ${title}`}>
<AlertCircle className="mx-auto size-4 text-amber-500" />
</span>
);
}
function ticketStatusKey(ticket: TicketSummary): "open" | "evaluated" { function ticketStatusKey(ticket: TicketSummary): "open" | "evaluated" {
return (ticket.ticket_open_count ?? ticket.open_count) > 0 ? "open" : "evaluated"; return (ticket.ticket_open_count ?? ticket.open_count) > 0 ? "open" : "evaluated";
} }
@@ -690,6 +725,9 @@ export function AnalysisPage({ currentUser, onNavigate }: AnalysisPageProps) {
<TableHeader> <TableHeader>
<TableRow> <TableRow>
<TableHead>Ticket</TableHead> <TableHead>Ticket</TableHead>
<TableHead className="w-10 text-center">
<span className="sr-only">Teamspace-Abweichung</span>
</TableHead>
<TableHead>Organisation</TableHead> <TableHead>Organisation</TableHead>
<TableHead>Art</TableHead> <TableHead>Art</TableHead>
<TableHead>Sessions</TableHead> <TableHead>Sessions</TableHead>
@@ -730,6 +768,9 @@ export function AnalysisPage({ currentUser, onNavigate }: AnalysisPageProps) {
</div> </div>
{ticket.title ? <p className="max-w-[220px] truncate text-xs text-muted-foreground">{ticket.title}</p> : null} {ticket.title ? <p className="max-w-[220px] truncate text-xs text-muted-foreground">{ticket.title}</p> : null}
</TableCell> </TableCell>
<TableCell className="text-center">
<TeamspaceDeviationIcon ticket={ticket} />
</TableCell>
<TableCell>{ticket.customer_name ?? "-"}</TableCell> <TableCell>{ticket.customer_name ?? "-"}</TableCell>
<TableCell>{workTypeLabel(ticket.work_type)}</TableCell> <TableCell>{workTypeLabel(ticket.work_type)}</TableCell>
<TableCell>{ticket.session_count}</TableCell> <TableCell>{ticket.session_count}</TableCell>
@@ -785,8 +826,11 @@ export function AnalysisPage({ currentUser, onNavigate }: AnalysisPageProps) {
{ticket.title ? <p className="truncate text-xs text-muted-foreground">{ticket.title}</p> : null} {ticket.title ? <p className="truncate text-xs text-muted-foreground">{ticket.title}</p> : null}
<p className="text-sm text-muted-foreground">{ticket.customer_name ?? "Keine Organisation"} · {workTypeLabel(ticket.work_type)} · {ticket.session_count} Session(s)</p> <p className="text-sm text-muted-foreground">{ticket.customer_name ?? "Keine Organisation"} · {workTypeLabel(ticket.work_type)} · {ticket.session_count} Session(s)</p>
</div> </div>
<div className="flex shrink-0 items-center gap-2">
<TeamspaceDeviationIcon ticket={ticket} />
<TicketStatus ticket={ticket} /> <TicketStatus ticket={ticket} />
</div> </div>
</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 bg-muted/50 p-2"> <div className="rounded-md bg-muted/50 p-2">
<p className="text-muted-foreground">Abr.</p> <p className="text-muted-foreground">Abr.</p>
+19 -6
View File
@@ -722,7 +722,7 @@ function projectFilterGroup(filter: string) {
function matchesSingleProjectFilter(project: ProjectSummary, filterBy: string, fixedBillingTickets: Set<string>) { function matchesSingleProjectFilter(project: ProjectSummary, filterBy: string, fixedBillingTickets: Set<string>) {
const consumptionMinutes = project.consumption_minutes ?? 0; const consumptionMinutes = project.consumption_minutes ?? 0;
const budgetMinutes = project.hour_budget_minutes ?? 0; const budgetMinutes = projectBudgetExcluded(project) ? 0 : project.hour_budget_minutes ?? 0;
const percent = budgetMinutes > 0 ? consumptionMinutes / budgetMinutes : 0; const percent = budgetMinutes > 0 ? consumptionMinutes / budgetMinutes : 0;
const hasFixedBilling = Number(project.recurring_session_count ?? 0) > 0 || hasFixedBillingForTicket(fixedBillingTickets, project.ticket_id, project.ticket_number); const hasFixedBilling = Number(project.recurring_session_count ?? 0) > 0 || hasFixedBillingForTicket(fixedBillingTickets, project.ticket_id, project.ticket_number);
@@ -792,6 +792,10 @@ function matchesProjectFilters(project: ProjectSummary, filters: string[], fixed
} }
function projectConsumptionPercent(project: ProjectSummary) { function projectConsumptionPercent(project: ProjectSummary) {
if (projectBudgetExcluded(project)) {
return null;
}
const budgetMinutes = project.hour_budget_minutes ?? 0; const budgetMinutes = project.hour_budget_minutes ?? 0;
if (budgetMinutes <= 0) { if (budgetMinutes <= 0) {
@@ -821,12 +825,20 @@ function projectTeamspaceEntries(project: ProjectSummary): ProjectTeamspaceEntry
]; ];
} }
function projectBudgetExcluded(project: ProjectSummary) {
return Boolean(project.teamspace_budget_excluded || projectTeamspaceEntries(project).some((entry) => entry.teamspace_budget_excluded));
}
function projectTeamspaceEntryLabel(entry: ProjectTeamspaceEntry) { function projectTeamspaceEntryLabel(entry: ProjectTeamspaceEntry) {
return entry.teamspace_description?.trim() || entry.teamspace_project || (entry.is_default ? "Standard" : "Ohne Teamspace-ID"); return entry.teamspace_description?.trim() || entry.teamspace_project || (entry.is_default ? "Standard" : "Ohne Teamspace-ID");
} }
function budgetedProjectTeamspaceEntries(project: ProjectSummary) { function budgetedProjectTeamspaceEntries(project: ProjectSummary) {
return projectTeamspaceEntries(project).filter((entry) => Number(entry.hour_budget_minutes ?? 0) > 0); if (projectBudgetExcluded(project)) {
return [];
}
return projectTeamspaceEntries(project).filter((entry) => !entry.teamspace_budget_excluded && Number(entry.hour_budget_minutes ?? 0) > 0);
} }
function openRatingFilterGroup(filter: string) { function openRatingFilterGroup(filter: string) {
@@ -1017,9 +1029,9 @@ export function DashboardPage({ currentUser, onNavigate }: DashboardPageProps) {
const dashboardStats = useMemo(() => { 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) => !projectBudgetExcluded(project) && project.hour_budget_minutes !== null && project.hour_budget_minutes !== undefined && (project.consumption_minutes ?? 0) > project.hour_budget_minutes);
const nearlyFull = projects.filter((project) => { const nearlyFull = projects.filter((project) => {
if (!project.hour_budget_minutes || project.hour_budget_minutes <= 0) { if (projectBudgetExcluded(project) || !project.hour_budget_minutes || project.hour_budget_minutes <= 0) {
return false; return false;
} }
@@ -2349,7 +2361,8 @@ function ProjectBudgetWidget({ widget, editing, projects, stats, onNavigate, onU
<div className="space-y-1"> <div className="space-y-1">
{visible.map((project) => { {visible.map((project) => {
const consumptionMinutes = project.consumption_minutes ?? 0; const consumptionMinutes = project.consumption_minutes ?? 0;
const budgetMinutes = project.hour_budget_minutes ?? 0; const budgetExcluded = projectBudgetExcluded(project);
const budgetMinutes = budgetExcluded ? 0 : project.hour_budget_minutes ?? 0;
const percent = projectConsumptionPercent(project); const percent = projectConsumptionPercent(project);
const budgetedEntries = budgetedProjectTeamspaceEntries(project); const budgetedEntries = budgetedProjectTeamspaceEntries(project);
const showBudgetedTeamspaceEntries = budgetedEntries.length > 1; const showBudgetedTeamspaceEntries = budgetedEntries.length > 1;
@@ -2358,7 +2371,7 @@ function ProjectBudgetWidget({ widget, editing, projects, stats, onNavigate, onU
<button key={project.id} type="button" className="block w-full min-w-0 rounded-md px-2 py-1.5 text-left hover:bg-muted" onClick={() => onNavigate(`/tickets/${project.ticket_id}?month=${currentMonth()}`)}> <button key={project.id} type="button" className="block w-full min-w-0 rounded-md px-2 py-1.5 text-left hover:bg-muted" onClick={() => onNavigate(`/tickets/${project.ticket_id}?month=${currentMonth()}`)}>
<span className="block truncate text-xs font-medium">{project.title || project.ticket_number}</span> <span className="block truncate text-xs font-medium">{project.title || project.ticket_number}</span>
<span className="block truncate text-[11px] text-muted-foreground"> <span className="block truncate text-[11px] text-muted-foreground">
{project.ticket_number} · {showBudgetedTeamspaceEntries ? `${budgetedEntries.length} Budgets · ` : ""}{budgetMinutes > 0 ? `${decimalHours(consumptionMinutes)} / ${decimalHours(budgetMinutes)} · ${percent ?? 0}%` : "ohne Kontingent"} {project.ticket_number} · {budgetExcluded ? `${decimalHours(consumptionMinutes)} · Budgetprüfung ausgenommen` : `${showBudgetedTeamspaceEntries ? `${budgetedEntries.length} Budgets · ` : ""}${budgetMinutes > 0 ? `${decimalHours(consumptionMinutes)} / ${decimalHours(budgetMinutes)} · ${percent ?? 0}%` : "ohne Kontingent"}`}
</span> </span>
<span className="mt-1 flex flex-wrap gap-1"> <span className="mt-1 flex flex-wrap gap-1">
<ZammadStateBadge stateId={project.zammad_state_id} /> <ZammadStateBadge stateId={project.zammad_state_id} />
+105 -22
View File
@@ -23,9 +23,10 @@ import { CopyTicketButton } from "@/components/CopyTicketButton";
import { HelpLink } from "@/components/HelpLink"; import { HelpLink } from "@/components/HelpLink";
import { acknowledgeMissingTicketDayBilling, closePeriod, getStatisticsOverview, reopenPeriod } from "../api"; import { acknowledgeMissingTicketDayBilling, closePeriod, getStatisticsOverview, reopenPeriod } from "../api";
import { currentMonth, currentWeek, formatDate, formatDateTime, formatMinutes } from "../format"; import { currentMonth, currentWeek, formatDate, formatDateTime, formatMinutes } from "../format";
import type { StatisticsCrmDay, StatisticsOpenSession, StatisticsOverview, StatisticsTeamspaceDay } from "../types"; import type { AuthUser, StatisticsCrmDay, StatisticsOpenSession, StatisticsOverview, StatisticsTeamspaceDay, StatisticsTeamspaceMonthMismatch } from "../types";
type MonthlyClosePageProps = { type MonthlyClosePageProps = {
currentUser: AuthUser;
onNavigate: (to: string) => void; onNavigate: (to: string) => void;
}; };
@@ -56,6 +57,18 @@ function formatTeamspaceDelta(minutes: number) {
return formatSignedMinutes(minutes); return formatSignedMinutes(minutes);
} }
function formatMissingTarget(minutes: number, teamspaceTimeTrackingEnabled: boolean) {
if (minutes === 0) {
return "ausgeglichen";
}
if (minutes > 0) {
return `${formatMinutes(minutes)} fehlt ${teamspaceTimeTrackingEnabled ? "in TT" : "in den abgerechneten Zeiten"}`;
}
return `${formatMinutes(Math.abs(minutes))} fehlt in TS`;
}
function StatusCard({ label, value, detail, tone }: { label: string; value: string | number; detail: string; tone?: "ok" | "warn" | "neutral" }) { function StatusCard({ label, value, detail, tone }: { label: string; value: string | number; detail: string; tone?: "ok" | "warn" | "neutral" }) {
const valueClassName = tone === "ok" ? "text-emerald-700 dark:text-emerald-300" : tone === "warn" ? "text-amber-700 dark:text-amber-300" : ""; const valueClassName = tone === "ok" ? "text-emerald-700 dark:text-emerald-300" : tone === "warn" ? "text-amber-700 dark:text-amber-300" : "";
@@ -64,7 +77,7 @@ function StatusCard({ label, value, detail, tone }: { label: string; value: stri
<CardContent className="p-3"> <CardContent className="p-3">
<p className="text-xs font-medium text-muted-foreground">{label}</p> <p className="text-xs font-medium text-muted-foreground">{label}</p>
<p className={`text-xl font-semibold tracking-normal ${valueClassName}`}>{value}</p> <p className={`text-xl font-semibold tracking-normal ${valueClassName}`}>{value}</p>
<p className="text-xs text-muted-foreground">{detail}</p> <p className="text-xs text-muted-foreground ">{detail}</p>
</CardContent> </CardContent>
</Card> </Card>
); );
@@ -79,6 +92,10 @@ type OpenTicketGroup = {
totalMinutes: number; totalMinutes: number;
}; };
type OpenReviewGroup = OpenTicketGroup & {
mismatch: StatisticsTeamspaceMonthMismatch | null;
};
function groupOpenSessions(sessions: StatisticsOpenSession[]) { function groupOpenSessions(sessions: StatisticsOpenSession[]) {
const groups = new Map<string, OpenTicketGroup>(); const groups = new Map<string, OpenTicketGroup>();
@@ -104,7 +121,44 @@ function groupOpenSessions(sessions: StatisticsOpenSession[]) {
return Array.from(groups.values()).sort((left, right) => left.ticketNumber.localeCompare(right.ticketNumber, "de", { numeric: true, sensitivity: "base" })); return Array.from(groups.values()).sort((left, right) => left.ticketNumber.localeCompare(right.ticketNumber, "de", { numeric: true, sensitivity: "base" }));
} }
function OpenTicketItem({ group, onNavigate }: { group: OpenTicketGroup; onNavigate: (to: string) => void }) { function buildOpenReviewGroups(openGroups: OpenTicketGroup[], mismatches: StatisticsTeamspaceMonthMismatch[], month: string) {
const groups = new Map<string, OpenReviewGroup>();
for (const group of openGroups) {
groups.set(group.ticketId, { ...group, mismatch: null });
}
for (const mismatch of mismatches) {
const existing = groups.get(mismatch.ticket_id);
if (existing) {
existing.mismatch = mismatch;
continue;
}
groups.set(mismatch.ticket_id, {
ticketId: mismatch.ticket_id,
ticketNumber: mismatch.ticket_number,
organizationName: mismatch.organization_name,
month,
openCount: 0,
totalMinutes: 0,
mismatch
});
}
return Array.from(groups.values()).sort((left, right) => left.ticketNumber.localeCompare(right.ticketNumber, "de", { numeric: true, sensitivity: "base" }));
}
function OpenReviewItem({
group,
teamspaceTimeTrackingEnabled,
onNavigate
}: {
group: OpenReviewGroup;
teamspaceTimeTrackingEnabled: boolean;
onNavigate: (to: string) => void;
}) {
return ( return (
<div className="rounded-md border bg-background px-3 py-2"> <div className="rounded-md border bg-background px-3 py-2">
<div className="flex items-start justify-between gap-3"> <div className="flex items-start justify-between gap-3">
@@ -112,10 +166,13 @@ function OpenTicketItem({ group, onNavigate }: { group: OpenTicketGroup; onNavig
<div className="flex flex-wrap items-center gap-2"> <div className="flex flex-wrap items-center gap-2">
<span className="font-medium">{group.ticketNumber}</span> <span className="font-medium">{group.ticketNumber}</span>
<CopyTicketButton ticketNumber={group.ticketNumber} /> <CopyTicketButton ticketNumber={group.ticketNumber} />
<Badge variant="warning">{group.openCount} offen</Badge> {group.openCount > 0 ? <Badge variant="warning" className="text-xxs">{group.openCount} Bewertung(en)</Badge> : null}
{group.mismatch ? <Badge variant="warning" className="text-xxs">{formatMissingTarget(group.mismatch.teamspace_month_delta_minutes, teamspaceTimeTrackingEnabled)}</Badge> : null}
</div> </div>
<p className="truncate text-sm text-muted-foreground">{group.organizationName}</p> <p className="truncate text-sm text-muted-foreground">{group.organizationName}</p>
<p className={`text-xs font-medium ${trackedTextClass}`}>{formatMinutes(group.totalMinutes)} offen zu bewerten</p> <div className="flex flex-wrap gap-x-3 gap-y-1 text-xs">
{group.openCount > 0 ? <span className={trackedTextClass}>{formatMinutes(group.totalMinutes)} offen zu bewerten</span> : null}
</div>
</div> </div>
<Button className="h-8 shrink-0" variant="secondary" size="sm" onClick={() => onNavigate(`/tickets/${group.ticketId}?month=${group.month}`)}> <Button className="h-8 shrink-0" variant="secondary" size="sm" onClick={() => onNavigate(`/tickets/${group.ticketId}?month=${group.month}`)}>
Ticket öffnen Ticket öffnen
@@ -197,7 +254,7 @@ function TeamspaceDayItem({ item, onNavigate }: { item: StatisticsTeamspaceDay;
); );
} }
export function MonthlyClosePage({ onNavigate }: MonthlyClosePageProps) { export function MonthlyClosePage({ currentUser, onNavigate }: MonthlyClosePageProps) {
const [viewMode, setViewMode] = useState<MonthlyCloseViewMode>(() => readMonthlyCloseViewMode()); const [viewMode, setViewMode] = useState<MonthlyCloseViewMode>(() => readMonthlyCloseViewMode());
const [month, setMonth] = useState(currentMonth()); const [month, setMonth] = useState(currentMonth());
const [week, setWeek] = useState(currentWeek()); const [week, setWeek] = useState(currentWeek());
@@ -214,6 +271,7 @@ export function MonthlyClosePage({ onNavigate }: MonthlyClosePageProps) {
const periodAttentionLabel = viewMode === "month" ? "diesen Monat" : "diese Woche"; const periodAttentionLabel = viewMode === "month" ? "diesen Monat" : "diese Woche";
const periodInLabel = viewMode === "month" ? "diesem Monat" : "dieser Woche"; const periodInLabel = viewMode === "month" ? "diesem Monat" : "dieser Woche";
const isMonthView = viewMode === "month"; const isMonthView = viewMode === "month";
const teamspaceTimeTrackingEnabled = currentUser.teamspace_time_tracking_enabled ?? true;
async function load() { async function load() {
const requestId = loadRequestId.current + 1; const requestId = loadRequestId.current + 1;
@@ -342,8 +400,14 @@ export function MonthlyClosePage({ onNavigate }: MonthlyClosePageProps) {
const openSessions = stats?.attention.openSessions ?? []; const openSessions = stats?.attention.openSessions ?? [];
const missingCrmDays = stats?.attention.missingCrmDays ?? []; const missingCrmDays = stats?.attention.missingCrmDays ?? [];
const acknowledgedMissingCrmDays = stats?.attention.acknowledgedMissingCrmDays ?? []; const acknowledgedMissingCrmDays = stats?.attention.acknowledgedMissingCrmDays ?? [];
const teamspaceMonthMismatches = stats?.attention.teamspaceMonthMismatches ?? [];
const teamspaceDays = stats?.teamspaceDays ?? []; const teamspaceDays = stats?.teamspaceDays ?? [];
const billedMinutes = stats?.totals.billedMinutes ?? 0;
const ttTeamspaceMinutes = stats?.totals.crmBilledMinutes ?? 0;
const teamspaceApiBillableMinutes = stats?.totals.teamspaceApiBillableMinutes ?? 0;
const teamspaceApiDeltaMinutes = stats?.totals.teamspaceApiDeltaMinutes ?? 0;
const openTicketGroups = useMemo(() => groupOpenSessions(openSessions), [openSessions]); const openTicketGroups = useMemo(() => groupOpenSessions(openSessions), [openSessions]);
const openReviewGroups = useMemo(() => buildOpenReviewGroups(openTicketGroups, teamspaceMonthMismatches, month), [month, openTicketGroups, teamspaceMonthMismatches]);
const visibleMissingCrmDays = useMemo(() => { const visibleMissingCrmDays = useMemo(() => {
const days = showAcknowledgedMissingCrm ? [...missingCrmDays, ...acknowledgedMissingCrmDays] : [...missingCrmDays]; const days = showAcknowledgedMissingCrm ? [...missingCrmDays, ...acknowledgedMissingCrmDays] : [...missingCrmDays];
return days.sort((left, right) => left.day.localeCompare(right.day) || left.ticket_number.localeCompare(right.ticket_number, "de", { numeric: true, sensitivity: "base" })); return days.sort((left, right) => left.day.localeCompare(right.day) || left.ticket_number.localeCompare(right.ticket_number, "de", { numeric: true, sensitivity: "base" }));
@@ -352,16 +416,16 @@ export function MonthlyClosePage({ onNavigate }: MonthlyClosePageProps) {
const checklist = useMemo( const checklist = useMemo(
() => [ () => [
{ {
label: "Offene Bewertungen", label: "Offene Prüfungen",
ok: openSessions.length === 0, ok: openReviewGroups.length === 0,
value: openSessions.length, value: openReviewGroups.length,
detail: openSessions.length === 0 ? "Keine offenen Bewertungen." : `${openTicketGroups.length} Ticket(s) bearbeiten` detail: openReviewGroups.length === 0 ? "Keine offenen Prüfungen." : `${openReviewGroups.length} Ticket(s) prüfen`
}, },
{ {
label: "Teamspace geprüft", label: "Teamspace geprüft",
ok: missingCrmDays.length === 0, ok: missingCrmDays.length === 0 && teamspaceMonthMismatches.length === 0,
value: missingCrmDays.length, value: missingCrmDays.length + teamspaceMonthMismatches.length,
detail: missingCrmDays.length === 0 ? "Keine offenen Teamspace-Prüfpunkte." : `${acknowledgedMissingCrmDays.length} zur Kenntnis genommen` detail: missingCrmDays.length === 0 && teamspaceMonthMismatches.length === 0 ? "Keine offenen Teamspace-Prüfpunkte." : `${teamspaceMonthMismatches.length} Monatsabweichung(en), ${acknowledgedMissingCrmDays.length} zur Kenntnis genommen`
}, },
{ {
label: isMonthView ? "Monat abschließbar" : "Wochenansicht", label: isMonthView ? "Monat abschließbar" : "Wochenansicht",
@@ -370,7 +434,7 @@ export function MonthlyClosePage({ onNavigate }: MonthlyClosePageProps) {
detail: isMonthView ? (stats?.closed ? "Monat ist gesperrt." : canClose ? "Kann abgeschlossen werden." : "Bewertungen prüfen.") : "Abschluss bleibt monatlich." detail: isMonthView ? (stats?.closed ? "Monat ist gesperrt." : canClose ? "Kann abgeschlossen werden." : "Bewertungen prüfen.") : "Abschluss bleibt monatlich."
} }
], ],
[acknowledgedMissingCrmDays.length, canClose, isMonthView, openSessions.length, openTicketGroups.length, missingCrmDays.length, stats?.closed] [acknowledgedMissingCrmDays.length, canClose, isMonthView, missingCrmDays.length, openReviewGroups.length, stats?.closed, teamspaceMonthMismatches.length]
); );
return ( return (
@@ -439,10 +503,29 @@ export function MonthlyClosePage({ onNavigate }: MonthlyClosePageProps) {
) : null} ) : null}
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-5"> <div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-5">
<StatusCard label="Sessions" value={stats?.totals.sessions ?? 0} detail={`${formatMinutes(stats?.totals.minutes ?? 0)} abgerechnet`} /> <StatusCard
<StatusCard label="Teamspace" value={formatMinutes(stats?.totals.crmBilledMinutes ?? 0)} detail={`Differenz ${formatTeamspaceDelta(stats?.totals.crmDeltaMinutes ?? 0)}`} /> label="Tickettracker-Werte"
<StatusCard label="Offene Bewertungen" value={openSessions.length} detail={`${openTicketGroups.length} Ticket(s)`} tone={openSessions.length > 0 ? "warn" : "ok"} /> value={stats?.totals.sessions ?? 0}
<StatusCard label="Ohne Teamspace" value={missingCrmDays.length} detail={`${acknowledgedMissingCrmDays.length} zur Kenntnis genommen`} tone={missingCrmDays.length > 0 ? "warn" : "ok"} /> detail={`${formatMinutes(stats?.totals.minutes ?? 0)} abgerechnet`}
/>
<StatusCard
label="Teamspace-Werte"
value={formatMinutes(ttTeamspaceMinutes)}
detail={`aus TS API ${formatMinutes(teamspaceApiBillableMinutes)}\n${formatMissingTarget(teamspaceApiDeltaMinutes, teamspaceTimeTrackingEnabled)}`}
tone={teamspaceApiDeltaMinutes !== 0 ? "warn" : "ok"}
/>
<StatusCard
label="Offene Prüfungen"
value={openReviewGroups.length}
detail={`${openSessions.length} Bewertung(en)\n${teamspaceMonthMismatches.length}TS-Abweichung(en)`}
tone={openReviewGroups.length > 0 ? "warn" : "ok"}
/>
<StatusCard
label="Ohne Teamspace"
value={missingCrmDays.length}
detail={`${acknowledgedMissingCrmDays.length} zur Kenntnis genommen`}
tone={missingCrmDays.length > 0 ? "warn" : "ok"}
/>
<StatusCard <StatusCard
label="Status" label="Status"
value={isMonthView ? (stats?.closed ? "geschlossen" : canClose ? "bereit" : "offen") : "Ansicht"} value={isMonthView ? (stats?.closed ? "geschlossen" : canClose ? "bereit" : "offen") : "Ansicht"}
@@ -498,12 +581,12 @@ export function MonthlyClosePage({ onNavigate }: MonthlyClosePageProps) {
<section className="space-y-2"> <section className="space-y-2">
<div className="flex min-h-8 items-center gap-2 text-sm font-medium"> <div className="flex min-h-8 items-center gap-2 text-sm font-medium">
<FileWarning className="size-4 text-muted-foreground" /> <FileWarning className="size-4 text-muted-foreground" />
Offene Bewertungen Offene Prüfungen
</div> </div>
{openTicketGroups.map((group) => ( {openReviewGroups.map((group) => (
<OpenTicketItem key={group.ticketId} group={group} onNavigate={onNavigate} /> <OpenReviewItem key={group.ticketId} group={group} teamspaceTimeTrackingEnabled={teamspaceTimeTrackingEnabled} onNavigate={onNavigate} />
))} ))}
{openTicketGroups.length === 0 ? <p className="rounded-md border bg-muted/20 p-3 text-sm text-muted-foreground">Keine offenen Bewertungen.</p> : null} {openReviewGroups.length === 0 ? <p className="rounded-md border bg-muted/20 p-3 text-sm text-muted-foreground">Keine offenen Prüfungen.</p> : null}
</section> </section>
<section className="space-y-2"> <section className="space-y-2">
+28 -2
View File
@@ -347,7 +347,11 @@ function projectTeamspaceEntryLabel(entry: { teamspace_project: string | null; t
} }
function budgetedProjectTeamspaceEntries(project: ProjectSummary) { function budgetedProjectTeamspaceEntries(project: ProjectSummary) {
return projectTeamspaceEntries(project).filter((entry) => Number(entry.hour_budget_minutes ?? 0) > 0); if (projectBudgetExcluded(project)) {
return [];
}
return projectTeamspaceEntries(project).filter((entry) => !entry.teamspace_budget_excluded && Number(entry.hour_budget_minutes ?? 0) > 0);
} }
function projectEntryConsumption(entry: ProjectTeamspaceEntry) { function projectEntryConsumption(entry: ProjectTeamspaceEntry) {
@@ -412,7 +416,15 @@ 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 projectBudgetExcluded(project: ProjectSummary) {
return Boolean(project.teamspace_budget_excluded || projectTeamspaceEntries(project).some((entry) => entry.teamspace_budget_excluded));
}
function projectBudget(project: ProjectSummary, _standardTeamspaceOnly = false) { function projectBudget(project: ProjectSummary, _standardTeamspaceOnly = false) {
if (projectBudgetExcluded(project)) {
return null;
}
return project.hour_budget_minutes ?? null; return project.hour_budget_minutes ?? null;
} }
@@ -1656,6 +1668,7 @@ export function ProjectsPage({ currentUser, onNavigate, onStartTimer }: Projects
const usedPercent = projectConsumptionPercent(project, viewSettings.standardTeamspaceOnly); const usedPercent = projectConsumptionPercent(project, viewSettings.standardTeamspaceOnly);
const budgetExceeded = budgetMinutes !== null && budgetMinutes > 0 && consumptionMinutes > budgetMinutes; const budgetExceeded = budgetMinutes !== null && budgetMinutes > 0 && consumptionMinutes > budgetMinutes;
const showBudgetedTeamspaceEntries = !viewSettings.standardTeamspaceOnly && budgetedTeamspaceEntries.length > 1; const showBudgetedTeamspaceEntries = !viewSettings.standardTeamspaceOnly && budgetedTeamspaceEntries.length > 1;
const budgetExcluded = projectBudgetExcluded(project);
const canEditProject = project.can_edit === true; const canEditProject = project.can_edit === true;
const unassignedTeamspaceEntryCount = Number(project.unassigned_teamspace_session_count ?? 0); const unassignedTeamspaceEntryCount = Number(project.unassigned_teamspace_session_count ?? 0);
const unassignedTeamspaceSubentryCount = 0; const unassignedTeamspaceSubentryCount = 0;
@@ -1695,17 +1708,28 @@ export function ProjectsPage({ currentUser, onNavigate, onStartTimer }: Projects
</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>
{budgetExcluded ? (
<>
<p className="text-sm font-semibold">{formatDecimalHours(consumptionMinutes)}</p>
<p className="text-xs text-muted-foreground">Budgetprüfung ausgenommen</p>
</>
) : (
<p className={cn("inline-flex items-center gap-1 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)}>
{budgetExceeded ? <AlertCircle className="size-3.5 text-amber-500" aria-label="Budgetüberschreitung" /> : null} {budgetExceeded ? <AlertCircle className="size-3.5 text-amber-500" aria-label="Budgetüberschreitung" /> : null}
{formatDecimalHours(consumptionMinutes)} / {budgetMinutes ? formatDecimalHours(budgetMinutes) : "offen"} {formatDecimalHours(consumptionMinutes)} / {budgetMinutes ? formatDecimalHours(budgetMinutes) : "offen"}
</p> </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">
<p className="truncate font-medium xl:hidden">Verbrauch %</p> <p className="truncate font-medium xl:hidden">Verbrauch %</p>
{budgetExcluded ? (
<p className="text-xs text-muted-foreground">keine Prüfung</p>
) : (
<p className={cn("font-semibold", usedPercent !== null && usedPercent >= 100 ? "text-blue-500" : usedPercent !== null && usedPercent >= 90 ? "text-red-500" : undefined)}> <p className={cn("font-semibold", usedPercent !== null && usedPercent >= 100 ? "text-blue-500" : usedPercent !== null && usedPercent >= 90 ? "text-red-500" : undefined)}>
{usedPercent !== null ? `${usedPercent}%` : "-"} {usedPercent !== null ? `${usedPercent}%` : "-"}
</p> </p>
)}
</div> </div>
<div className="flex justify-end"> <div className="flex justify-end">
<DropdownMenu> <DropdownMenu>
@@ -1807,7 +1831,9 @@ export function ProjectsPage({ currentUser, onNavigate, onStartTimer }: Projects
</Button> </Button>
</div> </div>
) : null} ) : null}
{showBudgetedTeamspaceEntries ? ( {budgetExcluded ? (
<p className="text-xs text-muted-foreground">Für dieses Teamspace-Projekt werden keine Differenzen und kein Budgetverbrauch ausgewertet. Der Verbrauch zählt weiter in Summen.</p>
) : 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);
+69 -4
View File
@@ -27,7 +27,7 @@ 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 { AuthUser, StatisticsDailyBucket, StatisticsGroup, StatisticsOverview, StatisticsProjectFollowup, StatisticsProjectFollowupOverview, StatisticsTicket, WorkType } from "../types"; import type { AuthUser, StatisticsDailyBucket, StatisticsGroup, StatisticsOverview, StatisticsProjectFollowup, StatisticsProjectFollowupOverview, StatisticsTicket, TeamspaceMonthActual, WorkType } from "../types";
type StatisticsPageProps = { type StatisticsPageProps = {
currentUser: AuthUser; currentUser: AuthUser;
@@ -76,6 +76,63 @@ function formatTeamspaceDelta(minutes: number) {
return formatSignedMinutes(minutes); return formatSignedMinutes(minutes);
} }
function teamspaceActualLabel(actual: TeamspaceMonthActual) {
return actual.teamspace_description?.trim() || actual.teamspace_project || actual.teamspace_api_project_id || "Teamspace";
}
function TeamspaceMonthActualsLine({
actuals,
tsMinutes,
deltaMinutes
}: {
actuals: TeamspaceMonthActual[] | undefined;
tsMinutes: number | undefined;
deltaMinutes: number | undefined;
}) {
const visibleActuals = actuals ?? [];
if (visibleActuals.length === 0) {
return null;
}
const effectiveTsMinutes = tsMinutes ?? visibleActuals.reduce((sum, actual) => sum + actual.teamspace_billable_minutes, 0);
const effectiveDeltaMinutes = deltaMinutes ?? visibleActuals.reduce((sum, actual) => sum + actual.delta_minutes, 0);
const positions = visibleActuals.flatMap((actual) =>
(actual.positions ?? []).map((position) => ({
...position,
entryLabel: teamspaceActualLabel(actual)
}))
);
return (
<div className="mt-1 space-y-1 text-[11px] text-muted-foreground">
<div className="flex flex-wrap items-center gap-1.5">
<Badge variant={effectiveDeltaMinutes === 0 ? "success" : "warning"} className="h-5 px-1.5 text-[10px]">
TS API {formatMinutes(effectiveTsMinutes)} · Diff {formatTeamspaceDelta(effectiveDeltaMinutes)}
</Badge>
{visibleActuals
.filter((actual) => actual.delta_minutes !== 0)
.slice(0, 3)
.map((actual) => (
<span key={actual.entry_id} className="rounded-sm bg-muted px-1.5 py-0.5">
{teamspaceActualLabel(actual)}: {formatTeamspaceDelta(actual.delta_minutes)}
</span>
))}
</div>
{positions.length > 0 ? (
<div className="flex flex-wrap gap-x-2 gap-y-0.5">
{positions.slice(0, 4).map((position) => (
<span key={`${position.entryLabel}-${position.apiProjectId}`} title={`${position.entryLabel} · API ${position.apiProjectId}`}>
{position.isSubentry ? `Pos. ${position.projectId ?? position.apiProjectId}` : "Hauptprojekt"}: {position.label} · {formatMinutes(position.billableMinutes)}
</span>
))}
{positions.length > 4 ? <span>+{positions.length - 4} weitere</span> : null}
</div>
) : null}
</div>
);
}
function deltaTextClass(minutes: number) { function deltaTextClass(minutes: number) {
return minutes < 0 ? "text-red-700 dark:text-red-300" : "text-emerald-700 dark:text-emerald-300"; return minutes < 0 ? "text-red-700 dark:text-red-300" : "text-emerald-700 dark:text-emerald-300";
} }
@@ -382,6 +439,11 @@ function TicketHotspot({
<span>{ticket.active_days} Tag(e)</span> <span>{ticket.active_days} Tag(e)</span>
{showDifference ? <span className={deltaTextClass(ticket.crm_delta_minutes)}>{formatTeamspaceDelta(ticket.crm_delta_minutes)}</span> : null} {showDifference ? <span className={deltaTextClass(ticket.crm_delta_minutes)}>{formatTeamspaceDelta(ticket.crm_delta_minutes)}</span> : null}
</div> </div>
<TeamspaceMonthActualsLine
actuals={ticket.teamspace_month_actuals}
tsMinutes={ticket.teamspace_month_billable_minutes}
deltaMinutes={ticket.teamspace_month_delta_minutes}
/>
</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}`)}>
Öffnen Öffnen
@@ -403,14 +465,17 @@ function daysSince(value: string) {
function ProjectFollowupRow({ project, onNavigate }: { project: StatisticsProjectFollowup; onNavigate: (to: string) => void }) { function ProjectFollowupRow({ project, onNavigate }: { project: StatisticsProjectFollowup; onNavigate: (to: string) => void }) {
const inactiveDays = daysSince(project.latest_tt_update); const inactiveDays = daysSince(project.latest_tt_update);
const hasBudget = project.hour_budget_minutes !== null; const budgetExcluded = Boolean(project.teamspace_budget_excluded);
const hasBudget = !budgetExcluded && project.hour_budget_minutes !== null;
return ( return (
<div className="grid gap-3 rounded-md border bg-background p-3 lg:grid-cols-[minmax(0,1fr)_auto] lg:items-center"> <div className="grid gap-3 rounded-md border bg-background p-3 lg:grid-cols-[minmax(0,1fr)_auto] lg:items-center">
<div className="min-w-0 space-y-2"> <div className="min-w-0 space-y-2">
<div className="flex flex-wrap items-center gap-2"> <div className="flex flex-wrap items-center gap-2">
<span className="font-semibold">{project.ticket_number}</span> <span className="font-semibold">{project.ticket_number}</span>
{hasBudget ? ( {budgetExcluded ? (
<Badge variant="outline">Budgetprüfung ausgenommen</Badge>
) : hasBudget ? (
<Badge variant={project.remaining_minutes !== null && project.remaining_minutes > 0 ? "warning" : "secondary"}> <Badge variant={project.remaining_minutes !== null && project.remaining_minutes > 0 ? "warning" : "secondary"}>
{project.remaining_minutes !== null && project.remaining_minutes > 0 ? `${formatMinutes(project.remaining_minutes)} offen` : "Budget verbraucht"} {project.remaining_minutes !== null && project.remaining_minutes > 0 ? `${formatMinutes(project.remaining_minutes)} offen` : "Budget verbraucht"}
</Badge> </Badge>
@@ -429,7 +494,7 @@ function ProjectFollowupRow({ project, onNavigate }: { project: StatisticsProjec
<div className="flex flex-wrap gap-x-3 gap-y-1 text-xs text-muted-foreground"> <div className="flex flex-wrap gap-x-3 gap-y-1 text-xs text-muted-foreground">
<span>Letztes TT-Update {formatDateTime(project.latest_tt_update)}</span> <span>Letztes TT-Update {formatDateTime(project.latest_tt_update)}</span>
{inactiveDays !== null ? <span>{inactiveDays} Tag(e) inaktiv</span> : null} {inactiveDays !== null ? <span>{inactiveDays} Tag(e) inaktiv</span> : null}
<span>Budget {hasBudget ? formatMinutes(project.hour_budget_minutes!) : "nicht gesetzt"}</span> <span>Budget {budgetExcluded ? "Prüfung ausgenommen" : hasBudget ? formatMinutes(project.hour_budget_minutes!) : "nicht gesetzt"}</span>
<span>Verbraucht {formatMinutes(project.consumption_minutes)}</span> <span>Verbraucht {formatMinutes(project.consumption_minutes)}</span>
<span>{project.open_todos} offene ToDo(s)</span> <span>{project.open_todos} offene ToDo(s)</span>
</div> </div>
+173 -46
View File
@@ -1,4 +1,4 @@
import { AlertCircle, ArrowLeft, CheckCircle2, ChevronDown, ChevronUp, ClipboardList, Copy, EyeOff, FolderKanban, MailCheck, MailPlus, Pencil, Play, Plus, RefreshCw, RotateCcw, Save, Send, Star, Trash2 } from "lucide-react"; import { AlertCircle, ArrowLeft, CheckCircle2, ChevronDown, ChevronUp, ClipboardList, Copy, FolderKanban, Info, MailCheck, MailPlus, Pencil, Play, Plus, RefreshCw, RotateCcw, Save, Send, Star, Trash2 } from "lucide-react";
import { type ClipboardEvent, FormEvent, useEffect, useMemo, useRef, useState } from "react"; import { type ClipboardEvent, FormEvent, useEffect, useMemo, useRef, useState } from "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";
@@ -123,6 +123,10 @@ function emptyTicketSessionForm(): TicketSessionFormState {
}; };
} }
function hasActivityContent(activity: string, zammadArticleBody: string | null | undefined) {
return activity.trim().length > 0 || Boolean(zammadArticleBody);
}
function notifyZammadArticleResult(result: CreateSessionResult) { function notifyZammadArticleResult(result: CreateSessionResult) {
if (result.zammadArticle?.status === "sent") { if (result.zammadArticle?.status === "sent") {
toast.success("Zammad-Notiz übertragen"); toast.success("Zammad-Notiz übertragen");
@@ -140,6 +144,38 @@ function notifyTodosChanged() {
window.dispatchEvent(new Event("tickettracker:todos-changed")); window.dispatchEvent(new Event("tickettracker:todos-changed"));
} }
function formatSignedMinutes(minutes: number) {
if (minutes === 0) {
return formatMinutes(0);
}
return `${minutes > 0 ? "+" : "-"}${formatMinutes(Math.abs(minutes))}`;
}
function formatTeamspaceMissingTarget(minutes: number, teamspaceTimeTrackingEnabled: boolean) {
if (minutes === 0) {
return "ausgeglichen";
}
if (minutes > 0) {
return `${formatMinutes(minutes)} fehlt ${teamspaceTimeTrackingEnabled ? "in TT" : "in den abgerechneten Zeiten"}`;
}
return `${formatMinutes(Math.abs(minutes))} fehlt in TS`;
}
function formatTeamspaceDifferenceTarget(minutes: number, teamspaceTimeTrackingEnabled: boolean) {
if (minutes === 0) {
return "0 min";
}
const target = minutes > 0
? teamspaceTimeTrackingEnabled ? "TT" : "abgerechnet"
: "TS";
return `${minutes > 0 ? "+" : "-"}${formatMinutes(Math.abs(minutes))} in ${target}`;
}
function zammadSyncStorageKey(userId: string) { function zammadSyncStorageKey(userId: string) {
return `tickettracker.zammad-sync.${userId}`; return `tickettracker.zammad-sync.${userId}`;
} }
@@ -271,7 +307,15 @@ function projectTeamspaceEntries(project: ProjectSummary): ProjectTeamspaceEntry
} }
function budgetedProjectTeamspaceEntries(project: ProjectSummary) { function budgetedProjectTeamspaceEntries(project: ProjectSummary) {
return projectTeamspaceEntries(project).filter((entry) => Number(entry.hour_budget_minutes ?? 0) > 0); if (projectBudgetExcluded(project)) {
return [];
}
return projectTeamspaceEntries(project).filter((entry) => !entry.teamspace_budget_excluded && Number(entry.hour_budget_minutes ?? 0) > 0);
}
function projectBudgetExcluded(project: ProjectSummary) {
return Boolean(project.teamspace_budget_excluded || projectTeamspaceEntries(project).some((entry) => entry.teamspace_budget_excluded));
} }
function teamspaceEntryDisplayLabel(entry: { teamspace_project: string | null; teamspace_description?: string | null; is_default: boolean }) { function teamspaceEntryDisplayLabel(entry: { teamspace_project: string | null; teamspace_description?: string | null; is_default: boolean }) {
@@ -1131,11 +1175,13 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onS
const hasProjectTeamspaceApiIds = projectEntries.some((entry) => Boolean(entry.teamspace_api_project_id)); const hasProjectTeamspaceApiIds = projectEntries.some((entry) => Boolean(entry.teamspace_api_project_id));
const showTeamspaceSubentryCard = Boolean(project && hasProjectTeamspaceApiIds); const showTeamspaceSubentryCard = Boolean(project && hasProjectTeamspaceApiIds);
const usesTeamspaceSubentries = false; const usesTeamspaceSubentries = false;
const budgetExcluded = project ? projectBudgetExcluded(project) : false;
const budgetConsumptionExcluded = budgetExcluded || isRecurringBillingTicket;
const showBudgetedProjectEntries = budgetedProjectEntries.length > 1; const showBudgetedProjectEntries = budgetedProjectEntries.length > 1;
const requiresProjectTeamspaceEntry = Boolean(project && hasMultipleProjectTeamspaceEntries); const requiresProjectTeamspaceEntry = Boolean(project && hasMultipleProjectTeamspaceEntries);
const teamspaceTimeTrackingEnabled = currentUser.teamspace_time_tracking_enabled ?? true; const teamspaceTimeTrackingEnabled = currentUser.teamspace_time_tracking_enabled ?? true;
const projectConsumptionMinutes = project?.consumption_minutes ?? 0; const projectConsumptionMinutes = project?.consumption_minutes ?? 0;
const projectBudgetMinutes = project?.hour_budget_minutes ?? null; const projectBudgetMinutes = budgetConsumptionExcluded ? null : project?.hour_budget_minutes ?? null;
const projectConsumptionUsagePercent = project ? projectConsumptionPercent(projectConsumptionMinutes, projectBudgetMinutes) : null; const projectConsumptionUsagePercent = project ? projectConsumptionPercent(projectConsumptionMinutes, projectBudgetMinutes) : null;
const projectRemainingMinutes = projectBudgetMinutes && projectBudgetMinutes > 0 ? projectBudgetMinutes - projectConsumptionMinutes : null; const projectRemainingMinutes = projectBudgetMinutes && projectBudgetMinutes > 0 ? projectBudgetMinutes - projectConsumptionMinutes : null;
const canEditProject = project?.can_edit === true; const canEditProject = project?.can_edit === true;
@@ -1154,6 +1200,10 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onS
const isProject = Boolean(project); const isProject = Boolean(project);
const isGlobalTicket = periodType === "all"; const isGlobalTicket = periodType === "all";
const isPeriodClosed = Boolean(data?.closed); const isPeriodClosed = Boolean(data?.closed);
const selectedTeamspaceMonth = isGlobalTicket ? globalMonthFilter : periodType === "month" ? period : period.slice(0, 7);
const teamspaceMonthActuals = data?.teamspaceMonthActuals ?? [];
const teamspaceMonthBillableMinutes = data?.teamspaceMonthBillableMinutes ?? teamspaceMonthActuals.reduce((sum, actual) => sum + actual.teamspace_billable_minutes, 0);
const teamspaceMonthDeltaMinutes = data?.teamspaceMonthDeltaMinutes ?? teamspaceMonthActuals.reduce((sum, actual) => sum + actual.delta_minutes, 0);
const mailDaysBack = useMemo(() => mailDaysBackFromZammadSyncSettings(currentUser.id), [currentUser.id]); const mailDaysBack = useMemo(() => mailDaysBackFromZammadSyncSettings(currentUser.id), [currentUser.id]);
const selectedMailArticleIds = useMemo( const selectedMailArticleIds = useMemo(
() => (mailPreview?.items ?? []).filter((item) => mailSelection[item.articleId] !== false).map((item) => item.articleId), () => (mailPreview?.items ?? []).filter((item) => mailSelection[item.articleId] !== false).map((item) => item.articleId),
@@ -1186,6 +1236,60 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onS
}, [data?.dayBillings, defaultProjectEntryId, requiresProjectTeamspaceEntry]); }, [data?.dayBillings, defaultProjectEntryId, requiresProjectTeamspaceEntry]);
const savedDayBillingInput = (key: string) => formatHoursInput(dayBillingMap.get(key)); const savedDayBillingInput = (key: string) => formatHoursInput(dayBillingMap.get(key));
const isDayBillingDirty = (key: string) => (dayBillingInputs[key] ?? "") !== savedDayBillingInput(key); const isDayBillingDirty = (key: string) => (dayBillingInputs[key] ?? "") !== savedDayBillingInput(key);
const dayBillingTotalByDayMap = useMemo(() => {
const billingMap = new Map<string, number>();
for (const billing of data?.dayBillings ?? []) {
billingMap.set(billing.day, (billingMap.get(billing.day) ?? 0) + Number(billing.billed_minutes ?? 0));
}
return billingMap;
}, [data?.dayBillings]);
const sessionBilledTotalByDayMap = useMemo(() => {
const billingMap = new Map<string, number>();
for (const session of data?.sessions ?? []) {
if (session.billing_status !== "billed") {
continue;
}
const day = sessionDayKey(session.started_at);
billingMap.set(day, (billingMap.get(day) ?? 0) + Number(session.rounded_minutes ?? 0));
}
return billingMap;
}, [data?.sessions]);
const teamspaceApiDayBillingTotalByDayMap = useMemo(() => {
const billingMap = new Map<string, number>();
for (const actual of teamspaceMonthActuals) {
for (const position of actual.positions ?? []) {
for (const day of position.days ?? []) {
billingMap.set(day.day, (billingMap.get(day.day) ?? 0) + Number(day.billableMinutes ?? 0));
}
}
}
return billingMap;
}, [teamspaceMonthActuals]);
const teamspaceDayDeviation = (day: string) => {
if (!project || ticket?.work_type === "support" || budgetExcluded) {
return {
ttMinutes: 0,
tsMinutes: 0,
deltaMinutes: 0
};
}
const ttMinutes = teamspaceTimeTrackingEnabled ? dayBillingTotalByDayMap.get(day) ?? 0 : sessionBilledTotalByDayMap.get(day) ?? 0;
const tsMinutes = teamspaceApiDayBillingTotalByDayMap.get(day) ?? 0;
return {
ttMinutes,
tsMinutes,
deltaMinutes: tsMinutes - ttMinutes
};
};
const unassignedTeamspaceSessions = useMemo( const unassignedTeamspaceSessions = useMemo(
() => teamspaceTimeTrackingEnabled () => teamspaceTimeTrackingEnabled
? (data?.sessions ?? []).filter((session) => ? (data?.sessions ?? []).filter((session) =>
@@ -1396,7 +1500,7 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onS
} }
async function sendSessionToZammad(session: SessionEntry) { async function sendSessionToZammad(session: SessionEntry) {
if (!session.activity.trim() && !session.zammad_article_body) { if (!hasActivityContent(session.activity, session.zammad_article_body)) {
toast.warning("Keine Zammad-Notiz möglich", { toast.warning("Keine Zammad-Notiz möglich", {
description: "Die Session hat keine Tätigkeit." description: "Die Session hat keine Tätigkeit."
}); });
@@ -1700,9 +1804,12 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onS
setSyncingTeamspaceSubentries(true); setSyncingTeamspaceSubentries(true);
try { try {
const { result } = await syncProjectTeamspaceSubentries(project.id); const syncMonths = selectedTeamspaceMonth === "all"
? availableMonths.map((month) => month.month)
: [selectedTeamspaceMonth];
const { result } = await syncProjectTeamspaceSubentries(project.id, syncMonths);
toast.success("Teamspace-Unterpositionen synchronisiert", { toast.success("Teamspace-Unterpositionen synchronisiert", {
description: `${result.subentriesSynced} Position(en) abgeglichen, ${result.subentriesRemoved} entfernt.` description: `${result.subentriesSynced} Position(en), ${result.teamspaceTimeEntriesMapped} Zeiteintrag/Zeiteinträge in ${result.monthsSynced.length} Monat(en).`
}); });
await load(); await load();
} catch (error) { } catch (error) {
@@ -1793,7 +1900,7 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onS
return; return;
} }
if (!ticketSession.activity.trim() && !ticketSession.zammadArticleBody) { if (!hasActivityContent(ticketSession.activity, ticketSession.zammadArticleBody)) {
toast.error("Tätigkeit erfassen"); toast.error("Tätigkeit erfassen");
return; return;
} }
@@ -1838,7 +1945,7 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onS
durationSeconds, durationSeconds,
projectTeamspaceEntryId: effectiveWorkType === "consulting" && requiresProjectTeamspaceEntry ? ticketSession.projectTeamspaceEntryId : null, projectTeamspaceEntryId: effectiveWorkType === "consulting" && requiresProjectTeamspaceEntry ? ticketSession.projectTeamspaceEntryId : null,
billingStatus: ticketSession.billingStatus, billingStatus: ticketSession.billingStatus,
createZammadArticle: ticketSession.createZammadArticle && (activityValue.activity.trim().length > 0 || Boolean(activityValue.zammadArticleBody)), createZammadArticle: ticketSession.createZammadArticle && hasActivityContent(activityValue.activity, activityValue.zammadArticleBody),
zammadArticleTypeId: ticketSession.zammadArticleTypeId zammadArticleTypeId: ticketSession.zammadArticleTypeId
}); });
toast.success("Session nachgetragen", { toast.success("Session nachgetragen", {
@@ -2298,12 +2405,23 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onS
) : null} ) : null}
<div className="flex flex-wrap gap-2 text-sm"> <div className="flex flex-wrap gap-2 text-sm">
<Badge variant="secondary">Zeitraum {project.starts_on || "offen"} - {project.ends_on || "offen"}</Badge> <Badge variant="secondary">Zeitraum {project.starts_on || "offen"} - {project.ends_on || "offen"}</Badge>
<Badge variant="secondary">Kontingent {projectBudgetMinutes ? formatMinutes(projectBudgetMinutes) : "offen"}</Badge> {budgetConsumptionExcluded ? <Badge variant="outline">Budgetprüfung ausgenommen</Badge> : <Badge variant="secondary">Kontingent {projectBudgetMinutes ? formatMinutes(projectBudgetMinutes) : "offen"}</Badge>}
<Badge variant="secondary">Verbrauch {formatMinutes(projectConsumptionMinutes)}</Badge> <Badge variant="secondary">Verbrauch {formatMinutes(projectConsumptionMinutes)}</Badge>
<Badge variant={projectRemainingMinutes !== null && projectRemainingMinutes < 0 ? "destructive" : "secondary"}>{projectRemainingLabel(projectRemainingMinutes)}</Badge> {budgetConsumptionExcluded ? null : <Badge variant={projectRemainingMinutes !== null && projectRemainingMinutes < 0 ? "destructive" : "secondary"}>{projectRemainingLabel(projectRemainingMinutes)}</Badge>}
{showBudgetedProjectEntries ? <Badge variant="outline">{budgetedProjectEntries.length} Budgets</Badge> : null} {!budgetConsumptionExcluded && showBudgetedProjectEntries ? <Badge variant="outline">{budgetedProjectEntries.length} Budgets</Badge> : null}
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
{budgetConsumptionExcluded ? (
<Alert>
<Info className="size-4" />
<AlertTitle>Budgetverbrauch nicht auswertbar</AlertTitle>
<AlertDescription>
{budgetExcluded
? "Dieses Teamspace-Projekt ist systemweit von Differenz- und Budgetprüfung ausgenommen. Der Verbrauch zählt weiter in Summen."
: "Dieses Projekt ist eine Fixabrechnung. Der Verbrauch zählt weiter in Summen."}
</AlertDescription>
</Alert>
) : (
<div> <div>
<div className="mb-1 flex items-center justify-between gap-2 text-xs text-muted-foreground"> <div className="mb-1 flex items-center justify-between gap-2 text-xs text-muted-foreground">
<span>Budgetverbrauch</span> <span>Budgetverbrauch</span>
@@ -2327,7 +2445,8 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onS
/> />
</div> </div>
</div> </div>
{showBudgetedProjectEntries ? ( )}
{!budgetConsumptionExcluded && showBudgetedProjectEntries ? (
<div className="grid gap-1.5 md:grid-cols-2"> <div className="grid gap-1.5 md:grid-cols-2">
{budgetedProjectEntries.map((entry) => { {budgetedProjectEntries.map((entry) => {
const consumption = projectEntryConsumptionById.get(entry.id) ?? Number(entry.consumption_minutes ?? 0); const consumption = projectEntryConsumptionById.get(entry.id) ?? Number(entry.consumption_minutes ?? 0);
@@ -2515,7 +2634,7 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onS
<FolderKanban className="size-5 text-muted-foreground" /> <FolderKanban className="size-5 text-muted-foreground" />
Teamspace-Unterpositionen Teamspace-Unterpositionen
</CardTitle> </CardTitle>
<CardDescription>Positionen aus Teamspace für die granulare Zuordnung dieses Projekts.</CardDescription> <CardDescription>Positionen aus Teamspace als reine Ansicht dieses Projekts.</CardDescription>
</div> </div>
<div className="flex shrink-0 flex-wrap justify-end gap-2"> <div className="flex shrink-0 flex-wrap justify-end gap-2">
{canEditProject && projectTeamspaceSubentryCount > 0 ? ( {canEditProject && projectTeamspaceSubentryCount > 0 ? (
@@ -2563,12 +2682,15 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onS
onValueChange={updateOpenTeamspaceSubentryGroups} onValueChange={updateOpenTeamspaceSubentryGroups}
className="overflow-hidden rounded-md border bg-background/50" className="overflow-hidden rounded-md border bg-background/50"
> >
{projectTeamspaceSubentryGroups.map(({ entry, subentries }) => ( {projectTeamspaceSubentryGroups.map(({ entry, subentries }) => {
return (
<AccordionItem key={entry.id} value={entry.id} className="border-b last:border-b-0"> <AccordionItem key={entry.id} value={entry.id} className="border-b last:border-b-0">
<AccordionTrigger className="rounded-none border-l-4 border-l-primary/50 bg-muted/40 px-3 py-2 hover:bg-muted/55 hover:no-underline"> <AccordionTrigger className="rounded-none border-l-4 border-l-primary/50 bg-muted/40 px-3 py-2 hover:bg-muted/55 hover:no-underline">
<div className="grid min-w-0 flex-1 gap-1 pr-3 text-left sm:grid-cols-[minmax(220px,1fr)_auto] sm:items-center"> <div className="grid min-w-0 flex-1 gap-1 pr-3 text-left sm:grid-cols-[minmax(220px,1fr)_auto] sm:items-center">
<div className="min-w-0"> <div className="min-w-0">
<p className="truncate text-sm font-semibold">{teamspaceEntryDisplayLabel(entry)}</p> <p className="flex min-w-0 items-center gap-2 truncate text-sm font-semibold">
<span className="truncate">{teamspaceEntryDisplayLabel(entry)}</span>
</p>
<p className="truncate text-xs font-normal text-muted-foreground"> <p className="truncate text-xs font-normal text-muted-foreground">
{entry.teamspace_project || "Ohne Teamspace-ID"} {entry.teamspace_project || "Ohne Teamspace-ID"}
{entry.teamspace_api_project_id ? ` · API ${entry.teamspace_api_project_id}` : ""} {entry.teamspace_api_project_id ? ` · API ${entry.teamspace_api_project_id}` : ""}
@@ -2586,7 +2708,6 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onS
const fixedPrice = isTeamspaceFixedPricePosition(subentry.billing_type); const fixedPrice = isTeamspaceFixedPricePosition(subentry.billing_type);
const fixedPriceFulfilled = fixedPrice && teamspaceFixedPriceFulfilled(subentry); const fixedPriceFulfilled = fixedPrice && teamspaceFixedPriceFulfilled(subentry);
const assignedConsumptionMinutes = Number(subentry.sum_minutes_billable ?? subentry.sum_minutes ?? 0); const assignedConsumptionMinutes = Number(subentry.sum_minutes_billable ?? subentry.sum_minutes ?? 0);
const fixedPricePlannedMinutes = teamspaceFixedUnitCount(subentry, "planned");
const plannedIncome = teamspaceIncomeValue(subentry.planned_income); const plannedIncome = teamspaceIncomeValue(subentry.planned_income);
const sumIncome = teamspaceIncomeValue(subentry.sum_income); const sumIncome = teamspaceIncomeValue(subentry.sum_income);
const plannedBillableMinutes = Number(subentry.planned_minutes_billable ?? subentry.planned_minutes ?? 0); const plannedBillableMinutes = Number(subentry.planned_minutes_billable ?? subentry.planned_minutes ?? 0);
@@ -2606,7 +2727,9 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onS
> >
<div className="hidden text-muted-foreground lg:block">{positionDisplayId || index + 1}</div> <div className="hidden text-muted-foreground lg:block">{positionDisplayId || index + 1}</div>
<div className="min-w-0"> <div className="min-w-0">
<p className="truncate font-medium text-foreground/90">{title}</p> <p className="flex min-w-0 items-center gap-2 truncate font-medium text-foreground/90">
<span className="truncate">{title}</span>
</p>
<p className="truncate text-muted-foreground"> <p className="truncate text-muted-foreground">
{positionDisplayId ? `Position ${positionDisplayId}` : "Ohne Positions-ID"} {positionDisplayId ? `Position ${positionDisplayId}` : "Ohne Positions-ID"}
{subentry.teamspace_api_project_id ? ` · API ${subentry.teamspace_api_project_id}` : ""} {subentry.teamspace_api_project_id ? ` · API ${subentry.teamspace_api_project_id}` : ""}
@@ -2653,16 +2776,6 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onS
</> </>
)} )}
</span> </span>
{fixedPrice ? (
<span className="block text-[11px] text-muted-foreground/85">
TS {formatMinutes(assignedConsumptionMinutes)}
{fixedPricePlannedMinutes > 0 ? ` / ${formatMinutes(fixedPricePlannedMinutes)}` : ""}
</span>
) : (
<span className="block text-[11px] text-muted-foreground/85">
TS {formatMinutes(assignedConsumptionMinutes)}
</span>
)}
</div> </div>
{canEditProject && editingTeamspaceSubentries ? ( {canEditProject && editingTeamspaceSubentries ? (
<div className={cn( <div className={cn(
@@ -2706,7 +2819,8 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onS
</div> </div>
</AccordionContent> </AccordionContent>
</AccordionItem> </AccordionItem>
))} );
})}
</Accordion> </Accordion>
) : ( ) : (
<p className="rounded-md border bg-muted/20 p-3 text-sm text-muted-foreground"> <p className="rounded-md border bg-muted/20 p-3 text-sm text-muted-foreground">
@@ -2824,11 +2938,17 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onS
) : null} ) : null}
<div className="space-y-2"> <div className="space-y-2">
{sessionGroups.map((group) => ( {sessionGroups.map((group) => {
const dayDeviation = teamspaceDayDeviation(group.day);
const hasDayDeviation = dayDeviation.deltaMinutes !== 0;
return (
<div key={group.key} className="rounded-md border bg-background"> <div key={group.key} className="rounded-md border bg-background">
<div className="grid min-w-0 gap-2 border-b bg-muted/25 px-3 py-2 lg:grid-cols-[220px_minmax(0,150px)_minmax(260px,1fr)_220px] lg:items-center"> <div className="grid min-w-0 gap-2 border-b bg-muted/25 px-3 py-2 lg:grid-cols-[210px_minmax(0,120px)_minmax(240px,1fr)_44px_minmax(130px,0.45fr)_220px] lg:items-center">
<div className="min-w-0 text-left"> <div className="min-w-0 text-left">
<p className="truncate font-medium">{group.label}</p> <p className="flex min-w-0 items-center gap-2 truncate font-medium">
<span className="truncate">{group.label}</span>
</p>
<p className="text-xs text-muted-foreground">{group.sessions.length} Session(s)</p> <p className="text-xs text-muted-foreground">{group.sessions.length} Session(s)</p>
</div> </div>
<Button <Button
@@ -2842,19 +2962,25 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onS
<ClipboardList className="size-3.5" /> <ClipboardList className="size-3.5" />
TS TS
</Button> </Button>
<div className="grid min-w-0 grid-cols-3 gap-2 lg:gap-3"> <div className="flex min-w-0 flex-wrap gap-x-3 gap-y-1 text-xs text-muted-foreground">
<Badge variant="outline" className="flex h-8 min-w-0 justify-center gap-1 whitespace-nowrap px-2" title="Abrechenbar"> <span>Abrechb. {formatMinutes(group.billableMinutes)}</span>
<Play className="size-3.5 shrink-0" /> <span>Abger. {formatMinutes(group.totalMinutes)}</span>
<span className="truncate">{formatMinutes(group.billableMinutes)}</span> <span>Nicht {formatMinutes(group.nonBillableMinutes)}</span>
</Badge> </div>
<Badge variant="outline" className="flex h-8 min-w-0 justify-center gap-1 whitespace-nowrap px-2" title="Abgerechnet"> <div className="flex justify-start lg:justify-center">
<CheckCircle2 className="size-3.5 shrink-0" /> {hasDayDeviation ? (
<span className="truncate">{formatMinutes(group.totalMinutes)}</span> <AlertCircle
</Badge> className="size-4 shrink-0 text-amber-500"
<Badge variant="secondary" className="flex h-8 min-w-0 justify-center gap-1 whitespace-nowrap px-2" title="Nicht abrechenbar"> aria-label="Teamspace-Tagesabweichung"
<EyeOff className="size-3.5 shrink-0" /> />
<span className="truncate">{formatMinutes(group.nonBillableMinutes)}</span> ) : (
</Badge> <span className="h-4 w-4" aria-hidden="true" />
)}
</div>
<div className="min-w-0 text-xs lg:text-right">
<p className={cn("font-semibold", hasDayDeviation ? "text-amber-600 dark:text-amber-300" : "text-muted-foreground")}>
{formatTeamspaceDifferenceTarget(dayDeviation.deltaMinutes, teamspaceTimeTrackingEnabled)}
</p>
</div> </div>
<div className="flex min-w-0 items-center justify-start gap-2 lg:justify-end"> <div className="flex min-w-0 items-center justify-start gap-2 lg:justify-end">
{teamspaceTimeTrackingEnabled ? ( {teamspaceTimeTrackingEnabled ? (
@@ -2912,7 +3038,7 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onS
const zammadSent = session.zammad_article_status === "sent"; const zammadSent = session.zammad_article_status === "sent";
const zammadPending = session.zammad_article_status === "pending"; const zammadPending = session.zammad_article_status === "pending";
const zammadMailImported = Boolean(session.zammad_source_article_id); const zammadMailImported = Boolean(session.zammad_source_article_id);
const canSendZammad = !zammadMailImported && (session.activity.trim().length > 0 || Boolean(session.zammad_article_body)); const canSendZammad = !zammadMailImported && hasActivityContent(session.activity, session.zammad_article_body);
const sendingZammad = sendingZammadSessionId === session.id; const sendingZammad = sendingZammadSessionId === session.id;
const billedMissingTeamspaceEntry = const billedMissingTeamspaceEntry =
teamspaceTimeTrackingEnabled teamspaceTimeTrackingEnabled
@@ -3031,7 +3157,8 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onS
})} })}
</div> </div>
</div> </div>
))} );
})}
</div> </div>
{data?.sessions.length === 0 ? ( {data?.sessions.length === 0 ? (
+10 -6
View File
@@ -59,6 +59,10 @@ function emptyManualSession(): ManualSessionFormState {
}; };
} }
function hasActivityContent(activity: string, zammadArticleBody: string | null | undefined) {
return activity.trim().length > 0 || Boolean(zammadArticleBody);
}
type FinishedSession = { type FinishedSession = {
timerId: string; timerId: string;
ticketNumber: string; ticketNumber: string;
@@ -702,7 +706,7 @@ export function TimerPage({ timers, setTimers, selectedTimerId, setSelectedTimer
zammadArticleBody: timerZammadArticleBody, zammadArticleBody: timerZammadArticleBody,
billingStatus: timer.billingStatus, billingStatus: timer.billingStatus,
promptBillingStatus: currentUser.session_billing_prompt_enabled && timer.billingStatus === null, promptBillingStatus: currentUser.session_billing_prompt_enabled && timer.billingStatus === null,
createZammadArticle: currentUser.zammad_session_note_default_enabled && timerActivity.trim().length > 0, createZammadArticle: currentUser.zammad_session_note_default_enabled && hasActivityContent(timerActivity, timerZammadArticleBody),
zammadArticleTypeId: currentUser.zammad_article_type_id, zammadArticleTypeId: currentUser.zammad_article_type_id,
startedAt: new Date(timer.startedAt).toISOString(), startedAt: new Date(timer.startedAt).toISOString(),
endedAt: new Date(now).toISOString(), endedAt: new Date(now).toISOString(),
@@ -771,7 +775,7 @@ export function TimerPage({ timers, setTimers, selectedTimerId, setSelectedTimer
zammadLookup: Boolean(finished.ticket?.zammad_ticket_id), zammadLookup: Boolean(finished.ticket?.zammad_ticket_id),
projectTeamspaceEntryId: finishedRequiresTeamspaceEntry ? projectTeamspaceEntryId : null, projectTeamspaceEntryId: finishedRequiresTeamspaceEntry ? projectTeamspaceEntryId : null,
billingStatus: finished.billingStatus, billingStatus: finished.billingStatus,
createZammadArticle: finished.createZammadArticle && (activityValue.activity.trim().length > 0 || Boolean(activityValue.zammadArticleBody)), createZammadArticle: finished.createZammadArticle && hasActivityContent(activityValue.activity, activityValue.zammadArticleBody),
zammadArticleTypeId: finished.zammadArticleTypeId zammadArticleTypeId: finished.zammadArticleTypeId
}); });
@@ -870,7 +874,7 @@ export function TimerPage({ timers, setTimers, selectedTimerId, setSelectedTimer
zammadLookup: Boolean(manualLookupTicket?.zammad_ticket_id), zammadLookup: Boolean(manualLookupTicket?.zammad_ticket_id),
projectTeamspaceEntryId: manualRequiresTeamspaceEntry ? 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 && hasActivityContent(activityValue.activity, activityValue.zammadArticleBody),
zammadArticleTypeId: manualSession.zammadArticleTypeId zammadArticleTypeId: manualSession.zammadArticleTypeId
}); });
toast.success("Session nachgetragen", { toast.success("Session nachgetragen", {
@@ -1297,7 +1301,7 @@ export function TimerPage({ timers, setTimers, selectedTimerId, setSelectedTimer
...current, ...current,
activity, activity,
zammadArticleBody, zammadArticleBody,
createZammadArticle: activity.trim().length > 0 || zammadArticleBody ? current.createZammadArticle : false createZammadArticle: hasActivityContent(activity, zammadArticleBody) ? current.createZammadArticle : false
} : current); } : current);
}} }}
/> />
@@ -1306,7 +1310,7 @@ export function TimerPage({ timers, setTimers, selectedTimerId, setSelectedTimer
<div className="min-w-0 space-y-3 rounded-md border bg-muted/30 p-3"> <div className="min-w-0 space-y-3 rounded-md border bg-muted/30 p-3">
<div className="flex min-w-0 items-start gap-3"> <div className="flex min-w-0 items-start gap-3">
{(() => { {(() => {
const hasZammadContent = finished.activity.trim().length > 0 || Boolean(finished.zammadArticleBody); const hasZammadContent = hasActivityContent(finished.activity, finished.zammadArticleBody);
return ( return (
<> <>
@@ -1314,7 +1318,7 @@ export function TimerPage({ timers, setTimers, selectedTimerId, setSelectedTimer
id="finished-zammad-article" id="finished-zammad-article"
checked={finished.createZammadArticle && hasZammadContent} checked={finished.createZammadArticle && hasZammadContent}
disabled={!hasZammadContent} disabled={!hasZammadContent}
onCheckedChange={(checked) => setFinished((current) => (current ? { ...current, createZammadArticle: checked === true && (current.activity.trim().length > 0 || Boolean(current.zammadArticleBody)) } : current))} onCheckedChange={(checked) => setFinished((current) => (current ? { ...current, createZammadArticle: checked === true && hasActivityContent(current.activity, current.zammadArticleBody) } : current))}
/> />
<div className="min-w-0 space-y-1"> <div className="min-w-0 space-y-1">
<label className="break-words text-sm font-medium" htmlFor="finished-zammad-article"> <label className="break-words text-sm font-medium" htmlFor="finished-zammad-article">
File diff suppressed because it is too large. Load diff
-37
View File
@@ -1,37 +0,0 @@
[
{
"id": 66368,
"ticket_id": 3122,
"type_id": 1,
"sender_id": 1,
"from": "XX",
"to": "XX",
"cc": "XX",
"subject": "XX",
"reply_to": null,
"message_id": "XX",
"message_id_md5": "XX",
"in_reply_to": "XX",
"content_type": "text/html",
"body": "",
"internal": false,
"preferences": {
"subtype": "reply",
"email_address_id": 7,
"delivery_retry": 1,
"delivery_channel_id": 24,
"delivery_status_message": null,
"delivery_status": "success",
"delivery_status_date": "2026-09-25T10:55:51.214Z"
},
"updated_by_id": 77,
"created_by_id": 77,
"origin_by_id": null,
"created_at": "2026-09-25T10:55:48.201Z",
"updated_at": "2026-09-25T10:55:51.215Z",
"detected_language": null,
"ai_stored_result_ids": [],
"attachments": [],
"body_rendering_error": false
}
]
-48
View File
@@ -1,48 +0,0 @@
{
"_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"
}
}
]
}
}
]
}