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