1.1.0-rc1
This commit is contained in:
1 parent
649dd6f028
commit
45ff46b7eb
26 files changed
+4752
-1108
No files matched your search
@@ -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",
|
||||||
|
|||||||
+2341
-51
File diff suppressed because it is too large.
Load diff
@@ -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 +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
@@ -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 item;
|
||||||
|
})
|
||||||
|
.filter((item): item is OpenTicketNavItem => item !== null);
|
||||||
}
|
}
|
||||||
|
|
||||||
return [...current, ticket].slice(-12);
|
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
@@ -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
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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[];
|
||||||
};
|
};
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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,7 +826,10 @@ 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>
|
||||||
<TicketStatus ticket={ticket} />
|
<div className="flex shrink-0 items-center gap-2">
|
||||||
|
<TeamspaceDeviationIcon 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">
|
||||||
|
|||||||
@@ -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} />
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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>
|
||||||
<p className={cn("font-semibold", usedPercent !== null && usedPercent >= 100 ? "text-blue-500" : usedPercent !== null && usedPercent >= 90 ? "text-red-500" : undefined)}>
|
{budgetExcluded ? (
|
||||||
{usedPercent !== null ? `${usedPercent}%` : "-"}
|
<p className="text-xs text-muted-foreground">keine Prüfung</p>
|
||||||
</p>
|
) : (
|
||||||
|
<p className={cn("font-semibold", usedPercent !== null && usedPercent >= 100 ? "text-blue-500" : usedPercent !== null && usedPercent >= 90 ? "text-red-500" : undefined)}>
|
||||||
|
{usedPercent !== null ? `${usedPercent}%` : "-"}
|
||||||
|
</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);
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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}` : ""}
|
||||||
@@ -2579,14 +2701,13 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onS
|
|||||||
</AccordionTrigger>
|
</AccordionTrigger>
|
||||||
<AccordionContent className="p-0">
|
<AccordionContent className="p-0">
|
||||||
<div className="divide-y border-t bg-background">
|
<div className="divide-y border-t bg-background">
|
||||||
{subentries.map((subentry, index) => {
|
{subentries.map((subentry, index) => {
|
||||||
const description = teamspaceFormattedText(subentry.description);
|
const description = teamspaceFormattedText(subentry.description);
|
||||||
const title = teamspaceFormattedText(subentry.alias) || teamspaceFormattedText(subentry.name) || subentry.teamspace_project_id || "Teamspace-Position";
|
const title = teamspaceFormattedText(subentry.alias) || teamspaceFormattedText(subentry.name) || subentry.teamspace_project_id || "Teamspace-Position";
|
||||||
const positionDisplayId = teamspacePositionDisplayId(subentry.teamspace_project_id);
|
const positionDisplayId = teamspacePositionDisplayId(subentry.teamspace_project_id);
|
||||||
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);
|
||||||
@@ -2605,8 +2726,10 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onS
|
|||||||
className="grid gap-2 px-3 py-2 text-xs hover:bg-muted/20 lg:grid-cols-[3.5rem_minmax(190px,0.95fr)_minmax(240px,1.45fr)_105px_136px] lg:items-center"
|
className="grid gap-2 px-3 py-2 text-xs hover:bg-muted/20 lg:grid-cols-[3.5rem_minmax(190px,0.95fr)_minmax(240px,1.45fr)_105px_136px] lg:items-center"
|
||||||
>
|
>
|
||||||
<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,17 +2776,7 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onS
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</span>
|
</span>
|
||||||
{fixedPrice ? (
|
</div>
|
||||||
<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>
|
|
||||||
{canEditProject && editingTeamspaceSubentries ? (
|
{canEditProject && editingTeamspaceSubentries ? (
|
||||||
<div className={cn(
|
<div className={cn(
|
||||||
"grid gap-2 rounded-md border border-dashed bg-muted/10 p-2 lg:col-span-5 lg:items-end",
|
"grid gap-2 rounded-md border border-dashed bg-muted/10 p-2 lg:col-span-5 lg:items-end",
|
||||||
@@ -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">
|
||||||
@@ -2823,12 +2937,18 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onS
|
|||||||
</div>
|
</div>
|
||||||
) : 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 ? (
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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"
|
|
||||||
|
|
||||||
}
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
Reference in new issue
Block a user