1.2.0-rc1

This commit is contained in:
michael committed 2026-10-15 08:09:47 +02:00
1 parent 45ff46b7eb
commit fa9c122250
30 files changed
+6160 -795

No files matched your search

+6
View File
@@ -21,8 +21,10 @@ export type AuthUser = {
has_teamspace_credentials: boolean;
zammad_email_session_webhook_enabled: boolean;
session_billing_prompt_enabled: boolean;
session_work_segments_enabled: boolean;
teamspace_time_tracking_enabled: boolean;
zammad_session_note_default_enabled: boolean;
zammad_session_note_date_header_enabled: boolean;
zammad_time_unit_rounding_enabled: boolean;
zammad_time_unit_rounding_minutes: number;
zammad_atoss_email_sync_enabled: boolean;
@@ -97,8 +99,10 @@ function publicUser(user: AuthUser) {
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),
session_work_segments_enabled: Boolean(user.session_work_segments_enabled),
teamspace_time_tracking_enabled: Boolean(user.teamspace_time_tracking_enabled ?? true),
zammad_session_note_default_enabled: Boolean(user.zammad_session_note_default_enabled),
zammad_session_note_date_header_enabled: Boolean(user.zammad_session_note_date_header_enabled),
zammad_time_unit_rounding_enabled: Boolean(user.zammad_time_unit_rounding_enabled),
zammad_time_unit_rounding_minutes: Number(user.zammad_time_unit_rounding_minutes ?? 15),
zammad_atoss_email_sync_enabled: Boolean(user.zammad_atoss_email_sync_enabled ?? true),
@@ -169,8 +173,10 @@ export async function requireAuth(req: Request, res: Response, next: NextFunctio
(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.session_work_segments_enabled,
u.teamspace_time_tracking_enabled,
u.zammad_session_note_default_enabled,
u.zammad_session_note_date_header_enabled,
u.zammad_time_unit_rounding_enabled,
u.zammad_time_unit_rounding_minutes,
u.zammad_atoss_email_sync_enabled,
+28 -1
View File
@@ -12,12 +12,14 @@ export const databaseExportTables = [
"auth_sessions",
"app_settings",
"user_preferences",
"organization_attribute_options",
"teamspace_project_exclusions",
"teamspace_assignment_rules",
"organizations",
"tickets",
"projects",
"teamspace_project_resources",
"project_teamspace_entries",
"teamspace_project_exclusions",
"teamspace_monthly_user_actuals",
"project_teamspace_monthly_actuals",
"project_teamspace_subentries",
@@ -45,6 +47,17 @@ type ColumnInfo = {
};
const jsonDataTypes = new Set(["json", "jsonb"]);
const arrayElementTypes = new Map<string, string>([
["_bool", "boolean"],
["_date", "date"],
["_int2", "smallint"],
["_int4", "integer"],
["_int8", "bigint"],
["_numeric", "numeric"],
["_text", "text"],
["_timestamptz", "timestamptz"],
["_uuid", "uuid"]
]);
const insertBatchSize = 500;
export function quoteIdentifier(value: string) {
@@ -81,6 +94,20 @@ export function sqlLiteral(value: unknown, column?: Pick<ColumnInfo, "dataType"
return quoteSqlString(jsonValueForSql(value));
}
if (column?.dataType === "ARRAY") {
if (!Array.isArray(value)) {
throw new Error(`Expected PostgreSQL array value for ${column.udtName}`);
}
const elementType = arrayElementTypes.get(column.udtName);
if (!elementType) {
throw new Error(`Unsupported PostgreSQL array type: ${column.udtName}`);
}
return `ARRAY[${value.map((entry) => sqlLiteral(entry)).join(", ")}]::${elementType}[]`;
}
if (value instanceof Date) {
return quoteSqlString(value.toISOString());
}
+3357 -294
View File
File diff suppressed because it is too large. Load diff
+336 -28
View File
@@ -118,8 +118,10 @@ export async function migrate() {
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 session_work_segments_enabled BOOLEAN NOT NULL DEFAULT false;");
await query("ALTER TABLE users ADD COLUMN IF NOT EXISTS teamspace_time_tracking_enabled BOOLEAN NOT NULL DEFAULT true;");
await query("ALTER TABLE users ADD COLUMN IF NOT EXISTS zammad_session_note_default_enabled BOOLEAN NOT NULL DEFAULT false;");
await query("ALTER TABLE users ADD COLUMN IF NOT EXISTS zammad_session_note_date_header_enabled BOOLEAN NOT NULL DEFAULT false;");
await query("ALTER TABLE users ADD COLUMN IF NOT EXISTS zammad_time_unit_rounding_enabled BOOLEAN NOT NULL DEFAULT false;");
await query("ALTER TABLE users ADD COLUMN IF NOT EXISTS zammad_time_unit_rounding_minutes INTEGER NOT NULL DEFAULT 15;");
await query("ALTER TABLE users ADD COLUMN IF NOT EXISTS zammad_atoss_email_sync_enabled BOOLEAN NOT NULL DEFAULT true;");
@@ -129,6 +131,10 @@ 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 ADD COLUMN IF NOT EXISTS teamspace_initial_actuals_synced_at TIMESTAMPTZ;");
await query("ALTER TABLE users ADD COLUMN IF NOT EXISTS teamspace_initial_actuals_synced_by BIGINT REFERENCES users(id) ON DELETE SET NULL;");
await query("ALTER TABLE users ADD COLUMN IF NOT EXISTS teamspace_initial_actuals_unlocked_at TIMESTAMPTZ;");
await query("ALTER TABLE users ADD COLUMN IF NOT EXISTS teamspace_initial_actuals_unlocked_by BIGINT REFERENCES users(id) ON DELETE SET NULL;");
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;");
@@ -182,21 +188,79 @@ export async function migrate() {
);
`);
await query(`
CREATE TABLE IF NOT EXISTS organization_attribute_options (
id BIGSERIAL PRIMARY KEY,
zammad_attribute_id BIGINT NOT NULL,
attribute_name TEXT NOT NULL,
option_key TEXT NOT NULL,
display_name TEXT NOT NULL,
active BOOLEAN NOT NULL DEFAULT true,
synced_at TIMESTAMPTZ NOT NULL DEFAULT now(),
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
CONSTRAINT organization_attribute_options_attribute_valid
CHECK (attribute_name IN ('customer_owner', 'hotline_contract')),
CONSTRAINT organization_attribute_options_key_not_blank CHECK (btrim(option_key) <> ''),
CONSTRAINT organization_attribute_options_display_not_blank CHECK (btrim(display_name) <> ''),
UNIQUE (attribute_name, option_key)
);
`);
await query("CREATE INDEX IF NOT EXISTS idx_organization_attribute_options_attribute_active ON organization_attribute_options(attribute_name, active, display_name);");
await query(`
CREATE TABLE IF NOT EXISTS teamspace_project_exclusions (
teamspace_project TEXT PRIMARY KEY,
teamspace_api_project_id BIGINT,
teamspace_api_project_ids BIGINT[] NOT NULL DEFAULT '{}'::bigint[],
teamspace_name TEXT,
customer_name TEXT,
atoss_support_default BOOLEAN NOT NULL DEFAULT false,
atoss_support_valid_from DATE,
atoss_support_valid_to DATE,
customer_owner_option_id BIGINT REFERENCES organization_attribute_options(id) ON DELETE SET NULL,
hotline_contract_option_id BIGINT REFERENCES organization_attribute_options(id) ON DELETE SET NULL,
note TEXT NOT NULL DEFAULT '',
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
CONSTRAINT teamspace_project_exclusions_project_not_blank CHECK (btrim(teamspace_project) <> '')
);
`);
await query("ALTER TABLE teamspace_project_exclusions ADD COLUMN IF NOT EXISTS teamspace_api_project_id BIGINT;");
await query("ALTER TABLE teamspace_project_exclusions ADD COLUMN IF NOT EXISTS teamspace_api_project_ids BIGINT[] NOT NULL DEFAULT '{}'::bigint[];");
await query("ALTER TABLE teamspace_project_exclusions ADD COLUMN IF NOT EXISTS teamspace_name TEXT;");
await query("ALTER TABLE teamspace_project_exclusions ADD COLUMN IF NOT EXISTS customer_name TEXT;");
await query("ALTER TABLE teamspace_project_exclusions ADD COLUMN IF NOT EXISTS atoss_support_default BOOLEAN NOT NULL DEFAULT false;");
await query("ALTER TABLE teamspace_project_exclusions ADD COLUMN IF NOT EXISTS atoss_support_valid_from DATE;");
await query("ALTER TABLE teamspace_project_exclusions ADD COLUMN IF NOT EXISTS atoss_support_valid_to DATE;");
await query("ALTER TABLE teamspace_project_exclusions ADD COLUMN IF NOT EXISTS customer_owner_option_id BIGINT REFERENCES organization_attribute_options(id) ON DELETE SET NULL;");
await query("ALTER TABLE teamspace_project_exclusions ADD COLUMN IF NOT EXISTS hotline_contract_option_id BIGINT REFERENCES organization_attribute_options(id) ON DELETE SET NULL;");
await query("CREATE UNIQUE INDEX IF NOT EXISTS idx_teamspace_project_exclusions_lower ON teamspace_project_exclusions (lower(btrim(teamspace_project)));");
await query(`
CREATE TABLE IF NOT EXISTS teamspace_assignment_rules (
id BIGSERIAL PRIMARY KEY,
name TEXT NOT NULL,
active BOOLEAN NOT NULL DEFAULT true,
priority INTEGER NOT NULL DEFAULT 100,
customer_owner_option_id BIGINT REFERENCES organization_attribute_options(id) ON DELETE SET NULL,
hotline_contract_option_id BIGINT REFERENCES organization_attribute_options(id) ON DELETE SET NULL,
support_enabled BOOLEAN NOT NULL DEFAULT true,
consulting_enabled BOOLEAN NOT NULL DEFAULT false,
teamspace_project TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
CONSTRAINT teamspace_assignment_rules_name_not_blank CHECK (btrim(name) <> ''),
CONSTRAINT teamspace_assignment_rules_project_not_blank CHECK (btrim(teamspace_project) <> ''),
CONSTRAINT teamspace_assignment_rules_priority_valid CHECK (priority BETWEEN 0 AND 100000),
CONSTRAINT teamspace_assignment_rules_condition_required CHECK (
customer_owner_option_id IS NOT NULL OR hotline_contract_option_id IS NOT NULL
),
CONSTRAINT teamspace_assignment_rules_work_type_required CHECK (support_enabled OR consulting_enabled)
);
`);
await query("CREATE INDEX IF NOT EXISTS idx_teamspace_assignment_rules_matching ON teamspace_assignment_rules(active, priority, id);");
await query(`
CREATE TABLE IF NOT EXISTS teamspace_project_resources (
id BIGSERIAL PRIMARY KEY,
@@ -234,6 +298,8 @@ export async function migrate() {
id BIGSERIAL PRIMARY KEY,
zammad_id BIGINT NOT NULL UNIQUE,
name TEXT NOT NULL UNIQUE,
customer_owner_option_id BIGINT REFERENCES organization_attribute_options(id) ON DELETE SET NULL,
hotline_contract_option_id BIGINT REFERENCES organization_attribute_options(id) ON DELETE SET NULL,
synced_at TIMESTAMPTZ NOT NULL DEFAULT now(),
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
@@ -246,6 +312,58 @@ export async function migrate() {
await query("ALTER TABLE organizations DROP COLUMN IF EXISTS domain;");
await query("ALTER TABLE organizations DROP COLUMN IF EXISTS note;");
await query("ALTER TABLE organizations DROP COLUMN IF EXISTS zammad_updated_at;");
await query("ALTER TABLE organizations ADD COLUMN IF NOT EXISTS customer_owner_option_id BIGINT REFERENCES organization_attribute_options(id) ON DELETE SET NULL;");
await query("ALTER TABLE organizations ADD COLUMN IF NOT EXISTS hotline_contract_option_id BIGINT REFERENCES organization_attribute_options(id) ON DELETE SET NULL;");
await query(`
DO $$
BEGIN
IF EXISTS (
SELECT 1
FROM information_schema.columns
WHERE table_schema = 'public'
AND table_name = 'organizations'
AND column_name = 'customer_owner'
) THEN
INSERT INTO organization_attribute_options (zammad_attribute_id, attribute_name, option_key, display_name, active)
SELECT 0, 'customer_owner', btrim(customer_owner), btrim(customer_owner), false
FROM organizations
WHERE customer_owner IS NOT NULL AND btrim(customer_owner) <> ''
ON CONFLICT (attribute_name, option_key) DO NOTHING;
UPDATE organizations organization
SET customer_owner_option_id = option.id
FROM organization_attribute_options option
WHERE option.attribute_name = 'customer_owner'
AND option.option_key = btrim(organization.customer_owner)
AND organization.customer_owner_option_id IS NULL;
ALTER TABLE organizations DROP COLUMN customer_owner;
END IF;
IF EXISTS (
SELECT 1
FROM information_schema.columns
WHERE table_schema = 'public'
AND table_name = 'organizations'
AND column_name = 'hotline_contract'
) THEN
INSERT INTO organization_attribute_options (zammad_attribute_id, attribute_name, option_key, display_name, active)
SELECT 0, 'hotline_contract', btrim(hotline_contract), btrim(hotline_contract), false
FROM organizations
WHERE hotline_contract IS NOT NULL AND btrim(hotline_contract) <> ''
ON CONFLICT (attribute_name, option_key) DO NOTHING;
UPDATE organizations organization
SET hotline_contract_option_id = option.id
FROM organization_attribute_options option
WHERE option.attribute_name = 'hotline_contract'
AND option.option_key = btrim(organization.hotline_contract)
AND organization.hotline_contract_option_id IS NULL;
ALTER TABLE organizations DROP COLUMN hotline_contract;
END IF;
END $$;
`);
await query("CREATE INDEX IF NOT EXISTS idx_organizations_name_lower ON organizations(lower(name));");
await query("DROP INDEX IF EXISTS idx_organizations_active_name;");
@@ -257,6 +375,10 @@ export async function migrate() {
organization_id BIGINT REFERENCES organizations(zammad_id),
customer_name TEXT,
work_type TEXT,
atoss_support BOOLEAN NOT NULL DEFAULT false,
teamspace_assignment_rule_id BIGINT REFERENCES teamspace_assignment_rules(id) ON DELETE SET NULL,
teamspace_assignment_project TEXT,
teamspace_assignment_synced_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
CONSTRAINT ticket_number_format CHECK (ticket_number ~ '^Ticket#[0-9]{6}$'),
CONSTRAINT ticket_work_type_valid CHECK (work_type IS NULL OR work_type IN ('support', 'consulting'))
@@ -267,6 +389,7 @@ export async function migrate() {
await query("ALTER TABLE tickets ADD COLUMN IF NOT EXISTS title TEXT;");
await query("ALTER TABLE tickets ADD COLUMN IF NOT EXISTS organization_id BIGINT REFERENCES organizations(zammad_id);");
await query("ALTER TABLE tickets ADD COLUMN IF NOT EXISTS work_type TEXT;");
await query("ALTER TABLE tickets ADD COLUMN IF NOT EXISTS atoss_support BOOLEAN NOT NULL DEFAULT false;");
await query("ALTER TABLE tickets ADD COLUMN IF NOT EXISTS zammad_ticket_id BIGINT UNIQUE;");
await query("ALTER TABLE tickets ADD COLUMN IF NOT EXISTS zammad_group_id BIGINT;");
await query("ALTER TABLE tickets ADD COLUMN IF NOT EXISTS zammad_priority_id BIGINT;");
@@ -277,6 +400,9 @@ export async function migrate() {
await query("ALTER TABLE tickets ADD COLUMN IF NOT EXISTS zammad_merged_from_ticket_id BIGINT;");
await query("ALTER TABLE tickets ADD COLUMN IF NOT EXISTS zammad_merged_from_ticket_number TEXT;");
await query("ALTER TABLE tickets ADD COLUMN IF NOT EXISTS zammad_preferences JSONB NOT NULL DEFAULT '{}'::jsonb;");
await query("ALTER TABLE tickets ADD COLUMN IF NOT EXISTS teamspace_assignment_rule_id BIGINT REFERENCES teamspace_assignment_rules(id) ON DELETE SET NULL;");
await query("ALTER TABLE tickets ADD COLUMN IF NOT EXISTS teamspace_assignment_project TEXT;");
await query("ALTER TABLE tickets ADD COLUMN IF NOT EXISTS teamspace_assignment_synced_at TIMESTAMPTZ;");
await query(`
ALTER TABLE tickets
DROP CONSTRAINT IF EXISTS ticket_number_format,
@@ -315,31 +441,6 @@ export async function migrate() {
WHERE t.id = zto.id
AND t.user_id IS DISTINCT FROM zto.user_id;
`);
await query(`
UPDATE tickets t
SET user_id = owner.user_id
FROM (
SELECT ticket_id, MIN(user_id) AS user_id
FROM projects
GROUP BY ticket_id
) owner
WHERE t.user_id IS NULL
AND t.zammad_owner_id IS NULL
AND t.id = owner.ticket_id;
`);
await query(`
UPDATE tickets t
SET user_id = owner.user_id
FROM (
SELECT ticket_id, MIN(user_id) AS user_id
FROM sessions
WHERE user_id IS NOT NULL
GROUP BY ticket_id
) owner
WHERE t.user_id IS NULL
AND t.zammad_owner_id IS NULL
AND t.id = owner.ticket_id;
`);
await query("CREATE INDEX IF NOT EXISTS idx_tickets_user ON tickets(user_id);");
await query("CREATE INDEX IF NOT EXISTS idx_tickets_zammad_owner ON tickets(zammad_owner_id);");
@@ -375,6 +476,7 @@ export async function migrate() {
await query("ALTER TABLE sessions ADD COLUMN IF NOT EXISTS recurring_occurrence_key TEXT;");
await query("ALTER TABLE sessions ADD COLUMN IF NOT EXISTS project_teamspace_entry_id BIGINT;");
await query("ALTER TABLE sessions ADD COLUMN IF NOT EXISTS activity_html TEXT;");
await query("ALTER TABLE sessions ADD COLUMN IF NOT EXISTS work_segments JSONB NOT NULL DEFAULT '[]'::jsonb;");
await query(`
CREATE TABLE IF NOT EXISTS active_timers (
id BIGSERIAL PRIMARY KEY,
@@ -416,6 +518,7 @@ export async function migrate() {
await query("ALTER TABLE active_timers ADD COLUMN IF NOT EXISTS paused_total_ms BIGINT NOT NULL DEFAULT 0;");
await query("ALTER TABLE active_timers ADD COLUMN IF NOT EXISTS paused_at TIMESTAMPTZ;");
await query("ALTER TABLE active_timers ADD COLUMN IF NOT EXISTS phase TEXT NOT NULL DEFAULT 'running';");
await query("ALTER TABLE active_timers ADD COLUMN IF NOT EXISTS work_segments JSONB NOT NULL DEFAULT '[]'::jsonb;");
await query("ALTER TABLE active_timers ADD COLUMN IF NOT EXISTS created_at TIMESTAMPTZ NOT NULL DEFAULT now();");
await query("ALTER TABLE active_timers ADD COLUMN IF NOT EXISTS updated_at TIMESTAMPTZ NOT NULL DEFAULT now();");
await query(`
@@ -877,6 +980,32 @@ export async function migrate() {
END $$;
`);
await query(`
UPDATE tickets t
SET user_id = owner.user_id
FROM (
SELECT ticket_id, MIN(user_id) AS user_id
FROM projects
GROUP BY ticket_id
) owner
WHERE t.user_id IS NULL
AND t.zammad_owner_id IS NULL
AND t.id = owner.ticket_id;
`);
await query(`
UPDATE tickets t
SET user_id = owner.user_id
FROM (
SELECT ticket_id, MIN(user_id) AS user_id
FROM sessions
WHERE user_id IS NOT NULL
GROUP BY ticket_id
) owner
WHERE t.user_id IS NULL
AND t.zammad_owner_id IS NULL
AND t.id = owner.ticket_id;
`);
await query(`
CREATE TABLE IF NOT EXISTS project_teamspace_entries (
id BIGSERIAL PRIMARY KEY,
@@ -1120,6 +1249,37 @@ export async function migrate() {
await query("CREATE INDEX IF NOT EXISTS idx_project_teamspace_subentries_entry_order ON project_teamspace_subentries(project_teamspace_entry_id, display_order, id);");
await query("CREATE INDEX IF NOT EXISTS idx_project_teamspace_subentries_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(`
WITH exclusion_api_ids AS (
SELECT
tpe.teamspace_project,
MIN(api_id) AS main_api_id,
array_agg(DISTINCT api_id ORDER BY api_id) AS api_ids
FROM teamspace_project_exclusions tpe
CROSS JOIN LATERAL (
SELECT pte.teamspace_api_project_id AS api_id
FROM project_teamspace_entries pte
WHERE lower(btrim(pte.teamspace_project)) = lower(btrim(tpe.teamspace_project))
AND pte.teamspace_api_project_id IS NOT NULL
UNION
SELECT pts.teamspace_api_project_id AS api_id
FROM project_teamspace_entries pte
JOIN project_teamspace_subentries pts ON pts.project_teamspace_entry_id = pte.id
WHERE lower(btrim(pte.teamspace_project)) = lower(btrim(tpe.teamspace_project))
) api_source
GROUP BY tpe.teamspace_project
)
UPDATE teamspace_project_exclusions tpe
SET teamspace_api_project_id = COALESCE(tpe.teamspace_api_project_id, exclusion_api_ids.main_api_id),
teamspace_api_project_ids = CASE
WHEN cardinality(tpe.teamspace_api_project_ids) = 0 THEN exclusion_api_ids.api_ids
ELSE tpe.teamspace_api_project_ids
END
FROM exclusion_api_ids
WHERE tpe.teamspace_project = exclusion_api_ids.teamspace_project;
`);
await query(`
WITH duplicate_keys AS (
SELECT lower(btrim(teamspace_project)) AS teamspace_key
@@ -1561,7 +1721,7 @@ export async function migrate() {
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)
SET project_teamspace_entry_id = pts.project_teamspace_entry_id
FROM project_teamspace_subentries pts
WHERE s.project_teamspace_subentry_id = pts.id;
END IF;
@@ -1685,7 +1845,15 @@ export async function migrate() {
WHERE s.ticket_id = sep.ticket_id
AND s.user_id = sep.user_id
AND s.work_type = 'consulting'
AND s.project_teamspace_entry_id IS NULL;
AND (
s.project_teamspace_entry_id IS NULL
OR NOT EXISTS (
SELECT 1
FROM project_teamspace_entries current_pte
WHERE current_pte.id = s.project_teamspace_entry_id
AND current_pte.project_id = sep.project_id
)
);
`);
await query(`
@@ -1703,7 +1871,44 @@ export async function migrate() {
FROM single_entry_tickets single_ticket
WHERE s.ticket_id = single_ticket.ticket_id
AND s.work_type = 'consulting'
AND s.project_teamspace_entry_id IS NULL;
AND (
s.project_teamspace_entry_id IS NULL
OR NOT EXISTS (
SELECT 1
FROM project_teamspace_entries current_pte
JOIN projects current_project ON current_project.id = current_pte.project_id
WHERE current_pte.id = s.project_teamspace_entry_id
AND current_project.ticket_id = single_ticket.ticket_id
)
);
`);
await query(`
WITH default_entry_tickets AS (
SELECT
p.ticket_id,
MIN(pte.id) AS project_teamspace_entry_id
FROM projects p
JOIN project_teamspace_entries pte ON pte.project_id = p.id
WHERE pte.is_default
GROUP BY p.ticket_id
HAVING COUNT(pte.id) = 1
)
UPDATE sessions s
SET project_teamspace_entry_id = default_ticket.project_teamspace_entry_id
FROM default_entry_tickets default_ticket
WHERE s.ticket_id = default_ticket.ticket_id
AND s.work_type = 'consulting'
AND (
s.project_teamspace_entry_id IS NULL
OR NOT EXISTS (
SELECT 1
FROM project_teamspace_entries current_pte
JOIN projects current_project ON current_project.id = current_pte.project_id
WHERE current_pte.id = s.project_teamspace_entry_id
AND current_project.ticket_id = default_ticket.ticket_id
)
);
`);
await query(`
@@ -1832,6 +2037,63 @@ export async function migrate() {
await query("CREATE INDEX IF NOT EXISTS idx_recurring_billings_user_active ON recurring_billings(user_id, active);");
await query("UPDATE recurring_billings SET active = true WHERE active = false;");
await query("ALTER TABLE recurring_billings ADD COLUMN IF NOT EXISTS teamspace_project TEXT;");
await query("ALTER TABLE recurring_billings ADD COLUMN IF NOT EXISTS hour_budget_minutes INTEGER;");
await query("ALTER TABLE recurring_billings ADD COLUMN IF NOT EXISTS manual_consumption_minutes INTEGER NOT NULL DEFAULT 0;");
await query(`
WITH existing_configuration AS (
SELECT DISTINCT ON (rb.id)
rb.id AS recurring_billing_id,
COALESCE(NULLIF(btrim(pte.teamspace_project), ''), NULLIF(btrim(p.teamspace_project), '')) AS teamspace_project,
COALESCE(pte.hour_budget_minutes, p.hour_budget_minutes) AS hour_budget_minutes,
GREATEST(
COALESCE(pte.manual_consumption_minutes, 0),
COALESCE(p.manual_consumption_minutes, 0)
)::int AS manual_consumption_minutes
FROM recurring_billings rb
JOIN tickets t ON t.ticket_number = COALESCE(rb.ticket_number, 'Fix#' || rb.id)
JOIN projects p
ON p.ticket_id = t.id
AND p.user_id = rb.user_id
LEFT JOIN project_teamspace_entries pte
ON pte.project_id = p.id
AND pte.is_default
ORDER BY rb.id, p.id ASC, pte.id ASC NULLS LAST
)
UPDATE recurring_billings rb
SET teamspace_project = COALESCE(rb.teamspace_project, existing.teamspace_project),
hour_budget_minutes = COALESCE(rb.hour_budget_minutes, existing.hour_budget_minutes),
manual_consumption_minutes = GREATEST(
rb.manual_consumption_minutes,
existing.manual_consumption_minutes
),
updated_at = CASE
WHEN rb.teamspace_project IS NULL AND existing.teamspace_project IS NOT NULL
OR rb.hour_budget_minutes IS NULL AND existing.hour_budget_minutes IS NOT NULL
OR existing.manual_consumption_minutes > rb.manual_consumption_minutes
THEN now()
ELSE rb.updated_at
END
FROM existing_configuration existing
WHERE existing.recurring_billing_id = rb.id;
`);
await query(`
ALTER TABLE recurring_billings
DROP CONSTRAINT IF EXISTS recurring_billings_budget_non_negative,
ADD CONSTRAINT recurring_billings_budget_non_negative CHECK (hour_budget_minutes IS NULL OR hour_budget_minutes >= 0),
DROP CONSTRAINT IF EXISTS recurring_billings_manual_consumption_non_negative,
ADD CONSTRAINT recurring_billings_manual_consumption_non_negative CHECK (manual_consumption_minutes >= 0);
`);
await query(`
UPDATE tickets t
SET organization_id = rb.organization_id,
customer_name = o.name,
work_type = rb.work_type
FROM recurring_billings rb
JOIN organizations o ON o.zammad_id = rb.organization_id
WHERE t.ticket_number = COALESCE(rb.ticket_number, 'Fix#' || rb.id)
AND (t.user_id IS NULL OR t.user_id = rb.user_id);
`);
await query(`
WITH deleted_orphan_sessions AS (
DELETE FROM sessions s
@@ -1889,6 +2151,7 @@ export async function migrate() {
await query(`
DELETE FROM tickets t
WHERE t.ticket_number ~ '^Fix#[0-9]+$'
AND t.ticket_number <> 'Fix#0'
AND NOT EXISTS (
SELECT 1
FROM sessions s
@@ -1916,4 +2179,49 @@ export async function migrate() {
WHERE t.id = latest.ticket_id
AND (t.customer_name IS NULL OR t.work_type IS NULL);
`);
// Preserve an Atoss fallback that was already applied while the choice still
// lived on individual sessions. Rule-based assignments use the same
// technical Fix#0 container, so only sessions without a stored rule marker
// and on the configured, date-valid Atoss target qualify for this backfill.
await query(`
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1
FROM app_settings
WHERE key = 'migration.atoss_support_ticket_backfill_v1'
) THEN
UPDATE tickets ticket
SET atoss_support = true
WHERE ticket.work_type = 'support'
AND ticket.atoss_support = false
AND ticket.teamspace_assignment_rule_id IS NULL
AND EXISTS (
SELECT 1
FROM sessions session
JOIN project_teamspace_entries entry ON entry.id = session.project_teamspace_entry_id
JOIN projects target_project ON target_project.id = entry.project_id
JOIN tickets target_ticket ON target_ticket.id = target_project.ticket_id
JOIN teamspace_project_exclusions exclusion
ON lower(btrim(exclusion.teamspace_project)) = lower(btrim(entry.teamspace_project))
WHERE session.ticket_id = ticket.id
AND target_ticket.ticket_number = 'Fix#0'
AND exclusion.atoss_support_default = true
AND (
exclusion.atoss_support_valid_from IS NULL
OR exclusion.atoss_support_valid_from <= (session.started_at AT TIME ZONE 'Europe/Berlin')::date
)
AND (
exclusion.atoss_support_valid_to IS NULL
OR exclusion.atoss_support_valid_to >= (session.started_at AT TIME ZONE 'Europe/Berlin')::date
)
);
INSERT INTO app_settings (key, value, updated_at)
VALUES ('migration.atoss_support_ticket_backfill_v1', 'done', now());
END IF;
END $$;
`);
}
@@ -0,0 +1,71 @@
export const organizationAttributeNames = ["customer_owner", "hotline_contract"] as const;
export type OrganizationAttributeName = (typeof organizationAttributeNames)[number];
export type ZammadOrganizationAttributeOption = {
zammadAttributeId: number;
attributeName: OrganizationAttributeName;
optionKey: string;
displayName: string;
};
function isRecord(value: unknown): value is Record<string, unknown> {
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
}
function isOrganizationAttributeName(value: unknown): value is OrganizationAttributeName {
return typeof value === "string" && organizationAttributeNames.includes(value as OrganizationAttributeName);
}
export function parseZammadOrganizationAttributeOptions(payload: unknown): ZammadOrganizationAttributeOption[] {
if (!Array.isArray(payload)) {
throw new Error("Zammad response must be an object-manager attribute array");
}
const options: ZammadOrganizationAttributeOption[] = [];
const foundAttributes = new Set<OrganizationAttributeName>();
for (const item of payload) {
if (!isRecord(item) || !isOrganizationAttributeName(item.name)) {
continue;
}
const zammadAttributeId = Number(item.id);
const dataOption = isRecord(item.data_option)
? item.data_option
: isRecord(item.data_options)
? item.data_options
: null;
const rawOptions = dataOption && isRecord(dataOption.options) ? dataOption.options : null;
if (!Number.isInteger(zammadAttributeId) || zammadAttributeId <= 0 || !rawOptions) {
throw new Error(`Zammad object-manager attribute ${item.name} is invalid`);
}
foundAttributes.add(item.name);
for (const [rawKey, rawDisplayName] of Object.entries(rawOptions)) {
const optionKey = rawKey.trim();
const displayName = typeof rawDisplayName === "string" ? rawDisplayName.trim() : "";
if (!optionKey || !displayName) {
continue;
}
options.push({
zammadAttributeId,
attributeName: item.name,
optionKey,
displayName
});
}
}
const missingAttributes = organizationAttributeNames.filter((name) => !foundAttributes.has(name));
if (missingAttributes.length > 0) {
throw new Error(`Zammad object-manager attributes missing: ${missingAttributes.join(", ")}`);
}
return options;
}
+24 -2
View File
@@ -1,9 +1,17 @@
import assert from "node:assert/strict";
import test from "node:test";
import { nextSequenceIdAfterRestore, restoreSqlText, sequenceSetvalSql, sqlLiteral, type QueryExecutor } from "../src/databaseExport.ts";
import { exportTableSql } from "../src/databaseExport.ts";
import {
databaseExportTables,
exportTableSql,
nextSequenceIdAfterRestore,
restoreSqlText,
sequenceSetvalSql,
sqlLiteral,
type QueryExecutor
} from "../src/databaseExport.ts";
const jsonbColumn = { dataType: "jsonb", udtName: "jsonb" };
const bigintArrayColumn = { dataType: "ARRAY", udtName: "_int8" };
test("sqlLiteral serializes primitive values", () => {
assert.equal(sqlLiteral(null), "NULL");
@@ -36,6 +44,20 @@ test("sqlLiteral serializes JSON columns as valid JSON values", () => {
assert.equal(sqlLiteral(true, jsonbColumn), "'true'");
});
test("sqlLiteral serializes PostgreSQL arrays with an explicit element type", () => {
assert.equal(sqlLiteral([], bigintArrayColumn), "ARRAY[]::bigint[]");
assert.equal(sqlLiteral(["32349965", "32349982"], bigintArrayColumn), "ARRAY['32349965', '32349982']::bigint[]");
assert.throws(
() => sqlLiteral(["value"], { dataType: "ARRAY", udtName: "_unsupported" }),
/Unsupported PostgreSQL array type/
);
});
test("database export inserts assignment rules before tickets that reference them", () => {
assert.ok(databaseExportTables.indexOf("organization_attribute_options") < databaseExportTables.indexOf("teamspace_assignment_rules"));
assert.ok(databaseExportTables.indexOf("teamspace_assignment_rules") < databaseExportTables.indexOf("tickets"));
});
test("generated table export never contains implicit object stringification", async () => {
const client: QueryExecutor = {
async query<T extends Record<string, unknown>>(text: string): Promise<{ rows: T[]; rowCount: number | null }> {
@@ -0,0 +1,38 @@
import assert from "node:assert/strict";
import test from "node:test";
import { parseZammadOrganizationAttributeOptions } from "../src/zammadOrganizationAttributes.ts";
test("parses the two organization select attributes and ignores unrelated attributes", () => {
const options = parseZammadOrganizationAttributeOptions([
{
id: 85,
name: "customer_owner",
data_option: { options: { ATOSS: "ATOSS", sdw: "SD Worx" } }
},
{
id: 12,
name: "unrelated",
data_option: { options: { no: "No" } }
},
{
id: 86,
name: "hotline_contract",
data_option: { options: { none: "(kein Hotlinevertrag)" } }
}
]);
assert.deepEqual(options, [
{ zammadAttributeId: 85, attributeName: "customer_owner", optionKey: "ATOSS", displayName: "ATOSS" },
{ zammadAttributeId: 85, attributeName: "customer_owner", optionKey: "sdw", displayName: "SD Worx" },
{ zammadAttributeId: 86, attributeName: "hotline_contract", optionKey: "none", displayName: "(kein Hotlinevertrag)" }
]);
});
test("rejects responses without both required attributes", () => {
assert.throws(
() => parseZammadOrganizationAttributeOptions([
{ id: 85, name: "customer_owner", data_option: { options: { ATOSS: "ATOSS" } } }
]),
/hotline_contract/
);
});
@@ -73,12 +73,16 @@ Der Monatsabschluss ist die zentrale Kontrollseite für einen Benutzer und Monat
Sie zeigt unter anderem:
- offene Bewertungen
- Sessions mit Zwischenstatus **Abrechenbar**
- fehlende oder abweichende Teamspace-Werte, wenn Teamspace-Erfassung in TT aktiv ist
- eine nach Ticket zusammengefuehrte Pruefliste
- offene Bewertungen und Sessions mit Zwischenstatus **Abrechenbar**
- fehlende oder abweichende Teamspace-Werte als farbige, spaltenweise ausgerichtete Badges; die Tagesdetails stehen am jeweiligen Tag im Ticketdetail
- Prüfpunkte vor dem Abschluss
- Möglichkeit, den Monat zu schließen oder wieder zu öffnen
Jedes Ticket erscheint in der Pruefliste nur einmal. Monatsabweichungen, offene Bewertungen und betroffene Tage werden innerhalb dieses Eintrags gebuendelt; Hinweise zum gleichen Tag stehen in einer gemeinsamen Tageszeile.
Ein TT-Teamspace-Tageswert ist nur konsistent, wenn fuer dasselbe Ticket und denselben Tag mindestens eine Session den Status **Abgerechnet** hat. Bei ausschliesslich abrechenbaren, nicht abrechenbaren oder unbewerteten Sessions wird der hinterlegte Wert als Fehler ausgewiesen; ein zusaetzlicher Unterschied zur TS-API bleibt ebenfalls sichtbar.
Ein abgeschlossener Monat ist gegen versehentliche Änderungen geschützt. Wird eine Session in einem abgeschlossenen Zeitraum geändert, muss der Zeitraum vorher wieder geöffnet werden.
## Regeln bei deaktivierter Teamspace-Erfassung
+5 -4
View File
@@ -400,13 +400,14 @@ Einen separaten Tagesabschluss gibt es nicht. Die Tagesansicht ist nur eine gefi
Die Seite `Monatsabschluss` enthaelt:
- Checkliste
- Offene Bewertungen
- Tage ohne Teamspace-Wert
- eine gemeinsame Pruefliste nach Ticket
- Button zum Monat abschliessen oder wieder oeffnen
Offene Bewertungen werden nach Ticket gruppiert. Mit `Ticket oeffnen` gelangt man direkt zur Bewertung.
Die Pruefliste fuehrt offene Bewertungen, fehlende Teamspace-Tageswerte sowie Monats- und ticketgenaue Tagesabweichungen zusammen. Jedes Ticket erscheint nur einmal. Farbige Badges stehen in festen Spalten fuer die jeweilige Pruefart. Die zugehoerigen Tagesdetails mit TT-Teamspace-Wert, TS-API-Wert und Differenz werden im Ticketdetail am betroffenen Tag angezeigt. Mit `Ticket oeffnen` gelangt man direkt dorthin.
Tage ohne Teamspace-Wert sind eine Pruefliste. Wenn ein Tag bewusst keinen Teamspace-Wert bekommen soll, kann der Eintrag mit `Zur Kenntnis` abgehakt werden.
Wenn ein Tag bewusst keinen Teamspace-Wert bekommen soll, kann er innerhalb des Ticket-Eintrags mit `Zur Kenntnis` abgehakt werden. Trifft fuer denselben Tag zugleich eine TT/TS-Abweichung zu, werden beide Hinweise in einer gemeinsamen Tageszeile dargestellt.
Ein hinterlegter TT-Teamspace-Wert setzt mindestens eine als `Abgerechnet` bewertete Session desselben Tickets und Tages voraus. Gibt es nur `Abrechenbar`, `Nicht abrechenbar` oder unbewertete Sessions, erscheint der Tageswert als Fehler. Der TT-Teamspace-Wert und eine eventuelle Abweichung zur TS-API werden trotzdem angezeigt.
Zur Kenntnis genommene Eintraege koennen ueber die Checkbox `Zur Kenntnis anzeigen` wieder eingeblendet werden. Diese Anzeigeeinstellung bleibt im Browser gespeichert. Ein abgehakter Eintrag kann mit `Zuruecknehmen` wieder offen gemacht werden.
+37 -4
View File
@@ -1,5 +1,6 @@
import {
CalendarDays,
Building2,
CheckCircle2,
ClipboardCheck,
Command,
@@ -87,8 +88,10 @@ import { isRecurringBillingTicketNumber, ticketHasRecurringBilling } from "./rec
import { activeElapsedMs, mergeTimerActivityDrafts, readStoredTimers, storageKeyForUser, ticketPattern, type TimerEntry } from "./timers";
import { formatTodoPeriod, isTodoActiveOnDay, isTodoFixedOnDay, isTodoOverdue } from "./todos";
import { AdminUsersPage } from "./views/AdminUsersPage";
import { AdminOrganizationsPage } from "./views/AdminOrganizationsPage";
import { AnalysisPage } from "./views/AnalysisPage";
import { DashboardPage } from "./views/DashboardPage";
import { DashboardTimersPage } from "./views/DashboardTimersPage";
import { FaqPage } from "./views/FaqPage";
import { LoginPage } from "./views/LoginPage";
import { MonthlyClosePage } from "./views/MonthlyClosePage";
@@ -225,6 +228,10 @@ function routeFromPath(pathWithSearch: string) {
}
if (pathname.startsWith("/dashboard")) {
if (pathname.startsWith("/dashboard/timers")) {
return { page: "dashboard-timers" as const };
}
return { page: "dashboard" as const };
}
@@ -260,6 +267,10 @@ function routeFromPath(pathWithSearch: string) {
return { page: "admin-users" as const };
}
if (pathname.startsWith("/admin/organizations")) {
return { page: "admin-organizations" as const };
}
if (pathname.startsWith("/profile")) {
return { page: "profile" as const };
}
@@ -827,6 +838,7 @@ export function App() {
const [timersLoaded, setTimersLoaded] = useState(false);
const [timerOwnerId, setTimerOwnerId] = useState<string | null>(null);
const [selectedTimerId, setSelectedTimerId] = useState<string | null>(null);
const [finishRequestTimerId, setFinishRequestTimerId] = useState<string | null>(null);
const [tick, setTick] = useState(Date.now());
const [openTickets, setOpenTickets] = useState<OpenTicketNavItem[]>([]);
const [ticketReturnPaths, setTicketReturnPaths] = useState<Record<string, string>>({});
@@ -1160,7 +1172,7 @@ export function App() {
return;
}
if (route.page === "admin-users" || route.page === "profile" || route.page === "faq") {
if (route.page === "admin-users" || route.page === "admin-organizations" || route.page === "profile" || route.page === "faq") {
return;
}
@@ -1390,13 +1402,15 @@ export function App() {
...(currentUser.role === "admin"
? [
{ type: "item" as const, href: "/admin/users", label: "Administration", icon: Shield, active: path.startsWith("/admin/users") },
{ type: "item" as const, href: "/admin/organizations", label: "Organisationen", icon: Building2, active: path.startsWith("/admin/organizations") },
{ type: "separator" as const, key: "before-profile" },
{ type: "item" as const, href: "/profile", label: "Profil", icon: UserCog, active: path.startsWith("/profile") },
{ type: "separator" as const, key: "before-faq" },
{ type: "item" as const, href: "/faq", label: "FAQ", icon: CircleHelp, active: path.startsWith("/faq") }
]
: [
{ type: "item" as const, href: "/dashboard", label: "Dashboard", icon: LayoutDashboard, active: path.startsWith("/dashboard") },
{ type: "item" as const, href: "/dashboard", label: "Dashboard", icon: LayoutDashboard, active: path === "/dashboard" },
{ type: "item" as const, href: "/dashboard/timers", label: "Timer-Übersicht", icon: Timer, active: path.startsWith("/dashboard/timers") },
{ type: "separator" as const, key: "after-dashboard" },
{ type: "item" as const, href: "/timer", label: "Session starten", icon: Timer, active: path.startsWith("/timer") || path === "/" },
{ type: "item" as const, href: "/analysis", label: "Ticket-Sessions", icon: Ticket, active: path.startsWith("/analysis") },
@@ -1567,7 +1581,7 @@ export function App() {
<SidebarMenuItem>
<SidebarMenuButton tooltip="Worklog">
<FolderGit />
<span className="text-xs text-muted-foreground">v1.1.0-rc1</span>
<span className="text-xs text-muted-foreground">v1.2.0-rc1</span>
</SidebarMenuButton>
</SidebarMenuItem>
</SidebarMenu>
@@ -1662,9 +1676,27 @@ export function App() {
setSelectedTimerId={setSelectedTimerId}
tick={tick}
currentUser={currentUser}
finishRequestId={finishRequestTimerId}
onFinishRequestHandled={() => setFinishRequestTimerId(null)}
/>
) : null}
{!isAdmin && route.page === "dashboard" ? <DashboardPage currentUser={currentUser} onNavigate={navigate} /> : null}
{!isAdmin && route.page === "dashboard-timers" ? (
<DashboardTimersPage
timers={timers}
setTimers={setTimers}
selectedTimerId={selectedTimerId}
setSelectedTimerId={setSelectedTimerId}
tick={tick}
currentUser={currentUser}
onFinishTimer={(timerId) => {
setSelectedTimerId(timerId);
setFinishRequestTimerId(timerId);
navigate("/timer");
}}
onOpenActiveTimer={() => navigate("/timer")}
/>
) : null}
{!isAdmin && route.page === "analysis" ? <AnalysisPage currentUser={currentUser} onNavigate={navigate} /> : null}
{!isAdmin && route.page === "monthly-close" ? <MonthlyClosePage currentUser={currentUser} onNavigate={navigate} /> : null}
{!isAdmin && route.page === "statistics" ? <StatisticsPage currentUser={currentUser} onNavigate={navigate} /> : null}
@@ -1675,6 +1707,7 @@ export function App() {
{route.page === "faq" ? <FaqPage /> : null}
{route.page === "profile" ? <ProfilePage currentUser={currentUser} onUserUpdated={setCurrentUser} /> : null}
{route.page === "admin-users" && isAdmin ? <AdminUsersPage currentUser={currentUser} /> : null}
{route.page === "admin-organizations" && isAdmin ? <AdminOrganizationsPage /> : null}
{route.page === "admin-users" && !isAdmin ? (
<TimerPage
timers={timers}
@@ -1697,7 +1730,7 @@ export function App() {
returnPath={ticketReturnPaths[route.ticketId] ?? openTickets.find((ticket) => ticket.ticketId === route.ticketId)?.returnPath ?? "/analysis"}
/>
) : null}
{isAdmin && route.page !== "admin-users" && route.page !== "profile" && route.page !== "faq" ? <AdminUsersPage currentUser={currentUser} /> : null}
{isAdmin && route.page !== "admin-users" && route.page !== "admin-organizations" && route.page !== "profile" && route.page !== "faq" ? <AdminUsersPage currentUser={currentUser} /> : null}
</div>
</main>
</SidebarInset>
+123 -2
View File
@@ -19,6 +19,7 @@ import type {
TodoPriority,
TodoStatus,
WorkType,
WorkSegment,
ZammadSessionArticleQueueItem
} from "./types";
import type { TimerEntry, TimerPhase } from "./timers";
@@ -32,10 +33,12 @@ type CreateSessionPayload = {
startedAt: string;
endedAt: string;
durationSeconds: number;
workSegments?: WorkSegment[];
zammadLookup?: boolean;
projectTeamspaceEntryId?: string | null;
billingStatus?: BillingStatus;
createZammadArticle?: boolean;
zammadArticleDateHeader?: boolean;
zammadArticleTypeId?: number;
};
@@ -330,8 +333,10 @@ export function updateCurrentUser(payload: {
teamspaceDevicePassword?: string;
zammadEmailSessionWebhookEnabled?: boolean;
sessionBillingPromptEnabled?: boolean;
sessionWorkSegmentsEnabled?: boolean;
teamspaceTimeTrackingEnabled?: boolean;
zammadSessionNoteDefaultEnabled?: boolean;
zammadSessionNoteDateHeaderEnabled?: boolean;
zammadTimeUnitRoundingEnabled?: boolean;
zammadTimeUnitRoundingMinutes?: number;
zammadAtossEmailSyncEnabled?: boolean;
@@ -436,6 +441,37 @@ export function updateAdminUser(
});
}
export type TeamspaceInitialActualsSyncResult = {
userId: string;
fromMonth: string;
toMonth: string;
checkedEntries: number;
matchedEntries: number;
updatedProjectIds: number;
missingEntries: number;
ambiguousEntries: number;
monthsChecked: number;
monthsSkippedClosed: number;
teamspaceTimeEntriesScanned: number;
updatedManualEntries: number;
importedManualMinutes: number;
existingDayBillingMinutes: number;
};
export function syncAdminUserTeamspaceInitialActuals(userId: string, payload: { fromMonth?: string; toMonth?: string }) {
return request<{ result: TeamspaceInitialActualsSyncResult }>(`/api/admin/users/${userId}/teamspace-initial-actuals/sync`, {
method: "POST",
timeoutMs: 10 * 60 * 1000,
body: JSON.stringify(payload)
});
}
export function unlockAdminUserTeamspaceInitialActuals(userId: string) {
return request<{ user: AdminUser }>(`/api/admin/users/${userId}/teamspace-initial-actuals/unlock`, {
method: "POST"
});
}
export async function downloadDatabaseExport() {
const response = await fetch("/api/admin/export/database", {
credentials: "same-origin"
@@ -502,6 +538,33 @@ export function getOrganizations(search = "") {
return request<{ organizations: Organization[] }>(`/api/organizations${params.size ? `?${params}` : ""}`);
}
export function getAdminOrganizations(options: {
search?: string;
page?: number;
pageSize?: number | "all";
missingAssignment?: boolean;
} = {}) {
const params = new URLSearchParams();
if (options.search?.trim()) {
params.set("search", options.search.trim());
}
if (options.page) {
params.set("page", String(options.page));
}
if (options.pageSize) {
params.set("pageSize", String(options.pageSize));
}
if (options.missingAssignment) {
params.set("missingAssignment", "true");
}
return request<{ organizations: Organization[]; total: number; page: number; pageSize: number | "all" }>(`/api/admin/organizations${params.size ? `?${params}` : ""}`);
}
export function getZammadSettings() {
return request<{ settings: { baseUrl: string; hasApiKey: boolean } }>("/api/admin/zammad/settings");
}
@@ -526,16 +589,61 @@ export function saveTeamspaceSettings(payload: { baseUrl: string; credentialsUse
export type TeamspaceProjectExclusion = {
teamspace_project: string;
teamspace_api_project_id: string | null;
teamspace_api_project_ids: string[] | null;
teamspace_name: string | null;
customer_name: string | null;
atoss_support_default: boolean;
atoss_support_valid_from: string | null;
atoss_support_valid_to: string | null;
customer_owner_option_id: string | null;
hotline_contract_option_id: string | null;
note: string;
};
export type OrganizationAttributeOption = {
id: string;
zammad_attribute_id: string;
attribute_name: "customer_owner" | "hotline_contract";
option_key: string;
display_name: string;
active: boolean;
};
export type TeamspaceAssignmentRule = {
id?: string;
name: string;
active: boolean;
priority: number;
customer_owner_option_id: string | null;
hotline_contract_option_id: string | null;
support_enabled: boolean;
consulting_enabled: boolean;
teamspace_project: string;
created_at?: string;
updated_at?: string;
};
export function getOrganizationAttributeOptions() {
return request<{ options: OrganizationAttributeOption[] }>("/api/admin/zammad/organization-attribute-options");
}
export function getTeamspaceAssignmentRules() {
return request<{ rules: TeamspaceAssignmentRule[] }>("/api/admin/teamspace/assignment-rules");
}
export function saveTeamspaceAssignmentRules(rules: TeamspaceAssignmentRule[]) {
return request<{ rules: TeamspaceAssignmentRule[] }>("/api/admin/teamspace/assignment-rules", {
method: "PUT",
body: JSON.stringify({ rules })
});
}
export function getTeamspaceProjectExclusions() {
return request<{ exclusions: TeamspaceProjectExclusion[] }>("/api/admin/teamspace/project-exclusions");
}
export function saveTeamspaceProjectExclusions(projects: string[]) {
export function saveTeamspaceProjectExclusions(projects: Array<Pick<TeamspaceProjectExclusion, "teamspace_project" | "atoss_support_default" | "atoss_support_valid_from" | "atoss_support_valid_to" | "customer_owner_option_id" | "hotline_contract_option_id">>) {
return request<{ exclusions: TeamspaceProjectExclusion[] }>("/api/admin/teamspace/project-exclusions", {
method: "PUT",
body: JSON.stringify({ projects })
@@ -543,7 +651,7 @@ export function saveTeamspaceProjectExclusions(projects: string[]) {
}
export function syncZammadOrganizations(payload: { baseUrl?: string; apiKey?: string }) {
return request<{ synced: number; skipped: number; removed: number; unlinkedTickets: number; unlinkedSessions: number }>("/api/admin/zammad/organizations/sync", {
return request<{ synced: number; attributeOptions: number; skipped: number; removed: number; unlinkedTickets: number; unlinkedSessions: number }>("/api/admin/zammad/organizations/sync", {
method: "POST",
body: JSON.stringify(payload)
});
@@ -712,6 +820,9 @@ export function createRecurringBilling(payload: {
organizationId: string;
activity: string;
workType: WorkType;
teamspaceProject?: string | null;
hourBudgetMinutes?: number | null;
manualConsumptionMinutes?: number | null;
recurrenceType: "weekly" | "every_n_weeks";
intervalValue: number;
validFrom: string;
@@ -737,6 +848,9 @@ export function updateRecurringBilling(
organizationId: string;
activity: string;
workType: WorkType;
teamspaceProject?: string | null;
hourBudgetMinutes?: number | null;
manualConsumptionMinutes?: number | null;
recurrenceType: "weekly" | "every_n_weeks";
intervalValue: number;
validFrom: string;
@@ -1008,6 +1122,13 @@ export function updateTicket(
});
}
export function updateTicketAtossSupport(ticketId: string, atossSupport: boolean) {
return request<{ ticket: { atoss_support: boolean } }>(`/api/tickets/${ticketId}/atoss-support`, {
method: "PATCH",
body: JSON.stringify({ atossSupport })
});
}
export function updateSessionDetails(
sessionId: string,
payload: {
+13 -7
View File
@@ -1,5 +1,5 @@
import { CalendarDays, CheckCircle2, Circle, GripVertical, Pencil, Plus, Save, Trash2, X } from "lucide-react";
import { FormEvent, useState } from "react";
import { type FormEvent, type ReactNode, useState } from "react";
import { toast } from "sonner";
import { Badge } from "@/components/ui/badge";
@@ -40,9 +40,11 @@ type TodoPanelProps = {
onNavigate?: (to: string) => void;
reorderEnabled?: boolean;
onReorder?: (draggedTodoId: string, targetTodoId: string) => void;
toolbar?: ReactNode;
compact?: boolean;
};
export function TodoPanel({ ticketId, todos, onChanged, showCreate = true, showTicketMeta = false, onNavigate, reorderEnabled = false, onReorder }: TodoPanelProps) {
export function TodoPanel({ ticketId, todos, onChanged, showCreate = true, showTicketMeta = false, onNavigate, reorderEnabled = false, onReorder, toolbar, compact = false }: TodoPanelProps) {
const [title, setTitle] = useState("");
const [description, setDescription] = useState("");
const [priority, setPriority] = useState<TodoPriority>("medium");
@@ -156,9 +158,11 @@ export function TodoPanel({ ticketId, todos, onChanged, showCreate = true, showT
}
return (
<div className="space-y-3">
<div className={compact ? "space-y-2" : "space-y-3"}>
{toolbar || (showCreate && ticketId) ? (
<div className={cn("flex flex-wrap items-center gap-2", toolbar ? "justify-start" : "justify-end")}>
<div className="min-w-0">{toolbar}</div>
{showCreate && ticketId ? (
<div className="flex justify-end">
<Dialog open={createOpen} onOpenChange={setCreateOpen}>
<DialogTrigger asChild>
<Button type="button" size="sm">
@@ -243,10 +247,11 @@ export function TodoPanel({ ticketId, todos, onChanged, showCreate = true, showT
</form>
</DialogContent>
</Dialog>
) : null}
</div>
) : null}
<div className="space-y-2">
<div className={compact ? "space-y-1.5" : "space-y-2"}>
{todos.map((todo) => {
const isEditing = editingId === todo.id;
const periodLabel = formatTodoPeriod(todo);
@@ -256,7 +261,8 @@ export function TodoPanel({ ticketId, todos, onChanged, showCreate = true, showT
<div
key={todo.id}
className={cn(
"rounded-md border bg-card/50 p-3 transition-colors",
"rounded-md border bg-card/50 transition-colors",
compact ? "p-2" : "p-3",
overdue && "border-red-500/50 bg-red-500/10",
todo.status === "done" && "opacity-70",
reorderEnabled && draggingId === todo.id && "border-primary/50 bg-primary/5"
@@ -393,7 +399,7 @@ export function TodoPanel({ ticketId, todos, onChanged, showCreate = true, showT
})}
</div>
{todos.length === 0 ? <p className="rounded-md border bg-muted/20 p-4 text-center text-sm text-muted-foreground">Keine ToDos vorhanden.</p> : null}
{todos.length === 0 ? <p className={cn("rounded-md border bg-muted/20 text-center text-sm text-muted-foreground", compact ? "p-3" : "p-4")}>Keine ToDos vorhanden.</p> : null}
</div>
);
}
@@ -0,0 +1,16 @@
import { Icon, type IconNode, type LucideProps } from "lucide-react";
const listClockIconNode: IconNode = [
["path", { d: "M3 6h.01", key: "1es8zl" }],
["path", { d: "M3 12h.01", key: "1v72e3" }],
["path", { d: "M3 18h.01", key: "1h3o6d" }],
["path", { d: "M7 6h10", key: "1b9f9n" }],
["path", { d: "M7 12h5", key: "1f8yah" }],
["path", { d: "M7 18h3", key: "1ul03r" }],
["circle", { cx: "17", cy: "17", r: "4", key: "y9qv7f" }],
["path", { d: "M17 15v2l1.5 1.5", key: "d4d9bm" }]
];
export function ListClockIcon(props: LucideProps) {
return <Icon iconNode={listClockIconNode} {...props} />;
}
+16 -2
View File
@@ -158,12 +158,16 @@ export const helpSections: HelpSection[] = [
quickItems: [
"Ticketnummer, Organisation und Art sind bearbeitbar.",
"Organisationen kommen aus der lokalen Zammad-Synchronisation.",
"Atoss Support ist ein bewusster Fallback am gesamten Support-Ticket.",
"Die Art wiederkehrender Sessions kommt aus der Regel."
],
details: [
"Ticketnummern können korrigiert werden, falls beim Starten oder Nachtragen ein falscher Bezug gewählt wurde.",
"Die Organisation wird immer über die Suchliste gewählt. Dadurch sind spätere Auswertungen pro Organisation zuverlässig.",
"Änderungen an Organisation und Art werden auf vorhandene manuelle Sessions dieses Tickets übernommen.",
"Zammad-Verwaltung und automatische Teamspace-Zuordnungen werden kompakt als Badges in den Ticketdaten angezeigt.",
"Die automatische TS-Zuordnung prüft zuerst ein am Ticket hinterlegtes Projekt und danach die Zuordnungsregeln aus dem Adminbereich. Der Haken Atoss Support greift nur, wenn beides fehlt.",
"Die Auswahl Atoss Support wird nur bei Support-Tickets ohne Projekt- oder Regelzuordnung angezeigt. Ist sie aktiviert, werden alle Sessions des Tickets anhand ihres Datums dem dafür im Adminbereich gültigen Atoss-Support-Projekt zugewiesen. Ohne Haken bleibt das Ticket bewusst unzugeordnet und muss manuell geprüft werden.",
"Bei wiederkehrenden Sessions ist die Art nicht direkt an der Session änderbar. Sie wird ausschließlich über die fixe Abrechnungsregel gesteuert.",
"Wenn ein Ticket bereits durch einen anderen Benutzer bekannt ist, können dessen Ticketdaten beim Beenden eines Timers übernommen werden. Die Session selbst bleibt trotzdem deinem Benutzer zugeordnet."
]
@@ -341,7 +345,9 @@ export const helpSections: HelpSection[] = [
description: "Der Monatsabschluss ist die Kontrollseite für offene Bewertungen und Teamspace-Punkte vor dem Abschluss.",
quickItems: [
"Bewertungen sind Pflicht für den Abschluss.",
"Tage ohne Teamspace-Wert können zur Kenntnis genommen werden.",
"Alle Prüfpunkte werden pro Ticket in einem Eintrag zusammengeführt.",
"Tage ohne Teamspace-Wert können innerhalb des Tickets zur Kenntnis genommen werden.",
"Support und ausgenommene Consulting-Projekte werden über Ticket#XXXXXX pro Tag mit der TS-API abgeglichen.",
"Der Monat kann wieder geöffnet werden."
],
details: [
@@ -351,7 +357,11 @@ export const helpSections: HelpSection[] = [
"Zur Kenntnis genommene Einträge können über die Checkbox Zur Kenntnis anzeigen wieder eingeblendet werden.",
"Die Kenntnisnahme kann pro Eintrag über Zurücknehmen rückgängig gemacht werden.",
"Die Einstellung, ob zur Kenntnis genommene Einträge angezeigt werden, bleibt im Browser gespeichert.",
"Offene Bewertungen werden nach Ticket gruppiert. Ein Klick auf Ticket öffnen führt zur Bewertung im Ticket.",
"Die Prüfliste führt offene Bewertungen, fehlende Teamspace-Tageswerte sowie Monats- und Tagesabweichungen nach Ticket zusammen. Dadurch erscheint jedes Ticket nur einmal.",
"Die Ticketzeile zeigt die Prüfarten als farbige Badges in festen Spalten. Die betroffenen Tage mit TT-Teamspace-Wert, TS-API-Wert und Differenz stehen direkt im Ticketdetail. Ein Klick auf Ticket öffnen führt dorthin.",
"Ein hinterlegter TT-Teamspace-Wert ist nur gültig, wenn es an diesem Ticket-Tag mindestens eine als Abgerechnet bewertete Session gibt. Andernfalls zeigt die Prüfliste einen Fehler und nennt zusätzlich vorhandene abrechenbare, nicht abrechenbare oder unbewertete Sessions.",
"Der ticketgenaue TS-Abgleich erkennt Teamspace-Unterpositionen, deren Bezeichnung mit Ticket#XXXXXX beginnt. Er vergleicht deren API-Tageswerte mit den in TicketTracker gepflegten Teamspace-Zeiten an den Session-Tagen desselben Tickets.",
"Der ticketgenaue Abgleich gilt für Support-Tickets und für Consulting-Tickets auf einem in der Administration ausgenommenen Teamspace-Projekt. Angezeigt werden sowohl fehlende Zeit in Teamspace als auch fehlende Zeit in TicketTracker.",
"Es gibt keinen Tagesabschluss. Tagesansichten dienen nur der Kontrolle."
]
},
@@ -561,6 +571,10 @@ export const helpSections: HelpSection[] = [
"Admins können bestehende Benutzer bearbeiten, Passwörter setzen und Benutzer deaktivieren.",
"Der feste Admin-Zugang kann nicht deaktiviert oder zur normalen User-Rolle herabgestuft werden.",
"Der Data-Sync speichert Zammad-Zugangsdaten und synchronisiert Organisationen in die lokale Datenbank.",
"Beim Organisationsabgleich werden zuerst die Zammad-Auswahloptionen für Zugeordnet und Hotline synchronisiert. In den globalen Teamspace-Projektausnahmen können diese Optionen anschließend pro Projekt ausgewählt werden.",
"Automatische TS-Zuordnungsregeln kombinieren optional Zugeordnet und Hotline mit Support und/oder Consulting sowie einem Zielprojekt. Sind beide Attribute gesetzt, müssen beide passen; bei mehreren Treffern gewinnt die kleinste Priorität.",
"Beim Zammad-Sync gilt die Reihenfolge: Ein konkretes TS-Projekt hat Vorrang. Genau ein Projekt wird allen Sessions automatisch zugeordnet; bei mehreren Projekten erfolgt die Auswahl beim Speichern einer Session manuell. Nur ohne konkretes Projekt wird eine passende Regel angewendet und allen Sessions des Tickets zugeordnet.",
"Die im Sync ermittelte Regelzuordnung wird im Ticketdetail angezeigt. Greift keine Regel, bleibt das Ticket ohne automatische TS-Zuordnung.",
"Der Zammad-Ticket-Sync aktualisiert Titel, Organisation, Typ, Zammad-Status, Zammad-Priorität sowie Teamspace-Projekte und Budgets je nach gewählter Option.",
"Zammad-Projektwerte können mehrere Teamspace-IDs enthalten. TicketTracker erkennt zusammenhängende IDs und legt entsprechend mehrere Teamspace-Einträge an.",
"Beim Budget-Sync bleiben geschützte TicketTracker-Budgets unverändert. Die Option Budget-Schutz im Sync ignorieren überschreibt auch geschützte Budgets mit Zammad-Werten.",
+31 -1
View File
@@ -1,4 +1,4 @@
import type { BillingStatus } from "./types";
import type { BillingStatus, WorkSegment } from "./types";
export type TimerPhase = "running" | "paused";
@@ -16,6 +16,7 @@ export type TimerEntry = {
startedAt: number;
pausedTotalMs: number;
pausedAt: number | null;
workSegments?: WorkSegment[];
phase: TimerPhase;
};
@@ -58,6 +59,34 @@ export function resumeEntry(timer: TimerEntry, now = Date.now()): TimerEntry {
};
}
function normalizeWorkSegments(value: unknown): WorkSegment[] {
if (!Array.isArray(value)) {
return [];
}
return value
.map((segment) => {
if (!segment || typeof segment !== "object") {
return null;
}
const raw = segment as Partial<WorkSegment>;
if (typeof raw.startedAt !== "string" || Number.isNaN(new Date(raw.startedAt).getTime())) {
return null;
}
if (raw.endedAt !== null && raw.endedAt !== undefined && (typeof raw.endedAt !== "string" || Number.isNaN(new Date(raw.endedAt).getTime()))) {
return null;
}
return {
startedAt: new Date(raw.startedAt).toISOString(),
endedAt: raw.endedAt ? new Date(raw.endedAt).toISOString() : null,
};
})
.filter((segment): segment is WorkSegment => Boolean(segment));
}
export function normalizeTimers(timers: TimerEntry[]) {
let runningSeen = false;
const now = Date.now();
@@ -70,6 +99,7 @@ export function normalizeTimers(timers: TimerEntry[]) {
activity: typeof timer.activity === "string" ? timer.activity : "",
zammadArticleBody: typeof timer.zammadArticleBody === "string" ? timer.zammadArticleBody : "",
billingStatus: timer.billingStatus === "billed" ? "billable" : timer.billingStatus === "billable" || timer.billingStatus === "non_billable" ? timer.billingStatus : null,
workSegments: normalizeWorkSegments(timer.workSegments),
};
if (timer.phase !== "running") {
+74 -2
View File
@@ -16,8 +16,10 @@ export type AuthUser = {
has_teamspace_credentials: boolean;
zammad_email_session_webhook_enabled: boolean;
session_billing_prompt_enabled: boolean;
session_work_segments_enabled: boolean;
teamspace_time_tracking_enabled: boolean;
zammad_session_note_default_enabled: boolean;
zammad_session_note_date_header_enabled: boolean;
zammad_time_unit_rounding_enabled: boolean;
zammad_time_unit_rounding_minutes: number;
zammad_atoss_email_sync_enabled: boolean;
@@ -73,10 +75,19 @@ export type DashboardDayTicket = {
};
export type AdminUser = AuthUser & {
teamspace_initial_actuals_synced_at: string | null;
teamspace_initial_actuals_synced_by: string | null;
teamspace_initial_actuals_unlocked_at: string | null;
teamspace_initial_actuals_unlocked_by: string | null;
created_at: string;
updated_at: string;
};
export type WorkSegment = {
startedAt: string;
endedAt: string | null;
};
export type ZammadSessionArticleQueueItem = {
id: string;
session_id: string;
@@ -98,7 +109,15 @@ export type Organization = {
id: string;
zammad_id: string;
name: string;
customer_owner_option_id?: string | null;
customer_owner?: string | null;
customer_owner_display_name?: string | null;
hotline_contract_option_id?: string | null;
hotline_contract?: string | null;
hotline_contract_display_name?: string | null;
synced_at: string;
created_at?: string;
updated_at?: string;
};
export type RecurringBillingSlot = {
@@ -119,6 +138,9 @@ export type RecurringBilling = {
organization_name: string;
activity: string;
work_type: WorkType;
teamspace_project: string | null;
hour_budget_minutes: number | null;
manual_consumption_minutes: number;
recurrence_type: "weekly" | "every_n_weeks";
interval_value: number;
valid_from: string;
@@ -138,6 +160,12 @@ export type TicketMeta = {
organization_name: string | null;
customer_name: string | null;
work_type: WorkType | null;
atoss_support?: boolean;
can_manage_atoss_support?: boolean;
teamspace_assignment_rule_id?: string | null;
teamspace_assignment_rule_name?: string | null;
teamspace_assignment_project?: string | null;
teamspace_assignment_synced_at?: string | null;
zammad_ticket_id?: string | null;
zammad_group_id?: string | null;
zammad_priority_id?: string | null;
@@ -333,7 +361,11 @@ export type TeamspaceMonthActual = {
export type TeamspaceMonthActualPosition = {
apiProjectId: string;
projectId: string | null;
mainProjectId?: string | null;
label: string;
description?: string | null;
ticketNumber?: string | null;
customerName?: string | null;
isSubentry: boolean;
billingType?: string | null;
minutes: number;
@@ -398,6 +430,27 @@ export type TicketDayBilling = {
billed_minutes: number;
};
export type TicketTeamspaceDay = {
day: string;
tracked_minutes: number;
crm_billed_minutes: number;
teamspace_entry_count: number;
delta_minutes: number;
session_count: number;
billed_session_count: number;
billable_session_count: number;
non_billable_session_count: number;
unrated_session_count: number;
has_tt_value: boolean;
missing_tt_value: boolean;
tt_value_without_billed_session: boolean;
acknowledged: boolean;
acknowledged_at: string | null;
comparison_enabled: boolean;
teamspace_excluded: boolean;
work_type: WorkType | null;
};
export type TicketAvailableMonth = {
month: string;
session_count: number;
@@ -417,6 +470,7 @@ export type SessionEntry = {
ended_at: string;
duration_seconds: number;
rounded_minutes: number;
work_segments: WorkSegment[];
billing_status: BillingStatus;
billing_updated_at: string | null;
created_at: string;
@@ -448,6 +502,12 @@ export type TicketPeriod = {
organization_name: string | null;
customer_name: string | null;
work_type: WorkType | null;
atoss_support?: boolean;
can_manage_atoss_support?: boolean;
teamspace_assignment_rule_id?: string | null;
teamspace_assignment_rule_name?: string | null;
teamspace_assignment_project?: string | null;
teamspace_assignment_synced_at?: string | null;
zammad_ticket_id?: string | null;
zammad_group_id?: string | null;
zammad_priority_id?: string | null;
@@ -466,6 +526,7 @@ export type TicketPeriod = {
};
sessions: SessionEntry[];
dayBillings: TicketDayBilling[];
ticketTeamspaceDays?: TicketTeamspaceDay[];
project?: ProjectSummary | null;
todos?: TicketTodo[];
teamspaceMonthActuals?: TeamspaceMonthActual[];
@@ -562,15 +623,25 @@ export type StatisticsCrmDay = {
};
export type StatisticsTeamspaceDay = {
ticket_id: string;
ticket_number: string;
ticket_id: string | null;
ticket_number: string | null;
organization_name: string;
day: string;
tracked_minutes: number;
crm_billed_minutes: number;
teamspace_entry_count: number;
delta_minutes: number;
session_count: number;
project_teamspace_project: string | null;
api_project_id?: string | null;
teamspace_project_id?: string | null;
teamspace_excluded?: boolean;
work_type?: WorkType | null;
tt_value_without_billed_session?: boolean;
billed_session_count?: number;
billable_session_count?: number;
non_billable_session_count?: number;
unrated_session_count?: number;
};
export type StatisticsTeamspaceMonthMismatch = {
@@ -651,6 +722,7 @@ export type StatisticsOverview = {
acknowledgedMissingCrmDays: StatisticsCrmDay[];
crmMismatches: StatisticsCrmDay[];
teamspaceMonthMismatches: StatisticsTeamspaceMonthMismatch[];
ticketTeamspaceMismatches: StatisticsTeamspaceDay[];
};
teamspaceDays: StatisticsTeamspaceDay[];
};
@@ -0,0 +1,330 @@
import { FormEvent, useEffect, useMemo, useRef, useState } from "react";
import { Building2, DatabaseZap, RefreshCcw, Save, Search } from "lucide-react";
import { toast } from "sonner";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Checkbox } from "@/components/ui/checkbox";
import { Input } from "@/components/ui/input";
import { NativeSelect, NativeSelectOption } from "@/components/ui/native-select";
import { Pagination, PaginationContent, PaginationItem, PaginationNext, PaginationPrevious } from "@/components/ui/pagination";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { HelpLink } from "@/components/HelpLink";
import { getAdminOrganizations, getZammadSettings, saveZammadSettings, syncZammadOrganizations } from "../api";
import { formatDateTime } from "../format";
import type { Organization } from "../types";
type PageSize = 10 | 20 | 50 | 100 | "all";
const pageSizeOptions: PageSize[] = [10, 20, 50, 100, "all"];
function pageSizeFromValue(value: string): PageSize {
if (value === "all") {
return "all";
}
const parsed = Number(value);
return pageSizeOptions.includes(parsed as PageSize) ? parsed as PageSize : 20;
}
function emptyText(value: string | null | undefined) {
return value?.trim() || "leer";
}
export function AdminOrganizationsPage() {
const [organizations, setOrganizations] = useState<Organization[]>([]);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState<PageSize>(20);
const [search, setSearch] = useState("");
const [missingAssignment, setMissingAssignment] = useState(false);
const [zammadBaseUrl, setZammadBaseUrl] = useState("");
const [zammadApiKey, setZammadApiKey] = useState("");
const [hasZammadApiKey, setHasZammadApiKey] = useState(false);
const [loading, setLoading] = useState(false);
const [savingZammadSettings, setSavingZammadSettings] = useState(false);
const [syncingOrganizations, setSyncingOrganizations] = useState(false);
const loadRequestId = useRef(0);
const pageCount = pageSize === "all" ? 1 : Math.max(1, Math.ceil(total / pageSize));
const safePage = Math.min(page, pageCount);
const rangeStart = total === 0 ? 0 : pageSize === "all" ? 1 : (safePage - 1) * pageSize + 1;
const rangeEnd = pageSize === "all" ? total : Math.min(safePage * pageSize, total);
const activeFilterCount = useMemo(
() => [search.trim(), missingAssignment].filter(Boolean).length,
[missingAssignment, search]
);
async function loadSettings() {
const result = await getZammadSettings();
setZammadBaseUrl(result.settings.baseUrl);
setHasZammadApiKey(result.settings.hasApiKey);
}
async function loadOrganizations() {
const requestId = loadRequestId.current + 1;
loadRequestId.current = requestId;
setLoading(true);
try {
const result = await getAdminOrganizations({
search,
page: safePage,
pageSize,
missingAssignment
});
if (requestId === loadRequestId.current) {
setOrganizations(result.organizations);
setTotal(result.total);
}
} catch (error) {
if (requestId === loadRequestId.current) {
toast.error("Organisationen konnten nicht geladen werden", {
description: error instanceof Error ? error.message : "Unbekannter Fehler"
});
}
} finally {
if (requestId === loadRequestId.current) {
setLoading(false);
}
}
}
useEffect(() => {
void loadSettings();
}, []);
useEffect(() => {
setPage(1);
}, [search, pageSize, missingAssignment]);
useEffect(() => {
void loadOrganizations();
}, [safePage, pageSize, search, missingAssignment]);
async function saveZammadAccess() {
setSavingZammadSettings(true);
try {
const result = await saveZammadSettings({
baseUrl: zammadBaseUrl,
apiKey: zammadApiKey.trim() || undefined
});
setZammadBaseUrl(result.settings.baseUrl);
setHasZammadApiKey(result.settings.hasApiKey);
setZammadApiKey("");
toast.success("Zammad-Zugang gespeichert", {
description: result.settings.hasApiKey ? "URL und API-Key sind hinterlegt." : "URL wurde gespeichert."
});
} catch (error) {
toast.error("Zammad-Zugang konnte nicht gespeichert werden", {
description: error instanceof Error ? error.message : "Unbekannter Fehler"
});
} finally {
setSavingZammadSettings(false);
}
}
async function syncOrganizations(event: FormEvent) {
event.preventDefault();
setSyncingOrganizations(true);
try {
const result = await syncZammadOrganizations({
baseUrl: zammadBaseUrl,
apiKey: zammadApiKey.trim() || undefined
});
toast.success("Organisationen synchronisiert", {
description: `${result.attributeOptions} Attributoptionen und ${result.synced} Organisationen gespeichert, ${result.removed} Organisationen entfernt, ${result.unlinkedTickets + result.unlinkedSessions} Verknüpfung(en) gelöst.`
});
setHasZammadApiKey(true);
setZammadApiKey("");
await loadOrganizations();
} catch (error) {
toast.error("Zammad-Sync fehlgeschlagen", {
description: error instanceof Error ? error.message : "Unbekannter Fehler"
});
} finally {
setSyncingOrganizations(false);
}
}
return (
<div className="space-y-4">
<div className="flex flex-col gap-3 lg:flex-row lg:items-end lg:justify-between">
<div>
<h2 className="text-xl font-semibold tracking-normal sm:text-2xl">Organisationsabgleich</h2>
<p className="text-sm text-muted-foreground">Zammad-Organisationen synchronisieren, prüfen und nach fehlenden Zuordnungen filtern.</p>
</div>
<Badge variant={activeFilterCount > 0 ? "secondary" : "outline"}>{activeFilterCount} Filter aktiv</Badge>
</div>
<Card>
<CardHeader className="gap-3 p-4 sm:flex-row sm:items-start sm:justify-between sm:space-y-0">
<div>
<CardTitle>Zammad-Sync</CardTitle>
<CardDescription>Zugang speichern und Organisationen aus Zammad in die lokale Datenbank übernehmen.</CardDescription>
</div>
<HelpLink anchor="organisationen" label="Hilfe zum Organisationsabgleich" />
</CardHeader>
<CardContent className="px-4 pb-4">
<form className="grid gap-3 lg:grid-cols-[minmax(220px,1fr)_minmax(220px,1fr)_auto_auto] lg:items-end" onSubmit={syncOrganizations}>
<div className="space-y-2">
<label className="text-sm font-medium" htmlFor="zammad-base-url">Zammad URL</label>
<Input
id="zammad-base-url"
placeholder="https://zammad.example.de"
value={zammadBaseUrl}
onChange={(event) => setZammadBaseUrl(event.currentTarget.value)}
required
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium" htmlFor="zammad-api-key">API-Key</label>
<Input
id="zammad-api-key"
type="password"
placeholder={hasZammadApiKey ? "••••••••••••••••" : ""}
value={zammadApiKey}
onChange={(event) => setZammadApiKey(event.currentTarget.value)}
/>
</div>
<Button type="button" variant="secondary" disabled={savingZammadSettings} onClick={() => void saveZammadAccess()}>
<Save className="size-4" />
{savingZammadSettings ? "Speichert..." : "Zugang speichern"}
</Button>
<Button type="submit" disabled={syncingOrganizations}>
<DatabaseZap className="size-4" />
{syncingOrganizations ? "Synchronisiert..." : "Synchronisieren"}
</Button>
</form>
</CardContent>
</Card>
<Card>
<CardHeader className="gap-3 p-4 xl:flex-row xl:items-end xl:justify-between xl:space-y-0">
<div>
<CardTitle>Organisationen</CardTitle>
<CardDescription>{loading ? "Lädt..." : `${rangeStart}-${rangeEnd} von ${total} Organisation(en)`}</CardDescription>
</div>
<div className="grid gap-2 md:grid-cols-[minmax(220px,1fr)_auto_auto] md:items-end">
<div className="space-y-1">
<label className="text-xs font-medium text-muted-foreground" htmlFor="organization-search">Suche</label>
<div className="relative">
<Search className="pointer-events-none absolute left-2.5 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
<Input
id="organization-search"
className="pl-8"
placeholder="Name, ID, Zuordnung, Hotline..."
value={search}
onChange={(event) => setSearch(event.currentTarget.value)}
/>
</div>
</div>
<label className="flex h-9 items-center gap-2 rounded-md border bg-background px-3 text-sm">
<Checkbox checked={missingAssignment} onCheckedChange={(checked) => setMissingAssignment(checked === true)} />
Zuordnung fehlt
</label>
<div className="space-y-1">
<label className="text-xs font-medium text-muted-foreground" htmlFor="organization-page-size">Anzeigen</label>
<NativeSelect
id="organization-page-size"
className="w-28"
value={String(pageSize)}
onChange={(event) => setPageSize(pageSizeFromValue(event.currentTarget.value))}
>
{pageSizeOptions.map((option) => (
<NativeSelectOption key={String(option)} value={String(option)}>
{option === "all" ? "Alle" : option}
</NativeSelectOption>
))}
</NativeSelect>
</div>
</div>
</CardHeader>
<CardContent className="space-y-3 px-4 pb-4">
<div className="overflow-x-auto rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead className="min-w-20">Zammad-ID</TableHead>
<TableHead className="min-w-[260px]">Organisation</TableHead>
<TableHead className="min-w-[180px]">Zugeordnet</TableHead>
<TableHead className="min-w-[160px]">Hotline</TableHead>
<TableHead className="min-w-[150px]">Synchronisiert</TableHead>
<TableHead className="min-w-[150px]">Geändert</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{organizations.map((organization) => (
<TableRow key={organization.zammad_id}>
<TableCell className="font-mono text-xs">{organization.zammad_id}</TableCell>
<TableCell>
<div className="flex min-w-0 items-center gap-2">
<Building2 className="size-4 shrink-0 text-muted-foreground" />
<span className="truncate font-medium">{organization.name}</span>
</div>
</TableCell>
<TableCell className={organization.customer_owner ? "" : "text-muted-foreground"}>{emptyText(organization.customer_owner)}</TableCell>
<TableCell className={organization.hotline_contract ? "" : "text-muted-foreground"}>{emptyText(organization.hotline_contract)}</TableCell>
<TableCell className="text-xs text-muted-foreground">{formatDateTime(organization.synced_at)}</TableCell>
<TableCell className="text-xs text-muted-foreground">{organization.updated_at ? formatDateTime(organization.updated_at) : "-"}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
{organizations.length === 0 ? (
<div className="grid place-items-center gap-2 px-4 py-10 text-center text-sm text-muted-foreground">
<Building2 className="size-8" />
Keine Organisationen für die aktuellen Filter.
</div>
) : null}
</div>
{pageSize !== "all" ? (
<Pagination className="justify-end">
<PaginationContent>
<PaginationItem>
<PaginationPrevious
href="#"
text="Zurück"
aria-disabled={safePage <= 1}
className={safePage <= 1 ? "pointer-events-none opacity-50" : undefined}
onClick={(event) => {
event.preventDefault();
setPage((current) => Math.max(1, current - 1));
}}
/>
</PaginationItem>
<PaginationItem>
<span className="flex h-9 items-center px-3 text-sm text-muted-foreground">
Seite {safePage} von {pageCount}
</span>
</PaginationItem>
<PaginationItem>
<PaginationNext
href="#"
text="Weiter"
aria-disabled={safePage >= pageCount}
className={safePage >= pageCount ? "pointer-events-none opacity-50" : undefined}
onClick={(event) => {
event.preventDefault();
setPage((current) => Math.min(pageCount, current + 1));
}}
/>
</PaginationItem>
</PaginationContent>
</Pagination>
) : null}
<Button type="button" variant="secondary" size="sm" disabled={loading} onClick={() => void loadOrganizations()}>
<RefreshCcw className="size-4" />
Aktualisieren
</Button>
</CardContent>
</Card>
</div>
);
}
+398 -92
View File
@@ -1,5 +1,5 @@
import { ChangeEvent, FormEvent, useEffect, useState } from "react";
import { CheckCircle2, DatabaseBackup, DatabaseZap, Plus, Save, Shield, Trash2, Unplug, Upload, UserPlus } from "lucide-react";
import { CheckCircle2, DatabaseBackup, Plus, RotateCcw, Save, Shield, TimerReset, Trash2, Unplug, Upload, UserPlus } from "lucide-react";
import { toast } from "sonner";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
@@ -14,17 +14,21 @@ import {
createAdminUser,
downloadDatabaseExport,
getAdminUsers,
getOrganizationAttributeOptions,
getTeamspaceAssignmentRules,
getTeamspaceSettings,
getTeamspaceProjectExclusions,
getZammadSettings,
restoreDatabaseBackup,
saveZammadSettings,
saveTeamspaceSettings,
saveTeamspaceAssignmentRules,
saveTeamspaceProjectExclusions,
syncZammadOrganizations,
syncAdminUserTeamspaceInitialActuals,
unlockAdminUserTeamspaceInitialActuals,
updateAdminUser,
validateDatabaseBackup,
type TeamspaceProjectExclusion,
type TeamspaceAssignmentRule,
type OrganizationAttributeOption,
type RestoreValidationResult
} from "../api";
import type { AdminUser, AuthUser } from "../types";
@@ -56,17 +60,15 @@ export function AdminUsersPage({ currentUser }: { currentUser: AuthUser }) {
const [users, setUsers] = useState<AdminUser[]>([]);
const [forms, setForms] = useState<Record<string, UserFormState>>({});
const [newUser, setNewUser] = useState<UserFormState>(emptyForm);
const [zammadBaseUrl, setZammadBaseUrl] = useState("");
const [zammadApiKey, setZammadApiKey] = useState("");
const [hasZammadApiKey, setHasZammadApiKey] = useState(false);
const [teamspaceBaseUrl, setTeamspaceBaseUrl] = useState("");
const [teamspaceCredentialsUserId, setTeamspaceCredentialsUserId] = useState("");
const [teamspaceProjectExclusions, setTeamspaceProjectExclusions] = useState<TeamspaceProjectExclusion[]>([]);
const [organizationAttributeOptions, setOrganizationAttributeOptions] = useState<OrganizationAttributeOption[]>([]);
const [teamspaceAssignmentRules, setTeamspaceAssignmentRules] = useState<TeamspaceAssignmentRule[]>([]);
const [newTeamspaceProjectExclusion, setNewTeamspaceProjectExclusion] = useState("");
const [syncingOrganizations, setSyncingOrganizations] = useState(false);
const [savingZammadSettings, setSavingZammadSettings] = useState(false);
const [savingTeamspaceSettings, setSavingTeamspaceSettings] = useState(false);
const [savingTeamspaceProjectExclusions, setSavingTeamspaceProjectExclusions] = useState(false);
const [savingTeamspaceAssignmentRules, setSavingTeamspaceAssignmentRules] = useState(false);
const [exportingDatabase, setExportingDatabase] = useState(false);
const [restoreSql, setRestoreSql] = useState("");
const [restoreValidation, setRestoreValidation] = useState<RestoreValidationResult | null>(null);
@@ -74,25 +76,29 @@ export function AdminUsersPage({ currentUser }: { currentUser: AuthUser }) {
const [restoringDatabase, setRestoringDatabase] = useState(false);
const [loading, setLoading] = useState(false);
const [savingId, setSavingId] = useState<string | null>(null);
const [syncingInitialActualsId, setSyncingInitialActualsId] = useState<string | null>(null);
const [unlockingInitialActualsId, setUnlockingInitialActualsId] = useState<string | null>(null);
const [initialActualsFromMonthByUser, setInitialActualsFromMonthByUser] = useState<Record<string, string>>({});
const [creating, setCreating] = useState(false);
async function load(options: { preserveEdits?: boolean } = {}) {
setLoading(true);
try {
const [usersResult, zammadSettingsResult, teamspaceSettingsResult, teamspaceProjectExclusionsResult] = await Promise.all([
const [usersResult, teamspaceSettingsResult, teamspaceProjectExclusionsResult, organizationAttributeOptionsResult, teamspaceAssignmentRulesResult] = await Promise.all([
getAdminUsers(),
getZammadSettings(),
getTeamspaceSettings(),
getTeamspaceProjectExclusions()
getTeamspaceProjectExclusions(),
getOrganizationAttributeOptions(),
getTeamspaceAssignmentRules()
]);
setUsers(usersResult.users);
if (!options.preserveEdits) {
setZammadBaseUrl(zammadSettingsResult.settings.baseUrl);
setHasZammadApiKey(zammadSettingsResult.settings.hasApiKey);
setTeamspaceBaseUrl(teamspaceSettingsResult.settings.baseUrl);
setTeamspaceCredentialsUserId(teamspaceSettingsResult.settings.credentialsUserId ?? "");
setTeamspaceProjectExclusions(teamspaceProjectExclusionsResult.exclusions);
setOrganizationAttributeOptions(organizationAttributeOptionsResult.options);
setTeamspaceAssignmentRules(teamspaceAssignmentRulesResult.rules);
}
setForms((current) =>
@@ -185,49 +191,64 @@ export function AdminUsersPage({ currentUser }: { currentUser: AuthUser }) {
}
}
async function syncOrganizations(event: FormEvent) {
event.preventDefault();
setSyncingOrganizations(true);
function initialActualsLocked(user: AdminUser) {
return Boolean(
user.teamspace_initial_actuals_synced_at &&
(!user.teamspace_initial_actuals_unlocked_at || user.teamspace_initial_actuals_unlocked_at <= user.teamspace_initial_actuals_synced_at)
);
}
function initialActualsStatus(user: AdminUser) {
if (initialActualsLocked(user)) {
return `ausgeführt ${new Date(user.teamspace_initial_actuals_synced_at as string).toLocaleDateString("de-DE")}`;
}
if (user.teamspace_initial_actuals_unlocked_at) {
return "entsperrt";
}
return "bereit";
}
async function syncInitialActuals(user: AdminUser) {
if (!window.confirm(`Teamspace-Istzeiten für ${user.display_name} initial als manuelle TS-Zeiten übernehmen? Vorhandene Tageswerte bleiben unverändert.`)) {
return;
}
setSyncingInitialActualsId(user.id);
try {
const result = await syncZammadOrganizations({
baseUrl: zammadBaseUrl,
apiKey: zammadApiKey.trim() || undefined
const result = await syncAdminUserTeamspaceInitialActuals(user.id, {
fromMonth: initialActualsFromMonthByUser[user.id] || undefined
});
toast.success("Organisationen synchronisiert", {
description: `${result.synced} gespeichert, ${result.removed} entfernt, ${result.unlinkedTickets + result.unlinkedSessions} Verknüpfung(en) gelöst.`
toast.success("TS-Istzeiten übernommen", {
description: `${result.result.updatedManualEntries} TS-Eintrag(e), ${Math.round(result.result.importedManualMinutes / 60 * 100) / 100} Std., Zeitraum ${result.result.fromMonth} bis ${result.result.toMonth}.`
});
setHasZammadApiKey(true);
setZammadApiKey("");
await load();
} catch (error) {
toast.error("Zammad-Sync fehlgeschlagen", {
toast.error("TS-Istzeiten konnten nicht übernommen werden", {
description: error instanceof Error ? error.message : "Unbekannter Fehler"
});
} finally {
setSyncingOrganizations(false);
setSyncingInitialActualsId(null);
}
}
async function saveZammadAccess() {
setSavingZammadSettings(true);
async function unlockInitialActuals(user: AdminUser) {
if (!window.confirm(`Initialen Teamspace-Istzeiten-Sync für ${user.display_name} wieder freigeben?`)) {
return;
}
setUnlockingInitialActualsId(user.id);
try {
const result = await saveZammadSettings({
baseUrl: zammadBaseUrl,
apiKey: zammadApiKey.trim() || undefined
});
setZammadBaseUrl(result.settings.baseUrl);
setHasZammadApiKey(result.settings.hasApiKey);
setZammadApiKey("");
toast.success("Zammad-Zugang gespeichert", {
description: result.settings.hasApiKey ? "URL und API-Key sind hinterlegt." : "URL wurde gespeichert."
});
await unlockAdminUserTeamspaceInitialActuals(user.id);
toast.success("Initialsync entsperrt");
await load({ preserveEdits: true });
} catch (error) {
toast.error("Zammad-Zugang konnte nicht gespeichert werden", {
toast.error("Initialsync konnte nicht entsperrt werden", {
description: error instanceof Error ? error.message : "Unbekannter Fehler"
});
} finally {
setSavingZammadSettings(false);
setUnlockingInitialActualsId(null);
}
}
@@ -274,6 +295,13 @@ export function AdminUsersPage({ currentUser }: { currentUser: AuthUser }) {
teamspace_project: project,
teamspace_name: null,
customer_name: null,
teamspace_api_project_id: null,
teamspace_api_project_ids: [],
atoss_support_default: false,
atoss_support_valid_from: null,
atoss_support_valid_to: null,
customer_owner_option_id: null,
hotline_contract_option_id: null,
note: ""
}
]);
@@ -284,11 +312,26 @@ export function AdminUsersPage({ currentUser }: { currentUser: AuthUser }) {
setTeamspaceProjectExclusions((current) => current.filter((entry) => entry.teamspace_project !== project));
}
function updateTeamspaceExclusion(project: string, patch: Partial<TeamspaceProjectExclusion>) {
setTeamspaceProjectExclusions((current) =>
current.map((entry) => entry.teamspace_project === project ? { ...entry, ...patch } : entry)
);
}
async function saveTeamspaceExclusions() {
setSavingTeamspaceProjectExclusions(true);
try {
const projects = teamspaceProjectExclusions.map((entry) => entry.teamspace_project.trim()).filter(Boolean);
const projects = teamspaceProjectExclusions
.map((entry) => ({
teamspace_project: entry.teamspace_project.trim(),
atoss_support_default: entry.atoss_support_default,
atoss_support_valid_from: entry.atoss_support_valid_from || null,
atoss_support_valid_to: entry.atoss_support_valid_to || null,
customer_owner_option_id: entry.customer_owner_option_id || null,
hotline_contract_option_id: entry.hotline_contract_option_id || null
}))
.filter((entry) => entry.teamspace_project);
const result = await saveTeamspaceProjectExclusions(projects);
setTeamspaceProjectExclusions(result.exclusions);
toast.success("Teamspace-Ausnahmen gespeichert", {
@@ -303,6 +346,59 @@ export function AdminUsersPage({ currentUser }: { currentUser: AuthUser }) {
}
}
function addTeamspaceAssignmentRule() {
const highestPriority = teamspaceAssignmentRules.reduce((highest, rule) => Math.max(highest, rule.priority), 0);
const defaultTarget = teamspaceProjectExclusions[0] ?? null;
setTeamspaceAssignmentRules((current) => [
...current,
{
name: `Regel ${current.length + 1}`,
active: true,
priority: highestPriority + 10,
customer_owner_option_id: defaultTarget?.customer_owner_option_id ?? null,
hotline_contract_option_id: defaultTarget?.hotline_contract_option_id ?? null,
support_enabled: true,
consulting_enabled: false,
teamspace_project: defaultTarget?.teamspace_project ?? ""
}
]);
}
function updateTeamspaceAssignmentRule(index: number, patch: Partial<TeamspaceAssignmentRule>) {
setTeamspaceAssignmentRules((current) => current.map((rule, currentIndex) => currentIndex === index ? { ...rule, ...patch } : rule));
}
function removeTeamspaceAssignmentRule(index: number) {
setTeamspaceAssignmentRules((current) => current.filter((_, currentIndex) => currentIndex !== index));
}
function updateTeamspaceAssignmentRuleTarget(index: number, teamspaceProject: string) {
const target = teamspaceProjectExclusions.find((entry) => entry.teamspace_project === teamspaceProject);
updateTeamspaceAssignmentRule(index, {
teamspace_project: teamspaceProject,
customer_owner_option_id: target?.customer_owner_option_id ?? null,
hotline_contract_option_id: target?.hotline_contract_option_id ?? null
});
}
async function saveAssignmentRules() {
setSavingTeamspaceAssignmentRules(true);
try {
const result = await saveTeamspaceAssignmentRules(teamspaceAssignmentRules);
setTeamspaceAssignmentRules(result.rules);
toast.success("Automatische Zuordnungsregeln gespeichert", {
description: `${result.rules.length} Regel(n) gespeichert. Kleinere Prioritätswerte werden zuerst geprüft.`
});
} catch (error) {
toast.error("Zuordnungsregeln konnten nicht gespeichert werden", {
description: error instanceof Error ? error.message : "Unbekannter Fehler"
});
} finally {
setSavingTeamspaceAssignmentRules(false);
}
}
async function exportDatabase() {
setExportingDatabase(true);
@@ -408,6 +504,16 @@ export function AdminUsersPage({ currentUser }: { currentUser: AuthUser }) {
}
const regularUserCount = users.filter((user) => user.role === "user").length;
const customerOwnerOptions = organizationAttributeOptions.filter((option) => option.attribute_name === "customer_owner");
const hotlineContractOptions = organizationAttributeOptions.filter((option) => option.attribute_name === "hotline_contract");
function organizationAttributeOptionLabel(option: OrganizationAttributeOption) {
const label = option.display_name === option.option_key
? option.display_name
: `${option.display_name} (${option.option_key})`;
return option.active ? label : `${label} · nicht mehr in Zammad`;
}
return (
<div className="space-y-4">
@@ -416,56 +522,20 @@ export function AdminUsersPage({ currentUser }: { currentUser: AuthUser }) {
<p className="text-sm text-muted-foreground">Systemverwaltung für Benutzer, Zammad-Organisationen und restore-fähige Backups.</p>
</div>
<div className="grid min-w-0 gap-4 2xl:grid-cols-[minmax(0,1.2fr)_minmax(340px,0.8fr)]">
<div className="grid min-w-0 gap-4">
<Card className="min-w-0">
<CardHeader className="p-4">
<div className="flex items-start justify-between gap-3">
<div className="flex items-center gap-2">
<DatabaseZap className="size-5 text-muted-foreground" />
<CardTitle>Data-Sync</CardTitle>
<Unplug className="size-5 text-muted-foreground" />
<CardTitle>Teamspace API</CardTitle>
</div>
<HelpLink anchor="organisationen" label="Hilfe zum Zammad-Sync" />
<HelpLink anchor="admin" label="Hilfe zu Teamspace API" />
</div>
<CardDescription>Zammad-Zugang speichern und Organisationen in die lokale TicketTracker-Datenbank übernehmen.</CardDescription>
<CardDescription>Teamspace-Zugang für Adminabfragen und globale Projekt-Ausnahmen verwalten.</CardDescription>
</CardHeader>
<CardContent className="px-4 pb-4">
<form className="grid gap-3 xl:grid-cols-2" onSubmit={syncOrganizations}>
<div className="space-y-2">
<label className="text-sm font-medium" htmlFor="zammad-base-url">Zammad URL</label>
<Input
id="zammad-base-url"
placeholder="https://zammad.example.de"
value={zammadBaseUrl}
onChange={(event) => setZammadBaseUrl(event.currentTarget.value)}
required
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium" htmlFor="zammad-api-key">API-Key</label>
<Input
id="zammad-api-key"
type="password"
placeholder={hasZammadApiKey ? "••••••••••••••••" : ""}
value={zammadApiKey}
onChange={(event) => setZammadApiKey(event.currentTarget.value)}
/>
</div>
<div className="flex flex-wrap gap-2 xl:col-span-2">
<Button type="button" variant="secondary" disabled={savingZammadSettings} onClick={() => void saveZammadAccess()}>
<Save className="size-4" />
{savingZammadSettings ? "Speichert..." : "Zugang speichern"}
</Button>
<Button type="submit" disabled={syncingOrganizations}>
<DatabaseZap className="size-4" />
{syncingOrganizations ? "Synchronisiert..." : "Organisationen synchronisieren"}
</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="space-y-4">
<div className="grid gap-3 xl:grid-cols-[minmax(220px,1fr)_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>
@@ -502,7 +572,7 @@ export function AdminUsersPage({ currentUser }: { currentUser: AuthUser }) {
<div>
<p className="text-sm font-medium">TS-Projekte ohne Differenz-/Budgetprüfung</p>
<p className="text-xs text-muted-foreground">
Sync und Gesamtverbrauch laufen weiter, aber TS/TT-Differenzen, Budgetbalken und Budgetverbrauch werden für diese TS-Projekte ausgeblendet. Beim Speichern werden Name und Kunde aus Teamspace geladen.
Sync und Gesamtverbrauch laufen weiter, aber TS/TT-Differenzen, Budgetbalken und Budgetverbrauch werden für diese TS-Projekte ausgeblendet. Zugeordnet und Hotline stammen aus den Zammad-Organisationsattributen.
</p>
</div>
<div className="flex flex-col gap-2 sm:flex-row">
@@ -523,22 +593,54 @@ export function AdminUsersPage({ currentUser }: { currentUser: AuthUser }) {
Hinzufügen
</Button>
</div>
<div className="overflow-hidden rounded-md border bg-background">
<div className="overflow-x-auto rounded-md border bg-background">
{teamspaceProjectExclusions.length === 0 ? (
<p className="px-3 py-4 text-sm text-muted-foreground">Noch keine Teamspace-Projekte ausgenommen.</p>
) : (
<div className="divide-y">
<div className="min-w-[1120px] divide-y">
{teamspaceProjectExclusions.map((entry) => (
<div key={entry.teamspace_project} className="grid gap-2 px-3 py-2 sm:grid-cols-[140px_minmax(0,1fr)_minmax(0,1fr)_auto] sm:items-center">
<div key={entry.teamspace_project} className="grid grid-cols-[180px_minmax(280px,1fr)_minmax(240px,0.9fr)_210px_210px_2.5rem] items-center gap-4 px-4 py-3">
<div className="font-mono text-sm font-semibold">{entry.teamspace_project}</div>
<div className="min-w-0">
<p className="truncate text-sm">{entry.teamspace_name ?? "Name wird beim Speichern aus Teamspace geladen"}</p>
<p className="text-xs text-muted-foreground sm:hidden">{entry.customer_name ?? "Kunde wird beim Speichern aus Teamspace geladen"}</p>
<p className="truncate text-sm font-medium">{entry.teamspace_name ?? "Name wird beim Speichern aus Teamspace geladen"}</p>
{entry.teamspace_api_project_ids?.length ? <p className="text-xs text-muted-foreground">{entry.teamspace_api_project_ids.length} API-ID(s)</p> : null}
</div>
<div className="hidden min-w-0 text-sm text-muted-foreground sm:block">
<span className="truncate">{entry.customer_name ?? "Kunde wird beim Speichern aus Teamspace geladen"}</span>
<div className="min-w-0 text-sm text-muted-foreground">
<p className="truncate">{entry.customer_name ?? "Kunde wird beim Speichern aus Teamspace geladen"}</p>
</div>
<Button type="button" variant="ghost" size="icon" className="justify-self-start sm:justify-self-end" onClick={() => removeTeamspaceExclusion(entry.teamspace_project)}>
<div className="space-y-1">
<label className="text-xs text-muted-foreground" htmlFor={`customer-owner-${entry.teamspace_project}`}>Zugeordnet</label>
<select
id={`customer-owner-${entry.teamspace_project}`}
className="h-9 w-full rounded-md border border-input bg-background px-3 text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring"
value={entry.customer_owner_option_id ?? ""}
onChange={(event) => updateTeamspaceExclusion(entry.teamspace_project, { customer_owner_option_id: event.currentTarget.value || null })}
>
<option value="">Nicht zugeordnet</option>
{customerOwnerOptions.map((option) => (
<option key={option.id} value={option.id} disabled={!option.active && option.id !== entry.customer_owner_option_id}>
{organizationAttributeOptionLabel(option)}
</option>
))}
</select>
</div>
<div className="space-y-1">
<label className="text-xs text-muted-foreground" htmlFor={`hotline-contract-${entry.teamspace_project}`}>Hotline</label>
<select
id={`hotline-contract-${entry.teamspace_project}`}
className="h-9 w-full rounded-md border border-input bg-background px-3 text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring"
value={entry.hotline_contract_option_id ?? ""}
onChange={(event) => updateTeamspaceExclusion(entry.teamspace_project, { hotline_contract_option_id: event.currentTarget.value || null })}
>
<option value="">Nicht zugeordnet</option>
{hotlineContractOptions.map((option) => (
<option key={option.id} value={option.id} disabled={!option.active && option.id !== entry.hotline_contract_option_id}>
{organizationAttributeOptionLabel(option)}
</option>
))}
</select>
</div>
<Button type="button" variant="ghost" size="icon" className="justify-self-end" onClick={() => removeTeamspaceExclusion(entry.teamspace_project)}>
<Trash2 className="size-4" />
<span className="sr-only">Teamspace-Projekt entfernen</span>
</Button>
@@ -552,6 +654,135 @@ export function AdminUsersPage({ currentUser }: { currentUser: AuthUser }) {
{savingTeamspaceProjectExclusions ? "Speichert..." : "Ausnahmen speichern"}
</Button>
</div>
<div className="space-y-3 rounded-md border bg-muted/20 p-3">
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div>
<p className="text-sm font-medium">Automatische TS-Zuordnung</p>
<p className="text-xs text-muted-foreground">
Angegebene Bedingungen innerhalb einer Regel gelten gemeinsam. Beim Auswählen des Zielprojekts werden dessen hinterlegte Zugeordnet-/Hotline-Werte übernommen und können danach angepasst werden. Die erste passende aktive Regel gewinnt. Regeln werden beim Zammad-Sync nur angewendet, wenn das Ticket keine konkrete TS-Projektnummer besitzt; anschließend werden alle Sessions dem Zielprojekt zugeordnet.
</p>
</div>
<Button type="button" variant="secondary" onClick={addTeamspaceAssignmentRule}>
<Plus className="size-4" />
Regel hinzufügen
</Button>
</div>
<div className="overflow-x-auto rounded-md border bg-background">
{teamspaceAssignmentRules.length === 0 ? (
<p className="px-3 py-4 text-sm text-muted-foreground">Noch keine automatische Zuordnungsregel angelegt.</p>
) : (
<div className="min-w-[1580px] divide-y">
{teamspaceAssignmentRules.map((rule, index) => {
const targetExists = teamspaceProjectExclusions.some((entry) => entry.teamspace_project === rule.teamspace_project);
return (
<div key={rule.id ?? `new-${index}`} className="grid grid-cols-[230px_100px_220px_220px_300px_240px_110px_2.5rem] items-end gap-3 px-4 py-3">
<div className="space-y-1">
<label className="text-xs text-muted-foreground" htmlFor={`assignment-rule-name-${rule.id ?? index}`}>Regelname</label>
<Input
id={`assignment-rule-name-${rule.id ?? index}`}
className="h-9"
value={rule.name}
onChange={(event) => updateTeamspaceAssignmentRule(index, { name: event.currentTarget.value })}
/>
</div>
<div className="space-y-1">
<label className="text-xs text-muted-foreground" htmlFor={`assignment-rule-priority-${rule.id ?? index}`}>Priorität</label>
<Input
id={`assignment-rule-priority-${rule.id ?? index}`}
className="h-9"
type="number"
min={0}
max={100000}
value={rule.priority}
onChange={(event) => updateTeamspaceAssignmentRule(index, { priority: Number(event.currentTarget.value) || 0 })}
/>
</div>
<div className="space-y-1">
<label className="text-xs text-muted-foreground" htmlFor={`assignment-rule-owner-${rule.id ?? index}`}>Zugeordnet ist</label>
<select
id={`assignment-rule-owner-${rule.id ?? index}`}
className="h-9 w-full rounded-md border border-input bg-background px-3 text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring"
value={rule.customer_owner_option_id ?? ""}
onChange={(event) => updateTeamspaceAssignmentRule(index, { customer_owner_option_id: event.currentTarget.value || null })}
>
<option value="">Bedingung nicht verwenden</option>
{customerOwnerOptions.map((option) => (
<option key={option.id} value={option.id} disabled={!option.active && option.id !== rule.customer_owner_option_id}>
{organizationAttributeOptionLabel(option)}
</option>
))}
</select>
</div>
<div className="space-y-1">
<label className="text-xs text-muted-foreground" htmlFor={`assignment-rule-hotline-${rule.id ?? index}`}>Hotline ist</label>
<select
id={`assignment-rule-hotline-${rule.id ?? index}`}
className="h-9 w-full rounded-md border border-input bg-background px-3 text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring"
value={rule.hotline_contract_option_id ?? ""}
onChange={(event) => updateTeamspaceAssignmentRule(index, { hotline_contract_option_id: event.currentTarget.value || null })}
>
<option value="">Bedingung nicht verwenden</option>
{hotlineContractOptions.map((option) => (
<option key={option.id} value={option.id} disabled={!option.active && option.id !== rule.hotline_contract_option_id}>
{organizationAttributeOptionLabel(option)}
</option>
))}
</select>
</div>
<div className="flex h-9 items-center gap-4 rounded-md border bg-background px-3">
<label className="flex items-center gap-2 text-sm">
<Switch
checked={rule.support_enabled}
onCheckedChange={(checked) => updateTeamspaceAssignmentRule(index, { support_enabled: checked })}
/>
Support
</label>
<label className="flex items-center gap-2 text-sm">
<Switch
checked={rule.consulting_enabled}
onCheckedChange={(checked) => updateTeamspaceAssignmentRule(index, { consulting_enabled: checked })}
/>
Consulting
</label>
</div>
<div className="space-y-1">
<label className="text-xs text-muted-foreground" htmlFor={`assignment-rule-target-${rule.id ?? index}`}>Ziel-TS-Projekt</label>
<select
id={`assignment-rule-target-${rule.id ?? index}`}
className="h-9 w-full rounded-md border border-input bg-background px-3 font-mono text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring"
value={rule.teamspace_project}
onChange={(event) => updateTeamspaceAssignmentRuleTarget(index, event.currentTarget.value)}
>
<option value="">Bitte wählen</option>
{!targetExists && rule.teamspace_project ? <option value={rule.teamspace_project}>{rule.teamspace_project} · nicht mehr vorhanden</option> : null}
{teamspaceProjectExclusions.map((entry) => (
<option key={entry.teamspace_project} value={entry.teamspace_project}>{entry.teamspace_project}</option>
))}
</select>
</div>
<label className="flex h-9 items-center gap-2 text-sm">
<Switch checked={rule.active} onCheckedChange={(checked) => updateTeamspaceAssignmentRule(index, { active: checked })} />
Aktiv
</label>
<Button type="button" variant="ghost" size="icon" onClick={() => removeTeamspaceAssignmentRule(index)}>
<Trash2 className="size-4" />
<span className="sr-only">Zuordnungsregel entfernen</span>
</Button>
</div>
);
})}
</div>
)}
</div>
<div className="flex flex-wrap items-center gap-3">
<Button type="button" variant="secondary" disabled={savingTeamspaceAssignmentRules} onClick={() => void saveAssignmentRules()}>
<Save className="size-4" />
{savingTeamspaceAssignmentRules ? "Speichert..." : "Zuordnungsregeln speichern"}
</Button>
<p className="text-xs text-muted-foreground">Zielprojekte müssen zuvor in der Ausnahmen-Liste gespeichert worden sein.</p>
</div>
</div>
</div>
</CardContent>
</Card>
@@ -671,6 +902,7 @@ export function AdminUsersPage({ currentUser }: { currentUser: AuthUser }) {
<TableHead>Rolle</TableHead>
<TableHead>Status</TableHead>
<TableHead>Neues Passwort</TableHead>
<TableHead>TS-Istzeiten</TableHead>
<TableHead />
</TableRow>
</TableHeader>
@@ -705,6 +937,45 @@ export function AdminUsersPage({ currentUser }: { currentUser: AuthUser }) {
onChange={(event) => updateForm(user.id, { password: event.currentTarget.value })}
/>
</TableCell>
<TableCell>
{isFixedAdmin ? (
<span className="text-sm text-muted-foreground">-</span>
) : (
<div className="grid min-w-[280px] gap-2">
<div className="flex items-center gap-2">
<Badge variant={initialActualsLocked(user) ? "secondary" : "outline"}>{initialActualsStatus(user)}</Badge>
{!user.has_teamspace_credentials || !user.teamspace_user_id ? <Badge variant="warning">TS-Zugang fehlt</Badge> : null}
</div>
<div className="grid grid-cols-[minmax(120px,1fr)_auto_auto] gap-2">
<Input
className="h-8"
type="month"
value={initialActualsFromMonthByUser[user.id] ?? ""}
onChange={(event) => setInitialActualsFromMonthByUser((current) => ({ ...current, [user.id]: event.currentTarget.value }))}
disabled={initialActualsLocked(user) || syncingInitialActualsId === user.id}
/>
<Button
size="sm"
variant="secondary"
disabled={initialActualsLocked(user) || !user.has_teamspace_credentials || !user.teamspace_user_id || syncingInitialActualsId === user.id}
onClick={() => void syncInitialActuals(user)}
>
<TimerReset className="size-4" />
{syncingInitialActualsId === user.id ? "Läuft..." : "Sync"}
</Button>
<Button
size="sm"
variant="ghost"
disabled={!initialActualsLocked(user) || unlockingInitialActualsId === user.id}
onClick={() => void unlockInitialActuals(user)}
>
<RotateCcw className="size-4" />
Entsperren
</Button>
</div>
</div>
)}
</TableCell>
<TableCell className="text-right">
<Button size="sm" onClick={() => void saveUser(user)} disabled={savingId === user.id}>
<Save className="size-4" />
@@ -740,6 +1011,41 @@ export function AdminUsersPage({ currentUser }: { currentUser: AuthUser }) {
value={form.password}
onChange={(event) => updateForm(user.id, { password: event.currentTarget.value })}
/>
{!isFixedAdmin ? (
<div className="space-y-2 rounded-md border bg-muted/20 p-2">
<div className="flex flex-wrap items-center gap-2">
<span className="text-sm font-medium">TS-Istzeiten</span>
<Badge variant={initialActualsLocked(user) ? "secondary" : "outline"}>{initialActualsStatus(user)}</Badge>
{!user.has_teamspace_credentials || !user.teamspace_user_id ? <Badge variant="warning">TS-Zugang fehlt</Badge> : null}
</div>
<div className="grid gap-2 sm:grid-cols-[minmax(140px,1fr)_auto_auto]">
<Input
type="month"
value={initialActualsFromMonthByUser[user.id] ?? ""}
onChange={(event) => setInitialActualsFromMonthByUser((current) => ({ ...current, [user.id]: event.currentTarget.value }))}
disabled={initialActualsLocked(user) || syncingInitialActualsId === user.id}
/>
<Button
size="sm"
variant="secondary"
disabled={initialActualsLocked(user) || !user.has_teamspace_credentials || !user.teamspace_user_id || syncingInitialActualsId === user.id}
onClick={() => void syncInitialActuals(user)}
>
<TimerReset className="size-4" />
{syncingInitialActualsId === user.id ? "Läuft..." : "Sync"}
</Button>
<Button
size="sm"
variant="ghost"
disabled={!initialActualsLocked(user) || unlockingInitialActualsId === user.id}
onClick={() => void unlockInitialActuals(user)}
>
<RotateCcw className="size-4" />
Entsperren
</Button>
</div>
</div>
) : null}
<div className="flex items-center justify-between gap-2">
<label className="flex items-center gap-2 text-sm">
<Switch checked={form.active} disabled={isFixedAdmin} onCheckedChange={(active) => updateForm(user.id, { active })} />
+1 -1
View File
@@ -194,7 +194,7 @@ function TeamspaceDeviationIcon({ ticket }: { ticket: TicketSummary }) {
return <span className="inline-block size-4" aria-hidden="true" />;
}
const title = deltaMinutes > 0 ? `${formatMinutes(deltaMinutes)} fehlt in TT` : `${formatMinutes(Math.abs(deltaMinutes))} fehlt in TS`;
const title = deltaMinutes > 0 ? `${formatMinutes(deltaMinutes)} mehr in TS API` : `${formatMinutes(Math.abs(deltaMinutes))} mehr in TT-Teamspace`;
return (
<span title={`${formatSignedMinutes(deltaMinutes)} · ${title}`} aria-label={`Teamspace-Abweichung: ${title}`}>
+179
View File
@@ -0,0 +1,179 @@
import { useMemo, useState } from "react";
import type { Dispatch, SetStateAction } from "react";
import { FolderGit, Pause, Play, RotateCcw, Square, Timer } from "lucide-react";
import { toast } from "sonner";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { deleteActiveTimer, updateActiveTimer } from "../api";
import { formatDateTime, formatTimer } from "../format";
import { activeElapsedMs, mergeTimerActivityDrafts, removeTimerActivityDraft, type TimerEntry } from "../timers";
import type { AuthUser } from "../types";
type DashboardTimersPageProps = {
timers: TimerEntry[];
setTimers: Dispatch<SetStateAction<TimerEntry[]>>;
selectedTimerId: string | null;
setSelectedTimerId: Dispatch<SetStateAction<string | null>>;
tick: number;
currentUser: AuthUser;
onFinishTimer: (timerId: string) => void;
onOpenActiveTimer: () => void;
};
export function DashboardTimersPage({
timers,
setTimers,
selectedTimerId,
setSelectedTimerId,
tick,
currentUser,
onFinishTimer,
onOpenActiveTimer
}: DashboardTimersPageProps) {
const [busyTimerId, setBusyTimerId] = useState<string | null>(null);
const sortedTimers = useMemo(
() =>
[...timers].sort((first, second) => {
if (first.phase === second.phase) {
return first.ticketNumber.localeCompare(second.ticketNumber);
}
return first.phase === "running" ? -1 : 1;
}),
[timers]
);
const runningTimer = timers.find((timer) => timer.phase === "running") ?? null;
async function setTimerPhase(timer: TimerEntry, phase: "running" | "paused") {
setBusyTimerId(timer.id);
try {
const result = await updateActiveTimer(timer.id, { phase });
setTimers(mergeTimerActivityDrafts(currentUser.id, result.timers));
setSelectedTimerId(timer.id);
} catch (error) {
toast.error("Timer konnte nicht aktualisiert werden", {
description: error instanceof Error ? error.message : "Unbekannter Fehler"
});
} finally {
setBusyTimerId(null);
}
}
async function discardTimer(timer: TimerEntry) {
const confirmed = window.confirm(`Timer ${timer.ticketNumber} verwerfen? Alle bisherigen Änderungen und Notizen an diesem Timer gehen verloren.`);
if (!confirmed) {
return;
}
setBusyTimerId(timer.id);
try {
const result = await deleteActiveTimer(timer.id);
removeTimerActivityDraft(currentUser.id, timer.id);
setTimers(mergeTimerActivityDrafts(currentUser.id, result.timers));
toast.success("Timer verworfen", {
description: timer.ticketNumber
});
} catch (error) {
toast.error("Timer konnte nicht verworfen werden", {
description: error instanceof Error ? error.message : "Unbekannter Fehler"
});
} finally {
setBusyTimerId(null);
}
}
return (
<div className="space-y-4">
<div className="flex flex-col gap-2 sm:flex-row sm:items-end sm:justify-between">
<div>
<h2 className="text-xl font-semibold tracking-normal sm:text-2xl">Timer-Übersicht</h2>
<p className="text-sm text-muted-foreground">Aktiven Timer und pausierte Timer zentral umschalten.</p>
</div>
{runningTimer ? <Badge variant="success">Aktiv: {runningTimer.ticketNumber}</Badge> : timers.length > 0 ? <Badge variant="warning">Alle pausiert</Badge> : <Badge variant="info">Kein Timer</Badge>}
</div>
<div className="grid gap-3">
{sortedTimers.map((timer) => {
const isRunning = timer.phase === "running";
const isSelected = selectedTimerId === timer.id;
const elapsedSeconds = Math.floor(activeElapsedMs(timer, tick) / 1000);
return (
<Card key={timer.id} className={isSelected ? "border-primary/60" : ""}>
<CardContent className="grid gap-3 p-4 lg:grid-cols-[minmax(0,1fr)_180px_auto] lg:items-center">
<button
type="button"
className={isRunning ? "min-w-0 text-left underline-offset-4 hover:underline" : "min-w-0 cursor-default text-left"}
onClick={() => {
setSelectedTimerId(timer.id);
if (isRunning) {
onOpenActiveTimer();
}
}}
>
<div className="flex min-w-0 flex-wrap items-center gap-2">
<span className={isRunning ? "size-2 rounded-full bg-emerald-500" : "size-2 rounded-full bg-muted-foreground/50"} />
<span className="font-semibold">{timer.ticketNumber}</span>
<Badge variant={isRunning ? "success" : "secondary"}>{isRunning ? "aktiv" : "pausiert"}</Badge>
{timer.workType ? <Badge variant="outline">{timer.workType === "support" ? "Support" : "Consulting"}</Badge> : null}
</div>
<p className="mt-1 truncate text-sm text-muted-foreground">
{timer.isProject ? <FolderGit className="mr-1 inline size-3.5" /> : null}
{timer.organizationName ?? timer.projectName ?? "Ticketdaten noch nicht geladen"}
</p>
{timer.activity.trim() ? <p className="mt-1 line-clamp-2 text-sm">{timer.activity}</p> : null}
<p className="mt-1 text-xs text-muted-foreground">Gestartet: {formatDateTime(new Date(timer.startedAt).toISOString())}</p>
</button>
<div className="rounded-md border bg-muted/20 px-3 py-2">
<p className="text-xs text-muted-foreground">Gemessen</p>
<p className="font-mono text-2xl font-semibold tracking-normal">{formatTimer(elapsedSeconds)}</p>
</div>
<div className="flex flex-wrap gap-2 lg:justify-end">
{isRunning ? (
<Button type="button" variant="secondary" disabled={busyTimerId === timer.id} onClick={() => void setTimerPhase(timer, "paused")}>
<Pause className="size-4" />
Pausieren
</Button>
) : (
<Button type="button" disabled={busyTimerId === timer.id} onClick={() => void setTimerPhase(timer, "running")}>
<Play className="size-4" />
Aktivieren
</Button>
)}
<Button type="button" variant="destructive" onClick={() => onFinishTimer(timer.id)}>
<Square className="size-4" />
Stoppen
</Button>
<Button type="button" variant="ghost" disabled={busyTimerId === timer.id} onClick={() => void discardTimer(timer)}>
<RotateCcw className="size-4" />
Verwerfen
</Button>
</div>
</CardContent>
</Card>
);
})}
{sortedTimers.length === 0 ? (
<Card>
<CardContent className="grid place-items-center gap-2 p-8 text-center">
<Timer className="size-8 text-muted-foreground" />
<div>
<p className="font-medium">Keine Timer vorbereitet</p>
<p className="text-sm text-muted-foreground">Starte oben einen Timer oder nutze den Schnellstart in der Kopfzeile.</p>
</div>
</CardContent>
</Card>
) : null}
</div>
</div>
);
}
+202 -248
View File
@@ -7,12 +7,10 @@ import {
FileWarning,
Lock,
LockOpen,
Sparkles,
} from "lucide-react";
import { useEffect, useMemo, useRef, useState } from "react";
import { toast } from "sonner";
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion";
import { Alert } from "@/components/ui/alert";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
@@ -21,8 +19,8 @@ import { Checkbox } from "@/components/ui/checkbox";
import { Input } from "@/components/ui/input";
import { CopyTicketButton } from "@/components/CopyTicketButton";
import { HelpLink } from "@/components/HelpLink";
import { acknowledgeMissingTicketDayBilling, closePeriod, getStatisticsOverview, reopenPeriod } from "../api";
import { currentMonth, currentWeek, formatDate, formatDateTime, formatMinutes } from "../format";
import { closePeriod, getStatisticsOverview, reopenPeriod } from "../api";
import { currentMonth, currentWeek, formatDateTime, formatMinutes } from "../format";
import type { AuthUser, StatisticsCrmDay, StatisticsOpenSession, StatisticsOverview, StatisticsTeamspaceDay, StatisticsTeamspaceMonthMismatch } from "../types";
type MonthlyClosePageProps = {
@@ -30,8 +28,6 @@ type MonthlyClosePageProps = {
onNavigate: (to: string) => void;
};
const trackedTextClass = "text-sky-700 dark:text-sky-300";
const teamspaceTextClass = "text-emerald-700 dark:text-emerald-300";
const showAcknowledgedMissingCrmStorageKey = "tickettracker.monthlyClose.showAcknowledgedMissingCrm";
const monthlyCloseViewModeStorageKey = "tickettracker.monthlyClose.viewMode";
@@ -45,28 +41,16 @@ function readMonthlyCloseViewMode(): MonthlyCloseViewMode {
return window.localStorage.getItem(monthlyCloseViewModeStorageKey) === "week" ? "week" : "month";
}
function formatSignedMinutes(minutes: number) {
if (minutes === 0) {
return "ausgeglichen";
}
return `${minutes > 0 ? "+" : "-"}${formatMinutes(Math.abs(minutes))}`;
}
function formatTeamspaceDelta(minutes: number) {
return formatSignedMinutes(minutes);
}
function formatMissingTarget(minutes: number, teamspaceTimeTrackingEnabled: boolean) {
if (minutes === 0) {
return "ausgeglichen";
}
if (minutes > 0) {
return `${formatMinutes(minutes)} fehlt ${teamspaceTimeTrackingEnabled ? "in TT" : "in den abgerechneten Zeiten"}`;
return `${formatMinutes(minutes)} mehr in TS API`;
}
return `${formatMinutes(Math.abs(minutes))} fehlt in TS`;
return `${formatMinutes(Math.abs(minutes))} mehr ${teamspaceTimeTrackingEnabled ? "in TT-Teamspace" : "in den abgerechneten Zeiten"}`;
}
function StatusCard({ label, value, detail, tone }: { label: string; value: string | number; detail: string; tone?: "ok" | "warn" | "neutral" }) {
@@ -77,104 +61,177 @@ function StatusCard({ label, value, detail, tone }: { label: string; value: stri
<CardContent className="p-3">
<p className="text-xs font-medium text-muted-foreground">{label}</p>
<p className={`text-xl font-semibold tracking-normal ${valueClassName}`}>{value}</p>
<p className="text-xs text-muted-foreground ">{detail}</p>
<p className="text-xs text-muted-foreground whitespace-pre-line">{detail}</p>
</CardContent>
</Card>
);
}
type OpenTicketGroup = {
type MonthlyCloseTicketDay = {
day: string;
missingCrm: StatisticsCrmDay | null;
ticketMismatch: StatisticsTeamspaceDay | null;
};
type MonthlyCloseTicketGroup = {
ticketId: string;
ticketNumber: string;
organizationName: string;
month: string;
openCount: number;
totalMinutes: number;
};
type OpenReviewGroup = OpenTicketGroup & {
mismatch: StatisticsTeamspaceMonthMismatch | null;
days: MonthlyCloseTicketDay[];
};
function groupOpenSessions(sessions: StatisticsOpenSession[]) {
const groups = new Map<string, OpenTicketGroup>();
type MutableMonthlyCloseTicketGroup = Omit<MonthlyCloseTicketGroup, "days"> & {
days: Map<string, MonthlyCloseTicketDay>;
};
for (const session of sessions) {
const existing = groups.get(session.ticket_id);
const monthlyCloseTicketGridClassName =
"xl:grid-cols-[minmax(10rem,1fr)_minmax(4.25rem,7rem)_minmax(4.25rem,7rem)_minmax(4.25rem,7rem)_minmax(4.25rem,7rem)_minmax(4.25rem,7rem)_11.5rem]";
function buildMonthlyCloseTicketGroups(
sessions: StatisticsOpenSession[],
monthMismatches: StatisticsTeamspaceMonthMismatch[],
ticketDayMismatches: StatisticsTeamspaceDay[],
missingCrmDays: StatisticsCrmDay[],
fallbackMonth: string
) {
const groups = new Map<string, MutableMonthlyCloseTicketGroup>();
function ensureGroup(ticketId: string, ticketNumber: string, organizationName: string, month: string) {
const existing = groups.get(ticketId);
if (existing) {
existing.openCount += 1;
existing.totalMinutes += session.rounded_minutes;
continue;
if (!existing.organizationName && organizationName) {
existing.organizationName = organizationName;
}
groups.set(session.ticket_id, {
ticketId: session.ticket_id,
ticketNumber: session.ticket_number,
organizationName: session.organization_name,
month: session.started_at.slice(0, 7),
openCount: 1,
totalMinutes: session.rounded_minutes
});
return existing;
}
return Array.from(groups.values()).sort((left, right) => left.ticketNumber.localeCompare(right.ticketNumber, "de", { numeric: true, sensitivity: "base" }));
}
function buildOpenReviewGroups(openGroups: OpenTicketGroup[], mismatches: StatisticsTeamspaceMonthMismatch[], month: string) {
const groups = new Map<string, OpenReviewGroup>();
for (const group of openGroups) {
groups.set(group.ticketId, { ...group, mismatch: null });
}
for (const mismatch of mismatches) {
const existing = groups.get(mismatch.ticket_id);
if (existing) {
existing.mismatch = mismatch;
continue;
}
groups.set(mismatch.ticket_id, {
ticketId: mismatch.ticket_id,
ticketNumber: mismatch.ticket_number,
organizationName: mismatch.organization_name,
const group: MutableMonthlyCloseTicketGroup = {
ticketId,
ticketNumber,
organizationName,
month,
openCount: 0,
totalMinutes: 0,
mismatch
});
mismatch: null,
days: new Map()
};
groups.set(ticketId, group);
return group;
}
return Array.from(groups.values()).sort((left, right) => left.ticketNumber.localeCompare(right.ticketNumber, "de", { numeric: true, sensitivity: "base" }));
for (const session of sessions) {
const group = ensureGroup(session.ticket_id, session.ticket_number, session.organization_name, session.started_at.slice(0, 7));
group.openCount += 1;
group.totalMinutes += session.rounded_minutes;
}
for (const mismatch of monthMismatches) {
ensureGroup(mismatch.ticket_id, mismatch.ticket_number, mismatch.organization_name, fallbackMonth).mismatch = mismatch;
}
for (const mismatch of ticketDayMismatches) {
if (!mismatch.ticket_id || !mismatch.ticket_number) {
continue;
}
const group = ensureGroup(mismatch.ticket_id, mismatch.ticket_number, mismatch.organization_name, mismatch.day.slice(0, 7));
const day = group.days.get(mismatch.day) ?? { day: mismatch.day, missingCrm: null, ticketMismatch: null };
day.ticketMismatch = mismatch;
group.days.set(mismatch.day, day);
}
for (const missingCrm of missingCrmDays) {
const group = ensureGroup(missingCrm.ticket_id, missingCrm.ticket_number, missingCrm.organization_name, missingCrm.day.slice(0, 7));
const day = group.days.get(missingCrm.day) ?? { day: missingCrm.day, missingCrm: null, ticketMismatch: null };
day.missingCrm = missingCrm;
group.days.set(missingCrm.day, day);
}
return Array.from(groups.values())
.map((group): MonthlyCloseTicketGroup => ({
...group,
days: Array.from(group.days.values()).sort((left, right) => left.day.localeCompare(right.day))
}))
.sort((left, right) => left.ticketNumber.localeCompare(right.ticketNumber, "de", { numeric: true, sensitivity: "base" }));
}
function OpenReviewItem({
function MonthlyCloseTicketItem({
group,
teamspaceTimeTrackingEnabled,
onNavigate
}: {
group: OpenReviewGroup;
teamspaceTimeTrackingEnabled: boolean;
group: MonthlyCloseTicketGroup;
onNavigate: (to: string) => void;
}) {
const missingDayCount = group.days.filter((day) => day.missingCrm && !day.missingCrm.acknowledged).length;
const acknowledgedDayCount = group.days.filter((day) => day.missingCrm?.acknowledged).length;
const mismatchDayCount = group.days.filter((day) => day.ticketMismatch && day.ticketMismatch.delta_minutes !== 0).length;
const invalidTeamspaceValueDayCount = group.days.filter((day) => day.ticketMismatch?.tt_value_without_billed_session).length;
const monthDeltaMinutes = group.mismatch?.teamspace_month_delta_minutes ?? 0;
const monthDeltaLabel = monthDeltaMinutes > 0
? `TS +${formatMinutes(monthDeltaMinutes)}`
: `TT +${formatMinutes(Math.abs(monthDeltaMinutes))}`;
const metricClassName = (visible: boolean) => `${visible ? "flex" : "hidden xl:flex"} min-h-7 items-center justify-between gap-2 rounded-md bg-muted/30 px-2 xl:min-h-5 xl:justify-center xl:rounded-none xl:bg-transparent xl:px-0`;
return (
<div className="rounded-md border bg-background px-3 py-2">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0 space-y-1">
<div className="flex flex-wrap items-center gap-2">
<span className="font-medium">{group.ticketNumber}</span>
<div className={`grid grid-cols-1 items-center gap-1.5 rounded-md border bg-background px-3 py-2.5 sm:grid-cols-2 xl:gap-2 ${monthlyCloseTicketGridClassName}`}>
<div className="min-w-0 sm:col-span-2 xl:col-span-1">
<p className="font-medium">{group.ticketNumber}</p>
<p className="truncate text-xs text-muted-foreground">{group.organizationName}</p>
</div>
<div className={metricClassName(group.openCount > 0)}>
<span className="text-[10px] font-medium uppercase tracking-wide text-muted-foreground xl:hidden">Offene Bewertungen</span>
{group.openCount > 0 ? (
<Badge className="bg-amber-500/12 text-amber-800 dark:bg-amber-500/20 dark:text-amber-200" title={`${group.openCount} offene Bewertung(en) · ${formatMinutes(group.totalMinutes)}`}>
{group.openCount}
</Badge>
) : null}
</div>
<div className={metricClassName(Boolean(group.mismatch))}>
<span className="text-[10px] font-medium uppercase tracking-wide text-muted-foreground xl:hidden">Monatsabweichung</span>
{group.mismatch ? (
<Badge className="bg-orange-500/12 text-orange-800 dark:bg-orange-500/20 dark:text-orange-200" title="Monatsabweichung zwischen TT-Teamspace und TS-API">
{monthDeltaLabel}
</Badge>
) : null}
</div>
<div className={metricClassName(missingDayCount > 0 || acknowledgedDayCount > 0)}>
<span className="text-[10px] font-medium uppercase tracking-wide text-muted-foreground xl:hidden">Fehlender TT-Wert</span>
<span className="flex flex-wrap items-center justify-end gap-1 xl:flex-col xl:justify-center">
{missingDayCount > 0 ? (
<Badge className="bg-sky-500/12 text-sky-800 dark:bg-sky-500/20 dark:text-sky-200" title={`${missingDayCount} Tag(e) ohne TT-Teamspace-Wert`}>
TT {missingDayCount}
</Badge>
) : null}
{acknowledgedDayCount > 0 ? (
<Badge className="bg-emerald-500/12 text-emerald-800 dark:bg-emerald-500/20 dark:text-emerald-200" title={`${acknowledgedDayCount} zur Kenntnis genommene(r) Tag(e)`}>
✓ {acknowledgedDayCount}
</Badge>
) : null}
</span>
</div>
<div className={metricClassName(mismatchDayCount > 0)}>
<span className="text-[10px] font-medium uppercase tracking-wide text-muted-foreground xl:hidden">Tagesabweichung</span>
{mismatchDayCount > 0 ? (
<Badge className="bg-violet-500/12 text-violet-800 dark:bg-violet-500/20 dark:text-violet-200" title={`${mismatchDayCount} Tag(e) mit Abweichung zwischen TT und TS`}>
{mismatchDayCount}
</Badge>
) : null}
</div>
<div className={metricClassName(invalidTeamspaceValueDayCount > 0)}>
<span className="text-[10px] font-medium uppercase tracking-wide text-muted-foreground xl:hidden">TT-Wert ohne Abrechnung</span>
{invalidTeamspaceValueDayCount > 0 ? (
<Badge variant="destructive" title={`${invalidTeamspaceValueDayCount} Tag(e) mit TT-Teamspace-Wert, aber ohne abgerechnete Session`}>{invalidTeamspaceValueDayCount}</Badge>
) : null}
</div>
<div className="flex shrink-0 items-center justify-end gap-1.5 pt-0.5 sm:col-span-2 xl:col-span-1 xl:pt-0">
<CopyTicketButton ticketNumber={group.ticketNumber} />
{group.openCount > 0 ? <Badge variant="warning" className="text-xxs">{group.openCount} Bewertung(en)</Badge> : null}
{group.mismatch ? <Badge variant="warning" className="text-xxs">{formatMissingTarget(group.mismatch.teamspace_month_delta_minutes, teamspaceTimeTrackingEnabled)}</Badge> : null}
</div>
<p className="truncate text-sm text-muted-foreground">{group.organizationName}</p>
<div className="flex flex-wrap gap-x-3 gap-y-1 text-xs">
{group.openCount > 0 ? <span className={trackedTextClass}>{formatMinutes(group.totalMinutes)} offen zu bewerten</span> : null}
</div>
</div>
<Button className="h-8 shrink-0" variant="secondary" size="sm" onClick={() => onNavigate(`/tickets/${group.ticketId}?month=${group.month}`)}>
<Button className="h-8" variant="secondary" size="sm" onClick={() => onNavigate(`/tickets/${group.ticketId}?month=${group.month}`)}>
Ticket öffnen
<ExternalLink className="size-4" />
</Button>
@@ -183,77 +240,6 @@ function OpenReviewItem({
);
}
function CrmDayItem({
item,
busy,
onSetAcknowledgement,
onNavigate
}: {
item: StatisticsCrmDay;
busy: boolean;
onSetAcknowledgement: (item: StatisticsCrmDay, acknowledged: boolean) => void;
onNavigate: (to: string) => void;
}) {
const isAcknowledged = Boolean(item.acknowledged);
return (
<div className={`rounded-md border bg-background px-3 py-2 ${isAcknowledged ? "opacity-75" : ""}`}>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0 space-y-1">
<div className="flex flex-wrap items-center gap-2">
<span className="font-medium">{item.ticket_number}</span>
<CopyTicketButton ticketNumber={item.ticket_number} />
<Badge variant="outline">{formatDate(`${item.day}T00:00:00`)}</Badge>
<Badge variant={isAcknowledged ? "success" : "warning"}>{isAcknowledged ? "zur Kenntnis" : "offen"}</Badge>
</div>
<p className="truncate text-sm text-muted-foreground">{item.organization_name}</p>
<p className="flex flex-wrap gap-x-3 gap-y-1 text-xs">
<span className={trackedTextClass}>Abgerechnet {formatMinutes(item.tracked_minutes)}</span>
<span className={teamspaceTextClass}>Teamspace {formatMinutes(item.crm_billed_minutes)}</span>
{typeof item.delta_minutes === "number" ? <span className="text-muted-foreground">Differenz {formatTeamspaceDelta(item.delta_minutes)}</span> : null}
</p>
</div>
<div className="flex shrink-0 flex-wrap justify-end gap-2">
<Button className="h-8" variant={isAcknowledged ? "secondary" : "ghost"} size="sm" disabled={busy} onClick={() => onSetAcknowledgement(item, !isAcknowledged)}>
{isAcknowledged ? "Zurücknehmen" : "Zur Kenntnis"}
</Button>
<Button className="h-8" variant="secondary" size="sm" onClick={() => onNavigate(`/tickets/${item.ticket_id}?month=${item.day.slice(0, 7)}`)}>
Tag öffnen
<ExternalLink className="size-4" />
</Button>
</div>
</div>
</div>
);
}
function TeamspaceDayItem({ item, onNavigate }: { item: StatisticsTeamspaceDay; onNavigate: (to: string) => void }) {
return (
<div className="grid gap-3 rounded-md border bg-background px-3 py-2 lg:grid-cols-[minmax(8rem,0.75fr)_minmax(0,1.65fr)_minmax(8rem,0.6fr)] lg:items-center">
<div className="flex flex-wrap items-center gap-2">
<Badge variant="outline">{formatDate(`${item.day}T00:00:00`)}</Badge>
</div>
<div className="min-w-0 space-y-1">
<div className="flex min-w-0 flex-wrap items-center gap-2">
<button
type="button"
className="font-medium underline-offset-4 hover:underline"
onClick={() => onNavigate(`/tickets/${item.ticket_id}?month=${item.day.slice(0, 7)}`)}
>
{item.ticket_number}
</button>
<CopyTicketButton ticketNumber={item.ticket_number} />
{item.project_teamspace_project ? <Badge variant="secondary">{item.project_teamspace_project}</Badge> : null}
</div>
<p className="truncate text-sm text-muted-foreground">{item.organization_name}</p>
</div>
<div className="text-sm">
<Badge variant="success">{formatMinutes(item.crm_billed_minutes)}</Badge>
</div>
</div>
);
}
export function MonthlyClosePage({ currentUser, onNavigate }: MonthlyClosePageProps) {
const [viewMode, setViewMode] = useState<MonthlyCloseViewMode>(() => readMonthlyCloseViewMode());
const [month, setMonth] = useState(currentMonth());
@@ -262,7 +248,6 @@ export function MonthlyClosePage({ currentUser, onNavigate }: MonthlyClosePagePr
const [loading, setLoading] = useState(false);
const [closing, setClosing] = useState(false);
const [reopening, setReopening] = useState(false);
const [acknowledgingKeys, setAcknowledgingKeys] = useState<Set<string>>(() => new Set());
const [showAcknowledgedMissingCrm, setShowAcknowledgedMissingCrm] = useState(false);
const [showAcknowledgedMissingCrmLoaded, setShowAcknowledgedMissingCrmLoaded] = useState(false);
const loadRequestId = useRef(0);
@@ -374,58 +359,47 @@ export function MonthlyClosePage({ currentUser, onNavigate }: MonthlyClosePagePr
}
}
async function setMissingCrmAcknowledgement(item: StatisticsCrmDay, acknowledged: boolean) {
const key = `${item.ticket_id}:${item.day}`;
setAcknowledgingKeys((current) => new Set(current).add(key));
try {
await acknowledgeMissingTicketDayBilling(item.ticket_id, item.day, acknowledged);
toast.success(acknowledged ? "Teamspace-Prüfung abgehakt" : "Kenntnisnahme zurückgenommen", {
description: `${item.ticket_number} am ${formatDate(`${item.day}T00:00:00`)}`
});
await load();
} catch (error) {
toast.error(acknowledged ? "Konnte nicht abgehakt werden" : "Konnte nicht zurückgenommen werden", {
description: error instanceof Error ? error.message : "Unbekannter Fehler"
});
} finally {
setAcknowledgingKeys((current) => {
const next = new Set(current);
next.delete(key);
return next;
});
}
}
const openSessions = stats?.attention.openSessions ?? [];
const missingCrmDays = stats?.attention.missingCrmDays ?? [];
const acknowledgedMissingCrmDays = stats?.attention.acknowledgedMissingCrmDays ?? [];
const teamspaceMonthMismatches = stats?.attention.teamspaceMonthMismatches ?? [];
const teamspaceDays = stats?.teamspaceDays ?? [];
const ticketTeamspaceMismatches = stats?.attention.ticketTeamspaceMismatches ?? [];
const billedMinutes = stats?.totals.billedMinutes ?? 0;
const ttTeamspaceMinutes = stats?.totals.crmBilledMinutes ?? 0;
const teamspaceApiBillableMinutes = stats?.totals.teamspaceApiBillableMinutes ?? 0;
const teamspaceApiDeltaMinutes = stats?.totals.teamspaceApiDeltaMinutes ?? 0;
const openTicketGroups = useMemo(() => groupOpenSessions(openSessions), [openSessions]);
const openReviewGroups = useMemo(() => buildOpenReviewGroups(openTicketGroups, teamspaceMonthMismatches, month), [month, openTicketGroups, teamspaceMonthMismatches]);
const visibleMissingCrmDays = useMemo(() => {
const days = showAcknowledgedMissingCrm ? [...missingCrmDays, ...acknowledgedMissingCrmDays] : [...missingCrmDays];
return days.sort((left, right) => left.day.localeCompare(right.day) || left.ticket_number.localeCompare(right.ticket_number, "de", { numeric: true, sensitivity: "base" }));
}, [acknowledgedMissingCrmDays, missingCrmDays, showAcknowledgedMissingCrm]);
const activeReviewTicketGroups = useMemo(
() => buildMonthlyCloseTicketGroups(openSessions, teamspaceMonthMismatches, ticketTeamspaceMismatches, missingCrmDays, month),
[missingCrmDays, month, openSessions, teamspaceMonthMismatches, ticketTeamspaceMismatches]
);
const visibleReviewTicketGroups = useMemo(
() => buildMonthlyCloseTicketGroups(openSessions, teamspaceMonthMismatches, ticketTeamspaceMismatches, visibleMissingCrmDays, month),
[month, openSessions, teamspaceMonthMismatches, ticketTeamspaceMismatches, visibleMissingCrmDays]
);
const teamspaceAttentionTicketCount = activeReviewTicketGroups.filter((group) => group.mismatch || group.days.length > 0).length;
const missingCrmTicketCount = new Set(missingCrmDays.map((item) => item.ticket_id)).size;
const ticketTeamspaceDeviationCount = ticketTeamspaceMismatches.filter((item) => item.delta_minutes !== 0).length;
const invalidTeamspaceValueDayCount = ticketTeamspaceMismatches.filter((item) => item.tt_value_without_billed_session).length;
const canClose = Boolean(isMonthView && stats && !stats.closed && stats.totals.sessions > 0 && openSessions.length === 0);
const checklist = useMemo(
() => [
{
label: "Offene Prüfungen",
ok: openReviewGroups.length === 0,
value: openReviewGroups.length,
detail: openReviewGroups.length === 0 ? "Keine offenen Prüfungen." : `${openReviewGroups.length} Ticket(s) prüfen`
label: "Tickets zu prüfen",
ok: activeReviewTicketGroups.length === 0,
value: activeReviewTicketGroups.length,
detail: activeReviewTicketGroups.length === 0 ? "Keine offenen Prüfpunkte." : `${activeReviewTicketGroups.length} Ticket(s) mit mindestens einem Prüfpunkt`
},
{
label: "Teamspace geprüft",
ok: missingCrmDays.length === 0 && teamspaceMonthMismatches.length === 0,
value: missingCrmDays.length + teamspaceMonthMismatches.length,
detail: missingCrmDays.length === 0 && teamspaceMonthMismatches.length === 0 ? "Keine offenen Teamspace-Prüfpunkte." : `${teamspaceMonthMismatches.length} Monatsabweichung(en), ${acknowledgedMissingCrmDays.length} zur Kenntnis genommen`
ok: teamspaceAttentionTicketCount === 0,
value: teamspaceAttentionTicketCount,
detail: teamspaceAttentionTicketCount === 0
? "Keine offenen Teamspace-Prüfpunkte."
: `${missingCrmDays.length} fehlende Tageswerte, ${ticketTeamspaceDeviationCount} Tagesabweichungen, ${invalidTeamspaceValueDayCount} TS-Werte ohne Abrechnung, ${teamspaceMonthMismatches.length} Monatsabweichungen`
},
{
label: isMonthView ? "Monat abschließbar" : "Wochenansicht",
@@ -434,7 +408,7 @@ export function MonthlyClosePage({ currentUser, onNavigate }: MonthlyClosePagePr
detail: isMonthView ? (stats?.closed ? "Monat ist gesperrt." : canClose ? "Kann abgeschlossen werden." : "Bewertungen prüfen.") : "Abschluss bleibt monatlich."
}
],
[acknowledgedMissingCrmDays.length, canClose, isMonthView, missingCrmDays.length, openReviewGroups.length, stats?.closed, teamspaceMonthMismatches.length]
[activeReviewTicketGroups.length, canClose, invalidTeamspaceValueDayCount, isMonthView, missingCrmDays.length, stats?.closed, teamspaceAttentionTicketCount, teamspaceMonthMismatches.length, ticketTeamspaceDeviationCount]
);
return (
@@ -442,7 +416,7 @@ export function MonthlyClosePage({ currentUser, onNavigate }: MonthlyClosePagePr
<div className="flex flex-col gap-3 xl:flex-row xl:items-end xl:justify-between">
<div>
<h2 className="text-2xl font-semibold tracking-normal">Monatsabschluss</h2>
<p className="text-sm text-muted-foreground">Offene Bewertungen und Teamspace-Abgleich für {periodAttentionLabel}; der Abschluss selbst bleibt monatlich.</p>
<p className="text-sm text-muted-foreground">Offene Bewertungen und TS-API-Abgleich für {periodAttentionLabel}; der Abschluss selbst bleibt monatlich.</p>
</div>
<div className="grid gap-2 sm:grid-cols-[auto_minmax(220px,300px)_auto] sm:items-end">
<div className="space-y-1">
@@ -509,21 +483,21 @@ export function MonthlyClosePage({ currentUser, onNavigate }: MonthlyClosePagePr
detail={`${formatMinutes(stats?.totals.minutes ?? 0)} abgerechnet`}
/>
<StatusCard
label="Teamspace-Werte"
label="TT-Teamspace"
value={formatMinutes(ttTeamspaceMinutes)}
detail={`aus TS API ${formatMinutes(teamspaceApiBillableMinutes)}\n${formatMissingTarget(teamspaceApiDeltaMinutes, teamspaceTimeTrackingEnabled)}`}
tone={teamspaceApiDeltaMinutes !== 0 ? "warn" : "ok"}
/>
<StatusCard
label="Offene Prüfungen"
value={openReviewGroups.length}
detail={`${openSessions.length} Bewertung(en)\n${teamspaceMonthMismatches.length}TS-Abweichung(en)`}
tone={openReviewGroups.length > 0 ? "warn" : "ok"}
label="Tickets zu prüfen"
value={activeReviewTicketGroups.length}
detail={`${openSessions.length} offene Bewertung(en)\n${teamspaceAttentionTicketCount} Ticket(s) mit TS-Prüfpunkten`}
tone={activeReviewTicketGroups.length > 0 ? "warn" : "ok"}
/>
<StatusCard
label="Ohne Teamspace"
value={missingCrmDays.length}
detail={`${acknowledgedMissingCrmDays.length} zur Kenntnis genommen`}
label="Ohne TT-Teamspace"
value={missingCrmTicketCount}
detail={`${missingCrmDays.length} Tag(e) · ${acknowledgedMissingCrmDays.length} zur Kenntnis`}
tone={missingCrmDays.length > 0 ? "warn" : "ok"}
/>
<StatusCard
@@ -561,8 +535,11 @@ export function MonthlyClosePage({ currentUser, onNavigate }: MonthlyClosePagePr
<CardHeader className="p-4">
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div>
<CardTitle>Aufräumen</CardTitle>
<CardDescription>Alles, was für {periodAttentionLabel} noch Aufmerksamkeit braucht.</CardDescription>
<CardTitle className="flex items-center gap-2">
<FileWarning className="size-4 text-muted-foreground" />
Prüfliste nach Ticket
</CardTitle>
<CardDescription>Offene Bewertungen, fehlende Tageswerte sowie Monats- und Tagesabweichungen – je Ticket einmal in festen Statusspalten.</CardDescription>
</div>
<div className="flex flex-wrap items-center gap-2">
<div className="inline-flex h-8 items-center gap-2 rounded-md border bg-background px-3 text-sm">
@@ -577,62 +554,39 @@ export function MonthlyClosePage({ currentUser, onNavigate }: MonthlyClosePagePr
</div>
</div>
</CardHeader>
<CardContent className="grid gap-4 px-4 pb-4 xl:grid-cols-2">
<section className="space-y-2">
<div className="flex min-h-8 items-center gap-2 text-sm font-medium">
<FileWarning className="size-4 text-muted-foreground" />
Offene Prüfungen
<CardContent className="space-y-3 px-4 pb-4">
<div className="flex flex-wrap items-center justify-between gap-2 text-xs text-muted-foreground">
<span>{visibleReviewTicketGroups.length} Ticket(s) angezeigt</span>
<span>Tagesdetails stehen im jeweiligen Ticket</span>
</div>
{openReviewGroups.map((group) => (
<OpenReviewItem key={group.ticketId} group={group} teamspaceTimeTrackingEnabled={teamspaceTimeTrackingEnabled} onNavigate={onNavigate} />
))}
{openReviewGroups.length === 0 ? <p className="rounded-md border bg-muted/20 p-3 text-sm text-muted-foreground">Keine offenen Prüfungen.</p> : null}
</section>
<section className="space-y-2">
<div className="flex min-h-8 items-center gap-2 text-sm font-medium">
<Sparkles className="size-4 text-muted-foreground" />
Tage ohne Teamspace-Wert
{visibleReviewTicketGroups.length > 0 ? (
<div className="space-y-2">
<div className={`hidden items-end gap-2 border border-transparent px-3 text-[11px] font-medium leading-tight uppercase tracking-wide text-muted-foreground xl:grid ${monthlyCloseTicketGridClassName}`}>
<span>Ticket</span>
<span className="text-center">Offene<br />Bewertungen</span>
<span className="text-center">Monatsabweichung<br />TT / TS</span>
<span className="text-center">Fehlender<br />TT-Wert</span>
<span className="text-center">Tagesabweichung<br />TT / TS</span>
<span className="text-center">TT-Wert ohne<br />Abrechnung</span>
<span className="sr-only">Aktionen</span>
</div>
{visibleMissingCrmDays.map((item) => (
<CrmDayItem
key={`${item.ticket_id}-${item.day}-missing`}
item={item}
busy={acknowledgingKeys.has(`${item.ticket_id}:${item.day}`)}
onSetAcknowledgement={setMissingCrmAcknowledgement}
{visibleReviewTicketGroups.map((group) => (
<MonthlyCloseTicketItem
key={group.ticketId}
group={group}
onNavigate={onNavigate}
/>
))}
{visibleMissingCrmDays.length === 0 ? (
<p className="rounded-md border bg-muted/20 p-3 text-sm text-muted-foreground">
{acknowledgedMissingCrmDays.length > 0 ? "Alle offenen Punkte sind zur Kenntnis genommen." : "Alle abrechenbaren Tage haben einen Teamspace-Wert."}
</p>
) : null}
</section>
</CardContent>
</Card>
<Accordion type="single" collapsible className="rounded-md border bg-card px-4">
<AccordionItem value="teamspace-entries" className="border-b-0">
<AccordionTrigger className="py-3 hover:no-underline">
<span className="flex min-w-0 flex-wrap items-center gap-2">
<span>Teamspace-Einträge</span>
<Badge variant="outline">{teamspaceDays.length} Eintrag(e)</Badge>
</span>
</AccordionTrigger>
<AccordionContent className="space-y-2 pb-4">
{teamspaceDays.length > 0 ? (
<div className="max-h-[28rem] space-y-2 overflow-y-auto pr-1">
{teamspaceDays.map((item) => (
<TeamspaceDayItem key={`${item.ticket_id}-${item.day}`} item={item} onNavigate={onNavigate} />
))}
</div>
) : (
<p className="rounded-md border bg-muted/20 p-3 text-sm text-muted-foreground">Für {periodAttentionLabel} sind noch keine Teamspace-Werte zu abgerechneten Ticket-Tagen vorhanden.</p>
<p className="rounded-md border bg-muted/20 p-3 text-sm text-muted-foreground">
{acknowledgedMissingCrmDays.length > 0 && !showAcknowledgedMissingCrm
? "Keine offenen Prüfpunkte. Zur Kenntnis genommene Tageswerte können eingeblendet werden."
: "Keine offenen Prüfpunkte für diesen Zeitraum."}
</p>
)}
</AccordionContent>
</AccordionItem>
</Accordion>
</CardContent>
</Card>
{stats && stats.totals.sessions === 0 ? (
<Card>
+41
View File
@@ -6,6 +6,7 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/com
import { Input } from "@/components/ui/input";
import { Switch } from "@/components/ui/switch";
import { HelpLink } from "@/components/HelpLink";
import { ListClockIcon } from "@/components/icons/ListClockIcon";
import { getZammadSessionArticleQueue, retryZammadSessionArticleQueue, updateCurrentUser } from "../api";
import { formatDateTime, formatMinutes } from "../format";
import type { AuthUser, ZammadSessionArticleQueueItem } from "../types";
@@ -30,8 +31,10 @@ export function ProfilePage({ currentUser, onUserUpdated }: ProfilePageProps) {
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 [sessionWorkSegmentsEnabled, setSessionWorkSegmentsEnabled] = useState(currentUser.session_work_segments_enabled);
const [teamspaceTimeTrackingEnabled, setTeamspaceTimeTrackingEnabled] = useState(currentUser.teamspace_time_tracking_enabled ?? true);
const [zammadSessionNoteDefaultEnabled, setZammadSessionNoteDefaultEnabled] = useState(currentUser.zammad_session_note_default_enabled);
const [zammadSessionNoteDateHeaderEnabled, setZammadSessionNoteDateHeaderEnabled] = useState(currentUser.zammad_session_note_date_header_enabled);
const [zammadTimeUnitRoundingEnabled, setZammadTimeUnitRoundingEnabled] = useState(currentUser.zammad_time_unit_rounding_enabled);
const [zammadTimeUnitRoundingMinutes, setZammadTimeUnitRoundingMinutes] = useState(currentUser.zammad_time_unit_rounding_minutes);
const [zammadArticleTypeId, setZammadArticleTypeId] = useState<ZammadArticleTypeId>(currentUser.zammad_article_type_id ?? 6);
@@ -56,8 +59,10 @@ export function ProfilePage({ currentUser, onUserUpdated }: ProfilePageProps) {
setZammadEmailSessionWebhookEnabled(currentUser.zammad_email_session_webhook_enabled);
setZammadAtossEmailSyncEnabled(currentUser.zammad_atoss_email_sync_enabled);
setSessionBillingPromptEnabled(currentUser.session_billing_prompt_enabled);
setSessionWorkSegmentsEnabled(currentUser.session_work_segments_enabled);
setTeamspaceTimeTrackingEnabled(currentUser.teamspace_time_tracking_enabled ?? true);
setZammadSessionNoteDefaultEnabled(currentUser.zammad_session_note_default_enabled);
setZammadSessionNoteDateHeaderEnabled(currentUser.zammad_session_note_date_header_enabled);
setZammadTimeUnitRoundingEnabled(currentUser.zammad_time_unit_rounding_enabled);
setZammadTimeUnitRoundingMinutes(currentUser.zammad_time_unit_rounding_minutes);
setZammadArticleTypeId(currentUser.zammad_article_type_id ?? 6);
@@ -148,8 +153,10 @@ export function ProfilePage({ currentUser, onUserUpdated }: ProfilePageProps) {
zammadEmailSessionWebhookEnabled,
zammadAtossEmailSyncEnabled,
sessionBillingPromptEnabled,
sessionWorkSegmentsEnabled,
teamspaceTimeTrackingEnabled,
zammadSessionNoteDefaultEnabled,
zammadSessionNoteDateHeaderEnabled,
zammadTimeUnitRoundingEnabled,
zammadTimeUnitRoundingMinutes,
zammadArticleTypeId,
@@ -442,6 +449,22 @@ export function ProfilePage({ currentUser, onUserUpdated }: ProfilePageProps) {
onCheckedChange={setSessionBillingPromptEnabled}
/>
</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">
<ListClockIcon className="size-4 text-muted-foreground" />
<div>
<label className="text-sm font-medium" htmlFor="profile-session-work-segments">
Arbeitsbereiche bei Pausen speichern
</label>
<p className="text-xs text-muted-foreground">Beim Pausieren und Fortsetzen die konkreten Von-bis-Zeiten an der Session merken.</p>
</div>
</div>
<Switch
id="profile-session-work-segments"
checked={sessionWorkSegmentsEnabled}
onCheckedChange={setSessionWorkSegmentsEnabled}
/>
</div>
<div className="mt-3 space-y-3 rounded-md border bg-background p-3">
<div className="flex items-center justify-between gap-3">
<div>
@@ -547,6 +570,24 @@ export function ProfilePage({ currentUser, onUserUpdated }: ProfilePageProps) {
}}
/>
</div>
<div className="mt-3 rounded-md border bg-background p-3">
<div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-2">
<FileText className="size-4 text-muted-foreground" />
<div>
<label className="text-sm font-medium" htmlFor="profile-zammad-date-header">
Datum bei nachgetragenen Sessions voranstellen
</label>
<p className="text-xs text-muted-foreground">Bei Einträgen vor heute wird nur in der Zammad-Notiz das Datum mit Leerzeile eingefügt.</p>
</div>
</div>
<Switch
id="profile-zammad-date-header"
checked={zammadSessionNoteDateHeaderEnabled}
onCheckedChange={setZammadSessionNoteDateHeaderEnabled}
/>
</div>
</div>
{zammadSessionNoteDefaultEnabled ? (
<div className="mt-3 space-y-3">
<div className="rounded-md border bg-background p-3">
+15
View File
@@ -95,6 +95,7 @@ const projectStaticFilterOptions: ProjectFilterOption[] = [
{ value: "open", label: "TT Offen" },
{ value: "closed", label: "TT Geschl." },
{ value: "hidden", label: "Ausgebl." },
{ value: "teamspaceExcluded", label: "TS ausgenommen" },
{ value: "budgeted", label: "Mit Kontingent" },
{ value: "withoutBudget", label: "Ohne Kontingent" },
{ value: "remaining", label: "Mit Rest" },
@@ -499,6 +500,10 @@ function projectFilterGroup(filter: string) {
return "visibility";
}
if (filter === "teamspaceExcluded") {
return "teamspaceExclusion";
}
if (filter.startsWith("zammadState:")) {
return "zammadState";
}
@@ -523,6 +528,10 @@ function matchesSingleProjectFilter(project: ProjectSummary, filter: string, sta
return Boolean(project.hidden_for_user);
}
if (filter === "teamspaceExcluded") {
return projectBudgetExcluded(project);
}
if (filter.startsWith("zammadState:")) {
return matchesZammadStateFilter(project.zammad_state_id, filter.slice("zammadState:".length) as Parameters<typeof matchesZammadStateFilter>[1]);
}
@@ -558,6 +567,12 @@ function matchesProjectFilters(project: ProjectSummary, filters: string[], organ
return false;
}
const showTeamspaceExcluded = filters.includes("teamspaceExcluded");
if (projectBudgetExcluded(project) !== showTeamspaceExcluded) {
return false;
}
return groupedProjectFilterMatch(filters, projectFilterGroup, (filter) => matchesSingleProjectFilter(project, filter, standardTeamspaceOnly));
}
@@ -26,6 +26,9 @@ type RecurringFormState = {
organizationName: string | null;
activity: string;
workType: WorkType;
teamspaceProject: string;
hourBudgetHours: string;
manualConsumptionHours: string;
recurrenceType: "weekly" | "every_n_weeks";
intervalValue: number;
validFrom: string;
@@ -50,6 +53,9 @@ function emptyRecurringForm(): RecurringFormState {
organizationName: null,
activity: "",
workType: "support",
teamspaceProject: "",
hourBudgetHours: "",
manualConsumptionHours: "",
recurrenceType: "weekly",
intervalValue: 1,
validFrom: currentDay(),
@@ -118,6 +124,9 @@ export function RecurringBillingsPage() {
organizationName: billing.organization_name,
activity: billing.activity,
workType: billing.work_type,
teamspaceProject: billing.teamspace_project ?? "",
hourBudgetHours: billing.hour_budget_minutes === null ? "" : durationHoursLabel(billing.hour_budget_minutes),
manualConsumptionHours: billing.manual_consumption_minutes > 0 ? durationHoursLabel(billing.manual_consumption_minutes) : "",
recurrenceType: billing.recurrence_type,
intervalValue: billing.recurrence_type === "weekly" ? 1 : billing.interval_value,
validFrom: billing.valid_from,
@@ -162,8 +171,26 @@ export function RecurringBillingsPage() {
}
let slots: Array<{ id?: string; weekday: number | null; startTime: string; durationMinutes: number }>;
let hourBudgetMinutes: number | null;
let manualConsumptionMinutes: number;
try {
const parseOptionalHours = (value: string, label: string) => {
if (!value.trim()) {
return null;
}
const hours = Number(value.replace(",", "."));
if (!Number.isFinite(hours) || hours < 0) {
throw new Error(`${label} prüfen`);
}
return Math.round(hours * 60);
};
hourBudgetMinutes = parseOptionalHours(recurringForm.hourBudgetHours, "Budget");
manualConsumptionMinutes = parseOptionalHours(recurringForm.manualConsumptionHours, "Manuelle Stunden") ?? 0;
slots = recurringForm.slots.map((slot) => {
const hours = Number(slot.durationHours.replace(",", "."));
@@ -191,6 +218,9 @@ export function RecurringBillingsPage() {
organizationId: recurringForm.organizationId,
activity: recurringForm.activity,
workType: recurringForm.workType,
teamspaceProject: recurringForm.teamspaceProject.trim() || null,
hourBudgetMinutes,
manualConsumptionMinutes,
recurrenceType: recurringForm.recurrenceType,
intervalValue: recurringForm.recurrenceType === "weekly" ? 1 : recurringForm.intervalValue,
validFrom: recurringForm.validFrom,
@@ -355,6 +385,44 @@ export function RecurringBillingsPage() {
) : null}
</div>
<div className="grid gap-3 lg:grid-cols-[minmax(220px,1fr)_180px_180px]">
<div className="space-y-2">
<label className="text-sm font-medium" htmlFor="recurring-teamspace-project">
TS-Projekt (optional)
</label>
<Input
id="recurring-teamspace-project"
value={recurringForm.teamspaceProject}
onChange={(event) => setRecurringForm({ ...recurringForm, teamspaceProject: event.currentTarget.value })}
placeholder="Teamspace-Projekt"
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium" htmlFor="recurring-budget-hours">
Budget in Stunden (optional)
</label>
<Input
id="recurring-budget-hours"
inputMode="decimal"
value={recurringForm.hourBudgetHours}
onChange={(event) => setRecurringForm({ ...recurringForm, hourBudgetHours: event.currentTarget.value })}
placeholder="z. B. 20"
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium" htmlFor="recurring-manual-hours">
Manuelle Stunden (optional)
</label>
<Input
id="recurring-manual-hours"
inputMode="decimal"
value={recurringForm.manualConsumptionHours}
onChange={(event) => setRecurringForm({ ...recurringForm, manualConsumptionHours: event.currentTarget.value })}
placeholder="z. B. 2,5"
/>
</div>
</div>
<div className="space-y-2">
<label className="text-sm font-medium" htmlFor="recurring-activity">
Tätigkeit
@@ -473,6 +541,9 @@ export function RecurringBillingsPage() {
{billing.valid_until ? ` bis ${formatDateLabel(billing.valid_until)}` : ""}
</p>
<div className="mt-1 flex flex-wrap gap-1">
{billing.teamspace_project ? <Badge variant="secondary">TS {billing.teamspace_project}</Badge> : null}
{billing.hour_budget_minutes !== null ? <Badge variant="outline">Budget {formatMinutes(billing.hour_budget_minutes)}</Badge> : null}
{billing.manual_consumption_minutes > 0 ? <Badge variant="outline">Manuell {formatMinutes(billing.manual_consumption_minutes)}</Badge> : null}
{billing.slots.map((slot) => (
<Badge key={slot.id} variant="outline">
{slot.weekday === null ? "Starttag" : weekdays.find((weekday) => weekday.value === slot.weekday)?.label} · {slot.start_time?.slice(0, 5)} · {formatMinutes(slot.duration_minutes)}
+16 -16
View File
@@ -147,21 +147,21 @@ function DeltaBadge({ minutes }: { minutes: number }) {
function deltaDescription(minutes: number) {
if (minutes > 0) {
return "Teamspace liegt über der abgerechneten Zeit";
return "TT-Teamspace liegt über der Session-Zeit";
}
if (minutes < 0) {
return "Teamspace liegt unter der abgerechneten Zeit";
return "TT-Teamspace liegt unter der Session-Zeit";
}
return "Teamspace und abgerechnete Zeit sind ausgeglichen";
return "TT-Teamspace und Session-Zeit sind ausgeglichen";
}
function TimeComparison({ trackedMinutes, crmMinutes, sessions }: { trackedMinutes: number; crmMinutes: number; sessions?: number }) {
return (
<div className="space-y-0.5 text-sm">
<div className={`font-semibold ${trackedTextClass}`}>Abgerechnet {formatMinutes(trackedMinutes)}</div>
<div className={`font-semibold ${teamspaceTextClass}`}>Teamspace {formatMinutes(crmMinutes)}</div>
<div className={`font-semibold ${trackedTextClass}`}>Session-Zeit {formatMinutes(trackedMinutes)}</div>
<div className={`font-semibold ${teamspaceTextClass}`}>TT-Teamspace {formatMinutes(crmMinutes)}</div>
{typeof sessions === "number" ? <div className="text-xs text-muted-foreground">{sessions} Session(s)</div> : null}
</div>
);
@@ -176,7 +176,7 @@ function ColorLegend({ showDifference = true }: { showDifference?: boolean }) {
</span>
<span className="inline-flex items-center gap-1.5">
<span className={`h-0.5 w-4 ${teamspaceDotClass}`} />
<span className={teamspaceTextClass}>Teamspace</span>
<span className={teamspaceTextClass}>TT-Teamspace</span>
</span>
<span className="inline-flex items-center gap-1.5">
<span className="size-2 rounded-sm bg-slate-500 dark:bg-slate-400" />
@@ -433,7 +433,7 @@ function TicketHotspot({
<p className="truncate text-sm text-muted-foreground">{ticket.organization_name ?? "Keine Organisation"}</p>
<div className="mt-1 flex flex-wrap gap-x-3 gap-y-1 text-xs">
<span className={trackedTextClass}>Abgerechnet {formatMinutes(ticket.total_minutes)}</span>
<span className={teamspaceTextClass}>Teamspace {formatMinutes(ticket.crm_billed_minutes)}</span>
<span className={teamspaceTextClass}>TT-Teamspace {formatMinutes(ticket.crm_billed_minutes)}</span>
<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>
@@ -653,7 +653,7 @@ export function StatisticsPage({ currentUser, onNavigate }: StatisticsPageProps)
<h2 className="text-2xl font-semibold tracking-normal">Statistiken</h2>
<p className="text-sm text-muted-foreground">
{teamspaceTimeTrackingEnabled
? "Monatswerte, Teamspace-Abgleich, Organisationen und offene Nacharbeit."
? "Monatswerte, TT-Teamspace-Abgleich, Organisationen und offene Nacharbeit."
: "Monatswerte, Session-Status, Organisationen und Teamspace-Istwerte."}
</p>
</div>
@@ -702,7 +702,7 @@ export function StatisticsPage({ currentUser, onNavigate }: StatisticsPageProps)
helpLabel="Hilfe zu Bewertungen"
/>
<MetricCard
label="Teamspace"
label="TT-Teamspace"
value={formatMinutes(totals?.crmBilledMinutes ?? 0)}
detail={teamspaceTimeTrackingEnabled
? <span className={deltaTextClass(totals?.crmDeltaMinutes ?? 0)}>Differenz {formatTeamspaceDelta(totals?.crmDeltaMinutes ?? 0)}</span>
@@ -734,12 +734,12 @@ export function StatisticsPage({ currentUser, onNavigate }: StatisticsPageProps)
<CardTitle>Monatsverlauf und Abrechnungsqualität</CardTitle>
<CardDescription>
{teamspaceTimeTrackingEnabled
? "Abgerechnete Zeit pro Tag, Teamspace-Linie und Bewertungsstand in einem Überblick."
? "Abgerechnete Zeit pro Tag, TT-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">
{teamspaceTimeTrackingEnabled ? <Badge variant="outline">{crmCoverage}% Teamspace-Abdeckung</Badge> : <Badge variant="outline">Teamspace als Ist-Quelle</Badge>}
{teamspaceTimeTrackingEnabled ? <Badge variant="outline">{crmCoverage}% TT-Teamspace-Abdeckung</Badge> : <Badge variant="outline">Teamspace als Ist-Quelle</Badge>}
<HelpLink anchor="statistik-verlauf" label="Hilfe zu Monatsverlauf und Abrechnungsqualität" />
</div>
</CardHeader>
@@ -768,13 +768,13 @@ export function StatisticsPage({ currentUser, onNavigate }: StatisticsPageProps)
<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-sm font-medium">TT-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 className="text-xs text-muted-foreground">TT-Teamspace-Abdeckung: {crmCoverage}% der Session-Zeit</div>
</div>
) : (
<div className="space-y-2 rounded-md border bg-muted/20 p-3">
@@ -812,7 +812,7 @@ export function StatisticsPage({ currentUser, onNavigate }: StatisticsPageProps)
<p className="truncate font-semibold">{topOrganization.organization_name}</p>
<p className="flex flex-wrap gap-x-3 gap-y-1 text-xs">
<span className={trackedTextClass}>Abgerechnet {formatMinutes(topOrganization.total_minutes)}</span>
<span className={teamspaceTextClass}>Teamspace {formatMinutes(topOrganization.crm_billed_minutes)}</span>
<span className={teamspaceTextClass}>TT-Teamspace {formatMinutes(topOrganization.crm_billed_minutes)}</span>
</p>
</div>
) : null}
@@ -879,7 +879,7 @@ export function StatisticsPage({ currentUser, onNavigate }: StatisticsPageProps)
<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={teamspaceTextClass}>TT-Teamspace {formatMinutes(organization.crm_billed_minutes)}</span>
<span className="text-muted-foreground">{percentage(organization.total_minutes, totals?.minutes ?? 0)}% vom Monat</span>
{teamspaceTimeTrackingEnabled ? <span className={deltaTextClass(organization.crm_delta_minutes)}>{formatTeamspaceDelta(organization.crm_delta_minutes)}</span> : null}
</div>
@@ -948,7 +948,7 @@ export function StatisticsPage({ currentUser, onNavigate }: StatisticsPageProps)
<Progress value={percentage(group.total_minutes, totals?.minutes ?? 0)} />
<div className="flex flex-wrap gap-x-3 gap-y-1 text-xs">
<span className={trackedTextClass}>Abgerechnet {formatMinutes(group.total_minutes)}</span>
<span className={teamspaceTextClass}>Teamspace {formatMinutes(group.crm_billed_minutes)}</span>
<span className={teamspaceTextClass}>TT-Teamspace {formatMinutes(group.crm_billed_minutes)}</span>
</div>
<div className="grid gap-2 text-xs text-muted-foreground sm:grid-cols-3">
<span>Abr. {group.billed_sessions} / {formatMinutes(group.billed_minutes)}</span>
+253 -79
View File
@@ -12,10 +12,12 @@ import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { CopyTicketButton } from "@/components/CopyTicketButton";
import { HelpLink } from "@/components/HelpLink";
import { ListClockIcon } from "@/components/icons/ListClockIcon";
import { OrganizationSelect } from "@/components/OrganizationSelect";
import { readRichActivityEditorValue, RichActivityEditor } from "@/components/RichActivityEditor";
import { TodoPanel } from "@/components/TodoPanel";
import {
acknowledgeMissingTicketDayBilling,
acknowledgeProjectReview,
createSession,
deleteProject,
@@ -34,6 +36,7 @@ import {
updateSessionBilling,
updateSessionDetails,
updateTicket,
updateTicketAtossSupport,
updateTicketDayBilling,
updateProject,
updateProjectStatus
@@ -41,7 +44,7 @@ import {
import type { CreateSessionResult, TicketZammadMailSessionItem, TicketZammadMailSessionResult } from "../api";
import { currentDay, currentMonth, formatDate, formatDateTime, formatMinutes, formatTimeRange } from "../format";
import { ticketHasRecurringBilling } from "../recurring-billings";
import type { AuthUser, BillingStatus, PeriodType, ProjectSummary, ProjectTeamspaceEntry, ProjectTeamspaceSubentry, SessionEntry, TicketDetailPeriodType, TicketPeriod, WorkType } from "../types";
import type { AuthUser, BillingStatus, PeriodType, ProjectSummary, ProjectTeamspaceEntry, ProjectTeamspaceSubentry, SessionEntry, TicketDetailPeriodType, TicketPeriod, TicketTeamspaceDay, WorkType } from "../types";
import { ZammadPriorityBadge, ZammadStateBadge } from "../zammad-meta";
import { zammadArticleTypeFromValue, zammadArticleTypeLabel, zammadArticleTypeOptions, zammadArticleTypeValue, type ZammadArticleTypeId } from "../zammad-article-types";
import { getLocalStorageValue, setLocalStorageValue } from "@/lib/local-storage.client";
@@ -152,6 +155,20 @@ function formatSignedMinutes(minutes: number) {
return `${minutes > 0 ? "+" : "-"}${formatMinutes(Math.abs(minutes))}`;
}
function formatSegmentClock(value: string) {
const date = new Date(value);
if (Number.isNaN(date.getTime())) {
return "--:--";
}
return date.toLocaleTimeString("de-DE", { hour: "2-digit", minute: "2-digit" });
}
function formatWorkSegmentRange(segment: SessionEntry["work_segments"][number]) {
return `${formatSegmentClock(segment.startedAt)} - ${segment.endedAt ? formatSegmentClock(segment.endedAt) : "läuft"}`;
}
function formatTeamspaceMissingTarget(minutes: number, teamspaceTimeTrackingEnabled: boolean) {
if (minutes === 0) {
return "ausgeglichen";
@@ -958,6 +975,7 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onS
const [loadingId, setLoadingId] = useState<string | null>(null);
const [deletingId, setDeletingId] = useState<string | null>(null);
const [savingTicket, setSavingTicket] = useState(false);
const [savingAtossSupport, setSavingAtossSupport] = useState(false);
const [savingFavorite, setSavingFavorite] = useState(false);
const [isFavorite, setIsFavorite] = useState(false);
const [editTicketNumber, setEditTicketNumber] = useState("");
@@ -980,6 +998,7 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onS
const [sendingZammadSessionId, setSendingZammadSessionId] = useState<string | null>(null);
const [dayBillingInputs, setDayBillingInputs] = useState<Record<string, string>>({});
const [savingDayBillingKey, setSavingDayBillingKey] = useState<string | null>(null);
const [acknowledgingTeamspaceDay, setAcknowledgingTeamspaceDay] = useState<string | null>(null);
const [selectedTeamspaceEntryId, setSelectedTeamspaceEntryId] = useState<string | null>(null);
const [ticketSession, setTicketSession] = useState<TicketSessionFormState>(() => emptyTicketSessionForm());
const [savingTicketSession, setSavingTicketSession] = useState(false);
@@ -1042,6 +1061,24 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onS
}
}
async function setMissingTeamspaceDayAcknowledgement(day: TicketTeamspaceDay, acknowledged: boolean) {
setAcknowledgingTeamspaceDay(day.day);
try {
await acknowledgeMissingTicketDayBilling(ticketId, day.day, acknowledged);
toast.success(acknowledged ? "Teamspace-Prüfung abgehakt" : "Kenntnisnahme zurückgenommen", {
description: formatDate(`${day.day}T00:00:00`)
});
await load();
} catch (error) {
toast.error(acknowledged ? "Konnte nicht abgehakt werden" : "Konnte nicht zurückgenommen werden", {
description: error instanceof Error ? error.message : "Unbekannter Fehler"
});
} finally {
setAcknowledgingTeamspaceDay(null);
}
}
useEffect(() => {
void load();
}, [periodType, period, ticketId, globalMonthFilter, globalStatusFilter, globalSourceFilter]);
@@ -1127,6 +1164,7 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onS
const ticket = data?.ticket;
const project = data?.project ?? null;
const ticketSessionEffectiveWorkType = ticket?.organization_id && ticket.work_type ? ticket.work_type : ticketSession.workType;
const isRecurringBillingTicket = ticketHasRecurringBilling(ticket);
const projectDescription = project ? combinedProjectDescription(project) : "";
const projectEntries = useMemo(() => (project ? projectTeamspaceEntries(project) : []), [project]);
@@ -1173,10 +1211,10 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onS
const hasMultipleProjectTeamspaceEntries = projectEntries.length > 1;
const hasProjectTeamspaceApiIds = projectEntries.some((entry) => Boolean(entry.teamspace_api_project_id));
const showTeamspaceSubentryCard = Boolean(project && hasProjectTeamspaceApiIds);
const showTeamspaceSubentryCard = false;
const usesTeamspaceSubentries = false;
const budgetExcluded = project ? projectBudgetExcluded(project) : false;
const budgetConsumptionExcluded = budgetExcluded || isRecurringBillingTicket;
const budgetConsumptionExcluded = budgetExcluded;
const showBudgetedProjectEntries = budgetedProjectEntries.length > 1;
const requiresProjectTeamspaceEntry = Boolean(project && hasMultipleProjectTeamspaceEntries);
const teamspaceTimeTrackingEnabled = currentUser.teamspace_time_tracking_enabled ?? true;
@@ -1196,7 +1234,13 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onS
|| project?.owner_username
|| (project?.zammad_owner_id ? `Zammad-Owner ${project.zammad_owner_id}` : null)
|| zammadOwnerLabel;
const currentZammadUserLabel = currentUser.zammad_user_id ? `Zammad-User ${currentUser.zammad_user_id}` : "dein Zammad-User";
const canChooseAtossSupportFallback = Boolean(
ticket?.work_type === "support"
&& !isRecurringBillingTicket
&& !project
&& !ticket.teamspace_assignment_rule_id
&& !ticket.teamspace_assignment_project
);
const isProject = Boolean(project);
const isGlobalTicket = periodType === "all";
const isPeriodClosed = Boolean(data?.closed);
@@ -1217,7 +1261,7 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onS
? "im gesamten Ticketverlauf"
: `im Monat ${globalMonthFilter}`
: `im ${periodLabel.toLowerCase()} ${period}`;
const teamspaceLabel = isGlobalTicket || periodType === "month" ? "Teamspace gesamt" : "Teamspace Tag";
const teamspaceLabel = isGlobalTicket || periodType === "month" ? "Für Teamspace gesamt" : "Für Teamspace Tag";
const availableMonths = data?.availableMonths ?? [];
const selectedMonthHasSessions = availableMonths.some((month) => month.month === globalMonthFilter);
const defaultProjectEntryId = projectEntries.find((entry) => entry.is_default)?.id ?? null;
@@ -1272,12 +1316,28 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onS
return billingMap;
}, [teamspaceMonthActuals]);
const ticketTeamspaceDayByDayMap = useMemo(
() => new Map((data?.ticketTeamspaceDays ?? []).map((day) => [day.day, day])),
[data?.ticketTeamspaceDays]
);
const teamspaceDayDeviation = (day: string) => {
const ticketComparison = ticketTeamspaceDayByDayMap.get(day);
if (ticketComparison) {
return {
ttMinutes: Number(ticketComparison.tracked_minutes ?? 0),
tsMinutes: Number(ticketComparison.crm_billed_minutes ?? 0),
deltaMinutes: Number(ticketComparison.delta_minutes ?? 0),
comparison: ticketComparison
};
}
if (!project || ticket?.work_type === "support" || budgetExcluded) {
return {
ttMinutes: 0,
tsMinutes: 0,
deltaMinutes: 0
deltaMinutes: 0,
comparison: null
};
}
@@ -1287,7 +1347,8 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onS
return {
ttMinutes,
tsMinutes,
deltaMinutes: tsMinutes - ttMinutes
deltaMinutes: tsMinutes - ttMinutes,
comparison: null
};
};
const unassignedTeamspaceSessions = useMemo(
@@ -1397,13 +1458,36 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onS
});
}
for (const comparison of data?.ticketTeamspaceDays ?? []) {
const hasAttention = Number(comparison.delta_minutes ?? 0) !== 0
|| comparison.tt_value_without_billed_session
|| comparison.missing_tt_value;
if (!hasAttention || Array.from(groups.values()).some((group) => group.day === comparison.day)) {
continue;
}
const key = teamspaceBillingKey(comparison.day, null);
groups.set(key, {
key,
day: comparison.day,
projectTeamspaceEntryId: null,
label: formatDate(`${comparison.day}T00:00:00`),
totalMinutes: 0,
billableMinutes: 0,
nonBillableMinutes: 0,
openMinutes: 0,
sessions: []
});
}
return Array.from(groups.values())
.map((group) => ({
...group,
sessions: [...group.sessions].sort((left, right) => new Date(right.started_at).getTime() - new Date(left.started_at).getTime())
}))
.sort((left, right) => right.key.localeCompare(left.key));
}, [activeTeamspaceEntryId, data?.sessions, projectEntries, usesTeamspaceEntryTabs, usesTeamspaceSubentries]);
}, [activeTeamspaceEntryId, data?.sessions, data?.ticketTeamspaceDays, projectEntries, usesTeamspaceEntryTabs, usesTeamspaceSubentries]);
const sessionIds = useMemo(() => sessionGroups.flatMap((group) => group.sessions.map((session) => session.id)), [sessionGroups]);
const allSessionsExpanded = sessionIds.length > 0 && sessionIds.every((sessionId) => expandedSessionIds.includes(sessionId));
const periodTotalMinutes = sessionGroups.reduce((sum, group) => sum + group.totalMinutes, 0);
@@ -1697,6 +1781,29 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onS
}
}
async function setTicketAtossSupport(enabled: boolean) {
if (!ticket || ticket.work_type !== "support" || ticket.can_manage_atoss_support !== true) {
return;
}
setSavingAtossSupport(true);
try {
await updateTicketAtossSupport(ticket.id, enabled);
toast.success(enabled ? "Atoss-Support-Fallback aktiviert" : "Atoss-Support-Fallback deaktiviert", {
description: enabled
? "Der Fallback greift nur, wenn weder ein Projekt noch eine Zuordnungsregel vorhanden ist."
: "Ohne Projekt oder Regel bleibt das Ticket künftig unzugeordnet."
});
await load();
} catch (error) {
toast.error("Atoss-Support-Fallback konnte nicht gespeichert werden", {
description: error instanceof Error ? error.message : "Unbekannter Fehler"
});
} finally {
setSavingAtossSupport(false);
}
}
function openProjectEditor() {
if (!project) {
return;
@@ -1946,6 +2053,7 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onS
projectTeamspaceEntryId: effectiveWorkType === "consulting" && requiresProjectTeamspaceEntry ? ticketSession.projectTeamspaceEntryId : null,
billingStatus: ticketSession.billingStatus,
createZammadArticle: ticketSession.createZammadArticle && hasActivityContent(activityValue.activity, activityValue.zammadArticleBody),
zammadArticleDateHeader: true,
zammadArticleTypeId: ticketSession.zammadArticleTypeId
});
toast.success("Session nachgetragen", {
@@ -2235,50 +2343,35 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onS
</Alert>
) : null}
{ticket?.is_foreign_zammad_owner ? (
<Alert className="py-3 text-sm">
<AlertCircle className="size-4" />
<AlertTitle>Anderer Zammad-Owner</AlertTitle>
<AlertDescription>
Dieses Ticket gehört in Zammad {zammadOwnerLabel ? `${zammadOwnerLabel}` : "einem anderen Owner"}; du bist {currentZammadUserLabel}. Du kannst deine Sessions sehen, Projekt- und Ticketdaten sind für dich geschützt.
</AlertDescription>
</Alert>
) : null}
{ticketManagedByZammad ? (
<Alert className="py-3 text-sm">
<AlertCircle className="size-4" />
<AlertTitle>Ticketdaten werden von Zammad verwaltet</AlertTitle>
<AlertDescription>
Nummer, Art, Titel und Organisation wurden durch den Zammad-Sync übernommen und können in TicketTracker nicht manuell geändert werden.
</AlertDescription>
</Alert>
) : null}
<div className="grid gap-4">
<Card>
<CardHeader className="p-4 pb-2">
<CardHeader className="p-3 pb-2">
<div className="flex items-start justify-between gap-3">
<div>
<CardTitle>Ticketdaten</CardTitle>
<CardDescription>
{canEditTicketData
? "Änderungen an Organisation und Art werden auf alle vorhandenen Sessions dieses Tickets übernommen."
: ticketManagedByZammad
? "Nummer, Art, Titel und Organisation kommen aus Zammad und sind lokal gesperrt."
: "Nur der Owner kann Ticketdaten ändern."}
</CardDescription>
</div>
<CardTitle className="flex min-w-0 flex-wrap items-center gap-2">
Ticketdaten
{ticket?.is_foreign_zammad_owner ? (
<Badge variant="warning" title={zammadOwnerLabel ? `Zammad-Owner: ${zammadOwnerLabel}` : "Anderer Zammad-Owner"}>Fremder Zammad-Owner</Badge>
) : null}
{ticketManagedByZammad ? <Badge variant="secondary">Zammad-verwaltet</Badge> : null}
{isRecurringBillingTicket ? <Badge variant="secondary">Fixabrechnung</Badge> : null}
{ticket?.teamspace_assignment_project ? (
<Badge variant="success" title={ticket.teamspace_assignment_rule_name ?? "Automatische Zuordnung"}>
TS automatisch: {ticket.teamspace_assignment_project}
</Badge>
) : null}
{canChooseAtossSupportFallback && ticket?.atoss_support ? <Badge variant="secondary">Atoss-Fallback</Badge> : null}
{canChooseAtossSupportFallback && !ticket?.atoss_support ? <Badge variant="warning">Keine TS-Zuordnung</Badge> : null}
</CardTitle>
<HelpLink anchor="ticketdaten" label="Hilfe zu Ticketdaten" />
</div>
</CardHeader>
<CardContent className="px-4 pb-3">
<form className="grid gap-3 md:grid-cols-2 xl:grid-cols-[140px_145px_minmax(220px,1fr)_minmax(240px,1.2fr)_150px] xl:items-end" onSubmit={saveTicketData}>
<div className="space-y-2">
<CardContent className="px-3 pb-3">
<form className="grid gap-2 md:grid-cols-2 xl:grid-cols-[140px_145px_minmax(220px,1fr)_minmax(240px,1.2fr)_150px] xl:items-end" onSubmit={saveTicketData}>
<div className="space-y-1">
<label className="text-sm font-medium" htmlFor="edit-ticket-number">Ticketnummer</label>
<Input id="edit-ticket-number" value={editTicketNumber} disabled={!canEditTicketData} onChange={(event) => setEditTicketNumber(event.currentTarget.value)} required />
</div>
<div className="space-y-2">
<div className="space-y-1">
<label className="text-sm font-medium" htmlFor="edit-ticket-work-type">Art</label>
<select
id="edit-ticket-work-type"
@@ -2291,11 +2384,11 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onS
<option value="consulting">Consulting</option>
</select>
</div>
<div className="space-y-2">
<div className="space-y-1">
<label className="text-sm font-medium" htmlFor="edit-ticket-title">Titel</label>
<Input id="edit-ticket-title" value={editTicketTitle} disabled={!canEditTicketData} onChange={(event) => setEditTicketTitle(event.currentTarget.value)} placeholder="optional" />
</div>
<div className="space-y-2">
<div className="space-y-1">
<label className="text-sm font-medium" htmlFor="edit-ticket-organization">Organisation</label>
{ticket?.customer_name && !ticket.organization_id ? (
<p className="text-xs text-muted-foreground">Bisheriger Freitext: {ticket.customer_name}</p>
@@ -2316,6 +2409,17 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onS
{savingTicket ? "Speichert..." : "Speichern"}
</Button>
</form>
{canChooseAtossSupportFallback ? (
<div className="mt-2 flex items-center gap-2 rounded-md border bg-muted/20 px-2.5 py-2">
<Checkbox
id="ticket-atoss-support"
checked={Boolean(ticket?.atoss_support)}
disabled={ticket?.can_manage_atoss_support !== true || savingAtossSupport}
onCheckedChange={(checked) => void setTicketAtossSupport(checked === true)}
/>
<label className="text-sm font-medium" htmlFor="ticket-atoss-support">Atoss-Support-Fallback</label>
</div>
) : null}
</CardContent>
</Card>
</div>
@@ -2332,13 +2436,13 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onS
{project.closed_at ? <Badge variant="secondary">Geschlossen</Badge> : <Badge variant="success">Offen</Badge>}
<ZammadStateBadge stateId={project.zammad_state_id} />
<ZammadPriorityBadge priorityId={project.zammad_priority_id} />
{!canEditProject ? <Badge variant="outline">geteilt</Badge> : null}
{!canEditProject ? <Badge variant="outline">{isRecurringBillingTicket ? "Fixabrechnung" : "geteilt"}</Badge> : null}
{project.close_review_required ? <Badge variant="warning">TS/TT prüfen</Badge> : null}
{unassignedTeamspaceEntryCount > 0 ? <Badge variant="warning">{unassignedTeamspaceEntryCount} ohne TS</Badge> : null}
{unassignedTeamspaceSubentryCount > 0 ? <Badge variant="warning">{unassignedTeamspaceSubentryCount} ohne Pos.</Badge> : null}
</CardTitle>
<CardDescription>
{!canEditProject && projectOwnerLabel
{!canEditProject && !isRecurringBillingTicket && projectOwnerLabel
? `Eigentümer: ${projectOwnerLabel}`
: showBudgetedProjectEntries
? `${budgetedProjectEntries.length} budgetierte Teamspace-Einträge`
@@ -2346,6 +2450,8 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onS
</CardDescription>
</div>
<div className="flex shrink-0 items-center gap-1">
{!isRecurringBillingTicket ? (
<>
<Button
type="button"
size="sm"
@@ -2360,6 +2466,8 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onS
<Pencil className="size-4" />
Bearbeiten
</Button>
</>
) : null}
<HelpLink anchor="projekte" label="Hilfe zu Projekten" />
</div>
</div>
@@ -2407,6 +2515,7 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onS
<Badge variant="secondary">Zeitraum {project.starts_on || "offen"} - {project.ends_on || "offen"}</Badge>
{budgetConsumptionExcluded ? <Badge variant="outline">Budgetprüfung ausgenommen</Badge> : <Badge variant="secondary">Kontingent {projectBudgetMinutes ? formatMinutes(projectBudgetMinutes) : "offen"}</Badge>}
<Badge variant="secondary">Verbrauch {formatMinutes(projectConsumptionMinutes)}</Badge>
{project.manual_consumption_minutes > 0 ? <Badge variant="outline">Manuell {formatMinutes(project.manual_consumption_minutes)}</Badge> : null}
{budgetConsumptionExcluded ? null : <Badge variant={projectRemainingMinutes !== null && projectRemainingMinutes < 0 ? "destructive" : "secondary"}>{projectRemainingLabel(projectRemainingMinutes)}</Badge>}
{!budgetConsumptionExcluded && showBudgetedProjectEntries ? <Badge variant="outline">{budgetedProjectEntries.length} Budgets</Badge> : null}
</div>
@@ -2416,9 +2525,7 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onS
<Info className="size-4" />
<AlertTitle>Budgetverbrauch nicht auswertbar</AlertTitle>
<AlertDescription>
{budgetExcluded
? "Dieses Teamspace-Projekt ist systemweit von Differenz- und Budgetprüfung ausgenommen. Der Verbrauch zählt weiter in Summen."
: "Dieses Projekt ist eine Fixabrechnung. Der Verbrauch zählt weiter in Summen."}
Dieses Teamspace-Projekt ist systemweit von Differenz- und Budgetprüfung ausgenommen. Der Verbrauch zählt weiter in Summen.
</AlertDescription>
</Alert>
) : (
@@ -2591,16 +2698,22 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onS
</Dialog>
<Card>
<CardHeader className="p-4">
<CardHeader className="p-3 pb-2">
<div className="flex items-start justify-between gap-3">
<div>
<CardTitle className="flex items-center gap-2">
<ClipboardList className="size-5 text-muted-foreground" />
<ClipboardList className="size-4 text-muted-foreground" />
ToDos
</CardTitle>
<CardDescription>Aufgaben zu diesem Ticket oder Projekt verwalten.</CardDescription>
<HelpLink anchor="todos" label="Hilfe zu ToDos" />
</div>
<div className="flex shrink-0 items-center gap-2">
</CardHeader>
<CardContent className="px-3 pb-3">
<TodoPanel
ticketId={ticket?.id}
todos={visibleTodos}
onChanged={load}
compact
toolbar={(
<div className="flex rounded-md border bg-muted/20 p-0.5">
{(["open", "all", "done"] as const).map((value) => (
<Button
@@ -2615,12 +2728,8 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onS
</Button>
))}
</div>
<HelpLink anchor="todos" label="Hilfe zu ToDos" />
</div>
</div>
</CardHeader>
<CardContent className="px-4 pb-4">
<TodoPanel ticketId={ticket?.id} todos={visibleTodos} onChanged={load} />
)}
/>
</CardContent>
</Card>
</div>
@@ -2839,7 +2948,7 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onS
</div>
<div className="flex flex-wrap items-center gap-2">
<div className="flex items-center gap-2 rounded-md border bg-muted/30 px-3 py-2 text-sm">
<span className="text-muted-foreground">Abgerechnet</span>
<span className="text-muted-foreground">Abgerechnet (netto)</span>
<span className="font-semibold">{formatMinutes(periodTotalMinutes)}</span>
</div>
<div className="flex items-center gap-2 rounded-md border bg-muted/30 px-3 py-2 text-sm">
@@ -2941,6 +3050,10 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onS
{sessionGroups.map((group) => {
const dayDeviation = teamspaceDayDeviation(group.day);
const hasDayDeviation = dayDeviation.deltaMinutes !== 0;
const dayComparison = dayDeviation.comparison;
const hasInvalidTeamspaceValue = Boolean(dayComparison?.tt_value_without_billed_session);
const hasMissingTeamspaceValue = Boolean(dayComparison?.missing_tt_value);
const showDayTeamspaceDetails = hasDayDeviation || hasInvalidTeamspaceValue || hasMissingTeamspaceValue;
return (
<div key={group.key} className="rounded-md border bg-background">
@@ -2951,6 +3064,7 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onS
</p>
<p className="text-xs text-muted-foreground">{group.sessions.length} Session(s)</p>
</div>
{group.sessions.length > 0 ? (
<Button
type="button"
size="sm"
@@ -2962,28 +3076,18 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onS
<ClipboardList className="size-3.5" />
TS
</Button>
) : <span />}
<div className="flex min-w-0 flex-wrap gap-x-3 gap-y-1 text-xs text-muted-foreground">
<span>Abrechb. {formatMinutes(group.billableMinutes)}</span>
<span>Abger. {formatMinutes(group.totalMinutes)}</span>
<span>Nicht {formatMinutes(group.nonBillableMinutes)}</span>
</div>
<div className="flex justify-start lg:justify-center">
{hasDayDeviation ? (
<AlertCircle
className="size-4 shrink-0 text-amber-500"
aria-label="Teamspace-Tagesabweichung"
/>
) : (
<span className="h-4 w-4" aria-hidden="true" />
)}
</div>
<div className="min-w-0 text-xs lg:text-right">
<p className={cn("font-semibold", hasDayDeviation ? "text-amber-600 dark:text-amber-300" : "text-muted-foreground")}>
{formatTeamspaceDifferenceTarget(dayDeviation.deltaMinutes, teamspaceTimeTrackingEnabled)}
</p>
</div>
<div className="flex min-w-0 items-center justify-start gap-2 lg:justify-end">
{teamspaceTimeTrackingEnabled ? (
{teamspaceTimeTrackingEnabled && group.sessions.length > 0 ? (
<div className="flex min-w-0 items-center gap-1.5 text-xs text-muted-foreground">
<label htmlFor={`crm-billing-${group.key}`}>Teamspace</label>
<Input
@@ -3027,17 +3131,69 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onS
<span className="sr-only">{savingDayBillingKey === group.key ? "Speichert..." : "Speichern"}</span>
</Button>
</div>
) : (
) : group.sessions.length > 0 ? (
<span className="text-xs text-muted-foreground">Teamspace-Erfassung aus</span>
)}
) : null}
</div>
</div>
{showDayTeamspaceDetails ? (
<div className="flex flex-col gap-2 border-b bg-muted/10 px-3 py-2 sm:flex-row sm:items-center sm:justify-between">
<div className="flex min-w-0 flex-wrap items-center gap-2">
{hasMissingTeamspaceValue ? (
<Badge className={dayComparison?.acknowledged
? "bg-emerald-500/12 text-emerald-800 dark:bg-emerald-500/20 dark:text-emerald-200"
: "bg-sky-500/12 text-sky-800 dark:bg-sky-500/20 dark:text-sky-200"}
>
{dayComparison?.acknowledged ? "Fehlender TT-Wert zur Kenntnis" : "TT-Teamspace-Wert fehlt"}
</Badge>
) : null}
{hasInvalidTeamspaceValue ? (
<Badge variant="destructive">Fehler: TT-Teamspace-Wert ohne abgerechneten Eintrag</Badge>
) : null}
{hasDayDeviation ? (
<>
<Badge className="bg-cyan-500/12 text-cyan-800 dark:bg-cyan-500/20 dark:text-cyan-200">
TT-Teamspace {formatMinutes(dayDeviation.ttMinutes)}
</Badge>
<Badge className="bg-violet-500/12 text-violet-800 dark:bg-violet-500/20 dark:text-violet-200">
TS-API {formatMinutes(dayDeviation.tsMinutes)}
</Badge>
<Badge className="bg-orange-500/12 text-orange-800 dark:bg-orange-500/20 dark:text-orange-200">
{formatTeamspaceDifferenceTarget(dayDeviation.deltaMinutes, teamspaceTimeTrackingEnabled)}
</Badge>
</>
) : null}
{hasInvalidTeamspaceValue ? (
<span className="text-xs text-destructive">
Kein abgerechneter Eintrag
{Number(dayComparison?.non_billable_session_count ?? 0) > 0 ? ` · ${dayComparison?.non_billable_session_count} nicht abrechenbar` : ""}
{Number(dayComparison?.billable_session_count ?? 0) > 0 ? ` · ${dayComparison?.billable_session_count} abrechenbar` : ""}
{Number(dayComparison?.unrated_session_count ?? 0) > 0 ? ` · ${dayComparison?.unrated_session_count} ohne Bewertung` : ""}
</span>
) : null}
</div>
{hasMissingTeamspaceValue && dayComparison ? (
<Button
type="button"
size="sm"
variant={dayComparison.acknowledged ? "secondary" : "ghost"}
className="h-8 shrink-0"
disabled={isPeriodClosed || acknowledgingTeamspaceDay === group.day}
onClick={() => void setMissingTeamspaceDayAcknowledgement(dayComparison, !dayComparison.acknowledged)}
>
{dayComparison.acknowledged ? "Zurücknehmen" : "Zur Kenntnis"}
</Button>
) : null}
</div>
) : null}
<div className="space-y-1.5 p-2 sm:p-3 lg:px-0">
{group.sessions.map((session) => {
const expanded = expandedSessionIds.includes(session.id);
const zammadSent = session.zammad_article_status === "sent";
const zammadPending = session.zammad_article_status === "pending";
const zammadMailImported = Boolean(session.zammad_source_article_id);
const workSegments = Array.isArray(session.work_segments) ? session.work_segments : [];
const hasWorkSegments = workSegments.length > 1;
const canSendZammad = !zammadMailImported && hasActivityContent(session.activity, session.zammad_article_body);
const sendingZammad = sendingZammadSessionId === session.id;
const billedMissingTeamspaceEntry =
@@ -3048,7 +3204,7 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onS
return (
<div key={session.id} className="rounded-md border bg-card/40">
<div className="grid gap-2 p-2 lg:grid-cols-[220px_minmax(0,150px)_minmax(260px,1fr)_220px] lg:items-center">
<div className="grid gap-2 p-2 lg:grid-cols-[220px_minmax(0,170px)_minmax(260px,1fr)_220px] lg:items-center">
<button
type="button"
className="flex min-w-0 items-center gap-2 rounded-md text-left text-sm text-muted-foreground hover:cursor-pointer hover:text-foreground focus-visible:outline-none focus-visible:ring-3 focus-visible:ring-ring/50"
@@ -3072,9 +3228,16 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onS
</Badge>
) : null}
</button>
<div className="grid min-w-0 grid-cols-[minmax(0,max-content)_2rem] items-start gap-2">
<div className="flex min-w-0 flex-col items-start gap-1">
<Badge variant="outline" className="w-fit">{formatMinutes(session.rounded_minutes)}</Badge>
{session.recurring_billing_id ? <Badge variant="secondary">fix</Badge> : null}
</div>
<div className="flex size-8 items-center justify-center">
{hasWorkSegments ? (
<ListClockIcon className="size-4 text-muted-foreground" aria-label="Arbeitsbereiche gespeichert" />
) : null}
</div>
</div>
<BillingButtons
session={session}
@@ -3124,6 +3287,8 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onS
</div>
{expanded ? (
<div className="border-t px-3 pb-3 pt-2">
<div className="grid gap-3 lg:grid-cols-[minmax(0,1fr)_auto]">
<div className="min-w-0">
<SessionActivityBody session={session} />
<div className="mt-2 flex flex-wrap gap-x-3 gap-y-1 text-xs text-muted-foreground">
<span>{session.organization_name ?? session.customer_name}</span>
@@ -3151,6 +3316,15 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onS
<span>Erfasst {formatDateTime(session.created_at)}</span>
</div>
</div>
{hasWorkSegments ? (
<div className="space-y-1 text-right text-xs text-muted-foreground">
{workSegments.map((segment, index) => (
<div key={`${segment.startedAt}-${segment.endedAt ?? "open"}-${index}`}>{formatWorkSegmentRange(segment)}</div>
))}
</div>
) : null}
</div>
</div>
) : null}
</div>
);
+28 -3
View File
@@ -17,7 +17,7 @@ import type { CreateSessionResult } from "../api";
import { formatTimer } from "../format";
import { isRecurringBillingTicketNumber, ticketHasRecurringBilling } from "../recurring-billings";
import { activeElapsedMs, mergeTimerActivityDrafts, removeTimerActivityDraft, ticketPattern, type TimerEntry, writeTimerActivityDraft } from "../timers";
import type { AuthUser, BillingStatus, RecurringBilling, TicketMeta, WorkType } from "../types";
import type { AuthUser, BillingStatus, RecurringBilling, TicketMeta, WorkSegment, WorkType } from "../types";
import { zammadArticleTypeFromValue, zammadArticleTypeOptions, zammadArticleTypeValue, type ZammadArticleTypeId } from "../zammad-article-types";
type ManualSessionFormState = {
@@ -77,6 +77,7 @@ type FinishedSession = {
endedAt: string;
durationSeconds: number;
roundedMinutes: number;
workSegments: WorkSegment[];
};
function ticketTeamspaceEntries(ticket: TicketMeta | null | undefined) {
@@ -104,6 +105,8 @@ type TimerPageProps = {
setSelectedTimerId: Dispatch<SetStateAction<string | null>>;
tick: number;
currentUser: AuthUser;
finishRequestId?: string | null;
onFinishRequestHandled?: () => void;
};
function BillingStatusSelect({
@@ -297,7 +300,7 @@ function InlineImageActivityEditor({
);
}
export function TimerPage({ timers, setTimers, selectedTimerId, setSelectedTimerId, tick, currentUser }: TimerPageProps) {
export function TimerPage({ timers, setTimers, selectedTimerId, setSelectedTimerId, tick, currentUser, finishRequestId, onFinishRequestHandled }: TimerPageProps) {
const [ticketNumber, setTicketNumber] = useState("");
const [finished, setFinished] = useState<FinishedSession | null>(null);
const [finishingId, setFinishingId] = useState<string | null>(null);
@@ -680,6 +683,7 @@ export function TimerPage({ timers, setTimers, selectedTimerId, setSelectedTimer
});
setTimers(mergeTimerActivityDrafts(currentUser.id, timerResult.timers));
setSelectedTimerId(timer.id);
const finishedTimer = timerResult.timers.find((entry) => entry.id === timer.id) ?? timer;
const result = await lookupTicket(timer.ticketNumber);
const ticket = result.ticket;
@@ -711,7 +715,8 @@ export function TimerPage({ timers, setTimers, selectedTimerId, setSelectedTimer
startedAt: new Date(timer.startedAt).toISOString(),
endedAt: new Date(now).toISOString(),
durationSeconds,
roundedMinutes
roundedMinutes,
workSegments: currentUser.session_work_segments_enabled ? finishedTimer.workSegments ?? [] : []
});
} catch (error) {
toast.error("Ticket konnte nicht geprüft werden", {
@@ -722,6 +727,22 @@ export function TimerPage({ timers, setTimers, selectedTimerId, setSelectedTimer
}
}
useEffect(() => {
if (!finishRequestId) {
return;
}
const timerExists = timers.some((timer) => timer.id === finishRequestId);
onFinishRequestHandled?.();
if (!timerExists) {
return;
}
setSelectedTimerId(finishRequestId);
void finishTimer(finishRequestId);
}, [finishRequestId]);
async function submitSession(event: FormEvent) {
event.preventDefault();
@@ -768,6 +789,7 @@ export function TimerPage({ timers, setTimers, selectedTimerId, setSelectedTimer
startedAt: finished.startedAt,
endedAt: finished.endedAt,
durationSeconds: finished.durationSeconds,
workSegments: finished.workSegments,
organizationId: effectiveOrganizationId,
activity: activityValue.activity,
zammadArticleBody: activityValue.zammadArticleBody || null,
@@ -875,6 +897,7 @@ export function TimerPage({ timers, setTimers, selectedTimerId, setSelectedTimer
projectTeamspaceEntryId: manualRequiresTeamspaceEntry ? manualSession.projectTeamspaceEntryId : null,
billingStatus: manualSession.billingStatus,
createZammadArticle: manualSession.createZammadArticle && hasActivityContent(activityValue.activity, activityValue.zammadArticleBody),
zammadArticleDateHeader: true,
zammadArticleTypeId: manualSession.zammadArticleTypeId
});
toast.success("Session nachgetragen", {
@@ -897,6 +920,8 @@ export function TimerPage({ timers, setTimers, selectedTimerId, setSelectedTimer
}
}
const finishedEffectiveWorkType = finished?.ticket?.organization_id && finished.ticket.work_type ? finished.ticket.work_type : workType;
return (
<div className="space-y-4">
<section className="space-y-4">
+3 -6
View File
@@ -924,7 +924,7 @@ export function ZammadSyncPage({ currentUser }: ZammadSyncPageProps) {
const { result } = await syncTeamspaceProjectIds(zammadSyncPageState.teamspaceSyncMonth);
setZammadSyncPageState({ teamspaceProjectIdResult: result });
toast.success("Teamspace-Projekt-IDs synchronisiert", {
description: `${result.updated} aktualisiert, ${result.alreadyCurrent} bereits aktuell, ${result.subentriesSynced} Unterposition(en), TS ${formatMinutes(result.teamspaceTimeBillableMinutes)} im Monat ${result.month}.`
description: `${result.updated} aktualisiert, ${result.alreadyCurrent} bereits aktuell, TS ${formatMinutes(result.teamspaceTimeBillableMinutes)} im Monat ${result.month}.`
});
} catch (error) {
toast.error("Teamspace-Projekt-IDs konnten nicht synchronisiert werden", {
@@ -1113,7 +1113,7 @@ export function ZammadSyncPage({ currentUser }: ZammadSyncPageProps) {
<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>
<CardDescription>Offene Consulting-Projekte gegen die Teamspace-API auflösen und Hauptprojekt-ID sowie Gesamtbudget aus Teamspace speichern.</CardDescription>
</div>
<Badge variant={teamspaceReady ? "success" : "secondary"}>{teamspaceReady ? "bereit" : "nicht bereit"}</Badge>
</div>
@@ -1140,7 +1140,7 @@ export function ZammadSyncPage({ currentUser }: ZammadSyncPageProps) {
</div>
<Button type="button" disabled={!teamspaceReady || individualSyncDisabled} onClick={() => void runTeamspaceProjectIdSync()}>
<RefreshCw className="size-4" />
{syncingTeamspaceProjectIds ? "Synchronisiert..." : "Teamspace-IDs, Budgets und Positionen synchronisieren"}
{syncingTeamspaceProjectIds ? "Synchronisiert..." : "Teamspace-IDs und Budgets synchronisieren"}
</Button>
</div>
{teamspaceProjectIdResult ? (
@@ -1150,7 +1150,6 @@ export function ZammadSyncPage({ currentUser }: ZammadSyncPageProps) {
["Monat", teamspaceProjectIdResult.month],
["TT-Einträge", teamspaceProjectIdResult.checkedEntries],
["TS gelesen", teamspaceProjectIdResult.remoteProjectsScanned],
["TS Positionen gelesen", teamspaceProjectIdResult.remoteSubprojectsScanned],
["TS Zeiten gelesen", teamspaceProjectIdResult.teamspaceTimeEntriesScanned],
["TS Zeiten zugeordnet", teamspaceProjectIdResult.teamspaceTimeEntriesMapped],
["TS Monat", formatMinutes(teamspaceProjectIdResult.teamspaceTimeBillableMinutes)],
@@ -1158,8 +1157,6 @@ export function ZammadSyncPage({ currentUser }: ZammadSyncPageProps) {
["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">
+164
View File
@@ -0,0 +1,164 @@
[
{
"id": 85,
"name": "customer_owner",
"display": "Kunde zugeordnet zu",
"data_type": "select",
"data_option": {
"options": {
"AIKAVA": "AIKAVA",
"ATOSS": "ATOSS",
"Compdata": "Compdata",
"OSC": "OSC",
"SD Worx": "SD Worx",
"Pro Active": "Pro Active"
},
"default": "ATOSS",
"linktemplate": "",
"translate": false,
"null": true,
"relation": "",
"nulloption": true,
"maxlength": 255,
"historical_options": {
"ATOSS": "ATOSS",
"AIKAVA": "AIKAVA",
"Compdata": "Compdata",
"OSC": "OSC",
"SD Worx": "SD Worx",
"Pro Active": "Pro Active"
}
},
"data_option_new": {
},
"editable": true,
"active": true,
"screens": {
"view": {
"ticket.customer": {
"shown": true
},
"ticket.agent": {
"shown": true
},
"admin.organization": {
"shown": true
}
},
"create": {
"ticket.agent": {
"shown": true,
"required": false
},
"admin.organization": {
"shown": true,
"required": false
}
},
"edit": {
"ticket.agent": {
"shown": true,
"required": false
},
"admin.organization": {
"shown": true,
"required": false
}
}
},
"to_create": false,
"to_migrate": false,
"to_delete": false,
"to_config": false,
"position": 1813,
"created_by_id": 4,
"updated_by_id": 4,
"created_at": "2026-04-22T14:46:46.993Z",
"updated_at": "2026-08-19T12:35:58.304Z",
"internal": false,
"object": "Organization",
"deletable": true
},
{
"id": 86,
"name": "hotline_contract",
"display": "Hotlinevertrag bei",
"data_type": "select",
"data_option": {
"options": {
"AIKAVA": "AIKAVA",
"ATOSS": "ATOSS",
"Compdata": "Compdata",
"OSC": "OSC",
"SD Worx": "SD Worx",
"(kein Hotlinevertrag)": "(kein Hotlinevertrag)"
},
"default": "ATOSS",
"linktemplate": "",
"translate": false,
"null": true,
"relation": "",
"nulloption": true,
"maxlength": 255,
"historical_options": {
"ATOSS": "ATOSS",
"AIKAVA": "AIKAVA",
"Compdata": "Compdata",
"OSC": "OSC",
"SD Worx": "SD Worx",
"(kein Hotlinevertrag)": "(kein Hotlinevertrag)"
}
},
"data_option_new": {
},
"editable": true,
"active": true,
"screens": {
"view": {
"ticket.customer": {
"shown": true
},
"ticket.agent": {
"shown": true
},
"admin.organization": {
"shown": true
}
},
"create": {
"ticket.agent": {
"shown": true,
"required": false
},
"admin.organization": {
"shown": true,
"required": false
}
},
"edit": {
"ticket.agent": {
"shown": true,
"required": false
},
"admin.organization": {
"shown": true,
"required": false
}
}
},
"to_create": false,
"to_migrate": false,
"to_delete": false,
"to_config": false,
"position": 1814,
"created_by_id": 4,
"updated_by_id": 4,
"created_at": "2026-04-22T14:48:44.782Z",
"updated_at": "2026-04-22T15:11:55.575Z",
"internal": false,
"object": "Organization",
"deletable": true
}
]
@@ -0,0 +1,277 @@
import pg from "pg";
import { decryptSecret } from "../backend/src/secrets.js";
const apply = process.argv.includes("--apply");
if (!process.env.DATABASE_URL) {
throw new Error("DATABASE_URL fehlt");
}
type JsonPosition = {
apiProjectId?: string | number | null;
minutes?: number;
billableMinutes?: number;
entryCount?: number;
days?: Array<{ day?: string; minutes?: number; billableMinutes?: number; entryCount?: number }>;
};
type RemoteProject = {
apiProjectId: number;
mainApiProjectId: number;
projectId: string;
name: string;
customerApiId: number | null;
customerName: string | null;
url: string | null;
};
const pool = new pg.Pool({ connectionString: process.env.DATABASE_URL });
function positiveInteger(value: unknown) {
const parsed = Number(value);
return Number.isInteger(parsed) && parsed > 0 ? parsed : null;
}
function text(value: unknown) {
return typeof value === "string" && value.trim() ? value.trim() : null;
}
async function main() {
const settingsResult = await pool.query<{ key: string; value: string }>(
"SELECT key, value FROM app_settings WHERE key = ANY($1::text[]);",
[["teamspace.base_url", "teamspace.admin_credentials_user_id"]]
);
const settings = new Map(settingsResult.rows.map((row) => [row.key, row.value]));
const baseUrl = settings.get("teamspace.base_url")?.replace(/\/$/, "");
const credentialsUserId = settings.get("teamspace.admin_credentials_user_id");
if (!baseUrl || !credentialsUserId) {
throw new Error("Teamspace-Adminzugang ist nicht vollständig konfiguriert");
}
const credentialsResult = await pool.query<{ teamspace_device_id: string | null; teamspace_device_password: string | null }>(
"SELECT teamspace_device_id, teamspace_device_password FROM users WHERE id = $1;",
[credentialsUserId]
);
const deviceId = decryptSecret(credentialsResult.rows[0]?.teamspace_device_id);
const devicePassword = decryptSecret(credentialsResult.rows[0]?.teamspace_device_password);
if (!deviceId || !devicePassword) {
throw new Error("Teamspace-Adminzugang enthält keine Geräte-Credentials");
}
const authorization = `Basic ${Buffer.from(`${deviceId}:${devicePassword}`).toString("base64")}`;
const fetchJson = async (path: string) => {
const response = await fetch(`${baseUrl}${path}`, { headers: { Authorization: authorization, Accept: "application/json" } });
if (!response.ok) {
throw new Error(`Teamspace ${path} antwortet mit HTTP ${response.status}`);
}
return response.json() as Promise<Record<string, unknown>>;
};
const [entryResult, resourceResult, exclusionResult, userActualResult, entryActualResult] = await Promise.all([
pool.query<{ id: string; api_id: string }>("SELECT id::text, teamspace_api_project_id::text AS api_id FROM project_teamspace_entries WHERE teamspace_api_project_id IS NOT NULL;"),
pool.query<{ id: string; api_id: string }>("SELECT id::text, teamspace_api_project_id::text AS api_id FROM teamspace_project_resources WHERE teamspace_api_project_id IS NOT NULL;"),
pool.query<{ teamspace_project: string; api_id: string | null; api_ids: string[] }>(`
SELECT teamspace_project,
teamspace_api_project_id::text AS api_id,
ARRAY(SELECT value::text FROM unnest(teamspace_api_project_ids) AS value) AS api_ids
FROM teamspace_project_exclusions;
`),
pool.query<{ user_id: string; month: string; positions: JsonPosition[] }>("SELECT user_id::text, month::text, positions FROM teamspace_monthly_user_actuals;"),
pool.query<{ user_id: string; project_teamspace_entry_id: string; month: string; positions: JsonPosition[] }>(
"SELECT user_id::text, project_teamspace_entry_id::text, month::text, positions FROM project_teamspace_monthly_actuals;"
)
]);
const apiIds = new Set<number>();
const addApiId = (value: unknown) => {
const apiId = positiveInteger(value);
if (apiId) apiIds.add(apiId);
};
entryResult.rows.forEach((row) => addApiId(row.api_id));
resourceResult.rows.forEach((row) => addApiId(row.api_id));
exclusionResult.rows.forEach((row) => {
addApiId(row.api_id);
row.api_ids.forEach(addApiId);
});
[...userActualResult.rows, ...entryActualResult.rows].forEach((row) => row.positions.forEach((position) => addApiId(position.apiProjectId)));
const rawProjects = new Map<number, Record<string, unknown>>();
const ids = [...apiIds].sort((left, right) => left - right);
for (let offset = 0; offset < ids.length; offset += 8) {
const projects = await Promise.all(ids.slice(offset, offset + 8).map(async (apiId) => [apiId, await fetchJson(`/api/project/${apiId}`)] as const));
projects.forEach(([apiId, project]) => rawProjects.set(apiId, project));
}
const mainIds = new Set<number>();
for (const [apiId, project] of rawProjects) {
mainIds.add(positiveInteger((project.mainProject as Record<string, unknown> | undefined)?.value) ?? apiId);
}
const missingMainIds = [...mainIds].filter((apiId) => !rawProjects.has(apiId));
for (let offset = 0; offset < missingMainIds.length; offset += 8) {
const projects = await Promise.all(missingMainIds.slice(offset, offset + 8).map(async (apiId) => [apiId, await fetchJson(`/api/project/${apiId}`)] as const));
projects.forEach(([apiId, project]) => rawProjects.set(apiId, project));
}
const customerIds = new Set<number>();
for (const apiId of mainIds) {
const customerApiId = positiveInteger((rawProjects.get(apiId)?.customer as Record<string, unknown> | undefined)?.value);
if (customerApiId) customerIds.add(customerApiId);
}
const customerNames = new Map<number, string>();
for (let offset = 0; offset < customerIds.size; offset += 8) {
const chunk = [...customerIds].slice(offset, offset + 8);
const customers = await Promise.all(chunk.map(async (apiId) => [apiId, await fetchJson(`/api/organization/${apiId}`)] as const));
customers.forEach(([apiId, customer]) => {
const name = text(customer.name) ?? text(customer.companyName) ?? text(customer.displayName);
if (name) customerNames.set(apiId, name);
});
}
const projectMap = new Map<number, RemoteProject>();
for (const sourceApiId of apiIds) {
const source = rawProjects.get(sourceApiId);
if (!source) throw new Error(`Teamspace API-ID ${sourceApiId} konnte nicht aufgelöst werden`);
const mainApiProjectId = positiveInteger((source.mainProject as Record<string, unknown> | undefined)?.value) ?? sourceApiId;
const project = rawProjects.get(mainApiProjectId);
if (!project) throw new Error(`Teamspace Hauptprojekt ${mainApiProjectId} konnte nicht aufgelöst werden`);
const customerApiId = positiveInteger((project.customer as Record<string, unknown> | undefined)?.value);
const projectId = text(project.projectId);
if (!projectId) throw new Error(`Teamspace Hauptprojekt ${mainApiProjectId} hat keine Projektnummer`);
projectMap.set(sourceApiId, {
apiProjectId: mainApiProjectId,
mainApiProjectId,
projectId,
name: text(project.name) ?? projectId,
customerApiId,
customerName: customerApiId ? customerNames.get(customerApiId) ?? null : null,
url: text(project._url)
});
}
const normalizePositions = (positions: JsonPosition[]) => {
const grouped = new Map<number, {
project: RemoteProject;
minutes: number;
billableMinutes: number;
entryCount: number;
days: Map<string, { minutes: number; billableMinutes: number; entryCount: number }>;
}>();
for (const position of positions) {
const sourceApiId = positiveInteger(position.apiProjectId);
if (!sourceApiId) continue;
const project = projectMap.get(sourceApiId);
if (!project) throw new Error(`Keine Hauptprojekt-Zuordnung für API-ID ${sourceApiId}`);
const current = grouped.get(project.mainApiProjectId) ?? { project, minutes: 0, billableMinutes: 0, entryCount: 0, days: new Map() };
current.minutes += Number(position.minutes ?? 0);
current.billableMinutes += Number(position.billableMinutes ?? 0);
current.entryCount += Number(position.entryCount ?? 0);
for (const sourceDay of position.days ?? []) {
const dayLabel = text(sourceDay.day);
if (!dayLabel) continue;
const day = current.days.get(dayLabel) ?? { minutes: 0, billableMinutes: 0, entryCount: 0 };
day.minutes += Number(sourceDay.minutes ?? 0);
day.billableMinutes += Number(sourceDay.billableMinutes ?? 0);
day.entryCount += Number(sourceDay.entryCount ?? 0);
current.days.set(dayLabel, day);
}
grouped.set(project.mainApiProjectId, current);
}
return [...grouped.values()].sort((left, right) => left.project.mainApiProjectId - right.project.mainApiProjectId).map((value) => ({
apiProjectId: String(value.project.mainApiProjectId),
projectId: value.project.projectId,
mainProjectId: value.project.projectId,
label: value.project.name,
customerName: value.project.customerName,
isSubentry: false,
billingType: null,
minutes: value.minutes,
billableMinutes: value.billableMinutes,
entryCount: value.entryCount,
days: [...value.days.entries()].sort(([left], [right]) => left.localeCompare(right)).map(([day, totals]) => ({ day, ...totals }))
}));
};
const normalizedUserActuals = userActualResult.rows.map((row) => ({ ...row, positions: normalizePositions(row.positions) }));
const normalizedEntryActuals = entryActualResult.rows.map((row) => ({ ...row, positions: normalizePositions(row.positions) }));
const subentryCountResult = await pool.query<{ count: number }>("SELECT count(*)::int AS count FROM project_teamspace_subentries;");
const changedSourceIds = [...projectMap.entries()].filter(([sourceApiId, project]) => sourceApiId !== project.mainApiProjectId).length;
console.log(JSON.stringify({
mode: apply ? "apply" : "dry-run",
apiIdsChecked: apiIds.size,
subprojectIdsNormalized: changedSourceIds,
monthlyUserActuals: normalizedUserActuals.length,
monthlyProjectActuals: normalizedEntryActuals.length,
legacySubentriesToDelete: subentryCountResult.rows[0]?.count ?? 0
}, null, 2));
if (!apply) return;
const client = await pool.connect();
try {
await client.query("BEGIN");
for (const row of entryResult.rows) {
const project = projectMap.get(Number(row.api_id));
if (!project) continue;
await client.query("UPDATE project_teamspace_entries SET teamspace_api_project_id=$2, teamspace_api_project_url=$3, updated_at=now() WHERE id=$1;", [row.id, project.mainApiProjectId, project.url]);
}
for (const row of resourceResult.rows) {
const project = projectMap.get(Number(row.api_id));
if (!project) continue;
await client.query("UPDATE teamspace_project_resources SET teamspace_api_project_id=$2, teamspace_api_project_url=$3, updated_at=now() WHERE id=$1;", [row.id, project.mainApiProjectId, project.url]);
}
for (const row of exclusionResult.rows) {
const sourceApiId = positiveInteger(row.api_id) ?? row.api_ids.map(positiveInteger).find((value): value is number => value !== null);
const project = sourceApiId ? projectMap.get(sourceApiId) : null;
await client.query(`
UPDATE teamspace_project_exclusions
SET teamspace_api_project_id=$2,
teamspace_api_project_ids=CASE WHEN $2::bigint IS NULL THEN '{}'::bigint[] ELSE ARRAY[$2::bigint] END,
teamspace_name=COALESCE($3, teamspace_name),
customer_name=COALESCE($4, customer_name),
updated_at=now()
WHERE teamspace_project=$1;
`, [row.teamspace_project, project?.mainApiProjectId ?? null, project?.name ?? null, project?.customerName ?? null]);
}
for (const row of normalizedUserActuals) {
const minutes = row.positions.reduce((sum, position) => sum + position.minutes, 0);
const billableMinutes = row.positions.reduce((sum, position) => sum + position.billableMinutes, 0);
const entryCount = row.positions.reduce((sum, position) => sum + position.entryCount, 0);
await client.query(`UPDATE teamspace_monthly_user_actuals SET positions=$3::jsonb, teamspace_minutes=$4, teamspace_billable_minutes=$5, teamspace_time_entry_count=$6, updated_at=now() WHERE user_id=$1 AND month=$2::date;`, [row.user_id, row.month, JSON.stringify(row.positions), minutes, billableMinutes, entryCount]);
}
for (const row of normalizedEntryActuals) {
const minutes = row.positions.reduce((sum, position) => sum + position.minutes, 0);
const billableMinutes = row.positions.reduce((sum, position) => sum + position.billableMinutes, 0);
const entryCount = row.positions.reduce((sum, position) => sum + position.entryCount, 0);
await client.query(`UPDATE project_teamspace_monthly_actuals SET positions=$4::jsonb, teamspace_minutes=$5, teamspace_billable_minutes=$6, teamspace_time_entry_count=$7, updated_at=now() WHERE user_id=$1 AND project_teamspace_entry_id=$2 AND month=$3::date;`, [row.user_id, row.project_teamspace_entry_id, row.month, JSON.stringify(row.positions), minutes, billableMinutes, entryCount]);
}
await client.query("DELETE FROM project_teamspace_subentries;");
await client.query("COMMIT");
} catch (error) {
await client.query("ROLLBACK");
throw error;
} finally {
client.release();
}
}
try {
await main();
} finally {
await pool.end();
}