1.2.0-rc1
This commit is contained in:
1 parent
45ff46b7eb
commit
fa9c122250
30 files changed
+6354
-989
No files matched your search
@@ -21,8 +21,10 @@ export type AuthUser = {
|
|||||||
has_teamspace_credentials: boolean;
|
has_teamspace_credentials: boolean;
|
||||||
zammad_email_session_webhook_enabled: boolean;
|
zammad_email_session_webhook_enabled: boolean;
|
||||||
session_billing_prompt_enabled: boolean;
|
session_billing_prompt_enabled: boolean;
|
||||||
|
session_work_segments_enabled: boolean;
|
||||||
teamspace_time_tracking_enabled: boolean;
|
teamspace_time_tracking_enabled: boolean;
|
||||||
zammad_session_note_default_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_enabled: boolean;
|
||||||
zammad_time_unit_rounding_minutes: number;
|
zammad_time_unit_rounding_minutes: number;
|
||||||
zammad_atoss_email_sync_enabled: boolean;
|
zammad_atoss_email_sync_enabled: boolean;
|
||||||
@@ -97,8 +99,10 @@ function publicUser(user: AuthUser) {
|
|||||||
has_teamspace_credentials: Boolean(user.has_teamspace_credentials),
|
has_teamspace_credentials: Boolean(user.has_teamspace_credentials),
|
||||||
zammad_email_session_webhook_enabled: Boolean(user.zammad_email_session_webhook_enabled),
|
zammad_email_session_webhook_enabled: Boolean(user.zammad_email_session_webhook_enabled),
|
||||||
session_billing_prompt_enabled: Boolean(user.session_billing_prompt_enabled),
|
session_billing_prompt_enabled: Boolean(user.session_billing_prompt_enabled),
|
||||||
|
session_work_segments_enabled: Boolean(user.session_work_segments_enabled),
|
||||||
teamspace_time_tracking_enabled: Boolean(user.teamspace_time_tracking_enabled ?? true),
|
teamspace_time_tracking_enabled: Boolean(user.teamspace_time_tracking_enabled ?? true),
|
||||||
zammad_session_note_default_enabled: Boolean(user.zammad_session_note_default_enabled),
|
zammad_session_note_default_enabled: Boolean(user.zammad_session_note_default_enabled),
|
||||||
|
zammad_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_enabled: Boolean(user.zammad_time_unit_rounding_enabled),
|
||||||
zammad_time_unit_rounding_minutes: Number(user.zammad_time_unit_rounding_minutes ?? 15),
|
zammad_time_unit_rounding_minutes: Number(user.zammad_time_unit_rounding_minutes ?? 15),
|
||||||
zammad_atoss_email_sync_enabled: Boolean(user.zammad_atoss_email_sync_enabled ?? true),
|
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.teamspace_device_id IS NOT NULL AND u.teamspace_device_password IS NOT NULL) AS has_teamspace_credentials,
|
||||||
u.zammad_email_session_webhook_enabled,
|
u.zammad_email_session_webhook_enabled,
|
||||||
u.session_billing_prompt_enabled,
|
u.session_billing_prompt_enabled,
|
||||||
|
u.session_work_segments_enabled,
|
||||||
u.teamspace_time_tracking_enabled,
|
u.teamspace_time_tracking_enabled,
|
||||||
u.zammad_session_note_default_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_enabled,
|
||||||
u.zammad_time_unit_rounding_minutes,
|
u.zammad_time_unit_rounding_minutes,
|
||||||
u.zammad_atoss_email_sync_enabled,
|
u.zammad_atoss_email_sync_enabled,
|
||||||
|
|||||||
@@ -12,12 +12,14 @@ export const databaseExportTables = [
|
|||||||
"auth_sessions",
|
"auth_sessions",
|
||||||
"app_settings",
|
"app_settings",
|
||||||
"user_preferences",
|
"user_preferences",
|
||||||
|
"organization_attribute_options",
|
||||||
|
"teamspace_project_exclusions",
|
||||||
|
"teamspace_assignment_rules",
|
||||||
"organizations",
|
"organizations",
|
||||||
"tickets",
|
"tickets",
|
||||||
"projects",
|
"projects",
|
||||||
"teamspace_project_resources",
|
"teamspace_project_resources",
|
||||||
"project_teamspace_entries",
|
"project_teamspace_entries",
|
||||||
"teamspace_project_exclusions",
|
|
||||||
"teamspace_monthly_user_actuals",
|
"teamspace_monthly_user_actuals",
|
||||||
"project_teamspace_monthly_actuals",
|
"project_teamspace_monthly_actuals",
|
||||||
"project_teamspace_subentries",
|
"project_teamspace_subentries",
|
||||||
@@ -45,6 +47,17 @@ type ColumnInfo = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const jsonDataTypes = new Set(["json", "jsonb"]);
|
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;
|
const insertBatchSize = 500;
|
||||||
|
|
||||||
export function quoteIdentifier(value: string) {
|
export function quoteIdentifier(value: string) {
|
||||||
@@ -81,6 +94,20 @@ export function sqlLiteral(value: unknown, column?: Pick<ColumnInfo, "dataType"
|
|||||||
return quoteSqlString(jsonValueForSql(value));
|
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) {
|
if (value instanceof Date) {
|
||||||
return quoteSqlString(value.toISOString());
|
return quoteSqlString(value.toISOString());
|
||||||
}
|
}
|
||||||
|
|||||||
+3408
-345
File diff suppressed because it is too large.
Load diff
+336
-28
@@ -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 teamspace_user_id BIGINT;");
|
||||||
await query("ALTER TABLE users ADD COLUMN IF NOT EXISTS zammad_email_session_webhook_enabled BOOLEAN NOT NULL DEFAULT false;");
|
await query("ALTER TABLE users ADD COLUMN IF NOT EXISTS zammad_email_session_webhook_enabled BOOLEAN NOT NULL DEFAULT false;");
|
||||||
await query("ALTER TABLE users ADD COLUMN IF NOT EXISTS session_billing_prompt_enabled BOOLEAN NOT NULL DEFAULT true;");
|
await query("ALTER TABLE users ADD COLUMN IF NOT EXISTS session_billing_prompt_enabled BOOLEAN NOT NULL DEFAULT true;");
|
||||||
|
await query("ALTER TABLE users ADD COLUMN IF NOT EXISTS 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 teamspace_time_tracking_enabled BOOLEAN NOT NULL DEFAULT true;");
|
||||||
await query("ALTER TABLE users ADD COLUMN IF NOT EXISTS zammad_session_note_default_enabled BOOLEAN NOT NULL DEFAULT false;");
|
await query("ALTER TABLE users ADD COLUMN IF NOT EXISTS zammad_session_note_default_enabled BOOLEAN NOT NULL DEFAULT false;");
|
||||||
|
await query("ALTER TABLE users ADD COLUMN IF NOT EXISTS zammad_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_enabled BOOLEAN NOT NULL DEFAULT false;");
|
||||||
await query("ALTER TABLE users ADD COLUMN IF NOT EXISTS zammad_time_unit_rounding_minutes INTEGER NOT NULL DEFAULT 15;");
|
await query("ALTER TABLE users ADD COLUMN IF NOT EXISTS zammad_time_unit_rounding_minutes INTEGER NOT NULL DEFAULT 15;");
|
||||||
await query("ALTER TABLE users ADD COLUMN IF NOT EXISTS zammad_atoss_email_sync_enabled BOOLEAN NOT NULL DEFAULT true;");
|
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_enabled BOOLEAN NOT NULL DEFAULT true;");
|
||||||
await query("ALTER TABLE users ADD COLUMN IF NOT EXISTS project_followup_second_weeks INTEGER NOT NULL DEFAULT 2;");
|
await query("ALTER TABLE users ADD COLUMN IF NOT EXISTS project_followup_second_weeks INTEGER NOT NULL DEFAULT 2;");
|
||||||
await query("ALTER TABLE users ADD COLUMN IF NOT EXISTS project_followup_complete_on_session BOOLEAN NOT NULL DEFAULT true;");
|
await query("ALTER TABLE users ADD COLUMN IF NOT EXISTS project_followup_complete_on_session BOOLEAN NOT NULL DEFAULT true;");
|
||||||
|
await query("ALTER TABLE users 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("ALTER TABLE users DROP COLUMN IF EXISTS teamspace_subproject_assignment_enabled;");
|
||||||
await query("UPDATE users SET zammad_article_type_id = 6 WHERE zammad_article_type_id IS NULL;");
|
await query("UPDATE users SET zammad_article_type_id = 6 WHERE zammad_article_type_id IS NULL;");
|
||||||
await query("ALTER TABLE users ALTER COLUMN zammad_article_type_id SET DEFAULT 6;");
|
await query("ALTER TABLE users ALTER COLUMN zammad_article_type_id SET DEFAULT 6;");
|
||||||
@@ -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(`
|
await query(`
|
||||||
CREATE TABLE IF NOT EXISTS teamspace_project_exclusions (
|
CREATE TABLE IF NOT EXISTS teamspace_project_exclusions (
|
||||||
teamspace_project TEXT PRIMARY KEY,
|
teamspace_project TEXT PRIMARY KEY,
|
||||||
|
teamspace_api_project_id BIGINT,
|
||||||
|
teamspace_api_project_ids BIGINT[] NOT NULL DEFAULT '{}'::bigint[],
|
||||||
teamspace_name TEXT,
|
teamspace_name TEXT,
|
||||||
customer_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 '',
|
note TEXT NOT NULL DEFAULT '',
|
||||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
CONSTRAINT teamspace_project_exclusions_project_not_blank CHECK (btrim(teamspace_project) <> '')
|
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 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 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 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(`
|
await query(`
|
||||||
CREATE TABLE IF NOT EXISTS teamspace_project_resources (
|
CREATE TABLE IF NOT EXISTS teamspace_project_resources (
|
||||||
id BIGSERIAL PRIMARY KEY,
|
id BIGSERIAL PRIMARY KEY,
|
||||||
@@ -234,6 +298,8 @@ export async function migrate() {
|
|||||||
id BIGSERIAL PRIMARY KEY,
|
id BIGSERIAL PRIMARY KEY,
|
||||||
zammad_id BIGINT NOT NULL UNIQUE,
|
zammad_id BIGINT NOT NULL UNIQUE,
|
||||||
name TEXT 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(),
|
synced_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
updated_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 domain;");
|
||||||
await query("ALTER TABLE organizations DROP COLUMN IF EXISTS note;");
|
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 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("CREATE INDEX IF NOT EXISTS idx_organizations_name_lower ON organizations(lower(name));");
|
||||||
await query("DROP INDEX IF EXISTS idx_organizations_active_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),
|
organization_id BIGINT REFERENCES organizations(zammad_id),
|
||||||
customer_name TEXT,
|
customer_name TEXT,
|
||||||
work_type 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(),
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
CONSTRAINT ticket_number_format CHECK (ticket_number ~ '^Ticket#[0-9]{6}$'),
|
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'))
|
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 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 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 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_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_group_id BIGINT;");
|
||||||
await query("ALTER TABLE tickets ADD COLUMN IF NOT EXISTS zammad_priority_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_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_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 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(`
|
await query(`
|
||||||
ALTER TABLE tickets
|
ALTER TABLE tickets
|
||||||
DROP CONSTRAINT IF EXISTS ticket_number_format,
|
DROP CONSTRAINT IF EXISTS ticket_number_format,
|
||||||
@@ -315,31 +441,6 @@ export async function migrate() {
|
|||||||
WHERE t.id = zto.id
|
WHERE t.id = zto.id
|
||||||
AND t.user_id IS DISTINCT FROM zto.user_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_user ON tickets(user_id);");
|
||||||
await query("CREATE INDEX IF NOT EXISTS idx_tickets_zammad_owner ON tickets(zammad_owner_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 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 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 activity_html TEXT;");
|
||||||
|
await query("ALTER TABLE sessions ADD COLUMN IF NOT EXISTS work_segments JSONB NOT NULL DEFAULT '[]'::jsonb;");
|
||||||
await query(`
|
await query(`
|
||||||
CREATE TABLE IF NOT EXISTS active_timers (
|
CREATE TABLE IF NOT EXISTS active_timers (
|
||||||
id BIGSERIAL PRIMARY KEY,
|
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_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 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 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 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("ALTER TABLE active_timers ADD COLUMN IF NOT EXISTS updated_at TIMESTAMPTZ NOT NULL DEFAULT now();");
|
||||||
await query(`
|
await query(`
|
||||||
@@ -877,6 +980,32 @@ export async function migrate() {
|
|||||||
END $$;
|
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(`
|
await query(`
|
||||||
CREATE TABLE IF NOT EXISTS project_teamspace_entries (
|
CREATE TABLE IF NOT EXISTS project_teamspace_entries (
|
||||||
id BIGSERIAL PRIMARY KEY,
|
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_entry_order ON project_teamspace_subentries(project_teamspace_entry_id, display_order, id);");
|
||||||
await query("CREATE INDEX IF NOT EXISTS idx_project_teamspace_subentries_user_entry_order ON project_teamspace_subentries(user_id, project_teamspace_entry_id, display_order, id);");
|
await query("CREATE INDEX IF NOT EXISTS idx_project_teamspace_subentries_user_entry_order ON project_teamspace_subentries(user_id, project_teamspace_entry_id, display_order, id);");
|
||||||
await query("CREATE UNIQUE INDEX IF NOT EXISTS idx_project_teamspace_subentries_user_entry_api ON project_teamspace_subentries(user_id, project_teamspace_entry_id, teamspace_api_project_id);");
|
await query("CREATE UNIQUE INDEX IF NOT EXISTS idx_project_teamspace_subentries_user_entry_api ON project_teamspace_subentries(user_id, project_teamspace_entry_id, teamspace_api_project_id);");
|
||||||
|
await query(`
|
||||||
|
WITH 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(`
|
await query(`
|
||||||
WITH duplicate_keys AS (
|
WITH duplicate_keys AS (
|
||||||
SELECT lower(btrim(teamspace_project)) AS teamspace_key
|
SELECT lower(btrim(teamspace_project)) AS teamspace_key
|
||||||
@@ -1561,7 +1721,7 @@ export async function migrate() {
|
|||||||
AND column_name = 'project_teamspace_subentry_id'
|
AND column_name = 'project_teamspace_subentry_id'
|
||||||
) THEN
|
) THEN
|
||||||
UPDATE sessions s
|
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
|
FROM project_teamspace_subentries pts
|
||||||
WHERE s.project_teamspace_subentry_id = pts.id;
|
WHERE s.project_teamspace_subentry_id = pts.id;
|
||||||
END IF;
|
END IF;
|
||||||
@@ -1685,7 +1845,15 @@ export async function migrate() {
|
|||||||
WHERE s.ticket_id = sep.ticket_id
|
WHERE s.ticket_id = sep.ticket_id
|
||||||
AND s.user_id = sep.user_id
|
AND s.user_id = sep.user_id
|
||||||
AND s.work_type = 'consulting'
|
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(`
|
await query(`
|
||||||
@@ -1703,7 +1871,44 @@ export async function migrate() {
|
|||||||
FROM single_entry_tickets single_ticket
|
FROM single_entry_tickets single_ticket
|
||||||
WHERE s.ticket_id = single_ticket.ticket_id
|
WHERE s.ticket_id = single_ticket.ticket_id
|
||||||
AND s.work_type = 'consulting'
|
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(`
|
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("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("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(`
|
await query(`
|
||||||
WITH deleted_orphan_sessions AS (
|
WITH deleted_orphan_sessions AS (
|
||||||
DELETE FROM sessions s
|
DELETE FROM sessions s
|
||||||
@@ -1889,6 +2151,7 @@ export async function migrate() {
|
|||||||
await query(`
|
await query(`
|
||||||
DELETE FROM tickets t
|
DELETE FROM tickets t
|
||||||
WHERE t.ticket_number ~ '^Fix#[0-9]+$'
|
WHERE t.ticket_number ~ '^Fix#[0-9]+$'
|
||||||
|
AND t.ticket_number <> 'Fix#0'
|
||||||
AND NOT EXISTS (
|
AND NOT EXISTS (
|
||||||
SELECT 1
|
SELECT 1
|
||||||
FROM sessions s
|
FROM sessions s
|
||||||
@@ -1916,4 +2179,49 @@ export async function migrate() {
|
|||||||
WHERE t.id = latest.ticket_id
|
WHERE t.id = latest.ticket_id
|
||||||
AND (t.customer_name IS NULL OR t.work_type IS NULL);
|
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;
|
||||||
|
}
|
||||||
@@ -1,9 +1,17 @@
|
|||||||
import assert from "node:assert/strict";
|
import assert from "node:assert/strict";
|
||||||
import test from "node:test";
|
import test from "node:test";
|
||||||
import { nextSequenceIdAfterRestore, restoreSqlText, sequenceSetvalSql, sqlLiteral, type QueryExecutor } from "../src/databaseExport.ts";
|
import {
|
||||||
import { exportTableSql } from "../src/databaseExport.ts";
|
databaseExportTables,
|
||||||
|
exportTableSql,
|
||||||
|
nextSequenceIdAfterRestore,
|
||||||
|
restoreSqlText,
|
||||||
|
sequenceSetvalSql,
|
||||||
|
sqlLiteral,
|
||||||
|
type QueryExecutor
|
||||||
|
} from "../src/databaseExport.ts";
|
||||||
|
|
||||||
const jsonbColumn = { dataType: "jsonb", udtName: "jsonb" };
|
const jsonbColumn = { dataType: "jsonb", udtName: "jsonb" };
|
||||||
|
const bigintArrayColumn = { dataType: "ARRAY", udtName: "_int8" };
|
||||||
|
|
||||||
test("sqlLiteral serializes primitive values", () => {
|
test("sqlLiteral serializes primitive values", () => {
|
||||||
assert.equal(sqlLiteral(null), "NULL");
|
assert.equal(sqlLiteral(null), "NULL");
|
||||||
@@ -36,6 +44,20 @@ test("sqlLiteral serializes JSON columns as valid JSON values", () => {
|
|||||||
assert.equal(sqlLiteral(true, jsonbColumn), "'true'");
|
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 () => {
|
test("generated table export never contains implicit object stringification", async () => {
|
||||||
const client: QueryExecutor = {
|
const client: QueryExecutor = {
|
||||||
async query<T extends Record<string, unknown>>(text: string): Promise<{ rows: T[]; rowCount: number | null }> {
|
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:
|
Sie zeigt unter anderem:
|
||||||
|
|
||||||
- offene Bewertungen
|
- eine nach Ticket zusammengefuehrte Pruefliste
|
||||||
- Sessions mit Zwischenstatus **Abrechenbar**
|
- offene Bewertungen und Sessions mit Zwischenstatus **Abrechenbar**
|
||||||
- fehlende oder abweichende Teamspace-Werte, wenn Teamspace-Erfassung in TT aktiv ist
|
- fehlende oder abweichende Teamspace-Werte als farbige, spaltenweise ausgerichtete Badges; die Tagesdetails stehen am jeweiligen Tag im Ticketdetail
|
||||||
- Prüfpunkte vor dem Abschluss
|
- Prüfpunkte vor dem Abschluss
|
||||||
- Möglichkeit, den Monat zu schließen oder wieder zu öffnen
|
- 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.
|
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
|
## Regeln bei deaktivierter Teamspace-Erfassung
|
||||||
|
|||||||
+5
-4
@@ -400,13 +400,14 @@ Einen separaten Tagesabschluss gibt es nicht. Die Tagesansicht ist nur eine gefi
|
|||||||
Die Seite `Monatsabschluss` enthaelt:
|
Die Seite `Monatsabschluss` enthaelt:
|
||||||
|
|
||||||
- Checkliste
|
- Checkliste
|
||||||
- Offene Bewertungen
|
- eine gemeinsame Pruefliste nach Ticket
|
||||||
- Tage ohne Teamspace-Wert
|
|
||||||
- Button zum Monat abschliessen oder wieder oeffnen
|
- 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.
|
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
@@ -1,5 +1,6 @@
|
|||||||
import {
|
import {
|
||||||
CalendarDays,
|
CalendarDays,
|
||||||
|
Building2,
|
||||||
CheckCircle2,
|
CheckCircle2,
|
||||||
ClipboardCheck,
|
ClipboardCheck,
|
||||||
Command,
|
Command,
|
||||||
@@ -87,8 +88,10 @@ import { isRecurringBillingTicketNumber, ticketHasRecurringBilling } from "./rec
|
|||||||
import { activeElapsedMs, mergeTimerActivityDrafts, readStoredTimers, storageKeyForUser, ticketPattern, type TimerEntry } from "./timers";
|
import { activeElapsedMs, mergeTimerActivityDrafts, readStoredTimers, storageKeyForUser, ticketPattern, type TimerEntry } from "./timers";
|
||||||
import { formatTodoPeriod, isTodoActiveOnDay, isTodoFixedOnDay, isTodoOverdue } from "./todos";
|
import { formatTodoPeriod, isTodoActiveOnDay, isTodoFixedOnDay, isTodoOverdue } from "./todos";
|
||||||
import { AdminUsersPage } from "./views/AdminUsersPage";
|
import { AdminUsersPage } from "./views/AdminUsersPage";
|
||||||
|
import { AdminOrganizationsPage } from "./views/AdminOrganizationsPage";
|
||||||
import { AnalysisPage } from "./views/AnalysisPage";
|
import { AnalysisPage } from "./views/AnalysisPage";
|
||||||
import { DashboardPage } from "./views/DashboardPage";
|
import { DashboardPage } from "./views/DashboardPage";
|
||||||
|
import { DashboardTimersPage } from "./views/DashboardTimersPage";
|
||||||
import { FaqPage } from "./views/FaqPage";
|
import { FaqPage } from "./views/FaqPage";
|
||||||
import { LoginPage } from "./views/LoginPage";
|
import { LoginPage } from "./views/LoginPage";
|
||||||
import { MonthlyClosePage } from "./views/MonthlyClosePage";
|
import { MonthlyClosePage } from "./views/MonthlyClosePage";
|
||||||
@@ -225,6 +228,10 @@ function routeFromPath(pathWithSearch: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (pathname.startsWith("/dashboard")) {
|
if (pathname.startsWith("/dashboard")) {
|
||||||
|
if (pathname.startsWith("/dashboard/timers")) {
|
||||||
|
return { page: "dashboard-timers" as const };
|
||||||
|
}
|
||||||
|
|
||||||
return { page: "dashboard" as const };
|
return { page: "dashboard" as const };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -260,6 +267,10 @@ function routeFromPath(pathWithSearch: string) {
|
|||||||
return { page: "admin-users" as const };
|
return { page: "admin-users" as const };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (pathname.startsWith("/admin/organizations")) {
|
||||||
|
return { page: "admin-organizations" as const };
|
||||||
|
}
|
||||||
|
|
||||||
if (pathname.startsWith("/profile")) {
|
if (pathname.startsWith("/profile")) {
|
||||||
return { page: "profile" as const };
|
return { page: "profile" as const };
|
||||||
}
|
}
|
||||||
@@ -827,6 +838,7 @@ export function App() {
|
|||||||
const [timersLoaded, setTimersLoaded] = useState(false);
|
const [timersLoaded, setTimersLoaded] = useState(false);
|
||||||
const [timerOwnerId, setTimerOwnerId] = useState<string | null>(null);
|
const [timerOwnerId, setTimerOwnerId] = useState<string | null>(null);
|
||||||
const [selectedTimerId, setSelectedTimerId] = 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 [tick, setTick] = useState(Date.now());
|
||||||
const [openTickets, setOpenTickets] = useState<OpenTicketNavItem[]>([]);
|
const [openTickets, setOpenTickets] = useState<OpenTicketNavItem[]>([]);
|
||||||
const [ticketReturnPaths, setTicketReturnPaths] = useState<Record<string, string>>({});
|
const [ticketReturnPaths, setTicketReturnPaths] = useState<Record<string, string>>({});
|
||||||
@@ -1160,7 +1172,7 @@ export function App() {
|
|||||||
return;
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1390,13 +1402,15 @@ export function App() {
|
|||||||
...(currentUser.role === "admin"
|
...(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/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: "separator" as const, key: "before-profile" },
|
||||||
{ type: "item" as const, href: "/profile", label: "Profil", icon: UserCog, active: path.startsWith("/profile") },
|
{ type: "item" as const, href: "/profile", label: "Profil", icon: UserCog, active: path.startsWith("/profile") },
|
||||||
{ type: "separator" as const, key: "before-faq" },
|
{ 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: "/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: "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: "/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") },
|
{ type: "item" as const, href: "/analysis", label: "Ticket-Sessions", icon: Ticket, active: path.startsWith("/analysis") },
|
||||||
@@ -1567,7 +1581,7 @@ export function App() {
|
|||||||
<SidebarMenuItem>
|
<SidebarMenuItem>
|
||||||
<SidebarMenuButton tooltip="Worklog">
|
<SidebarMenuButton tooltip="Worklog">
|
||||||
<FolderGit />
|
<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>
|
</SidebarMenuButton>
|
||||||
</SidebarMenuItem>
|
</SidebarMenuItem>
|
||||||
</SidebarMenu>
|
</SidebarMenu>
|
||||||
@@ -1662,9 +1676,27 @@ export function App() {
|
|||||||
setSelectedTimerId={setSelectedTimerId}
|
setSelectedTimerId={setSelectedTimerId}
|
||||||
tick={tick}
|
tick={tick}
|
||||||
currentUser={currentUser}
|
currentUser={currentUser}
|
||||||
|
finishRequestId={finishRequestTimerId}
|
||||||
|
onFinishRequestHandled={() => setFinishRequestTimerId(null)}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
{!isAdmin && route.page === "dashboard" ? <DashboardPage currentUser={currentUser} onNavigate={navigate} /> : null}
|
{!isAdmin && route.page === "dashboard" ? <DashboardPage currentUser={currentUser} onNavigate={navigate} /> : null}
|
||||||
|
{!isAdmin && route.page === "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 === "analysis" ? <AnalysisPage currentUser={currentUser} onNavigate={navigate} /> : null}
|
||||||
{!isAdmin && route.page === "monthly-close" ? <MonthlyClosePage 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}
|
{!isAdmin && route.page === "statistics" ? <StatisticsPage currentUser={currentUser} onNavigate={navigate} /> : null}
|
||||||
@@ -1675,6 +1707,7 @@ export function App() {
|
|||||||
{route.page === "faq" ? <FaqPage /> : null}
|
{route.page === "faq" ? <FaqPage /> : null}
|
||||||
{route.page === "profile" ? <ProfilePage currentUser={currentUser} onUserUpdated={setCurrentUser} /> : null}
|
{route.page === "profile" ? <ProfilePage currentUser={currentUser} onUserUpdated={setCurrentUser} /> : null}
|
||||||
{route.page === "admin-users" && isAdmin ? <AdminUsersPage currentUser={currentUser} /> : null}
|
{route.page === "admin-users" && isAdmin ? <AdminUsersPage currentUser={currentUser} /> : null}
|
||||||
|
{route.page === "admin-organizations" && isAdmin ? <AdminOrganizationsPage /> : null}
|
||||||
{route.page === "admin-users" && !isAdmin ? (
|
{route.page === "admin-users" && !isAdmin ? (
|
||||||
<TimerPage
|
<TimerPage
|
||||||
timers={timers}
|
timers={timers}
|
||||||
@@ -1697,7 +1730,7 @@ export function App() {
|
|||||||
returnPath={ticketReturnPaths[route.ticketId] ?? openTickets.find((ticket) => ticket.ticketId === route.ticketId)?.returnPath ?? "/analysis"}
|
returnPath={ticketReturnPaths[route.ticketId] ?? openTickets.find((ticket) => ticket.ticketId === route.ticketId)?.returnPath ?? "/analysis"}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : 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>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
</SidebarInset>
|
</SidebarInset>
|
||||||
|
|||||||
+123
-2
@@ -19,6 +19,7 @@ import type {
|
|||||||
TodoPriority,
|
TodoPriority,
|
||||||
TodoStatus,
|
TodoStatus,
|
||||||
WorkType,
|
WorkType,
|
||||||
|
WorkSegment,
|
||||||
ZammadSessionArticleQueueItem
|
ZammadSessionArticleQueueItem
|
||||||
} from "./types";
|
} from "./types";
|
||||||
import type { TimerEntry, TimerPhase } from "./timers";
|
import type { TimerEntry, TimerPhase } from "./timers";
|
||||||
@@ -32,10 +33,12 @@ type CreateSessionPayload = {
|
|||||||
startedAt: string;
|
startedAt: string;
|
||||||
endedAt: string;
|
endedAt: string;
|
||||||
durationSeconds: number;
|
durationSeconds: number;
|
||||||
|
workSegments?: WorkSegment[];
|
||||||
zammadLookup?: boolean;
|
zammadLookup?: boolean;
|
||||||
projectTeamspaceEntryId?: string | null;
|
projectTeamspaceEntryId?: string | null;
|
||||||
billingStatus?: BillingStatus;
|
billingStatus?: BillingStatus;
|
||||||
createZammadArticle?: boolean;
|
createZammadArticle?: boolean;
|
||||||
|
zammadArticleDateHeader?: boolean;
|
||||||
zammadArticleTypeId?: number;
|
zammadArticleTypeId?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -330,8 +333,10 @@ export function updateCurrentUser(payload: {
|
|||||||
teamspaceDevicePassword?: string;
|
teamspaceDevicePassword?: string;
|
||||||
zammadEmailSessionWebhookEnabled?: boolean;
|
zammadEmailSessionWebhookEnabled?: boolean;
|
||||||
sessionBillingPromptEnabled?: boolean;
|
sessionBillingPromptEnabled?: boolean;
|
||||||
|
sessionWorkSegmentsEnabled?: boolean;
|
||||||
teamspaceTimeTrackingEnabled?: boolean;
|
teamspaceTimeTrackingEnabled?: boolean;
|
||||||
zammadSessionNoteDefaultEnabled?: boolean;
|
zammadSessionNoteDefaultEnabled?: boolean;
|
||||||
|
zammadSessionNoteDateHeaderEnabled?: boolean;
|
||||||
zammadTimeUnitRoundingEnabled?: boolean;
|
zammadTimeUnitRoundingEnabled?: boolean;
|
||||||
zammadTimeUnitRoundingMinutes?: number;
|
zammadTimeUnitRoundingMinutes?: number;
|
||||||
zammadAtossEmailSyncEnabled?: boolean;
|
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() {
|
export async function downloadDatabaseExport() {
|
||||||
const response = await fetch("/api/admin/export/database", {
|
const response = await fetch("/api/admin/export/database", {
|
||||||
credentials: "same-origin"
|
credentials: "same-origin"
|
||||||
@@ -502,6 +538,33 @@ export function getOrganizations(search = "") {
|
|||||||
return request<{ organizations: Organization[] }>(`/api/organizations${params.size ? `?${params}` : ""}`);
|
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() {
|
export function getZammadSettings() {
|
||||||
return request<{ settings: { baseUrl: string; hasApiKey: boolean } }>("/api/admin/zammad/settings");
|
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 = {
|
export type TeamspaceProjectExclusion = {
|
||||||
teamspace_project: string;
|
teamspace_project: string;
|
||||||
|
teamspace_api_project_id: string | null;
|
||||||
|
teamspace_api_project_ids: string[] | null;
|
||||||
teamspace_name: string | null;
|
teamspace_name: string | null;
|
||||||
customer_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;
|
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() {
|
export function getTeamspaceProjectExclusions() {
|
||||||
return request<{ exclusions: TeamspaceProjectExclusion[] }>("/api/admin/teamspace/project-exclusions");
|
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", {
|
return request<{ exclusions: TeamspaceProjectExclusion[] }>("/api/admin/teamspace/project-exclusions", {
|
||||||
method: "PUT",
|
method: "PUT",
|
||||||
body: JSON.stringify({ projects })
|
body: JSON.stringify({ projects })
|
||||||
@@ -543,7 +651,7 @@ export function saveTeamspaceProjectExclusions(projects: string[]) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function syncZammadOrganizations(payload: { baseUrl?: string; apiKey?: string }) {
|
export function syncZammadOrganizations(payload: { baseUrl?: string; apiKey?: string }) {
|
||||||
return request<{ synced: number; skipped: number; removed: number; unlinkedTickets: number; unlinkedSessions: number }>("/api/admin/zammad/organizations/sync", {
|
return request<{ synced: number; attributeOptions: number; skipped: number; removed: number; unlinkedTickets: number; unlinkedSessions: number }>("/api/admin/zammad/organizations/sync", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: JSON.stringify(payload)
|
body: JSON.stringify(payload)
|
||||||
});
|
});
|
||||||
@@ -712,6 +820,9 @@ export function createRecurringBilling(payload: {
|
|||||||
organizationId: string;
|
organizationId: string;
|
||||||
activity: string;
|
activity: string;
|
||||||
workType: WorkType;
|
workType: WorkType;
|
||||||
|
teamspaceProject?: string | null;
|
||||||
|
hourBudgetMinutes?: number | null;
|
||||||
|
manualConsumptionMinutes?: number | null;
|
||||||
recurrenceType: "weekly" | "every_n_weeks";
|
recurrenceType: "weekly" | "every_n_weeks";
|
||||||
intervalValue: number;
|
intervalValue: number;
|
||||||
validFrom: string;
|
validFrom: string;
|
||||||
@@ -737,6 +848,9 @@ export function updateRecurringBilling(
|
|||||||
organizationId: string;
|
organizationId: string;
|
||||||
activity: string;
|
activity: string;
|
||||||
workType: WorkType;
|
workType: WorkType;
|
||||||
|
teamspaceProject?: string | null;
|
||||||
|
hourBudgetMinutes?: number | null;
|
||||||
|
manualConsumptionMinutes?: number | null;
|
||||||
recurrenceType: "weekly" | "every_n_weeks";
|
recurrenceType: "weekly" | "every_n_weeks";
|
||||||
intervalValue: number;
|
intervalValue: number;
|
||||||
validFrom: string;
|
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(
|
export function updateSessionDetails(
|
||||||
sessionId: string,
|
sessionId: string,
|
||||||
payload: {
|
payload: {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { CalendarDays, CheckCircle2, Circle, GripVertical, Pencil, Plus, Save, Trash2, X } from "lucide-react";
|
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 { toast } from "sonner";
|
||||||
|
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
@@ -40,9 +40,11 @@ type TodoPanelProps = {
|
|||||||
onNavigate?: (to: string) => void;
|
onNavigate?: (to: string) => void;
|
||||||
reorderEnabled?: boolean;
|
reorderEnabled?: boolean;
|
||||||
onReorder?: (draggedTodoId: string, targetTodoId: string) => void;
|
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 [title, setTitle] = useState("");
|
||||||
const [description, setDescription] = useState("");
|
const [description, setDescription] = useState("");
|
||||||
const [priority, setPriority] = useState<TodoPriority>("medium");
|
const [priority, setPriority] = useState<TodoPriority>("medium");
|
||||||
@@ -156,97 +158,100 @@ export function TodoPanel({ ticketId, todos, onChanged, showCreate = true, showT
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-3">
|
<div className={compact ? "space-y-2" : "space-y-3"}>
|
||||||
{showCreate && ticketId ? (
|
{toolbar || (showCreate && ticketId) ? (
|
||||||
<div className="flex justify-end">
|
<div className={cn("flex flex-wrap items-center gap-2", toolbar ? "justify-start" : "justify-end")}>
|
||||||
<Dialog open={createOpen} onOpenChange={setCreateOpen}>
|
<div className="min-w-0">{toolbar}</div>
|
||||||
<DialogTrigger asChild>
|
{showCreate && ticketId ? (
|
||||||
<Button type="button" size="sm">
|
<Dialog open={createOpen} onOpenChange={setCreateOpen}>
|
||||||
<Plus className="size-4" />
|
<DialogTrigger asChild>
|
||||||
ToDo anlegen
|
<Button type="button" size="sm">
|
||||||
</Button>
|
<Plus className="size-4" />
|
||||||
</DialogTrigger>
|
ToDo anlegen
|
||||||
<DialogContent>
|
</Button>
|
||||||
<DialogHeader>
|
</DialogTrigger>
|
||||||
<DialogTitle>ToDo anlegen</DialogTitle>
|
<DialogContent>
|
||||||
<DialogDescription>Neue Aufgabe zu diesem Ticket oder Projekt erfassen.</DialogDescription>
|
<DialogHeader>
|
||||||
</DialogHeader>
|
<DialogTitle>ToDo anlegen</DialogTitle>
|
||||||
<form className="space-y-4" onSubmit={submitTodo}>
|
<DialogDescription>Neue Aufgabe zu diesem Ticket oder Projekt erfassen.</DialogDescription>
|
||||||
<div className="space-y-2">
|
</DialogHeader>
|
||||||
<label className="text-sm font-medium" htmlFor={`todo-title-${ticketId}`}>
|
<form className="space-y-4" onSubmit={submitTodo}>
|
||||||
Aufgabe
|
|
||||||
</label>
|
|
||||||
<Input id={`todo-title-${ticketId}`} value={title} onChange={(event) => setTitle(event.currentTarget.value)} autoFocus required />
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<label className="text-sm font-medium" htmlFor={`todo-priority-${ticketId}`}>
|
|
||||||
Priorität
|
|
||||||
</label>
|
|
||||||
<select
|
|
||||||
id={`todo-priority-${ticketId}`}
|
|
||||||
className="h-9 w-full rounded-lg border border-input bg-background px-2 text-sm focus-visible:outline-none focus-visible:ring-3 focus-visible:ring-ring/50"
|
|
||||||
value={priority}
|
|
||||||
onChange={(event) => setPriority(event.currentTarget.value as TodoPriority)}
|
|
||||||
>
|
|
||||||
{Object.entries(priorityLabels).map(([value, label]) => (
|
|
||||||
<option key={value} value={value}>
|
|
||||||
{label}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div className="grid gap-3 sm:grid-cols-2">
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label className="text-sm font-medium" htmlFor={`todo-starts-on-${ticketId}`}>
|
<label className="text-sm font-medium" htmlFor={`todo-title-${ticketId}`}>
|
||||||
Umsetzung von
|
Aufgabe
|
||||||
</label>
|
</label>
|
||||||
<div className="flex gap-2">
|
<Input id={`todo-title-${ticketId}`} value={title} onChange={(event) => setTitle(event.currentTarget.value)} autoFocus required />
|
||||||
<Input id={`todo-starts-on-${ticketId}`} type="date" value={startsOn} onChange={(event) => setStartsOn(event.currentTarget.value)} />
|
</div>
|
||||||
{startsOn ? (
|
<div className="space-y-2">
|
||||||
<Button type="button" size="icon" variant="ghost" className="shrink-0" onClick={() => setStartsOn("")} aria-label="Von-Datum entfernen">
|
<label className="text-sm font-medium" htmlFor={`todo-priority-${ticketId}`}>
|
||||||
<X className="size-4" />
|
Priorität
|
||||||
</Button>
|
</label>
|
||||||
) : null}
|
<select
|
||||||
|
id={`todo-priority-${ticketId}`}
|
||||||
|
className="h-9 w-full rounded-lg border border-input bg-background px-2 text-sm focus-visible:outline-none focus-visible:ring-3 focus-visible:ring-ring/50"
|
||||||
|
value={priority}
|
||||||
|
onChange={(event) => setPriority(event.currentTarget.value as TodoPriority)}
|
||||||
|
>
|
||||||
|
{Object.entries(priorityLabels).map(([value, label]) => (
|
||||||
|
<option key={value} value={value}>
|
||||||
|
{label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-3 sm:grid-cols-2">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-sm font-medium" htmlFor={`todo-starts-on-${ticketId}`}>
|
||||||
|
Umsetzung von
|
||||||
|
</label>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Input id={`todo-starts-on-${ticketId}`} type="date" value={startsOn} onChange={(event) => setStartsOn(event.currentTarget.value)} />
|
||||||
|
{startsOn ? (
|
||||||
|
<Button type="button" size="icon" variant="ghost" className="shrink-0" onClick={() => setStartsOn("")} aria-label="Von-Datum entfernen">
|
||||||
|
<X className="size-4" />
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-sm font-medium" htmlFor={`todo-ends-on-${ticketId}`}>
|
||||||
|
Umsetzung bis
|
||||||
|
</label>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Input id={`todo-ends-on-${ticketId}`} type="date" value={endsOn} onChange={(event) => setEndsOn(event.currentTarget.value)} />
|
||||||
|
{endsOn ? (
|
||||||
|
<Button type="button" size="icon" variant="ghost" className="shrink-0" onClick={() => setEndsOn("")} aria-label="Bis-Datum entfernen">
|
||||||
|
<X className="size-4" />
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label className="text-sm font-medium" htmlFor={`todo-ends-on-${ticketId}`}>
|
<label className="text-sm font-medium" htmlFor={`todo-description-${ticketId}`}>
|
||||||
Umsetzung bis
|
Details
|
||||||
</label>
|
</label>
|
||||||
<div className="flex gap-2">
|
<Textarea
|
||||||
<Input id={`todo-ends-on-${ticketId}`} type="date" value={endsOn} onChange={(event) => setEndsOn(event.currentTarget.value)} />
|
id={`todo-description-${ticketId}`}
|
||||||
{endsOn ? (
|
className="min-h-24"
|
||||||
<Button type="button" size="icon" variant="ghost" className="shrink-0" onClick={() => setEndsOn("")} aria-label="Bis-Datum entfernen">
|
value={description}
|
||||||
<X className="size-4" />
|
onChange={(event) => setDescription(event.currentTarget.value)}
|
||||||
</Button>
|
/>
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<div className="flex justify-end">
|
||||||
<div className="space-y-2">
|
<Button type="submit" disabled={saving}>
|
||||||
<label className="text-sm font-medium" htmlFor={`todo-description-${ticketId}`}>
|
<Plus className="size-4" />
|
||||||
Details
|
{saving ? "Speichert..." : "Anlegen"}
|
||||||
</label>
|
</Button>
|
||||||
<Textarea
|
</div>
|
||||||
id={`todo-description-${ticketId}`}
|
</form>
|
||||||
className="min-h-24"
|
</DialogContent>
|
||||||
value={description}
|
</Dialog>
|
||||||
onChange={(event) => setDescription(event.currentTarget.value)}
|
) : null}
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="flex justify-end">
|
|
||||||
<Button type="submit" disabled={saving}>
|
|
||||||
<Plus className="size-4" />
|
|
||||||
{saving ? "Speichert..." : "Anlegen"}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className={compact ? "space-y-1.5" : "space-y-2"}>
|
||||||
{todos.map((todo) => {
|
{todos.map((todo) => {
|
||||||
const isEditing = editingId === todo.id;
|
const isEditing = editingId === todo.id;
|
||||||
const periodLabel = formatTodoPeriod(todo);
|
const periodLabel = formatTodoPeriod(todo);
|
||||||
@@ -256,7 +261,8 @@ export function TodoPanel({ ticketId, todos, onChanged, showCreate = true, showT
|
|||||||
<div
|
<div
|
||||||
key={todo.id}
|
key={todo.id}
|
||||||
className={cn(
|
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",
|
overdue && "border-red-500/50 bg-red-500/10",
|
||||||
todo.status === "done" && "opacity-70",
|
todo.status === "done" && "opacity-70",
|
||||||
reorderEnabled && draggingId === todo.id && "border-primary/50 bg-primary/5"
|
reorderEnabled && draggingId === todo.id && "border-primary/50 bg-primary/5"
|
||||||
@@ -393,7 +399,7 @@ export function TodoPanel({ ticketId, todos, onChanged, showCreate = true, showT
|
|||||||
})}
|
})}
|
||||||
</div>
|
</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>
|
</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} />;
|
||||||
|
}
|
||||||
@@ -158,12 +158,16 @@ export const helpSections: HelpSection[] = [
|
|||||||
quickItems: [
|
quickItems: [
|
||||||
"Ticketnummer, Organisation und Art sind bearbeitbar.",
|
"Ticketnummer, Organisation und Art sind bearbeitbar.",
|
||||||
"Organisationen kommen aus der lokalen Zammad-Synchronisation.",
|
"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."
|
"Die Art wiederkehrender Sessions kommt aus der Regel."
|
||||||
],
|
],
|
||||||
details: [
|
details: [
|
||||||
"Ticketnummern können korrigiert werden, falls beim Starten oder Nachtragen ein falscher Bezug gewählt wurde.",
|
"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.",
|
"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.",
|
"Ä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.",
|
"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."
|
"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.",
|
description: "Der Monatsabschluss ist die Kontrollseite für offene Bewertungen und Teamspace-Punkte vor dem Abschluss.",
|
||||||
quickItems: [
|
quickItems: [
|
||||||
"Bewertungen sind Pflicht für den Abschluss.",
|
"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."
|
"Der Monat kann wieder geöffnet werden."
|
||||||
],
|
],
|
||||||
details: [
|
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.",
|
"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 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.",
|
"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."
|
"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.",
|
"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 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.",
|
"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.",
|
"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.",
|
"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.",
|
"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
@@ -1,4 +1,4 @@
|
|||||||
import type { BillingStatus } from "./types";
|
import type { BillingStatus, WorkSegment } from "./types";
|
||||||
|
|
||||||
export type TimerPhase = "running" | "paused";
|
export type TimerPhase = "running" | "paused";
|
||||||
|
|
||||||
@@ -16,6 +16,7 @@ export type TimerEntry = {
|
|||||||
startedAt: number;
|
startedAt: number;
|
||||||
pausedTotalMs: number;
|
pausedTotalMs: number;
|
||||||
pausedAt: number | null;
|
pausedAt: number | null;
|
||||||
|
workSegments?: WorkSegment[];
|
||||||
phase: TimerPhase;
|
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[]) {
|
export function normalizeTimers(timers: TimerEntry[]) {
|
||||||
let runningSeen = false;
|
let runningSeen = false;
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
@@ -70,6 +99,7 @@ export function normalizeTimers(timers: TimerEntry[]) {
|
|||||||
activity: typeof timer.activity === "string" ? timer.activity : "",
|
activity: typeof timer.activity === "string" ? timer.activity : "",
|
||||||
zammadArticleBody: typeof timer.zammadArticleBody === "string" ? timer.zammadArticleBody : "",
|
zammadArticleBody: typeof timer.zammadArticleBody === "string" ? timer.zammadArticleBody : "",
|
||||||
billingStatus: timer.billingStatus === "billed" ? "billable" : timer.billingStatus === "billable" || timer.billingStatus === "non_billable" ? timer.billingStatus : null,
|
billingStatus: timer.billingStatus === "billed" ? "billable" : timer.billingStatus === "billable" || timer.billingStatus === "non_billable" ? timer.billingStatus : null,
|
||||||
|
workSegments: normalizeWorkSegments(timer.workSegments),
|
||||||
};
|
};
|
||||||
|
|
||||||
if (timer.phase !== "running") {
|
if (timer.phase !== "running") {
|
||||||
|
|||||||
+74
-2
@@ -16,8 +16,10 @@ export type AuthUser = {
|
|||||||
has_teamspace_credentials: boolean;
|
has_teamspace_credentials: boolean;
|
||||||
zammad_email_session_webhook_enabled: boolean;
|
zammad_email_session_webhook_enabled: boolean;
|
||||||
session_billing_prompt_enabled: boolean;
|
session_billing_prompt_enabled: boolean;
|
||||||
|
session_work_segments_enabled: boolean;
|
||||||
teamspace_time_tracking_enabled: boolean;
|
teamspace_time_tracking_enabled: boolean;
|
||||||
zammad_session_note_default_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_enabled: boolean;
|
||||||
zammad_time_unit_rounding_minutes: number;
|
zammad_time_unit_rounding_minutes: number;
|
||||||
zammad_atoss_email_sync_enabled: boolean;
|
zammad_atoss_email_sync_enabled: boolean;
|
||||||
@@ -73,10 +75,19 @@ export type DashboardDayTicket = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export type AdminUser = AuthUser & {
|
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;
|
created_at: string;
|
||||||
updated_at: string;
|
updated_at: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type WorkSegment = {
|
||||||
|
startedAt: string;
|
||||||
|
endedAt: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
export type ZammadSessionArticleQueueItem = {
|
export type ZammadSessionArticleQueueItem = {
|
||||||
id: string;
|
id: string;
|
||||||
session_id: string;
|
session_id: string;
|
||||||
@@ -98,7 +109,15 @@ export type Organization = {
|
|||||||
id: string;
|
id: string;
|
||||||
zammad_id: string;
|
zammad_id: string;
|
||||||
name: 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;
|
synced_at: string;
|
||||||
|
created_at?: string;
|
||||||
|
updated_at?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type RecurringBillingSlot = {
|
export type RecurringBillingSlot = {
|
||||||
@@ -119,6 +138,9 @@ export type RecurringBilling = {
|
|||||||
organization_name: string;
|
organization_name: string;
|
||||||
activity: string;
|
activity: string;
|
||||||
work_type: WorkType;
|
work_type: WorkType;
|
||||||
|
teamspace_project: string | null;
|
||||||
|
hour_budget_minutes: number | null;
|
||||||
|
manual_consumption_minutes: number;
|
||||||
recurrence_type: "weekly" | "every_n_weeks";
|
recurrence_type: "weekly" | "every_n_weeks";
|
||||||
interval_value: number;
|
interval_value: number;
|
||||||
valid_from: string;
|
valid_from: string;
|
||||||
@@ -138,6 +160,12 @@ export type TicketMeta = {
|
|||||||
organization_name: string | null;
|
organization_name: string | null;
|
||||||
customer_name: string | null;
|
customer_name: string | null;
|
||||||
work_type: WorkType | 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_ticket_id?: string | null;
|
||||||
zammad_group_id?: string | null;
|
zammad_group_id?: string | null;
|
||||||
zammad_priority_id?: string | null;
|
zammad_priority_id?: string | null;
|
||||||
@@ -333,7 +361,11 @@ export type TeamspaceMonthActual = {
|
|||||||
export type TeamspaceMonthActualPosition = {
|
export type TeamspaceMonthActualPosition = {
|
||||||
apiProjectId: string;
|
apiProjectId: string;
|
||||||
projectId: string | null;
|
projectId: string | null;
|
||||||
|
mainProjectId?: string | null;
|
||||||
label: string;
|
label: string;
|
||||||
|
description?: string | null;
|
||||||
|
ticketNumber?: string | null;
|
||||||
|
customerName?: string | null;
|
||||||
isSubentry: boolean;
|
isSubentry: boolean;
|
||||||
billingType?: string | null;
|
billingType?: string | null;
|
||||||
minutes: number;
|
minutes: number;
|
||||||
@@ -398,6 +430,27 @@ export type TicketDayBilling = {
|
|||||||
billed_minutes: number;
|
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 = {
|
export type TicketAvailableMonth = {
|
||||||
month: string;
|
month: string;
|
||||||
session_count: number;
|
session_count: number;
|
||||||
@@ -417,6 +470,7 @@ export type SessionEntry = {
|
|||||||
ended_at: string;
|
ended_at: string;
|
||||||
duration_seconds: number;
|
duration_seconds: number;
|
||||||
rounded_minutes: number;
|
rounded_minutes: number;
|
||||||
|
work_segments: WorkSegment[];
|
||||||
billing_status: BillingStatus;
|
billing_status: BillingStatus;
|
||||||
billing_updated_at: string | null;
|
billing_updated_at: string | null;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
@@ -448,6 +502,12 @@ export type TicketPeriod = {
|
|||||||
organization_name: string | null;
|
organization_name: string | null;
|
||||||
customer_name: string | null;
|
customer_name: string | null;
|
||||||
work_type: WorkType | 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_ticket_id?: string | null;
|
||||||
zammad_group_id?: string | null;
|
zammad_group_id?: string | null;
|
||||||
zammad_priority_id?: string | null;
|
zammad_priority_id?: string | null;
|
||||||
@@ -466,6 +526,7 @@ export type TicketPeriod = {
|
|||||||
};
|
};
|
||||||
sessions: SessionEntry[];
|
sessions: SessionEntry[];
|
||||||
dayBillings: TicketDayBilling[];
|
dayBillings: TicketDayBilling[];
|
||||||
|
ticketTeamspaceDays?: TicketTeamspaceDay[];
|
||||||
project?: ProjectSummary | null;
|
project?: ProjectSummary | null;
|
||||||
todos?: TicketTodo[];
|
todos?: TicketTodo[];
|
||||||
teamspaceMonthActuals?: TeamspaceMonthActual[];
|
teamspaceMonthActuals?: TeamspaceMonthActual[];
|
||||||
@@ -562,15 +623,25 @@ export type StatisticsCrmDay = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export type StatisticsTeamspaceDay = {
|
export type StatisticsTeamspaceDay = {
|
||||||
ticket_id: string;
|
ticket_id: string | null;
|
||||||
ticket_number: string;
|
ticket_number: string | null;
|
||||||
organization_name: string;
|
organization_name: string;
|
||||||
day: string;
|
day: string;
|
||||||
tracked_minutes: number;
|
tracked_minutes: number;
|
||||||
crm_billed_minutes: number;
|
crm_billed_minutes: number;
|
||||||
|
teamspace_entry_count: number;
|
||||||
delta_minutes: number;
|
delta_minutes: number;
|
||||||
session_count: number;
|
session_count: number;
|
||||||
project_teamspace_project: string | null;
|
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 = {
|
export type StatisticsTeamspaceMonthMismatch = {
|
||||||
@@ -651,6 +722,7 @@ export type StatisticsOverview = {
|
|||||||
acknowledgedMissingCrmDays: StatisticsCrmDay[];
|
acknowledgedMissingCrmDays: StatisticsCrmDay[];
|
||||||
crmMismatches: StatisticsCrmDay[];
|
crmMismatches: StatisticsCrmDay[];
|
||||||
teamspaceMonthMismatches: StatisticsTeamspaceMonthMismatch[];
|
teamspaceMonthMismatches: StatisticsTeamspaceMonthMismatch[];
|
||||||
|
ticketTeamspaceMismatches: StatisticsTeamspaceDay[];
|
||||||
};
|
};
|
||||||
teamspaceDays: 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { ChangeEvent, FormEvent, useEffect, useState } from "react";
|
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 { toast } from "sonner";
|
||||||
|
|
||||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||||
@@ -14,17 +14,21 @@ import {
|
|||||||
createAdminUser,
|
createAdminUser,
|
||||||
downloadDatabaseExport,
|
downloadDatabaseExport,
|
||||||
getAdminUsers,
|
getAdminUsers,
|
||||||
|
getOrganizationAttributeOptions,
|
||||||
|
getTeamspaceAssignmentRules,
|
||||||
getTeamspaceSettings,
|
getTeamspaceSettings,
|
||||||
getTeamspaceProjectExclusions,
|
getTeamspaceProjectExclusions,
|
||||||
getZammadSettings,
|
|
||||||
restoreDatabaseBackup,
|
restoreDatabaseBackup,
|
||||||
saveZammadSettings,
|
|
||||||
saveTeamspaceSettings,
|
saveTeamspaceSettings,
|
||||||
|
saveTeamspaceAssignmentRules,
|
||||||
saveTeamspaceProjectExclusions,
|
saveTeamspaceProjectExclusions,
|
||||||
syncZammadOrganizations,
|
syncAdminUserTeamspaceInitialActuals,
|
||||||
|
unlockAdminUserTeamspaceInitialActuals,
|
||||||
updateAdminUser,
|
updateAdminUser,
|
||||||
validateDatabaseBackup,
|
validateDatabaseBackup,
|
||||||
type TeamspaceProjectExclusion,
|
type TeamspaceProjectExclusion,
|
||||||
|
type TeamspaceAssignmentRule,
|
||||||
|
type OrganizationAttributeOption,
|
||||||
type RestoreValidationResult
|
type RestoreValidationResult
|
||||||
} from "../api";
|
} from "../api";
|
||||||
import type { AdminUser, AuthUser } from "../types";
|
import type { AdminUser, AuthUser } from "../types";
|
||||||
@@ -56,17 +60,15 @@ export function AdminUsersPage({ currentUser }: { currentUser: AuthUser }) {
|
|||||||
const [users, setUsers] = useState<AdminUser[]>([]);
|
const [users, setUsers] = useState<AdminUser[]>([]);
|
||||||
const [forms, setForms] = useState<Record<string, UserFormState>>({});
|
const [forms, setForms] = useState<Record<string, UserFormState>>({});
|
||||||
const [newUser, setNewUser] = useState<UserFormState>(emptyForm);
|
const [newUser, setNewUser] = useState<UserFormState>(emptyForm);
|
||||||
const [zammadBaseUrl, setZammadBaseUrl] = useState("");
|
|
||||||
const [zammadApiKey, setZammadApiKey] = useState("");
|
|
||||||
const [hasZammadApiKey, setHasZammadApiKey] = useState(false);
|
|
||||||
const [teamspaceBaseUrl, setTeamspaceBaseUrl] = useState("");
|
const [teamspaceBaseUrl, setTeamspaceBaseUrl] = useState("");
|
||||||
const [teamspaceCredentialsUserId, setTeamspaceCredentialsUserId] = useState("");
|
const [teamspaceCredentialsUserId, setTeamspaceCredentialsUserId] = useState("");
|
||||||
const [teamspaceProjectExclusions, setTeamspaceProjectExclusions] = useState<TeamspaceProjectExclusion[]>([]);
|
const [teamspaceProjectExclusions, setTeamspaceProjectExclusions] = useState<TeamspaceProjectExclusion[]>([]);
|
||||||
|
const [organizationAttributeOptions, setOrganizationAttributeOptions] = useState<OrganizationAttributeOption[]>([]);
|
||||||
|
const [teamspaceAssignmentRules, setTeamspaceAssignmentRules] = useState<TeamspaceAssignmentRule[]>([]);
|
||||||
const [newTeamspaceProjectExclusion, setNewTeamspaceProjectExclusion] = useState("");
|
const [newTeamspaceProjectExclusion, setNewTeamspaceProjectExclusion] = useState("");
|
||||||
const [syncingOrganizations, setSyncingOrganizations] = useState(false);
|
|
||||||
const [savingZammadSettings, setSavingZammadSettings] = useState(false);
|
|
||||||
const [savingTeamspaceSettings, setSavingTeamspaceSettings] = useState(false);
|
const [savingTeamspaceSettings, setSavingTeamspaceSettings] = useState(false);
|
||||||
const [savingTeamspaceProjectExclusions, setSavingTeamspaceProjectExclusions] = useState(false);
|
const [savingTeamspaceProjectExclusions, setSavingTeamspaceProjectExclusions] = useState(false);
|
||||||
|
const [savingTeamspaceAssignmentRules, setSavingTeamspaceAssignmentRules] = useState(false);
|
||||||
const [exportingDatabase, setExportingDatabase] = useState(false);
|
const [exportingDatabase, setExportingDatabase] = useState(false);
|
||||||
const [restoreSql, setRestoreSql] = useState("");
|
const [restoreSql, setRestoreSql] = useState("");
|
||||||
const [restoreValidation, setRestoreValidation] = useState<RestoreValidationResult | null>(null);
|
const [restoreValidation, setRestoreValidation] = useState<RestoreValidationResult | null>(null);
|
||||||
@@ -74,25 +76,29 @@ export function AdminUsersPage({ currentUser }: { currentUser: AuthUser }) {
|
|||||||
const [restoringDatabase, setRestoringDatabase] = useState(false);
|
const [restoringDatabase, setRestoringDatabase] = useState(false);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [savingId, setSavingId] = useState<string | null>(null);
|
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);
|
const [creating, setCreating] = useState(false);
|
||||||
|
|
||||||
async function load(options: { preserveEdits?: boolean } = {}) {
|
async function load(options: { preserveEdits?: boolean } = {}) {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const [usersResult, zammadSettingsResult, teamspaceSettingsResult, teamspaceProjectExclusionsResult] = await Promise.all([
|
const [usersResult, teamspaceSettingsResult, teamspaceProjectExclusionsResult, organizationAttributeOptionsResult, teamspaceAssignmentRulesResult] = await Promise.all([
|
||||||
getAdminUsers(),
|
getAdminUsers(),
|
||||||
getZammadSettings(),
|
|
||||||
getTeamspaceSettings(),
|
getTeamspaceSettings(),
|
||||||
getTeamspaceProjectExclusions()
|
getTeamspaceProjectExclusions(),
|
||||||
|
getOrganizationAttributeOptions(),
|
||||||
|
getTeamspaceAssignmentRules()
|
||||||
]);
|
]);
|
||||||
setUsers(usersResult.users);
|
setUsers(usersResult.users);
|
||||||
|
|
||||||
if (!options.preserveEdits) {
|
if (!options.preserveEdits) {
|
||||||
setZammadBaseUrl(zammadSettingsResult.settings.baseUrl);
|
|
||||||
setHasZammadApiKey(zammadSettingsResult.settings.hasApiKey);
|
|
||||||
setTeamspaceBaseUrl(teamspaceSettingsResult.settings.baseUrl);
|
setTeamspaceBaseUrl(teamspaceSettingsResult.settings.baseUrl);
|
||||||
setTeamspaceCredentialsUserId(teamspaceSettingsResult.settings.credentialsUserId ?? "");
|
setTeamspaceCredentialsUserId(teamspaceSettingsResult.settings.credentialsUserId ?? "");
|
||||||
setTeamspaceProjectExclusions(teamspaceProjectExclusionsResult.exclusions);
|
setTeamspaceProjectExclusions(teamspaceProjectExclusionsResult.exclusions);
|
||||||
|
setOrganizationAttributeOptions(organizationAttributeOptionsResult.options);
|
||||||
|
setTeamspaceAssignmentRules(teamspaceAssignmentRulesResult.rules);
|
||||||
}
|
}
|
||||||
|
|
||||||
setForms((current) =>
|
setForms((current) =>
|
||||||
@@ -185,49 +191,64 @@ export function AdminUsersPage({ currentUser }: { currentUser: AuthUser }) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function syncOrganizations(event: FormEvent) {
|
function initialActualsLocked(user: AdminUser) {
|
||||||
event.preventDefault();
|
return Boolean(
|
||||||
setSyncingOrganizations(true);
|
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 {
|
try {
|
||||||
const result = await syncZammadOrganizations({
|
const result = await syncAdminUserTeamspaceInitialActuals(user.id, {
|
||||||
baseUrl: zammadBaseUrl,
|
fromMonth: initialActualsFromMonthByUser[user.id] || undefined
|
||||||
apiKey: zammadApiKey.trim() || undefined
|
|
||||||
});
|
});
|
||||||
toast.success("Organisationen synchronisiert", {
|
toast.success("TS-Istzeiten übernommen", {
|
||||||
description: `${result.synced} gespeichert, ${result.removed} entfernt, ${result.unlinkedTickets + result.unlinkedSessions} Verknüpfung(en) gelöst.`
|
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);
|
await load();
|
||||||
setZammadApiKey("");
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
toast.error("Zammad-Sync fehlgeschlagen", {
|
toast.error("TS-Istzeiten konnten nicht übernommen werden", {
|
||||||
description: error instanceof Error ? error.message : "Unbekannter Fehler"
|
description: error instanceof Error ? error.message : "Unbekannter Fehler"
|
||||||
});
|
});
|
||||||
} finally {
|
} finally {
|
||||||
setSyncingOrganizations(false);
|
setSyncingInitialActualsId(null);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function saveZammadAccess() {
|
async function unlockInitialActuals(user: AdminUser) {
|
||||||
setSavingZammadSettings(true);
|
if (!window.confirm(`Initialen Teamspace-Istzeiten-Sync für ${user.display_name} wieder freigeben?`)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setUnlockingInitialActualsId(user.id);
|
||||||
try {
|
try {
|
||||||
const result = await saveZammadSettings({
|
await unlockAdminUserTeamspaceInitialActuals(user.id);
|
||||||
baseUrl: zammadBaseUrl,
|
toast.success("Initialsync entsperrt");
|
||||||
apiKey: zammadApiKey.trim() || undefined
|
await load({ preserveEdits: true });
|
||||||
});
|
|
||||||
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) {
|
} 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"
|
description: error instanceof Error ? error.message : "Unbekannter Fehler"
|
||||||
});
|
});
|
||||||
} finally {
|
} finally {
|
||||||
setSavingZammadSettings(false);
|
setUnlockingInitialActualsId(null);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -274,6 +295,13 @@ export function AdminUsersPage({ currentUser }: { currentUser: AuthUser }) {
|
|||||||
teamspace_project: project,
|
teamspace_project: project,
|
||||||
teamspace_name: null,
|
teamspace_name: null,
|
||||||
customer_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: ""
|
note: ""
|
||||||
}
|
}
|
||||||
]);
|
]);
|
||||||
@@ -284,11 +312,26 @@ export function AdminUsersPage({ currentUser }: { currentUser: AuthUser }) {
|
|||||||
setTeamspaceProjectExclusions((current) => current.filter((entry) => entry.teamspace_project !== project));
|
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() {
|
async function saveTeamspaceExclusions() {
|
||||||
setSavingTeamspaceProjectExclusions(true);
|
setSavingTeamspaceProjectExclusions(true);
|
||||||
|
|
||||||
try {
|
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);
|
const result = await saveTeamspaceProjectExclusions(projects);
|
||||||
setTeamspaceProjectExclusions(result.exclusions);
|
setTeamspaceProjectExclusions(result.exclusions);
|
||||||
toast.success("Teamspace-Ausnahmen gespeichert", {
|
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() {
|
async function exportDatabase() {
|
||||||
setExportingDatabase(true);
|
setExportingDatabase(true);
|
||||||
|
|
||||||
@@ -408,6 +504,16 @@ export function AdminUsersPage({ currentUser }: { currentUser: AuthUser }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const regularUserCount = users.filter((user) => user.role === "user").length;
|
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 (
|
return (
|
||||||
<div className="space-y-4">
|
<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>
|
<p className="text-sm text-muted-foreground">Systemverwaltung für Benutzer, Zammad-Organisationen und restore-fähige Backups.</p>
|
||||||
</div>
|
</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">
|
<Card className="min-w-0">
|
||||||
<CardHeader className="p-4">
|
<CardHeader className="p-4">
|
||||||
<div className="flex items-start justify-between gap-3">
|
<div className="flex items-start justify-between gap-3">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<DatabaseZap className="size-5 text-muted-foreground" />
|
<Unplug className="size-5 text-muted-foreground" />
|
||||||
<CardTitle>Data-Sync</CardTitle>
|
<CardTitle>Teamspace API</CardTitle>
|
||||||
</div>
|
</div>
|
||||||
<HelpLink anchor="organisationen" label="Hilfe zum Zammad-Sync" />
|
<HelpLink anchor="admin" label="Hilfe zu Teamspace API" />
|
||||||
</div>
|
</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>
|
</CardHeader>
|
||||||
<CardContent className="px-4 pb-4">
|
<CardContent className="px-4 pb-4">
|
||||||
<form className="grid gap-3 xl:grid-cols-2" onSubmit={syncOrganizations}>
|
<div className="space-y-4">
|
||||||
<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="grid gap-3 xl:grid-cols-[minmax(220px,1fr)_minmax(220px,1fr)_auto] xl:items-end">
|
<div className="grid gap-3 xl:grid-cols-[minmax(220px,1fr)_minmax(220px,1fr)_auto] xl:items-end">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label className="text-sm font-medium" htmlFor="teamspace-base-url">Teamspace URL</label>
|
<label className="text-sm font-medium" htmlFor="teamspace-base-url">Teamspace URL</label>
|
||||||
@@ -502,7 +572,7 @@ export function AdminUsersPage({ currentUser }: { currentUser: AuthUser }) {
|
|||||||
<div>
|
<div>
|
||||||
<p className="text-sm font-medium">TS-Projekte ohne Differenz-/Budgetprüfung</p>
|
<p className="text-sm font-medium">TS-Projekte ohne Differenz-/Budgetprüfung</p>
|
||||||
<p className="text-xs text-muted-foreground">
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-2 sm:flex-row">
|
<div className="flex flex-col gap-2 sm:flex-row">
|
||||||
@@ -523,22 +593,54 @@ export function AdminUsersPage({ currentUser }: { currentUser: AuthUser }) {
|
|||||||
Hinzufügen
|
Hinzufügen
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<div className="overflow-hidden rounded-md border bg-background">
|
<div className="overflow-x-auto rounded-md border bg-background">
|
||||||
{teamspaceProjectExclusions.length === 0 ? (
|
{teamspaceProjectExclusions.length === 0 ? (
|
||||||
<p className="px-3 py-4 text-sm text-muted-foreground">Noch keine Teamspace-Projekte ausgenommen.</p>
|
<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) => (
|
{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="font-mono text-sm font-semibold">{entry.teamspace_project}</div>
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<p className="truncate text-sm">{entry.teamspace_name ?? "Name 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>
|
||||||
<p className="text-xs text-muted-foreground sm:hidden">{entry.customer_name ?? "Kunde 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>
|
||||||
<div className="hidden min-w-0 text-sm text-muted-foreground sm:block">
|
<div className="min-w-0 text-sm text-muted-foreground">
|
||||||
<span className="truncate">{entry.customer_name ?? "Kunde wird beim Speichern aus Teamspace geladen"}</span>
|
<p className="truncate">{entry.customer_name ?? "Kunde wird beim Speichern aus Teamspace geladen"}</p>
|
||||||
</div>
|
</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" />
|
<Trash2 className="size-4" />
|
||||||
<span className="sr-only">Teamspace-Projekt entfernen</span>
|
<span className="sr-only">Teamspace-Projekt entfernen</span>
|
||||||
</Button>
|
</Button>
|
||||||
@@ -552,6 +654,135 @@ export function AdminUsersPage({ currentUser }: { currentUser: AuthUser }) {
|
|||||||
{savingTeamspaceProjectExclusions ? "Speichert..." : "Ausnahmen speichern"}
|
{savingTeamspaceProjectExclusions ? "Speichert..." : "Ausnahmen speichern"}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
@@ -671,6 +902,7 @@ export function AdminUsersPage({ currentUser }: { currentUser: AuthUser }) {
|
|||||||
<TableHead>Rolle</TableHead>
|
<TableHead>Rolle</TableHead>
|
||||||
<TableHead>Status</TableHead>
|
<TableHead>Status</TableHead>
|
||||||
<TableHead>Neues Passwort</TableHead>
|
<TableHead>Neues Passwort</TableHead>
|
||||||
|
<TableHead>TS-Istzeiten</TableHead>
|
||||||
<TableHead />
|
<TableHead />
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
@@ -705,6 +937,45 @@ export function AdminUsersPage({ currentUser }: { currentUser: AuthUser }) {
|
|||||||
onChange={(event) => updateForm(user.id, { password: event.currentTarget.value })}
|
onChange={(event) => updateForm(user.id, { password: event.currentTarget.value })}
|
||||||
/>
|
/>
|
||||||
</TableCell>
|
</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">
|
<TableCell className="text-right">
|
||||||
<Button size="sm" onClick={() => void saveUser(user)} disabled={savingId === user.id}>
|
<Button size="sm" onClick={() => void saveUser(user)} disabled={savingId === user.id}>
|
||||||
<Save className="size-4" />
|
<Save className="size-4" />
|
||||||
@@ -740,6 +1011,41 @@ export function AdminUsersPage({ currentUser }: { currentUser: AuthUser }) {
|
|||||||
value={form.password}
|
value={form.password}
|
||||||
onChange={(event) => updateForm(user.id, { password: event.currentTarget.value })}
|
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">
|
<div className="flex items-center justify-between gap-2">
|
||||||
<label className="flex items-center gap-2 text-sm">
|
<label className="flex items-center gap-2 text-sm">
|
||||||
<Switch checked={form.active} disabled={isFixedAdmin} onCheckedChange={(active) => updateForm(user.id, { active })} />
|
<Switch checked={form.active} disabled={isFixedAdmin} onCheckedChange={(active) => updateForm(user.id, { active })} />
|
||||||
|
|||||||
@@ -194,7 +194,7 @@ function TeamspaceDeviationIcon({ ticket }: { ticket: TicketSummary }) {
|
|||||||
return <span className="inline-block size-4" aria-hidden="true" />;
|
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 (
|
return (
|
||||||
<span title={`${formatSignedMinutes(deltaMinutes)} · ${title}`} aria-label={`Teamspace-Abweichung: ${title}`}>
|
<span title={`${formatSignedMinutes(deltaMinutes)} · ${title}`} aria-label={`Teamspace-Abweichung: ${title}`}>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -7,12 +7,10 @@ import {
|
|||||||
FileWarning,
|
FileWarning,
|
||||||
Lock,
|
Lock,
|
||||||
LockOpen,
|
LockOpen,
|
||||||
Sparkles,
|
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { useEffect, useMemo, useRef, useState } from "react";
|
import { useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
|
||||||
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion";
|
|
||||||
import { Alert } from "@/components/ui/alert";
|
import { Alert } from "@/components/ui/alert";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
@@ -21,8 +19,8 @@ import { Checkbox } from "@/components/ui/checkbox";
|
|||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { CopyTicketButton } from "@/components/CopyTicketButton";
|
import { CopyTicketButton } from "@/components/CopyTicketButton";
|
||||||
import { HelpLink } from "@/components/HelpLink";
|
import { HelpLink } from "@/components/HelpLink";
|
||||||
import { acknowledgeMissingTicketDayBilling, closePeriod, getStatisticsOverview, reopenPeriod } from "../api";
|
import { closePeriod, getStatisticsOverview, reopenPeriod } from "../api";
|
||||||
import { currentMonth, currentWeek, formatDate, formatDateTime, formatMinutes } from "../format";
|
import { currentMonth, currentWeek, formatDateTime, formatMinutes } from "../format";
|
||||||
import type { AuthUser, StatisticsCrmDay, StatisticsOpenSession, StatisticsOverview, StatisticsTeamspaceDay, StatisticsTeamspaceMonthMismatch } from "../types";
|
import type { AuthUser, StatisticsCrmDay, StatisticsOpenSession, StatisticsOverview, StatisticsTeamspaceDay, StatisticsTeamspaceMonthMismatch } from "../types";
|
||||||
|
|
||||||
type MonthlyClosePageProps = {
|
type MonthlyClosePageProps = {
|
||||||
@@ -30,8 +28,6 @@ type MonthlyClosePageProps = {
|
|||||||
onNavigate: (to: string) => void;
|
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 showAcknowledgedMissingCrmStorageKey = "tickettracker.monthlyClose.showAcknowledgedMissingCrm";
|
||||||
const monthlyCloseViewModeStorageKey = "tickettracker.monthlyClose.viewMode";
|
const monthlyCloseViewModeStorageKey = "tickettracker.monthlyClose.viewMode";
|
||||||
|
|
||||||
@@ -45,28 +41,16 @@ function readMonthlyCloseViewMode(): MonthlyCloseViewMode {
|
|||||||
return window.localStorage.getItem(monthlyCloseViewModeStorageKey) === "week" ? "week" : "month";
|
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) {
|
function formatMissingTarget(minutes: number, teamspaceTimeTrackingEnabled: boolean) {
|
||||||
if (minutes === 0) {
|
if (minutes === 0) {
|
||||||
return "ausgeglichen";
|
return "ausgeglichen";
|
||||||
}
|
}
|
||||||
|
|
||||||
if (minutes > 0) {
|
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" }) {
|
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">
|
<CardContent className="p-3">
|
||||||
<p className="text-xs font-medium text-muted-foreground">{label}</p>
|
<p className="text-xs font-medium text-muted-foreground">{label}</p>
|
||||||
<p className={`text-xl font-semibold tracking-normal ${valueClassName}`}>{value}</p>
|
<p className={`text-xl font-semibold tracking-normal ${valueClassName}`}>{value}</p>
|
||||||
<p className="text-xs text-muted-foreground ">{detail}</p>
|
<p className="text-xs text-muted-foreground whitespace-pre-line">{detail}</p>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
type OpenTicketGroup = {
|
type MonthlyCloseTicketDay = {
|
||||||
|
day: string;
|
||||||
|
missingCrm: StatisticsCrmDay | null;
|
||||||
|
ticketMismatch: StatisticsTeamspaceDay | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
type MonthlyCloseTicketGroup = {
|
||||||
ticketId: string;
|
ticketId: string;
|
||||||
ticketNumber: string;
|
ticketNumber: string;
|
||||||
organizationName: string;
|
organizationName: string;
|
||||||
month: string;
|
month: string;
|
||||||
openCount: number;
|
openCount: number;
|
||||||
totalMinutes: number;
|
totalMinutes: number;
|
||||||
};
|
|
||||||
|
|
||||||
type OpenReviewGroup = OpenTicketGroup & {
|
|
||||||
mismatch: StatisticsTeamspaceMonthMismatch | null;
|
mismatch: StatisticsTeamspaceMonthMismatch | null;
|
||||||
|
days: MonthlyCloseTicketDay[];
|
||||||
};
|
};
|
||||||
|
|
||||||
function groupOpenSessions(sessions: StatisticsOpenSession[]) {
|
type MutableMonthlyCloseTicketGroup = Omit<MonthlyCloseTicketGroup, "days"> & {
|
||||||
const groups = new Map<string, OpenTicketGroup>();
|
days: Map<string, MonthlyCloseTicketDay>;
|
||||||
|
};
|
||||||
|
|
||||||
for (const session of sessions) {
|
const monthlyCloseTicketGridClassName =
|
||||||
const existing = groups.get(session.ticket_id);
|
"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) {
|
if (existing) {
|
||||||
existing.openCount += 1;
|
if (!existing.organizationName && organizationName) {
|
||||||
existing.totalMinutes += session.rounded_minutes;
|
existing.organizationName = organizationName;
|
||||||
continue;
|
}
|
||||||
|
|
||||||
|
return existing;
|
||||||
}
|
}
|
||||||
|
|
||||||
groups.set(session.ticket_id, {
|
const group: MutableMonthlyCloseTicketGroup = {
|
||||||
ticketId: session.ticket_id,
|
ticketId,
|
||||||
ticketNumber: session.ticket_number,
|
ticketNumber,
|
||||||
organizationName: session.organization_name,
|
organizationName,
|
||||||
month: session.started_at.slice(0, 7),
|
|
||||||
openCount: 1,
|
|
||||||
totalMinutes: session.rounded_minutes
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
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,
|
|
||||||
month,
|
month,
|
||||||
openCount: 0,
|
openCount: 0,
|
||||||
totalMinutes: 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,
|
group,
|
||||||
teamspaceTimeTrackingEnabled,
|
|
||||||
onNavigate
|
onNavigate
|
||||||
}: {
|
}: {
|
||||||
group: OpenReviewGroup;
|
group: MonthlyCloseTicketGroup;
|
||||||
teamspaceTimeTrackingEnabled: boolean;
|
|
||||||
onNavigate: (to: string) => void;
|
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 (
|
return (
|
||||||
<div className="rounded-md border bg-background px-3 py-2">
|
<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="flex items-start justify-between gap-3">
|
<div className="min-w-0 sm:col-span-2 xl:col-span-1">
|
||||||
<div className="min-w-0 space-y-1">
|
<p className="font-medium">{group.ticketNumber}</p>
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
<p className="truncate text-xs text-muted-foreground">{group.organizationName}</p>
|
||||||
<span className="font-medium">{group.ticketNumber}</span>
|
</div>
|
||||||
<CopyTicketButton ticketNumber={group.ticketNumber} />
|
<div className={metricClassName(group.openCount > 0)}>
|
||||||
{group.openCount > 0 ? <Badge variant="warning" className="text-xxs">{group.openCount} Bewertung(en)</Badge> : null}
|
<span className="text-[10px] font-medium uppercase tracking-wide text-muted-foreground xl:hidden">Offene Bewertungen</span>
|
||||||
{group.mismatch ? <Badge variant="warning" className="text-xxs">{formatMissingTarget(group.mismatch.teamspace_month_delta_minutes, teamspaceTimeTrackingEnabled)}</Badge> : null}
|
{group.openCount > 0 ? (
|
||||||
</div>
|
<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)}`}>
|
||||||
<p className="truncate text-sm text-muted-foreground">{group.organizationName}</p>
|
{group.openCount}
|
||||||
<div className="flex flex-wrap gap-x-3 gap-y-1 text-xs">
|
</Badge>
|
||||||
{group.openCount > 0 ? <span className={trackedTextClass}>{formatMinutes(group.totalMinutes)} offen zu bewerten</span> : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<div className={metricClassName(Boolean(group.mismatch))}>
|
||||||
<Button className="h-8 shrink-0" variant="secondary" size="sm" onClick={() => onNavigate(`/tickets/${group.ticketId}?month=${group.month}`)}>
|
<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} />
|
||||||
|
<Button className="h-8" variant="secondary" size="sm" onClick={() => onNavigate(`/tickets/${group.ticketId}?month=${group.month}`)}>
|
||||||
Ticket öffnen
|
Ticket öffnen
|
||||||
<ExternalLink className="size-4" />
|
<ExternalLink className="size-4" />
|
||||||
</Button>
|
</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) {
|
export function MonthlyClosePage({ currentUser, onNavigate }: MonthlyClosePageProps) {
|
||||||
const [viewMode, setViewMode] = useState<MonthlyCloseViewMode>(() => readMonthlyCloseViewMode());
|
const [viewMode, setViewMode] = useState<MonthlyCloseViewMode>(() => readMonthlyCloseViewMode());
|
||||||
const [month, setMonth] = useState(currentMonth());
|
const [month, setMonth] = useState(currentMonth());
|
||||||
@@ -262,7 +248,6 @@ export function MonthlyClosePage({ currentUser, onNavigate }: MonthlyClosePagePr
|
|||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [closing, setClosing] = useState(false);
|
const [closing, setClosing] = useState(false);
|
||||||
const [reopening, setReopening] = useState(false);
|
const [reopening, setReopening] = useState(false);
|
||||||
const [acknowledgingKeys, setAcknowledgingKeys] = useState<Set<string>>(() => new Set());
|
|
||||||
const [showAcknowledgedMissingCrm, setShowAcknowledgedMissingCrm] = useState(false);
|
const [showAcknowledgedMissingCrm, setShowAcknowledgedMissingCrm] = useState(false);
|
||||||
const [showAcknowledgedMissingCrmLoaded, setShowAcknowledgedMissingCrmLoaded] = useState(false);
|
const [showAcknowledgedMissingCrmLoaded, setShowAcknowledgedMissingCrmLoaded] = useState(false);
|
||||||
const loadRequestId = useRef(0);
|
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 openSessions = stats?.attention.openSessions ?? [];
|
||||||
const missingCrmDays = stats?.attention.missingCrmDays ?? [];
|
const missingCrmDays = stats?.attention.missingCrmDays ?? [];
|
||||||
const acknowledgedMissingCrmDays = stats?.attention.acknowledgedMissingCrmDays ?? [];
|
const acknowledgedMissingCrmDays = stats?.attention.acknowledgedMissingCrmDays ?? [];
|
||||||
const teamspaceMonthMismatches = stats?.attention.teamspaceMonthMismatches ?? [];
|
const teamspaceMonthMismatches = stats?.attention.teamspaceMonthMismatches ?? [];
|
||||||
const teamspaceDays = stats?.teamspaceDays ?? [];
|
const ticketTeamspaceMismatches = stats?.attention.ticketTeamspaceMismatches ?? [];
|
||||||
const billedMinutes = stats?.totals.billedMinutes ?? 0;
|
const billedMinutes = stats?.totals.billedMinutes ?? 0;
|
||||||
const ttTeamspaceMinutes = stats?.totals.crmBilledMinutes ?? 0;
|
const ttTeamspaceMinutes = stats?.totals.crmBilledMinutes ?? 0;
|
||||||
const teamspaceApiBillableMinutes = stats?.totals.teamspaceApiBillableMinutes ?? 0;
|
const teamspaceApiBillableMinutes = stats?.totals.teamspaceApiBillableMinutes ?? 0;
|
||||||
const teamspaceApiDeltaMinutes = stats?.totals.teamspaceApiDeltaMinutes ?? 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 visibleMissingCrmDays = useMemo(() => {
|
||||||
const days = showAcknowledgedMissingCrm ? [...missingCrmDays, ...acknowledgedMissingCrmDays] : [...missingCrmDays];
|
const days = showAcknowledgedMissingCrm ? [...missingCrmDays, ...acknowledgedMissingCrmDays] : [...missingCrmDays];
|
||||||
return days.sort((left, right) => left.day.localeCompare(right.day) || left.ticket_number.localeCompare(right.ticket_number, "de", { numeric: true, sensitivity: "base" }));
|
return days.sort((left, right) => left.day.localeCompare(right.day) || left.ticket_number.localeCompare(right.ticket_number, "de", { numeric: true, sensitivity: "base" }));
|
||||||
}, [acknowledgedMissingCrmDays, missingCrmDays, showAcknowledgedMissingCrm]);
|
}, [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 canClose = Boolean(isMonthView && stats && !stats.closed && stats.totals.sessions > 0 && openSessions.length === 0);
|
||||||
const checklist = useMemo(
|
const checklist = useMemo(
|
||||||
() => [
|
() => [
|
||||||
{
|
{
|
||||||
label: "Offene Prüfungen",
|
label: "Tickets zu prüfen",
|
||||||
ok: openReviewGroups.length === 0,
|
ok: activeReviewTicketGroups.length === 0,
|
||||||
value: openReviewGroups.length,
|
value: activeReviewTicketGroups.length,
|
||||||
detail: openReviewGroups.length === 0 ? "Keine offenen Prüfungen." : `${openReviewGroups.length} Ticket(s) prüfen`
|
detail: activeReviewTicketGroups.length === 0 ? "Keine offenen Prüfpunkte." : `${activeReviewTicketGroups.length} Ticket(s) mit mindestens einem Prüfpunkt`
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "Teamspace geprüft",
|
label: "Teamspace geprüft",
|
||||||
ok: missingCrmDays.length === 0 && teamspaceMonthMismatches.length === 0,
|
ok: teamspaceAttentionTicketCount === 0,
|
||||||
value: missingCrmDays.length + teamspaceMonthMismatches.length,
|
value: teamspaceAttentionTicketCount,
|
||||||
detail: missingCrmDays.length === 0 && teamspaceMonthMismatches.length === 0 ? "Keine offenen Teamspace-Prüfpunkte." : `${teamspaceMonthMismatches.length} Monatsabweichung(en), ${acknowledgedMissingCrmDays.length} zur Kenntnis genommen`
|
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",
|
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."
|
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 (
|
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 className="flex flex-col gap-3 xl:flex-row xl:items-end xl:justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-2xl font-semibold tracking-normal">Monatsabschluss</h2>
|
<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>
|
||||||
<div className="grid gap-2 sm:grid-cols-[auto_minmax(220px,300px)_auto] sm:items-end">
|
<div className="grid gap-2 sm:grid-cols-[auto_minmax(220px,300px)_auto] sm:items-end">
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
@@ -509,21 +483,21 @@ export function MonthlyClosePage({ currentUser, onNavigate }: MonthlyClosePagePr
|
|||||||
detail={`${formatMinutes(stats?.totals.minutes ?? 0)} abgerechnet`}
|
detail={`${formatMinutes(stats?.totals.minutes ?? 0)} abgerechnet`}
|
||||||
/>
|
/>
|
||||||
<StatusCard
|
<StatusCard
|
||||||
label="Teamspace-Werte"
|
label="TT-Teamspace"
|
||||||
value={formatMinutes(ttTeamspaceMinutes)}
|
value={formatMinutes(ttTeamspaceMinutes)}
|
||||||
detail={`aus TS API ${formatMinutes(teamspaceApiBillableMinutes)}\n${formatMissingTarget(teamspaceApiDeltaMinutes, teamspaceTimeTrackingEnabled)}`}
|
detail={`aus TS API ${formatMinutes(teamspaceApiBillableMinutes)}\n${formatMissingTarget(teamspaceApiDeltaMinutes, teamspaceTimeTrackingEnabled)}`}
|
||||||
tone={teamspaceApiDeltaMinutes !== 0 ? "warn" : "ok"}
|
tone={teamspaceApiDeltaMinutes !== 0 ? "warn" : "ok"}
|
||||||
/>
|
/>
|
||||||
<StatusCard
|
<StatusCard
|
||||||
label="Offene Prüfungen"
|
label="Tickets zu prüfen"
|
||||||
value={openReviewGroups.length}
|
value={activeReviewTicketGroups.length}
|
||||||
detail={`${openSessions.length} Bewertung(en)\n${teamspaceMonthMismatches.length}TS-Abweichung(en)`}
|
detail={`${openSessions.length} offene Bewertung(en)\n${teamspaceAttentionTicketCount} Ticket(s) mit TS-Prüfpunkten`}
|
||||||
tone={openReviewGroups.length > 0 ? "warn" : "ok"}
|
tone={activeReviewTicketGroups.length > 0 ? "warn" : "ok"}
|
||||||
/>
|
/>
|
||||||
<StatusCard
|
<StatusCard
|
||||||
label="Ohne Teamspace"
|
label="Ohne TT-Teamspace"
|
||||||
value={missingCrmDays.length}
|
value={missingCrmTicketCount}
|
||||||
detail={`${acknowledgedMissingCrmDays.length} zur Kenntnis genommen`}
|
detail={`${missingCrmDays.length} Tag(e) · ${acknowledgedMissingCrmDays.length} zur Kenntnis`}
|
||||||
tone={missingCrmDays.length > 0 ? "warn" : "ok"}
|
tone={missingCrmDays.length > 0 ? "warn" : "ok"}
|
||||||
/>
|
/>
|
||||||
<StatusCard
|
<StatusCard
|
||||||
@@ -561,8 +535,11 @@ export function MonthlyClosePage({ currentUser, onNavigate }: MonthlyClosePagePr
|
|||||||
<CardHeader className="p-4">
|
<CardHeader className="p-4">
|
||||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
|
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
|
||||||
<div>
|
<div>
|
||||||
<CardTitle>Aufräumen</CardTitle>
|
<CardTitle className="flex items-center gap-2">
|
||||||
<CardDescription>Alles, was für {periodAttentionLabel} noch Aufmerksamkeit braucht.</CardDescription>
|
<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>
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
<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">
|
<div className="inline-flex h-8 items-center gap-2 rounded-md border bg-background px-3 text-sm">
|
||||||
@@ -577,63 +554,40 @@ export function MonthlyClosePage({ currentUser, onNavigate }: MonthlyClosePagePr
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="grid gap-4 px-4 pb-4 xl:grid-cols-2">
|
<CardContent className="space-y-3 px-4 pb-4">
|
||||||
<section className="space-y-2">
|
<div className="flex flex-wrap items-center justify-between gap-2 text-xs text-muted-foreground">
|
||||||
<div className="flex min-h-8 items-center gap-2 text-sm font-medium">
|
<span>{visibleReviewTicketGroups.length} Ticket(s) angezeigt</span>
|
||||||
<FileWarning className="size-4 text-muted-foreground" />
|
<span>Tagesdetails stehen im jeweiligen Ticket</span>
|
||||||
Offene Prüfungen
|
</div>
|
||||||
|
{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>
|
||||||
|
{visibleReviewTicketGroups.map((group) => (
|
||||||
|
<MonthlyCloseTicketItem
|
||||||
|
key={group.ticketId}
|
||||||
|
group={group}
|
||||||
|
onNavigate={onNavigate}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
{openReviewGroups.map((group) => (
|
) : (
|
||||||
<OpenReviewItem key={group.ticketId} group={group} teamspaceTimeTrackingEnabled={teamspaceTimeTrackingEnabled} onNavigate={onNavigate} />
|
<p className="rounded-md border bg-muted/20 p-3 text-sm text-muted-foreground">
|
||||||
))}
|
{acknowledgedMissingCrmDays.length > 0 && !showAcknowledgedMissingCrm
|
||||||
{openReviewGroups.length === 0 ? <p className="rounded-md border bg-muted/20 p-3 text-sm text-muted-foreground">Keine offenen Prüfungen.</p> : null}
|
? "Keine offenen Prüfpunkte. Zur Kenntnis genommene Tageswerte können eingeblendet werden."
|
||||||
</section>
|
: "Keine offenen Prüfpunkte für diesen Zeitraum."}
|
||||||
|
</p>
|
||||||
<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
|
|
||||||
</div>
|
|
||||||
{visibleMissingCrmDays.map((item) => (
|
|
||||||
<CrmDayItem
|
|
||||||
key={`${item.ticket_id}-${item.day}-missing`}
|
|
||||||
item={item}
|
|
||||||
busy={acknowledgingKeys.has(`${item.ticket_id}:${item.day}`)}
|
|
||||||
onSetAcknowledgement={setMissingCrmAcknowledgement}
|
|
||||||
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>
|
</CardContent>
|
||||||
</Card>
|
</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>
|
|
||||||
)}
|
|
||||||
</AccordionContent>
|
|
||||||
</AccordionItem>
|
|
||||||
</Accordion>
|
|
||||||
|
|
||||||
{stats && stats.totals.sessions === 0 ? (
|
{stats && stats.totals.sessions === 0 ? (
|
||||||
<Card>
|
<Card>
|
||||||
<CardContent className="grid place-items-center gap-2 p-8 text-center">
|
<CardContent className="grid place-items-center gap-2 p-8 text-center">
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/com
|
|||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Switch } from "@/components/ui/switch";
|
import { Switch } from "@/components/ui/switch";
|
||||||
import { HelpLink } from "@/components/HelpLink";
|
import { HelpLink } from "@/components/HelpLink";
|
||||||
|
import { ListClockIcon } from "@/components/icons/ListClockIcon";
|
||||||
import { getZammadSessionArticleQueue, retryZammadSessionArticleQueue, updateCurrentUser } from "../api";
|
import { getZammadSessionArticleQueue, retryZammadSessionArticleQueue, updateCurrentUser } from "../api";
|
||||||
import { formatDateTime, formatMinutes } from "../format";
|
import { formatDateTime, formatMinutes } from "../format";
|
||||||
import type { AuthUser, ZammadSessionArticleQueueItem } from "../types";
|
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 [zammadEmailSessionWebhookEnabled, setZammadEmailSessionWebhookEnabled] = useState(currentUser.zammad_email_session_webhook_enabled);
|
||||||
const [zammadAtossEmailSyncEnabled, setZammadAtossEmailSyncEnabled] = useState(currentUser.zammad_atoss_email_sync_enabled);
|
const [zammadAtossEmailSyncEnabled, setZammadAtossEmailSyncEnabled] = useState(currentUser.zammad_atoss_email_sync_enabled);
|
||||||
const [sessionBillingPromptEnabled, setSessionBillingPromptEnabled] = useState(currentUser.session_billing_prompt_enabled);
|
const [sessionBillingPromptEnabled, setSessionBillingPromptEnabled] = useState(currentUser.session_billing_prompt_enabled);
|
||||||
|
const [sessionWorkSegmentsEnabled, setSessionWorkSegmentsEnabled] = useState(currentUser.session_work_segments_enabled);
|
||||||
const [teamspaceTimeTrackingEnabled, setTeamspaceTimeTrackingEnabled] = useState(currentUser.teamspace_time_tracking_enabled ?? true);
|
const [teamspaceTimeTrackingEnabled, setTeamspaceTimeTrackingEnabled] = useState(currentUser.teamspace_time_tracking_enabled ?? true);
|
||||||
const [zammadSessionNoteDefaultEnabled, setZammadSessionNoteDefaultEnabled] = useState(currentUser.zammad_session_note_default_enabled);
|
const [zammadSessionNoteDefaultEnabled, setZammadSessionNoteDefaultEnabled] = useState(currentUser.zammad_session_note_default_enabled);
|
||||||
|
const [zammadSessionNoteDateHeaderEnabled, setZammadSessionNoteDateHeaderEnabled] = useState(currentUser.zammad_session_note_date_header_enabled);
|
||||||
const [zammadTimeUnitRoundingEnabled, setZammadTimeUnitRoundingEnabled] = useState(currentUser.zammad_time_unit_rounding_enabled);
|
const [zammadTimeUnitRoundingEnabled, setZammadTimeUnitRoundingEnabled] = useState(currentUser.zammad_time_unit_rounding_enabled);
|
||||||
const [zammadTimeUnitRoundingMinutes, setZammadTimeUnitRoundingMinutes] = useState(currentUser.zammad_time_unit_rounding_minutes);
|
const [zammadTimeUnitRoundingMinutes, setZammadTimeUnitRoundingMinutes] = useState(currentUser.zammad_time_unit_rounding_minutes);
|
||||||
const [zammadArticleTypeId, setZammadArticleTypeId] = useState<ZammadArticleTypeId>(currentUser.zammad_article_type_id ?? 6);
|
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);
|
setZammadEmailSessionWebhookEnabled(currentUser.zammad_email_session_webhook_enabled);
|
||||||
setZammadAtossEmailSyncEnabled(currentUser.zammad_atoss_email_sync_enabled);
|
setZammadAtossEmailSyncEnabled(currentUser.zammad_atoss_email_sync_enabled);
|
||||||
setSessionBillingPromptEnabled(currentUser.session_billing_prompt_enabled);
|
setSessionBillingPromptEnabled(currentUser.session_billing_prompt_enabled);
|
||||||
|
setSessionWorkSegmentsEnabled(currentUser.session_work_segments_enabled);
|
||||||
setTeamspaceTimeTrackingEnabled(currentUser.teamspace_time_tracking_enabled ?? true);
|
setTeamspaceTimeTrackingEnabled(currentUser.teamspace_time_tracking_enabled ?? true);
|
||||||
setZammadSessionNoteDefaultEnabled(currentUser.zammad_session_note_default_enabled);
|
setZammadSessionNoteDefaultEnabled(currentUser.zammad_session_note_default_enabled);
|
||||||
|
setZammadSessionNoteDateHeaderEnabled(currentUser.zammad_session_note_date_header_enabled);
|
||||||
setZammadTimeUnitRoundingEnabled(currentUser.zammad_time_unit_rounding_enabled);
|
setZammadTimeUnitRoundingEnabled(currentUser.zammad_time_unit_rounding_enabled);
|
||||||
setZammadTimeUnitRoundingMinutes(currentUser.zammad_time_unit_rounding_minutes);
|
setZammadTimeUnitRoundingMinutes(currentUser.zammad_time_unit_rounding_minutes);
|
||||||
setZammadArticleTypeId(currentUser.zammad_article_type_id ?? 6);
|
setZammadArticleTypeId(currentUser.zammad_article_type_id ?? 6);
|
||||||
@@ -148,8 +153,10 @@ export function ProfilePage({ currentUser, onUserUpdated }: ProfilePageProps) {
|
|||||||
zammadEmailSessionWebhookEnabled,
|
zammadEmailSessionWebhookEnabled,
|
||||||
zammadAtossEmailSyncEnabled,
|
zammadAtossEmailSyncEnabled,
|
||||||
sessionBillingPromptEnabled,
|
sessionBillingPromptEnabled,
|
||||||
|
sessionWorkSegmentsEnabled,
|
||||||
teamspaceTimeTrackingEnabled,
|
teamspaceTimeTrackingEnabled,
|
||||||
zammadSessionNoteDefaultEnabled,
|
zammadSessionNoteDefaultEnabled,
|
||||||
|
zammadSessionNoteDateHeaderEnabled,
|
||||||
zammadTimeUnitRoundingEnabled,
|
zammadTimeUnitRoundingEnabled,
|
||||||
zammadTimeUnitRoundingMinutes,
|
zammadTimeUnitRoundingMinutes,
|
||||||
zammadArticleTypeId,
|
zammadArticleTypeId,
|
||||||
@@ -442,6 +449,22 @@ export function ProfilePage({ currentUser, onUserUpdated }: ProfilePageProps) {
|
|||||||
onCheckedChange={setSessionBillingPromptEnabled}
|
onCheckedChange={setSessionBillingPromptEnabled}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="mt-3 flex items-center justify-between gap-3 rounded-md border bg-background p-3">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<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="mt-3 space-y-3 rounded-md border bg-background p-3">
|
||||||
<div className="flex items-center justify-between gap-3">
|
<div className="flex items-center justify-between gap-3">
|
||||||
<div>
|
<div>
|
||||||
@@ -547,6 +570,24 @@ export function ProfilePage({ currentUser, onUserUpdated }: ProfilePageProps) {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</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 ? (
|
{zammadSessionNoteDefaultEnabled ? (
|
||||||
<div className="mt-3 space-y-3">
|
<div className="mt-3 space-y-3">
|
||||||
<div className="rounded-md border bg-background p-3">
|
<div className="rounded-md border bg-background p-3">
|
||||||
|
|||||||
@@ -95,6 +95,7 @@ const projectStaticFilterOptions: ProjectFilterOption[] = [
|
|||||||
{ value: "open", label: "TT Offen" },
|
{ value: "open", label: "TT Offen" },
|
||||||
{ value: "closed", label: "TT Geschl." },
|
{ value: "closed", label: "TT Geschl." },
|
||||||
{ value: "hidden", label: "Ausgebl." },
|
{ value: "hidden", label: "Ausgebl." },
|
||||||
|
{ value: "teamspaceExcluded", label: "TS ausgenommen" },
|
||||||
{ value: "budgeted", label: "Mit Kontingent" },
|
{ value: "budgeted", label: "Mit Kontingent" },
|
||||||
{ value: "withoutBudget", label: "Ohne Kontingent" },
|
{ value: "withoutBudget", label: "Ohne Kontingent" },
|
||||||
{ value: "remaining", label: "Mit Rest" },
|
{ value: "remaining", label: "Mit Rest" },
|
||||||
@@ -499,6 +500,10 @@ function projectFilterGroup(filter: string) {
|
|||||||
return "visibility";
|
return "visibility";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (filter === "teamspaceExcluded") {
|
||||||
|
return "teamspaceExclusion";
|
||||||
|
}
|
||||||
|
|
||||||
if (filter.startsWith("zammadState:")) {
|
if (filter.startsWith("zammadState:")) {
|
||||||
return "zammadState";
|
return "zammadState";
|
||||||
}
|
}
|
||||||
@@ -523,6 +528,10 @@ function matchesSingleProjectFilter(project: ProjectSummary, filter: string, sta
|
|||||||
return Boolean(project.hidden_for_user);
|
return Boolean(project.hidden_for_user);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (filter === "teamspaceExcluded") {
|
||||||
|
return projectBudgetExcluded(project);
|
||||||
|
}
|
||||||
|
|
||||||
if (filter.startsWith("zammadState:")) {
|
if (filter.startsWith("zammadState:")) {
|
||||||
return matchesZammadStateFilter(project.zammad_state_id, filter.slice("zammadState:".length) as Parameters<typeof matchesZammadStateFilter>[1]);
|
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;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const showTeamspaceExcluded = filters.includes("teamspaceExcluded");
|
||||||
|
|
||||||
|
if (projectBudgetExcluded(project) !== showTeamspaceExcluded) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
return groupedProjectFilterMatch(filters, projectFilterGroup, (filter) => matchesSingleProjectFilter(project, filter, standardTeamspaceOnly));
|
return groupedProjectFilterMatch(filters, projectFilterGroup, (filter) => matchesSingleProjectFilter(project, filter, standardTeamspaceOnly));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -26,6 +26,9 @@ type RecurringFormState = {
|
|||||||
organizationName: string | null;
|
organizationName: string | null;
|
||||||
activity: string;
|
activity: string;
|
||||||
workType: WorkType;
|
workType: WorkType;
|
||||||
|
teamspaceProject: string;
|
||||||
|
hourBudgetHours: string;
|
||||||
|
manualConsumptionHours: string;
|
||||||
recurrenceType: "weekly" | "every_n_weeks";
|
recurrenceType: "weekly" | "every_n_weeks";
|
||||||
intervalValue: number;
|
intervalValue: number;
|
||||||
validFrom: string;
|
validFrom: string;
|
||||||
@@ -50,6 +53,9 @@ function emptyRecurringForm(): RecurringFormState {
|
|||||||
organizationName: null,
|
organizationName: null,
|
||||||
activity: "",
|
activity: "",
|
||||||
workType: "support",
|
workType: "support",
|
||||||
|
teamspaceProject: "",
|
||||||
|
hourBudgetHours: "",
|
||||||
|
manualConsumptionHours: "",
|
||||||
recurrenceType: "weekly",
|
recurrenceType: "weekly",
|
||||||
intervalValue: 1,
|
intervalValue: 1,
|
||||||
validFrom: currentDay(),
|
validFrom: currentDay(),
|
||||||
@@ -118,6 +124,9 @@ export function RecurringBillingsPage() {
|
|||||||
organizationName: billing.organization_name,
|
organizationName: billing.organization_name,
|
||||||
activity: billing.activity,
|
activity: billing.activity,
|
||||||
workType: billing.work_type,
|
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,
|
recurrenceType: billing.recurrence_type,
|
||||||
intervalValue: billing.recurrence_type === "weekly" ? 1 : billing.interval_value,
|
intervalValue: billing.recurrence_type === "weekly" ? 1 : billing.interval_value,
|
||||||
validFrom: billing.valid_from,
|
validFrom: billing.valid_from,
|
||||||
@@ -162,8 +171,26 @@ export function RecurringBillingsPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let slots: Array<{ id?: string; weekday: number | null; startTime: string; durationMinutes: number }>;
|
let slots: Array<{ id?: string; weekday: number | null; startTime: string; durationMinutes: number }>;
|
||||||
|
let hourBudgetMinutes: number | null;
|
||||||
|
let manualConsumptionMinutes: number;
|
||||||
|
|
||||||
try {
|
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) => {
|
slots = recurringForm.slots.map((slot) => {
|
||||||
const hours = Number(slot.durationHours.replace(",", "."));
|
const hours = Number(slot.durationHours.replace(",", "."));
|
||||||
|
|
||||||
@@ -191,6 +218,9 @@ export function RecurringBillingsPage() {
|
|||||||
organizationId: recurringForm.organizationId,
|
organizationId: recurringForm.organizationId,
|
||||||
activity: recurringForm.activity,
|
activity: recurringForm.activity,
|
||||||
workType: recurringForm.workType,
|
workType: recurringForm.workType,
|
||||||
|
teamspaceProject: recurringForm.teamspaceProject.trim() || null,
|
||||||
|
hourBudgetMinutes,
|
||||||
|
manualConsumptionMinutes,
|
||||||
recurrenceType: recurringForm.recurrenceType,
|
recurrenceType: recurringForm.recurrenceType,
|
||||||
intervalValue: recurringForm.recurrenceType === "weekly" ? 1 : recurringForm.intervalValue,
|
intervalValue: recurringForm.recurrenceType === "weekly" ? 1 : recurringForm.intervalValue,
|
||||||
validFrom: recurringForm.validFrom,
|
validFrom: recurringForm.validFrom,
|
||||||
@@ -355,6 +385,44 @@ export function RecurringBillingsPage() {
|
|||||||
) : null}
|
) : null}
|
||||||
</div>
|
</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">
|
<div className="space-y-2">
|
||||||
<label className="text-sm font-medium" htmlFor="recurring-activity">
|
<label className="text-sm font-medium" htmlFor="recurring-activity">
|
||||||
Tätigkeit
|
Tätigkeit
|
||||||
@@ -473,6 +541,9 @@ export function RecurringBillingsPage() {
|
|||||||
{billing.valid_until ? ` bis ${formatDateLabel(billing.valid_until)}` : ""}
|
{billing.valid_until ? ` bis ${formatDateLabel(billing.valid_until)}` : ""}
|
||||||
</p>
|
</p>
|
||||||
<div className="mt-1 flex flex-wrap gap-1">
|
<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) => (
|
{billing.slots.map((slot) => (
|
||||||
<Badge key={slot.id} variant="outline">
|
<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)}
|
{slot.weekday === null ? "Starttag" : weekdays.find((weekday) => weekday.value === slot.weekday)?.label} · {slot.start_time?.slice(0, 5)} · {formatMinutes(slot.duration_minutes)}
|
||||||
|
|||||||
@@ -147,21 +147,21 @@ function DeltaBadge({ minutes }: { minutes: number }) {
|
|||||||
|
|
||||||
function deltaDescription(minutes: number) {
|
function deltaDescription(minutes: number) {
|
||||||
if (minutes > 0) {
|
if (minutes > 0) {
|
||||||
return "Teamspace liegt über der abgerechneten Zeit";
|
return "TT-Teamspace liegt über der Session-Zeit";
|
||||||
}
|
}
|
||||||
|
|
||||||
if (minutes < 0) {
|
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 }) {
|
function TimeComparison({ trackedMinutes, crmMinutes, sessions }: { trackedMinutes: number; crmMinutes: number; sessions?: number }) {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-0.5 text-sm">
|
<div className="space-y-0.5 text-sm">
|
||||||
<div className={`font-semibold ${trackedTextClass}`}>Abgerechnet {formatMinutes(trackedMinutes)}</div>
|
<div className={`font-semibold ${trackedTextClass}`}>Session-Zeit {formatMinutes(trackedMinutes)}</div>
|
||||||
<div className={`font-semibold ${teamspaceTextClass}`}>Teamspace {formatMinutes(crmMinutes)}</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}
|
{typeof sessions === "number" ? <div className="text-xs text-muted-foreground">{sessions} Session(s)</div> : null}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -176,7 +176,7 @@ function ColorLegend({ showDifference = true }: { showDifference?: boolean }) {
|
|||||||
</span>
|
</span>
|
||||||
<span className="inline-flex items-center gap-1.5">
|
<span className="inline-flex items-center gap-1.5">
|
||||||
<span className={`h-0.5 w-4 ${teamspaceDotClass}`} />
|
<span className={`h-0.5 w-4 ${teamspaceDotClass}`} />
|
||||||
<span className={teamspaceTextClass}>Teamspace</span>
|
<span className={teamspaceTextClass}>TT-Teamspace</span>
|
||||||
</span>
|
</span>
|
||||||
<span className="inline-flex items-center gap-1.5">
|
<span className="inline-flex items-center gap-1.5">
|
||||||
<span className="size-2 rounded-sm bg-slate-500 dark:bg-slate-400" />
|
<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>
|
<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">
|
<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={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.billed_sessions} abgerechnet</span>
|
||||||
<span className="text-muted-foreground">{ticket.billable_sessions} abrechenbar</span>
|
<span className="text-muted-foreground">{ticket.billable_sessions} abrechenbar</span>
|
||||||
<span>{ticket.active_days} Tag(e)</span>
|
<span>{ticket.active_days} Tag(e)</span>
|
||||||
@@ -653,7 +653,7 @@ export function StatisticsPage({ currentUser, onNavigate }: StatisticsPageProps)
|
|||||||
<h2 className="text-2xl font-semibold tracking-normal">Statistiken</h2>
|
<h2 className="text-2xl font-semibold tracking-normal">Statistiken</h2>
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
{teamspaceTimeTrackingEnabled
|
{teamspaceTimeTrackingEnabled
|
||||||
? "Monatswerte, Teamspace-Abgleich, Organisationen und offene Nacharbeit."
|
? "Monatswerte, TT-Teamspace-Abgleich, Organisationen und offene Nacharbeit."
|
||||||
: "Monatswerte, Session-Status, Organisationen und Teamspace-Istwerte."}
|
: "Monatswerte, Session-Status, Organisationen und Teamspace-Istwerte."}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -702,7 +702,7 @@ export function StatisticsPage({ currentUser, onNavigate }: StatisticsPageProps)
|
|||||||
helpLabel="Hilfe zu Bewertungen"
|
helpLabel="Hilfe zu Bewertungen"
|
||||||
/>
|
/>
|
||||||
<MetricCard
|
<MetricCard
|
||||||
label="Teamspace"
|
label="TT-Teamspace"
|
||||||
value={formatMinutes(totals?.crmBilledMinutes ?? 0)}
|
value={formatMinutes(totals?.crmBilledMinutes ?? 0)}
|
||||||
detail={teamspaceTimeTrackingEnabled
|
detail={teamspaceTimeTrackingEnabled
|
||||||
? <span className={deltaTextClass(totals?.crmDeltaMinutes ?? 0)}>Differenz {formatTeamspaceDelta(totals?.crmDeltaMinutes ?? 0)}</span>
|
? <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>
|
<CardTitle>Monatsverlauf und Abrechnungsqualität</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
{teamspaceTimeTrackingEnabled
|
{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."}
|
: "Abgerechnete Zeit pro Tag und Bewertungsstand in einem Überblick."}
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<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" />
|
<HelpLink anchor="statistik-verlauf" label="Hilfe zu Monatsverlauf und Abrechnungsqualität" />
|
||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</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="space-y-2 rounded-md border bg-muted/20 p-3">
|
||||||
<div className="flex items-start justify-between gap-3">
|
<div className="flex items-start justify-between gap-3">
|
||||||
<div>
|
<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>
|
<p className="text-xs text-muted-foreground">{deltaDescription(totals?.crmDeltaMinutes ?? 0)}</p>
|
||||||
</div>
|
</div>
|
||||||
<DeltaBadge minutes={totals?.crmDeltaMinutes ?? 0} />
|
<DeltaBadge minutes={totals?.crmDeltaMinutes ?? 0} />
|
||||||
</div>
|
</div>
|
||||||
<TimeComparison trackedMinutes={totals?.minutes ?? 0} crmMinutes={totals?.crmBilledMinutes ?? 0} sessions={totals?.billedSessions ?? 0} />
|
<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>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-2 rounded-md border bg-muted/20 p-3">
|
<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="truncate font-semibold">{topOrganization.organization_name}</p>
|
||||||
<p className="flex flex-wrap gap-x-3 gap-y-1 text-xs">
|
<p className="flex flex-wrap gap-x-3 gap-y-1 text-xs">
|
||||||
<span className={trackedTextClass}>Abgerechnet {formatMinutes(topOrganization.total_minutes)}</span>
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
@@ -879,7 +879,7 @@ export function StatisticsPage({ currentUser, onNavigate }: StatisticsPageProps)
|
|||||||
<Progress value={percentage(organization.total_minutes, maxOrganizationMinutes)} />
|
<Progress value={percentage(organization.total_minutes, maxOrganizationMinutes)} />
|
||||||
<div className="flex flex-wrap gap-x-3 gap-y-1 text-xs">
|
<div className="flex flex-wrap gap-x-3 gap-y-1 text-xs">
|
||||||
<span className={trackedTextClass}>Abgerechnet {formatMinutes(organization.total_minutes)}</span>
|
<span className={trackedTextClass}>Abgerechnet {formatMinutes(organization.total_minutes)}</span>
|
||||||
<span className={teamspaceTextClass}>Teamspace {formatMinutes(organization.crm_billed_minutes)}</span>
|
<span className={teamspaceTextClass}>TT-Teamspace {formatMinutes(organization.crm_billed_minutes)}</span>
|
||||||
<span className="text-muted-foreground">{percentage(organization.total_minutes, totals?.minutes ?? 0)}% vom Monat</span>
|
<span className="text-muted-foreground">{percentage(organization.total_minutes, totals?.minutes ?? 0)}% vom Monat</span>
|
||||||
{teamspaceTimeTrackingEnabled ? <span className={deltaTextClass(organization.crm_delta_minutes)}>{formatTeamspaceDelta(organization.crm_delta_minutes)}</span> : null}
|
{teamspaceTimeTrackingEnabled ? <span className={deltaTextClass(organization.crm_delta_minutes)}>{formatTeamspaceDelta(organization.crm_delta_minutes)}</span> : null}
|
||||||
</div>
|
</div>
|
||||||
@@ -948,7 +948,7 @@ export function StatisticsPage({ currentUser, onNavigate }: StatisticsPageProps)
|
|||||||
<Progress value={percentage(group.total_minutes, totals?.minutes ?? 0)} />
|
<Progress value={percentage(group.total_minutes, totals?.minutes ?? 0)} />
|
||||||
<div className="flex flex-wrap gap-x-3 gap-y-1 text-xs">
|
<div className="flex flex-wrap gap-x-3 gap-y-1 text-xs">
|
||||||
<span className={trackedTextClass}>Abgerechnet {formatMinutes(group.total_minutes)}</span>
|
<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>
|
||||||
<div className="grid gap-2 text-xs text-muted-foreground sm:grid-cols-3">
|
<div className="grid gap-2 text-xs text-muted-foreground sm:grid-cols-3">
|
||||||
<span>Abr. {group.billed_sessions} / {formatMinutes(group.billed_minutes)}</span>
|
<span>Abr. {group.billed_sessions} / {formatMinutes(group.billed_minutes)}</span>
|
||||||
|
|||||||
@@ -12,10 +12,12 @@ import { Input } from "@/components/ui/input";
|
|||||||
import { Textarea } from "@/components/ui/textarea";
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
import { CopyTicketButton } from "@/components/CopyTicketButton";
|
import { CopyTicketButton } from "@/components/CopyTicketButton";
|
||||||
import { HelpLink } from "@/components/HelpLink";
|
import { HelpLink } from "@/components/HelpLink";
|
||||||
|
import { ListClockIcon } from "@/components/icons/ListClockIcon";
|
||||||
import { OrganizationSelect } from "@/components/OrganizationSelect";
|
import { OrganizationSelect } from "@/components/OrganizationSelect";
|
||||||
import { readRichActivityEditorValue, RichActivityEditor } from "@/components/RichActivityEditor";
|
import { readRichActivityEditorValue, RichActivityEditor } from "@/components/RichActivityEditor";
|
||||||
import { TodoPanel } from "@/components/TodoPanel";
|
import { TodoPanel } from "@/components/TodoPanel";
|
||||||
import {
|
import {
|
||||||
|
acknowledgeMissingTicketDayBilling,
|
||||||
acknowledgeProjectReview,
|
acknowledgeProjectReview,
|
||||||
createSession,
|
createSession,
|
||||||
deleteProject,
|
deleteProject,
|
||||||
@@ -34,6 +36,7 @@ import {
|
|||||||
updateSessionBilling,
|
updateSessionBilling,
|
||||||
updateSessionDetails,
|
updateSessionDetails,
|
||||||
updateTicket,
|
updateTicket,
|
||||||
|
updateTicketAtossSupport,
|
||||||
updateTicketDayBilling,
|
updateTicketDayBilling,
|
||||||
updateProject,
|
updateProject,
|
||||||
updateProjectStatus
|
updateProjectStatus
|
||||||
@@ -41,7 +44,7 @@ import {
|
|||||||
import type { CreateSessionResult, TicketZammadMailSessionItem, TicketZammadMailSessionResult } from "../api";
|
import type { CreateSessionResult, TicketZammadMailSessionItem, TicketZammadMailSessionResult } from "../api";
|
||||||
import { currentDay, currentMonth, formatDate, formatDateTime, formatMinutes, formatTimeRange } from "../format";
|
import { currentDay, currentMonth, formatDate, formatDateTime, formatMinutes, formatTimeRange } from "../format";
|
||||||
import { ticketHasRecurringBilling } from "../recurring-billings";
|
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 { ZammadPriorityBadge, ZammadStateBadge } from "../zammad-meta";
|
||||||
import { zammadArticleTypeFromValue, zammadArticleTypeLabel, zammadArticleTypeOptions, zammadArticleTypeValue, type ZammadArticleTypeId } from "../zammad-article-types";
|
import { zammadArticleTypeFromValue, zammadArticleTypeLabel, zammadArticleTypeOptions, zammadArticleTypeValue, type ZammadArticleTypeId } from "../zammad-article-types";
|
||||||
import { getLocalStorageValue, setLocalStorageValue } from "@/lib/local-storage.client";
|
import { getLocalStorageValue, setLocalStorageValue } from "@/lib/local-storage.client";
|
||||||
@@ -152,6 +155,20 @@ function formatSignedMinutes(minutes: number) {
|
|||||||
return `${minutes > 0 ? "+" : "-"}${formatMinutes(Math.abs(minutes))}`;
|
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) {
|
function formatTeamspaceMissingTarget(minutes: number, teamspaceTimeTrackingEnabled: boolean) {
|
||||||
if (minutes === 0) {
|
if (minutes === 0) {
|
||||||
return "ausgeglichen";
|
return "ausgeglichen";
|
||||||
@@ -958,6 +975,7 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onS
|
|||||||
const [loadingId, setLoadingId] = useState<string | null>(null);
|
const [loadingId, setLoadingId] = useState<string | null>(null);
|
||||||
const [deletingId, setDeletingId] = useState<string | null>(null);
|
const [deletingId, setDeletingId] = useState<string | null>(null);
|
||||||
const [savingTicket, setSavingTicket] = useState(false);
|
const [savingTicket, setSavingTicket] = useState(false);
|
||||||
|
const [savingAtossSupport, setSavingAtossSupport] = useState(false);
|
||||||
const [savingFavorite, setSavingFavorite] = useState(false);
|
const [savingFavorite, setSavingFavorite] = useState(false);
|
||||||
const [isFavorite, setIsFavorite] = useState(false);
|
const [isFavorite, setIsFavorite] = useState(false);
|
||||||
const [editTicketNumber, setEditTicketNumber] = useState("");
|
const [editTicketNumber, setEditTicketNumber] = useState("");
|
||||||
@@ -980,6 +998,7 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onS
|
|||||||
const [sendingZammadSessionId, setSendingZammadSessionId] = useState<string | null>(null);
|
const [sendingZammadSessionId, setSendingZammadSessionId] = useState<string | null>(null);
|
||||||
const [dayBillingInputs, setDayBillingInputs] = useState<Record<string, string>>({});
|
const [dayBillingInputs, setDayBillingInputs] = useState<Record<string, string>>({});
|
||||||
const [savingDayBillingKey, setSavingDayBillingKey] = useState<string | null>(null);
|
const [savingDayBillingKey, setSavingDayBillingKey] = useState<string | null>(null);
|
||||||
|
const [acknowledgingTeamspaceDay, setAcknowledgingTeamspaceDay] = useState<string | null>(null);
|
||||||
const [selectedTeamspaceEntryId, setSelectedTeamspaceEntryId] = useState<string | null>(null);
|
const [selectedTeamspaceEntryId, setSelectedTeamspaceEntryId] = useState<string | null>(null);
|
||||||
const [ticketSession, setTicketSession] = useState<TicketSessionFormState>(() => emptyTicketSessionForm());
|
const [ticketSession, setTicketSession] = useState<TicketSessionFormState>(() => emptyTicketSessionForm());
|
||||||
const [savingTicketSession, setSavingTicketSession] = useState(false);
|
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(() => {
|
useEffect(() => {
|
||||||
void load();
|
void load();
|
||||||
}, [periodType, period, ticketId, globalMonthFilter, globalStatusFilter, globalSourceFilter]);
|
}, [periodType, period, ticketId, globalMonthFilter, globalStatusFilter, globalSourceFilter]);
|
||||||
@@ -1127,6 +1164,7 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onS
|
|||||||
|
|
||||||
const ticket = data?.ticket;
|
const ticket = data?.ticket;
|
||||||
const project = data?.project ?? null;
|
const project = data?.project ?? null;
|
||||||
|
const ticketSessionEffectiveWorkType = ticket?.organization_id && ticket.work_type ? ticket.work_type : ticketSession.workType;
|
||||||
const isRecurringBillingTicket = ticketHasRecurringBilling(ticket);
|
const isRecurringBillingTicket = ticketHasRecurringBilling(ticket);
|
||||||
const projectDescription = project ? combinedProjectDescription(project) : "";
|
const projectDescription = project ? combinedProjectDescription(project) : "";
|
||||||
const projectEntries = useMemo(() => (project ? projectTeamspaceEntries(project) : []), [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 hasMultipleProjectTeamspaceEntries = projectEntries.length > 1;
|
||||||
const hasProjectTeamspaceApiIds = projectEntries.some((entry) => Boolean(entry.teamspace_api_project_id));
|
const hasProjectTeamspaceApiIds = projectEntries.some((entry) => Boolean(entry.teamspace_api_project_id));
|
||||||
const showTeamspaceSubentryCard = Boolean(project && hasProjectTeamspaceApiIds);
|
const showTeamspaceSubentryCard = false;
|
||||||
const usesTeamspaceSubentries = false;
|
const usesTeamspaceSubentries = false;
|
||||||
const budgetExcluded = project ? projectBudgetExcluded(project) : false;
|
const budgetExcluded = project ? projectBudgetExcluded(project) : false;
|
||||||
const budgetConsumptionExcluded = budgetExcluded || isRecurringBillingTicket;
|
const budgetConsumptionExcluded = budgetExcluded;
|
||||||
const showBudgetedProjectEntries = budgetedProjectEntries.length > 1;
|
const showBudgetedProjectEntries = budgetedProjectEntries.length > 1;
|
||||||
const requiresProjectTeamspaceEntry = Boolean(project && hasMultipleProjectTeamspaceEntries);
|
const requiresProjectTeamspaceEntry = Boolean(project && hasMultipleProjectTeamspaceEntries);
|
||||||
const teamspaceTimeTrackingEnabled = currentUser.teamspace_time_tracking_enabled ?? true;
|
const teamspaceTimeTrackingEnabled = currentUser.teamspace_time_tracking_enabled ?? true;
|
||||||
@@ -1196,7 +1234,13 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onS
|
|||||||
|| project?.owner_username
|
|| project?.owner_username
|
||||||
|| (project?.zammad_owner_id ? `Zammad-Owner ${project.zammad_owner_id}` : null)
|
|| (project?.zammad_owner_id ? `Zammad-Owner ${project.zammad_owner_id}` : null)
|
||||||
|| zammadOwnerLabel;
|
|| 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 isProject = Boolean(project);
|
||||||
const isGlobalTicket = periodType === "all";
|
const isGlobalTicket = periodType === "all";
|
||||||
const isPeriodClosed = Boolean(data?.closed);
|
const isPeriodClosed = Boolean(data?.closed);
|
||||||
@@ -1217,7 +1261,7 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onS
|
|||||||
? "im gesamten Ticketverlauf"
|
? "im gesamten Ticketverlauf"
|
||||||
: `im Monat ${globalMonthFilter}`
|
: `im Monat ${globalMonthFilter}`
|
||||||
: `im ${periodLabel.toLowerCase()} ${period}`;
|
: `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 availableMonths = data?.availableMonths ?? [];
|
||||||
const selectedMonthHasSessions = availableMonths.some((month) => month.month === globalMonthFilter);
|
const selectedMonthHasSessions = availableMonths.some((month) => month.month === globalMonthFilter);
|
||||||
const defaultProjectEntryId = projectEntries.find((entry) => entry.is_default)?.id ?? null;
|
const defaultProjectEntryId = projectEntries.find((entry) => entry.is_default)?.id ?? null;
|
||||||
@@ -1272,12 +1316,28 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onS
|
|||||||
|
|
||||||
return billingMap;
|
return billingMap;
|
||||||
}, [teamspaceMonthActuals]);
|
}, [teamspaceMonthActuals]);
|
||||||
|
const ticketTeamspaceDayByDayMap = useMemo(
|
||||||
|
() => new Map((data?.ticketTeamspaceDays ?? []).map((day) => [day.day, day])),
|
||||||
|
[data?.ticketTeamspaceDays]
|
||||||
|
);
|
||||||
const teamspaceDayDeviation = (day: string) => {
|
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) {
|
if (!project || ticket?.work_type === "support" || budgetExcluded) {
|
||||||
return {
|
return {
|
||||||
ttMinutes: 0,
|
ttMinutes: 0,
|
||||||
tsMinutes: 0,
|
tsMinutes: 0,
|
||||||
deltaMinutes: 0
|
deltaMinutes: 0,
|
||||||
|
comparison: null
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1287,7 +1347,8 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onS
|
|||||||
return {
|
return {
|
||||||
ttMinutes,
|
ttMinutes,
|
||||||
tsMinutes,
|
tsMinutes,
|
||||||
deltaMinutes: tsMinutes - ttMinutes
|
deltaMinutes: tsMinutes - ttMinutes,
|
||||||
|
comparison: null
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
const unassignedTeamspaceSessions = useMemo(
|
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())
|
return Array.from(groups.values())
|
||||||
.map((group) => ({
|
.map((group) => ({
|
||||||
...group,
|
...group,
|
||||||
sessions: [...group.sessions].sort((left, right) => new Date(right.started_at).getTime() - new Date(left.started_at).getTime())
|
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));
|
.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 sessionIds = useMemo(() => sessionGroups.flatMap((group) => group.sessions.map((session) => session.id)), [sessionGroups]);
|
||||||
const allSessionsExpanded = sessionIds.length > 0 && sessionIds.every((sessionId) => expandedSessionIds.includes(sessionId));
|
const allSessionsExpanded = sessionIds.length > 0 && sessionIds.every((sessionId) => expandedSessionIds.includes(sessionId));
|
||||||
const periodTotalMinutes = sessionGroups.reduce((sum, group) => sum + group.totalMinutes, 0);
|
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() {
|
function openProjectEditor() {
|
||||||
if (!project) {
|
if (!project) {
|
||||||
return;
|
return;
|
||||||
@@ -1946,6 +2053,7 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onS
|
|||||||
projectTeamspaceEntryId: effectiveWorkType === "consulting" && requiresProjectTeamspaceEntry ? ticketSession.projectTeamspaceEntryId : null,
|
projectTeamspaceEntryId: effectiveWorkType === "consulting" && requiresProjectTeamspaceEntry ? ticketSession.projectTeamspaceEntryId : null,
|
||||||
billingStatus: ticketSession.billingStatus,
|
billingStatus: ticketSession.billingStatus,
|
||||||
createZammadArticle: ticketSession.createZammadArticle && hasActivityContent(activityValue.activity, activityValue.zammadArticleBody),
|
createZammadArticle: ticketSession.createZammadArticle && hasActivityContent(activityValue.activity, activityValue.zammadArticleBody),
|
||||||
|
zammadArticleDateHeader: true,
|
||||||
zammadArticleTypeId: ticketSession.zammadArticleTypeId
|
zammadArticleTypeId: ticketSession.zammadArticleTypeId
|
||||||
});
|
});
|
||||||
toast.success("Session nachgetragen", {
|
toast.success("Session nachgetragen", {
|
||||||
@@ -2235,50 +2343,35 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onS
|
|||||||
</Alert>
|
</Alert>
|
||||||
) : null}
|
) : 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">
|
<div className="grid gap-4">
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader className="p-4 pb-2">
|
<CardHeader className="p-3 pb-2">
|
||||||
<div className="flex items-start justify-between gap-3">
|
<div className="flex items-start justify-between gap-3">
|
||||||
<div>
|
<CardTitle className="flex min-w-0 flex-wrap items-center gap-2">
|
||||||
<CardTitle>Ticketdaten</CardTitle>
|
Ticketdaten
|
||||||
<CardDescription>
|
{ticket?.is_foreign_zammad_owner ? (
|
||||||
{canEditTicketData
|
<Badge variant="warning" title={zammadOwnerLabel ? `Zammad-Owner: ${zammadOwnerLabel}` : "Anderer Zammad-Owner"}>Fremder Zammad-Owner</Badge>
|
||||||
? "Änderungen an Organisation und Art werden auf alle vorhandenen Sessions dieses Tickets übernommen."
|
) : null}
|
||||||
: ticketManagedByZammad
|
{ticketManagedByZammad ? <Badge variant="secondary">Zammad-verwaltet</Badge> : null}
|
||||||
? "Nummer, Art, Titel und Organisation kommen aus Zammad und sind lokal gesperrt."
|
{isRecurringBillingTicket ? <Badge variant="secondary">Fixabrechnung</Badge> : null}
|
||||||
: "Nur der Owner kann Ticketdaten ändern."}
|
{ticket?.teamspace_assignment_project ? (
|
||||||
</CardDescription>
|
<Badge variant="success" title={ticket.teamspace_assignment_rule_name ?? "Automatische Zuordnung"}>
|
||||||
</div>
|
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" />
|
<HelpLink anchor="ticketdaten" label="Hilfe zu Ticketdaten" />
|
||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="px-4 pb-3">
|
<CardContent className="px-3 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}>
|
<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-2">
|
<div className="space-y-1">
|
||||||
<label className="text-sm font-medium" htmlFor="edit-ticket-number">Ticketnummer</label>
|
<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 />
|
<Input id="edit-ticket-number" value={editTicketNumber} disabled={!canEditTicketData} onChange={(event) => setEditTicketNumber(event.currentTarget.value)} required />
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-1">
|
||||||
<label className="text-sm font-medium" htmlFor="edit-ticket-work-type">Art</label>
|
<label className="text-sm font-medium" htmlFor="edit-ticket-work-type">Art</label>
|
||||||
<select
|
<select
|
||||||
id="edit-ticket-work-type"
|
id="edit-ticket-work-type"
|
||||||
@@ -2291,11 +2384,11 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onS
|
|||||||
<option value="consulting">Consulting</option>
|
<option value="consulting">Consulting</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-1">
|
||||||
<label className="text-sm font-medium" htmlFor="edit-ticket-title">Titel</label>
|
<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" />
|
<Input id="edit-ticket-title" value={editTicketTitle} disabled={!canEditTicketData} onChange={(event) => setEditTicketTitle(event.currentTarget.value)} placeholder="optional" />
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-1">
|
||||||
<label className="text-sm font-medium" htmlFor="edit-ticket-organization">Organisation</label>
|
<label className="text-sm font-medium" htmlFor="edit-ticket-organization">Organisation</label>
|
||||||
{ticket?.customer_name && !ticket.organization_id ? (
|
{ticket?.customer_name && !ticket.organization_id ? (
|
||||||
<p className="text-xs text-muted-foreground">Bisheriger Freitext: {ticket.customer_name}</p>
|
<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"}
|
{savingTicket ? "Speichert..." : "Speichern"}
|
||||||
</Button>
|
</Button>
|
||||||
</form>
|
</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>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</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>}
|
{project.closed_at ? <Badge variant="secondary">Geschlossen</Badge> : <Badge variant="success">Offen</Badge>}
|
||||||
<ZammadStateBadge stateId={project.zammad_state_id} />
|
<ZammadStateBadge stateId={project.zammad_state_id} />
|
||||||
<ZammadPriorityBadge priorityId={project.zammad_priority_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}
|
{project.close_review_required ? <Badge variant="warning">TS/TT prüfen</Badge> : null}
|
||||||
{unassignedTeamspaceEntryCount > 0 ? <Badge variant="warning">{unassignedTeamspaceEntryCount} ohne TS</Badge> : null}
|
{unassignedTeamspaceEntryCount > 0 ? <Badge variant="warning">{unassignedTeamspaceEntryCount} ohne TS</Badge> : null}
|
||||||
{unassignedTeamspaceSubentryCount > 0 ? <Badge variant="warning">{unassignedTeamspaceSubentryCount} ohne Pos.</Badge> : null}
|
{unassignedTeamspaceSubentryCount > 0 ? <Badge variant="warning">{unassignedTeamspaceSubentryCount} ohne Pos.</Badge> : null}
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
{!canEditProject && projectOwnerLabel
|
{!canEditProject && !isRecurringBillingTicket && projectOwnerLabel
|
||||||
? `Eigentümer: ${projectOwnerLabel}`
|
? `Eigentümer: ${projectOwnerLabel}`
|
||||||
: showBudgetedProjectEntries
|
: showBudgetedProjectEntries
|
||||||
? `${budgetedProjectEntries.length} budgetierte Teamspace-Einträge`
|
? `${budgetedProjectEntries.length} budgetierte Teamspace-Einträge`
|
||||||
@@ -2346,20 +2450,24 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onS
|
|||||||
</CardDescription>
|
</CardDescription>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex shrink-0 items-center gap-1">
|
<div className="flex shrink-0 items-center gap-1">
|
||||||
<Button
|
{!isRecurringBillingTicket ? (
|
||||||
type="button"
|
<>
|
||||||
size="sm"
|
<Button
|
||||||
variant="secondary"
|
type="button"
|
||||||
disabled={!canEditProject || savingProjectStatus}
|
size="sm"
|
||||||
onClick={() => void setProjectClosed(!project.closed_at)}
|
variant="secondary"
|
||||||
>
|
disabled={!canEditProject || savingProjectStatus}
|
||||||
{project.closed_at ? <RotateCcw className="size-4" /> : <CheckCircle2 className="size-4" />}
|
onClick={() => void setProjectClosed(!project.closed_at)}
|
||||||
{project.closed_at ? "Öffnen" : "Schließen"}
|
>
|
||||||
</Button>
|
{project.closed_at ? <RotateCcw className="size-4" /> : <CheckCircle2 className="size-4" />}
|
||||||
<Button type="button" size="sm" variant="secondary" disabled={!canEditProject} onClick={openProjectEditor}>
|
{project.closed_at ? "Öffnen" : "Schließen"}
|
||||||
<Pencil className="size-4" />
|
</Button>
|
||||||
Bearbeiten
|
<Button type="button" size="sm" variant="secondary" disabled={!canEditProject} onClick={openProjectEditor}>
|
||||||
</Button>
|
<Pencil className="size-4" />
|
||||||
|
Bearbeiten
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
<HelpLink anchor="projekte" label="Hilfe zu Projekten" />
|
<HelpLink anchor="projekte" label="Hilfe zu Projekten" />
|
||||||
</div>
|
</div>
|
||||||
</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>
|
<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>}
|
{budgetConsumptionExcluded ? <Badge variant="outline">Budgetprüfung ausgenommen</Badge> : <Badge variant="secondary">Kontingent {projectBudgetMinutes ? formatMinutes(projectBudgetMinutes) : "offen"}</Badge>}
|
||||||
<Badge variant="secondary">Verbrauch {formatMinutes(projectConsumptionMinutes)}</Badge>
|
<Badge variant="secondary">Verbrauch {formatMinutes(projectConsumptionMinutes)}</Badge>
|
||||||
|
{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 ? null : <Badge variant={projectRemainingMinutes !== null && projectRemainingMinutes < 0 ? "destructive" : "secondary"}>{projectRemainingLabel(projectRemainingMinutes)}</Badge>}
|
||||||
{!budgetConsumptionExcluded && showBudgetedProjectEntries ? <Badge variant="outline">{budgetedProjectEntries.length} Budgets</Badge> : null}
|
{!budgetConsumptionExcluded && showBudgetedProjectEntries ? <Badge variant="outline">{budgetedProjectEntries.length} Budgets</Badge> : null}
|
||||||
</div>
|
</div>
|
||||||
@@ -2416,9 +2525,7 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onS
|
|||||||
<Info className="size-4" />
|
<Info className="size-4" />
|
||||||
<AlertTitle>Budgetverbrauch nicht auswertbar</AlertTitle>
|
<AlertTitle>Budgetverbrauch nicht auswertbar</AlertTitle>
|
||||||
<AlertDescription>
|
<AlertDescription>
|
||||||
{budgetExcluded
|
Dieses Teamspace-Projekt ist systemweit von Differenz- und Budgetprüfung ausgenommen. 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."
|
|
||||||
: "Dieses Projekt ist eine Fixabrechnung. Der Verbrauch zählt weiter in Summen."}
|
|
||||||
</AlertDescription>
|
</AlertDescription>
|
||||||
</Alert>
|
</Alert>
|
||||||
) : (
|
) : (
|
||||||
@@ -2591,16 +2698,22 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onS
|
|||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader className="p-4">
|
<CardHeader className="p-3 pb-2">
|
||||||
<div className="flex items-start justify-between gap-3">
|
<div className="flex items-start justify-between gap-3">
|
||||||
<div>
|
<CardTitle className="flex items-center gap-2">
|
||||||
<CardTitle className="flex items-center gap-2">
|
<ClipboardList className="size-4 text-muted-foreground" />
|
||||||
<ClipboardList className="size-5 text-muted-foreground" />
|
ToDos
|
||||||
ToDos
|
</CardTitle>
|
||||||
</CardTitle>
|
<HelpLink anchor="todos" label="Hilfe zu ToDos" />
|
||||||
<CardDescription>Aufgaben zu diesem Ticket oder Projekt verwalten.</CardDescription>
|
</div>
|
||||||
</div>
|
</CardHeader>
|
||||||
<div className="flex shrink-0 items-center gap-2">
|
<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">
|
<div className="flex rounded-md border bg-muted/20 p-0.5">
|
||||||
{(["open", "all", "done"] as const).map((value) => (
|
{(["open", "all", "done"] as const).map((value) => (
|
||||||
<Button
|
<Button
|
||||||
@@ -2615,12 +2728,8 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onS
|
|||||||
</Button>
|
</Button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</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>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
@@ -2839,7 +2948,7 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onS
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
<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">
|
<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>
|
<span className="font-semibold">{formatMinutes(periodTotalMinutes)}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2 rounded-md border bg-muted/30 px-3 py-2 text-sm">
|
<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) => {
|
{sessionGroups.map((group) => {
|
||||||
const dayDeviation = teamspaceDayDeviation(group.day);
|
const dayDeviation = teamspaceDayDeviation(group.day);
|
||||||
const hasDayDeviation = dayDeviation.deltaMinutes !== 0;
|
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 (
|
return (
|
||||||
<div key={group.key} className="rounded-md border bg-background">
|
<div key={group.key} className="rounded-md border bg-background">
|
||||||
@@ -2951,39 +3064,30 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onS
|
|||||||
</p>
|
</p>
|
||||||
<p className="text-xs text-muted-foreground">{group.sessions.length} Session(s)</p>
|
<p className="text-xs text-muted-foreground">{group.sessions.length} Session(s)</p>
|
||||||
</div>
|
</div>
|
||||||
<Button
|
{group.sessions.length > 0 ? (
|
||||||
type="button"
|
<Button
|
||||||
size="sm"
|
type="button"
|
||||||
variant="secondary"
|
size="sm"
|
||||||
className="h-8 min-w-32 justify-center justify-self-start"
|
variant="secondary"
|
||||||
title="Teamspace-Aufbereitung"
|
className="h-8 min-w-32 justify-center justify-self-start"
|
||||||
onClick={() => openTeamspacePreparation(group)}
|
title="Teamspace-Aufbereitung"
|
||||||
>
|
onClick={() => openTeamspacePreparation(group)}
|
||||||
<ClipboardList className="size-3.5" />
|
>
|
||||||
TS
|
<ClipboardList className="size-3.5" />
|
||||||
</Button>
|
TS
|
||||||
|
</Button>
|
||||||
|
) : <span />}
|
||||||
<div className="flex min-w-0 flex-wrap gap-x-3 gap-y-1 text-xs text-muted-foreground">
|
<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>
|
||||||
<div className="flex justify-start lg:justify-center">
|
<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" />
|
<span className="h-4 w-4" aria-hidden="true" />
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
<div className="min-w-0 text-xs lg:text-right">
|
<div className="min-w-0 text-xs lg:text-right">
|
||||||
<p className={cn("font-semibold", hasDayDeviation ? "text-amber-600 dark:text-amber-300" : "text-muted-foreground")}>
|
|
||||||
{formatTeamspaceDifferenceTarget(dayDeviation.deltaMinutes, teamspaceTimeTrackingEnabled)}
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="flex min-w-0 items-center justify-start gap-2 lg:justify-end">
|
<div className="flex min-w-0 items-center justify-start gap-2 lg:justify-end">
|
||||||
{teamspaceTimeTrackingEnabled ? (
|
{teamspaceTimeTrackingEnabled && group.sessions.length > 0 ? (
|
||||||
<div className="flex min-w-0 items-center gap-1.5 text-xs text-muted-foreground">
|
<div className="flex min-w-0 items-center gap-1.5 text-xs text-muted-foreground">
|
||||||
<label htmlFor={`crm-billing-${group.key}`}>Teamspace</label>
|
<label htmlFor={`crm-billing-${group.key}`}>Teamspace</label>
|
||||||
<Input
|
<Input
|
||||||
@@ -3027,17 +3131,69 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onS
|
|||||||
<span className="sr-only">{savingDayBillingKey === group.key ? "Speichert..." : "Speichern"}</span>
|
<span className="sr-only">{savingDayBillingKey === group.key ? "Speichert..." : "Speichern"}</span>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : group.sessions.length > 0 ? (
|
||||||
<span className="text-xs text-muted-foreground">Teamspace-Erfassung aus</span>
|
<span className="text-xs text-muted-foreground">Teamspace-Erfassung aus</span>
|
||||||
)}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</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">
|
<div className="space-y-1.5 p-2 sm:p-3 lg:px-0">
|
||||||
{group.sessions.map((session) => {
|
{group.sessions.map((session) => {
|
||||||
const expanded = expandedSessionIds.includes(session.id);
|
const expanded = expandedSessionIds.includes(session.id);
|
||||||
const zammadSent = session.zammad_article_status === "sent";
|
const zammadSent = session.zammad_article_status === "sent";
|
||||||
const zammadPending = session.zammad_article_status === "pending";
|
const zammadPending = session.zammad_article_status === "pending";
|
||||||
const zammadMailImported = Boolean(session.zammad_source_article_id);
|
const zammadMailImported = Boolean(session.zammad_source_article_id);
|
||||||
|
const 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 canSendZammad = !zammadMailImported && hasActivityContent(session.activity, session.zammad_article_body);
|
||||||
const sendingZammad = sendingZammadSessionId === session.id;
|
const sendingZammad = sendingZammadSessionId === session.id;
|
||||||
const billedMissingTeamspaceEntry =
|
const billedMissingTeamspaceEntry =
|
||||||
@@ -3048,7 +3204,7 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onS
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={session.id} className="rounded-md border bg-card/40">
|
<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
|
<button
|
||||||
type="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"
|
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>
|
</Badge>
|
||||||
) : null}
|
) : null}
|
||||||
</button>
|
</button>
|
||||||
<div className="flex min-w-0 flex-col items-start gap-1">
|
<div className="grid min-w-0 grid-cols-[minmax(0,max-content)_2rem] items-start gap-2">
|
||||||
<Badge variant="outline" className="w-fit">{formatMinutes(session.rounded_minutes)}</Badge>
|
<div className="flex min-w-0 flex-col items-start gap-1">
|
||||||
{session.recurring_billing_id ? <Badge variant="secondary">fix</Badge> : null}
|
<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>
|
</div>
|
||||||
<BillingButtons
|
<BillingButtons
|
||||||
session={session}
|
session={session}
|
||||||
@@ -3124,31 +3287,42 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onS
|
|||||||
</div>
|
</div>
|
||||||
{expanded ? (
|
{expanded ? (
|
||||||
<div className="border-t px-3 pb-3 pt-2">
|
<div className="border-t px-3 pb-3 pt-2">
|
||||||
<SessionActivityBody session={session} />
|
<div className="grid gap-3 lg:grid-cols-[minmax(0,1fr)_auto]">
|
||||||
<div className="mt-2 flex flex-wrap gap-x-3 gap-y-1 text-xs text-muted-foreground">
|
<div className="min-w-0">
|
||||||
<span>{session.organization_name ?? session.customer_name}</span>
|
<SessionActivityBody session={session} />
|
||||||
<span>{session.work_type === "support" ? "Support" : "Consulting"}</span>
|
<div className="mt-2 flex flex-wrap gap-x-3 gap-y-1 text-xs text-muted-foreground">
|
||||||
{zammadMailImported ? (
|
<span>{session.organization_name ?? session.customer_name}</span>
|
||||||
<span className="inline-flex items-center gap-1">
|
<span>{session.work_type === "support" ? "Support" : "Consulting"}</span>
|
||||||
<MailCheck className="size-3.5 text-sky-500" aria-hidden="true" />
|
{zammadMailImported ? (
|
||||||
<span>Zammad-Mail importiert · Article {session.zammad_source_article_id}</span>
|
<span className="inline-flex items-center gap-1">
|
||||||
</span>
|
<MailCheck className="size-3.5 text-sky-500" aria-hidden="true" />
|
||||||
) : null}
|
<span>Zammad-Mail importiert · Article {session.zammad_source_article_id}</span>
|
||||||
{!zammadMailImported ? (
|
</span>
|
||||||
<span className="inline-flex items-center gap-1">
|
) : null}
|
||||||
{zammadSent ? <Send className="size-3.5 text-emerald-500" aria-hidden="true" /> : zammadPending ? <RotateCcw className="size-3.5 text-amber-500" aria-hidden="true" /> : <Send className="size-3.5 text-muted-foreground/60" aria-hidden="true" />}
|
{!zammadMailImported ? (
|
||||||
<span>
|
<span className="inline-flex items-center gap-1">
|
||||||
{zammadSent
|
{zammadSent ? <Send className="size-3.5 text-emerald-500" aria-hidden="true" /> : zammadPending ? <RotateCcw className="size-3.5 text-amber-500" aria-hidden="true" /> : <Send className="size-3.5 text-muted-foreground/60" aria-hidden="true" />}
|
||||||
? `Zammad übertragen · ${zammadArticleTypeLabel(session.zammad_article_type_id)}`
|
<span>
|
||||||
: zammadPending
|
{zammadSent
|
||||||
? `Zammad geparkt · ${zammadArticleTypeLabel(session.zammad_article_type_id)}`
|
? `Zammad übertragen · ${zammadArticleTypeLabel(session.zammad_article_type_id)}`
|
||||||
: canSendZammad
|
: zammadPending
|
||||||
? "Nicht an Zammad übertragen"
|
? `Zammad geparkt · ${zammadArticleTypeLabel(session.zammad_article_type_id)}`
|
||||||
: "Keine Zammad-Notiz (Tätigkeit leer)"}
|
: canSendZammad
|
||||||
</span>
|
? "Nicht an Zammad übertragen"
|
||||||
</span>
|
: "Keine Zammad-Notiz (Tätigkeit leer)"}
|
||||||
) : null}
|
</span>
|
||||||
<span>Erfasst {formatDateTime(session.created_at)}</span>
|
</span>
|
||||||
|
) : null}
|
||||||
|
<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>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ import type { CreateSessionResult } from "../api";
|
|||||||
import { formatTimer } from "../format";
|
import { formatTimer } from "../format";
|
||||||
import { isRecurringBillingTicketNumber, ticketHasRecurringBilling } from "../recurring-billings";
|
import { isRecurringBillingTicketNumber, ticketHasRecurringBilling } from "../recurring-billings";
|
||||||
import { activeElapsedMs, mergeTimerActivityDrafts, removeTimerActivityDraft, ticketPattern, type TimerEntry, writeTimerActivityDraft } from "../timers";
|
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";
|
import { zammadArticleTypeFromValue, zammadArticleTypeOptions, zammadArticleTypeValue, type ZammadArticleTypeId } from "../zammad-article-types";
|
||||||
|
|
||||||
type ManualSessionFormState = {
|
type ManualSessionFormState = {
|
||||||
@@ -77,6 +77,7 @@ type FinishedSession = {
|
|||||||
endedAt: string;
|
endedAt: string;
|
||||||
durationSeconds: number;
|
durationSeconds: number;
|
||||||
roundedMinutes: number;
|
roundedMinutes: number;
|
||||||
|
workSegments: WorkSegment[];
|
||||||
};
|
};
|
||||||
|
|
||||||
function ticketTeamspaceEntries(ticket: TicketMeta | null | undefined) {
|
function ticketTeamspaceEntries(ticket: TicketMeta | null | undefined) {
|
||||||
@@ -104,6 +105,8 @@ type TimerPageProps = {
|
|||||||
setSelectedTimerId: Dispatch<SetStateAction<string | null>>;
|
setSelectedTimerId: Dispatch<SetStateAction<string | null>>;
|
||||||
tick: number;
|
tick: number;
|
||||||
currentUser: AuthUser;
|
currentUser: AuthUser;
|
||||||
|
finishRequestId?: string | null;
|
||||||
|
onFinishRequestHandled?: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
function BillingStatusSelect({
|
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 [ticketNumber, setTicketNumber] = useState("");
|
||||||
const [finished, setFinished] = useState<FinishedSession | null>(null);
|
const [finished, setFinished] = useState<FinishedSession | null>(null);
|
||||||
const [finishingId, setFinishingId] = useState<string | 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));
|
setTimers(mergeTimerActivityDrafts(currentUser.id, timerResult.timers));
|
||||||
setSelectedTimerId(timer.id);
|
setSelectedTimerId(timer.id);
|
||||||
|
const finishedTimer = timerResult.timers.find((entry) => entry.id === timer.id) ?? timer;
|
||||||
|
|
||||||
const result = await lookupTicket(timer.ticketNumber);
|
const result = await lookupTicket(timer.ticketNumber);
|
||||||
const ticket = result.ticket;
|
const ticket = result.ticket;
|
||||||
@@ -711,7 +715,8 @@ export function TimerPage({ timers, setTimers, selectedTimerId, setSelectedTimer
|
|||||||
startedAt: new Date(timer.startedAt).toISOString(),
|
startedAt: new Date(timer.startedAt).toISOString(),
|
||||||
endedAt: new Date(now).toISOString(),
|
endedAt: new Date(now).toISOString(),
|
||||||
durationSeconds,
|
durationSeconds,
|
||||||
roundedMinutes
|
roundedMinutes,
|
||||||
|
workSegments: currentUser.session_work_segments_enabled ? finishedTimer.workSegments ?? [] : []
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
toast.error("Ticket konnte nicht geprüft werden", {
|
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) {
|
async function submitSession(event: FormEvent) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
|
|
||||||
@@ -768,6 +789,7 @@ export function TimerPage({ timers, setTimers, selectedTimerId, setSelectedTimer
|
|||||||
startedAt: finished.startedAt,
|
startedAt: finished.startedAt,
|
||||||
endedAt: finished.endedAt,
|
endedAt: finished.endedAt,
|
||||||
durationSeconds: finished.durationSeconds,
|
durationSeconds: finished.durationSeconds,
|
||||||
|
workSegments: finished.workSegments,
|
||||||
organizationId: effectiveOrganizationId,
|
organizationId: effectiveOrganizationId,
|
||||||
activity: activityValue.activity,
|
activity: activityValue.activity,
|
||||||
zammadArticleBody: activityValue.zammadArticleBody || null,
|
zammadArticleBody: activityValue.zammadArticleBody || null,
|
||||||
@@ -875,6 +897,7 @@ export function TimerPage({ timers, setTimers, selectedTimerId, setSelectedTimer
|
|||||||
projectTeamspaceEntryId: manualRequiresTeamspaceEntry ? manualSession.projectTeamspaceEntryId : null,
|
projectTeamspaceEntryId: manualRequiresTeamspaceEntry ? manualSession.projectTeamspaceEntryId : null,
|
||||||
billingStatus: manualSession.billingStatus,
|
billingStatus: manualSession.billingStatus,
|
||||||
createZammadArticle: manualSession.createZammadArticle && hasActivityContent(activityValue.activity, activityValue.zammadArticleBody),
|
createZammadArticle: manualSession.createZammadArticle && hasActivityContent(activityValue.activity, activityValue.zammadArticleBody),
|
||||||
|
zammadArticleDateHeader: true,
|
||||||
zammadArticleTypeId: manualSession.zammadArticleTypeId
|
zammadArticleTypeId: manualSession.zammadArticleTypeId
|
||||||
});
|
});
|
||||||
toast.success("Session nachgetragen", {
|
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 (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<section className="space-y-4">
|
<section className="space-y-4">
|
||||||
|
|||||||
@@ -924,7 +924,7 @@ export function ZammadSyncPage({ currentUser }: ZammadSyncPageProps) {
|
|||||||
const { result } = await syncTeamspaceProjectIds(zammadSyncPageState.teamspaceSyncMonth);
|
const { result } = await syncTeamspaceProjectIds(zammadSyncPageState.teamspaceSyncMonth);
|
||||||
setZammadSyncPageState({ teamspaceProjectIdResult: result });
|
setZammadSyncPageState({ teamspaceProjectIdResult: result });
|
||||||
toast.success("Teamspace-Projekt-IDs synchronisiert", {
|
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) {
|
} catch (error) {
|
||||||
toast.error("Teamspace-Projekt-IDs konnten nicht synchronisiert werden", {
|
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" />
|
<DatabaseZap className="size-5 text-muted-foreground" />
|
||||||
Teamspace-Projekt-IDs
|
Teamspace-Projekt-IDs
|
||||||
</CardTitle>
|
</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>
|
</div>
|
||||||
<Badge variant={teamspaceReady ? "success" : "secondary"}>{teamspaceReady ? "bereit" : "nicht bereit"}</Badge>
|
<Badge variant={teamspaceReady ? "success" : "secondary"}>{teamspaceReady ? "bereit" : "nicht bereit"}</Badge>
|
||||||
</div>
|
</div>
|
||||||
@@ -1140,7 +1140,7 @@ export function ZammadSyncPage({ currentUser }: ZammadSyncPageProps) {
|
|||||||
</div>
|
</div>
|
||||||
<Button type="button" disabled={!teamspaceReady || individualSyncDisabled} onClick={() => void runTeamspaceProjectIdSync()}>
|
<Button type="button" disabled={!teamspaceReady || individualSyncDisabled} onClick={() => void runTeamspaceProjectIdSync()}>
|
||||||
<RefreshCw className="size-4" />
|
<RefreshCw className="size-4" />
|
||||||
{syncingTeamspaceProjectIds ? "Synchronisiert..." : "Teamspace-IDs, Budgets und Positionen synchronisieren"}
|
{syncingTeamspaceProjectIds ? "Synchronisiert..." : "Teamspace-IDs und Budgets synchronisieren"}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
{teamspaceProjectIdResult ? (
|
{teamspaceProjectIdResult ? (
|
||||||
@@ -1150,7 +1150,6 @@ export function ZammadSyncPage({ currentUser }: ZammadSyncPageProps) {
|
|||||||
["Monat", teamspaceProjectIdResult.month],
|
["Monat", teamspaceProjectIdResult.month],
|
||||||
["TT-Einträge", teamspaceProjectIdResult.checkedEntries],
|
["TT-Einträge", teamspaceProjectIdResult.checkedEntries],
|
||||||
["TS gelesen", teamspaceProjectIdResult.remoteProjectsScanned],
|
["TS gelesen", teamspaceProjectIdResult.remoteProjectsScanned],
|
||||||
["TS Positionen gelesen", teamspaceProjectIdResult.remoteSubprojectsScanned],
|
|
||||||
["TS Zeiten gelesen", teamspaceProjectIdResult.teamspaceTimeEntriesScanned],
|
["TS Zeiten gelesen", teamspaceProjectIdResult.teamspaceTimeEntriesScanned],
|
||||||
["TS Zeiten zugeordnet", teamspaceProjectIdResult.teamspaceTimeEntriesMapped],
|
["TS Zeiten zugeordnet", teamspaceProjectIdResult.teamspaceTimeEntriesMapped],
|
||||||
["TS Monat", formatMinutes(teamspaceProjectIdResult.teamspaceTimeBillableMinutes)],
|
["TS Monat", formatMinutes(teamspaceProjectIdResult.teamspaceTimeBillableMinutes)],
|
||||||
@@ -1158,8 +1157,6 @@ export function ZammadSyncPage({ currentUser }: ZammadSyncPageProps) {
|
|||||||
["Treffer", teamspaceProjectIdResult.matched],
|
["Treffer", teamspaceProjectIdResult.matched],
|
||||||
["IDs aktualisiert", teamspaceProjectIdResult.updated],
|
["IDs aktualisiert", teamspaceProjectIdResult.updated],
|
||||||
["Aktuell", teamspaceProjectIdResult.alreadyCurrent],
|
["Aktuell", teamspaceProjectIdResult.alreadyCurrent],
|
||||||
["Positionen", teamspaceProjectIdResult.subentriesSynced],
|
|
||||||
["Entfernt", teamspaceProjectIdResult.subentriesRemoved],
|
|
||||||
["Offen", teamspaceProjectIdResult.missing + teamspaceProjectIdResult.ambiguous]
|
["Offen", teamspaceProjectIdResult.missing + teamspaceProjectIdResult.ambiguous]
|
||||||
].map(([label, value]) => (
|
].map(([label, value]) => (
|
||||||
<div key={label} className="rounded-md border bg-muted/20 px-3 py-2">
|
<div key={label} className="rounded-md border bg-muted/20 px-3 py-2">
|
||||||
|
|||||||
+164
@@ -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();
|
||||||
|
}
|
||||||
Reference in new issue
Block a user