Compare commits
37
Commits
07ef8ef025
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fa9c122250 | ||
|
|
45ff46b7eb | ||
|
|
649dd6f028 | ||
|
|
fc0dfd0556 | ||
|
|
e1456ed4ca | ||
|
|
d5952d82bb | ||
|
|
35cb578a96 | ||
|
|
39690b1d2b | ||
|
|
62794f72f1 | ||
|
|
c89d4825e2 | ||
|
|
2105a0c5f7 | ||
|
|
0c14a3acbd | ||
|
|
fa6db04370 | ||
|
|
9be6c6e36a | ||
|
|
9107296900 | ||
|
|
008bc8d09c | ||
|
|
d6c8877ed7 | ||
|
|
040f1903e6 | ||
|
|
89429c70cf | ||
|
|
904b123884 | ||
|
|
560b747d5d | ||
|
|
c5495ddffa | ||
|
|
4f4fb0baf2 | ||
|
|
312dd3b3ad | ||
|
|
3ebcea0b3d | ||
|
|
c66b5a9aa9 | ||
|
|
d33b2c487d | ||
|
|
8a4cf3fc9b | ||
|
|
62d8f537a8 | ||
|
|
8288d9f7bc | ||
|
|
227644cfeb | ||
|
|
235c19e51b | ||
|
|
c01d747352 | ||
|
|
b7f7d7e793 | ||
|
|
cfb96f03c0 | ||
|
|
9a08bfbf44 | ||
|
|
87d6c9b3ce |
No files matched your search
@@ -2,6 +2,8 @@ TICKETTRACKER_IMAGE=git.d-razz.de/michael/tickettracker:latest
|
||||
APP_PORT=3910
|
||||
|
||||
DATABASE_URL=postgresql://tickettracker:change-me@10.10.10.23:5432/tickettracker
|
||||
SECRET_ENCRYPTION_KEY=change-me-secret-encryption-key
|
||||
ZAMMAD_EMAIL_WEBHOOK_TOKEN=change-me-zammad-webhook-token
|
||||
DB_POOL_SIZE=10
|
||||
|
||||
COOKIE_SECURE=false
|
||||
|
||||
@@ -4,6 +4,8 @@ APP_PORT=3910
|
||||
POSTGRES_DB=tickettracker
|
||||
POSTGRES_USER=tickettracker
|
||||
POSTGRES_PASSWORD=change-me-db-password
|
||||
SECRET_ENCRYPTION_KEY=change-me-secret-encryption-key
|
||||
ZAMMAD_EMAIL_WEBHOOK_TOKEN=change-me-zammad-webhook-token
|
||||
DB_POOL_SIZE=10
|
||||
|
||||
COOKIE_SECURE=false
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
DATABASE_URL=postgresql://tickettracker:change-me@10.10.10.23:5432/tickettracker
|
||||
SECRET_ENCRYPTION_KEY=change-me-secret-encryption-key
|
||||
ZAMMAD_EMAIL_WEBHOOK_TOKEN=change-me-zammad-webhook-token
|
||||
APP_PORT=3910
|
||||
TICKETTRACKER_IMAGE=git.d-razz.de/michael/tickettracker:latest
|
||||
COOKIE_SECURE=false
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
DATABASE_URL=postgresql://ticket:ticket@10.10.10.21:5433/tickettracker_dev
|
||||
APP_PORT=3910
|
||||
PP_PASSWORD='$ouzs4D$@RAi9'
|
||||
DATABASE_URL=postgresql://tickettracker:${PP_PASSWORD}@172.22.0.4:5432/tickettracker_dev
|
||||
APP_PORT=3911
|
||||
TICKETTRACKER_IMAGE=git.d-razz.de/michael/tickettracker:latest
|
||||
COOKIE_SECURE=false
|
||||
DB_POOL_SIZE=10
|
||||
ZAMMAD_EMAIL_WEBHOOK_TOKEN='sPXJc87R@ySW3uTFKjGFMrti8BcDkNv8mN2g71hRkdg='
|
||||
SECRET_ENCRYPTION_KEY='7qDJ1bcJprs$eSv57c2QXmQKh4kT^kb/w0vPBeJ4z^4='
|
||||
@@ -8,6 +8,7 @@
|
||||
"dev": "tsx watch src/index.ts",
|
||||
"build": "tsc -p tsconfig.json",
|
||||
"start": "node dist/index.js",
|
||||
"test": "node --import tsx --test test/*.test.ts",
|
||||
"typecheck": "tsc -p tsconfig.json --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
|
||||
+53
-2
@@ -17,6 +17,23 @@ export type AuthUser = {
|
||||
active: boolean;
|
||||
zammad_user_id: string | null;
|
||||
has_zammad_api_token: boolean;
|
||||
teamspace_user_id: string | null;
|
||||
has_teamspace_credentials: boolean;
|
||||
zammad_email_session_webhook_enabled: boolean;
|
||||
session_billing_prompt_enabled: boolean;
|
||||
session_work_segments_enabled: boolean;
|
||||
teamspace_time_tracking_enabled: boolean;
|
||||
zammad_session_note_default_enabled: boolean;
|
||||
zammad_session_note_date_header_enabled: boolean;
|
||||
zammad_time_unit_rounding_enabled: boolean;
|
||||
zammad_time_unit_rounding_minutes: number;
|
||||
zammad_atoss_email_sync_enabled: boolean;
|
||||
zammad_article_type_id: number;
|
||||
project_followup_enabled: boolean;
|
||||
project_followup_weeks: number;
|
||||
project_followup_second_enabled: boolean;
|
||||
project_followup_second_weeks: number;
|
||||
project_followup_complete_on_session: boolean;
|
||||
};
|
||||
|
||||
declare global {
|
||||
@@ -77,7 +94,24 @@ function publicUser(user: AuthUser) {
|
||||
role: user.role,
|
||||
active: user.active,
|
||||
zammad_user_id: user.zammad_user_id ?? null,
|
||||
has_zammad_api_token: Boolean(user.has_zammad_api_token)
|
||||
has_zammad_api_token: Boolean(user.has_zammad_api_token),
|
||||
teamspace_user_id: user.teamspace_user_id ?? null,
|
||||
has_teamspace_credentials: Boolean(user.has_teamspace_credentials),
|
||||
zammad_email_session_webhook_enabled: Boolean(user.zammad_email_session_webhook_enabled),
|
||||
session_billing_prompt_enabled: Boolean(user.session_billing_prompt_enabled),
|
||||
session_work_segments_enabled: Boolean(user.session_work_segments_enabled),
|
||||
teamspace_time_tracking_enabled: Boolean(user.teamspace_time_tracking_enabled ?? true),
|
||||
zammad_session_note_default_enabled: Boolean(user.zammad_session_note_default_enabled),
|
||||
zammad_session_note_date_header_enabled: Boolean(user.zammad_session_note_date_header_enabled),
|
||||
zammad_time_unit_rounding_enabled: Boolean(user.zammad_time_unit_rounding_enabled),
|
||||
zammad_time_unit_rounding_minutes: Number(user.zammad_time_unit_rounding_minutes ?? 15),
|
||||
zammad_atoss_email_sync_enabled: Boolean(user.zammad_atoss_email_sync_enabled ?? true),
|
||||
zammad_article_type_id: Number(user.zammad_article_type_id ?? 6),
|
||||
project_followup_enabled: Boolean(user.project_followup_enabled ?? true),
|
||||
project_followup_weeks: Number(user.project_followup_weeks ?? 4),
|
||||
project_followup_second_enabled: Boolean(user.project_followup_second_enabled ?? true),
|
||||
project_followup_second_weeks: Number(user.project_followup_second_weeks ?? 2),
|
||||
project_followup_complete_on_session: Boolean(user.project_followup_complete_on_session ?? true)
|
||||
};
|
||||
}
|
||||
|
||||
@@ -134,7 +168,24 @@ export async function requireAuth(req: Request, res: Response, next: NextFunctio
|
||||
u.role,
|
||||
u.active,
|
||||
u.zammad_user_id::text AS zammad_user_id,
|
||||
(u.zammad_api_token IS NOT NULL) AS has_zammad_api_token
|
||||
(u.zammad_api_token IS NOT NULL) AS has_zammad_api_token,
|
||||
u.teamspace_user_id::text AS teamspace_user_id,
|
||||
(u.teamspace_device_id IS NOT NULL AND u.teamspace_device_password IS NOT NULL) AS has_teamspace_credentials,
|
||||
u.zammad_email_session_webhook_enabled,
|
||||
u.session_billing_prompt_enabled,
|
||||
u.session_work_segments_enabled,
|
||||
u.teamspace_time_tracking_enabled,
|
||||
u.zammad_session_note_default_enabled,
|
||||
u.zammad_session_note_date_header_enabled,
|
||||
u.zammad_time_unit_rounding_enabled,
|
||||
u.zammad_time_unit_rounding_minutes,
|
||||
u.zammad_atoss_email_sync_enabled,
|
||||
COALESCE(u.zammad_article_type_id, 6) AS zammad_article_type_id,
|
||||
u.project_followup_enabled,
|
||||
u.project_followup_weeks,
|
||||
u.project_followup_second_enabled,
|
||||
u.project_followup_second_weeks,
|
||||
u.project_followup_complete_on_session
|
||||
FROM auth_sessions s
|
||||
JOIN users u ON u.id = s.user_id
|
||||
WHERE s.token_hash = $1
|
||||
|
||||
@@ -0,0 +1,397 @@
|
||||
type QueryResult<T> = {
|
||||
rows: T[];
|
||||
rowCount: number | null;
|
||||
};
|
||||
|
||||
export type QueryExecutor = {
|
||||
query<T extends Record<string, unknown> = Record<string, unknown>>(text: string, params?: unknown[]): Promise<QueryResult<T>>;
|
||||
};
|
||||
|
||||
export const databaseExportTables = [
|
||||
"users",
|
||||
"auth_sessions",
|
||||
"app_settings",
|
||||
"user_preferences",
|
||||
"organization_attribute_options",
|
||||
"teamspace_project_exclusions",
|
||||
"teamspace_assignment_rules",
|
||||
"organizations",
|
||||
"tickets",
|
||||
"projects",
|
||||
"teamspace_project_resources",
|
||||
"project_teamspace_entries",
|
||||
"teamspace_monthly_user_actuals",
|
||||
"project_teamspace_monthly_actuals",
|
||||
"project_teamspace_subentries",
|
||||
"hidden_user_projects",
|
||||
"ticket_todos",
|
||||
"recurring_billings",
|
||||
"recurring_billing_slots",
|
||||
"recurring_billing_exceptions",
|
||||
"sessions",
|
||||
"active_timers",
|
||||
"ticket_month_closures",
|
||||
"month_closures",
|
||||
"ticket_day_closures",
|
||||
"day_closures",
|
||||
"ticket_day_billings",
|
||||
"ticket_day_billing_acknowledgements"
|
||||
] as const;
|
||||
|
||||
type ColumnInfo = {
|
||||
columnName: string;
|
||||
dataType: string;
|
||||
udtName: string;
|
||||
ordinalPosition: number;
|
||||
isNullable: "YES" | "NO";
|
||||
};
|
||||
|
||||
const jsonDataTypes = new Set(["json", "jsonb"]);
|
||||
const arrayElementTypes = new Map<string, string>([
|
||||
["_bool", "boolean"],
|
||||
["_date", "date"],
|
||||
["_int2", "smallint"],
|
||||
["_int4", "integer"],
|
||||
["_int8", "bigint"],
|
||||
["_numeric", "numeric"],
|
||||
["_text", "text"],
|
||||
["_timestamptz", "timestamptz"],
|
||||
["_uuid", "uuid"]
|
||||
]);
|
||||
const insertBatchSize = 500;
|
||||
|
||||
export function quoteIdentifier(value: string) {
|
||||
return `"${value.replace(/"/g, '""')}"`;
|
||||
}
|
||||
|
||||
function quoteSqlString(value: string) {
|
||||
return `'${value.replace(/'/g, "''")}'`;
|
||||
}
|
||||
|
||||
function isJsonColumn(column?: Pick<ColumnInfo, "dataType" | "udtName">) {
|
||||
return Boolean(column && (jsonDataTypes.has(column.dataType) || jsonDataTypes.has(column.udtName)));
|
||||
}
|
||||
|
||||
function jsonValueForSql(value: unknown) {
|
||||
if (value instanceof Date) {
|
||||
return JSON.stringify(value.toISOString());
|
||||
}
|
||||
|
||||
const json = JSON.stringify(value);
|
||||
return json === undefined ? "null" : json;
|
||||
}
|
||||
|
||||
export function sqlLiteral(value: unknown, column?: Pick<ColumnInfo, "dataType" | "udtName">): string {
|
||||
if (value === null || value === undefined) {
|
||||
return "NULL";
|
||||
}
|
||||
|
||||
if (Buffer.isBuffer(value)) {
|
||||
return `decode('${value.toString("hex")}', 'hex')`;
|
||||
}
|
||||
|
||||
if (isJsonColumn(column)) {
|
||||
return quoteSqlString(jsonValueForSql(value));
|
||||
}
|
||||
|
||||
if (column?.dataType === "ARRAY") {
|
||||
if (!Array.isArray(value)) {
|
||||
throw new Error(`Expected PostgreSQL array value for ${column.udtName}`);
|
||||
}
|
||||
|
||||
const elementType = arrayElementTypes.get(column.udtName);
|
||||
|
||||
if (!elementType) {
|
||||
throw new Error(`Unsupported PostgreSQL array type: ${column.udtName}`);
|
||||
}
|
||||
|
||||
return `ARRAY[${value.map((entry) => sqlLiteral(entry)).join(", ")}]::${elementType}[]`;
|
||||
}
|
||||
|
||||
if (value instanceof Date) {
|
||||
return quoteSqlString(value.toISOString());
|
||||
}
|
||||
|
||||
if (typeof value === "boolean") {
|
||||
return value ? "TRUE" : "FALSE";
|
||||
}
|
||||
|
||||
if (typeof value === "number") {
|
||||
if (!Number.isFinite(value)) {
|
||||
throw new Error("Cannot export non-finite number");
|
||||
}
|
||||
|
||||
return String(value);
|
||||
}
|
||||
|
||||
if (typeof value === "bigint") {
|
||||
return String(value);
|
||||
}
|
||||
|
||||
if (typeof value === "object") {
|
||||
return quoteSqlString(jsonValueForSql(value));
|
||||
}
|
||||
|
||||
return quoteSqlString(String(value));
|
||||
}
|
||||
|
||||
function chunk<T>(items: T[], size: number) {
|
||||
const chunks: T[][] = [];
|
||||
|
||||
for (let index = 0; index < items.length; index += size) {
|
||||
chunks.push(items.slice(index, index + size));
|
||||
}
|
||||
|
||||
return chunks;
|
||||
}
|
||||
|
||||
async function tableColumns(client: QueryExecutor, table: string) {
|
||||
const result = await client.query<ColumnInfo>(
|
||||
`
|
||||
SELECT
|
||||
column_name AS "columnName",
|
||||
data_type AS "dataType",
|
||||
udt_name AS "udtName",
|
||||
ordinal_position::int AS "ordinalPosition",
|
||||
is_nullable AS "isNullable"
|
||||
FROM information_schema.columns
|
||||
WHERE table_schema = 'public'
|
||||
AND table_name = $1
|
||||
ORDER BY ordinal_position ASC;
|
||||
`,
|
||||
[table]
|
||||
);
|
||||
|
||||
return result.rows;
|
||||
}
|
||||
|
||||
function orderByClause(columns: ColumnInfo[]) {
|
||||
if (columns.some((column) => column.columnName === "id")) {
|
||||
return ` ORDER BY ${quoteIdentifier("id")}`;
|
||||
}
|
||||
|
||||
const orderableColumns = columns.filter((column) => !isJsonColumn(column)).map((column) => quoteIdentifier(column.columnName));
|
||||
return orderableColumns.length > 0 ? ` ORDER BY ${orderableColumns.join(", ")}` : "";
|
||||
}
|
||||
|
||||
async function sequenceNameForIdColumn(client: QueryExecutor, table: string, columns: ColumnInfo[]) {
|
||||
if (!columns.some((column) => column.columnName === "id")) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const result = await client.query<{ sequenceName: string | null }>(
|
||||
"SELECT pg_get_serial_sequence($1, 'id') AS \"sequenceName\";",
|
||||
[`public.${table}`]
|
||||
);
|
||||
|
||||
return result.rows[0]?.sequenceName ?? null;
|
||||
}
|
||||
|
||||
export function sequenceSetvalSql(table: string, sequenceName: string) {
|
||||
const quotedTable = quoteIdentifier(table);
|
||||
|
||||
return `SELECT setval(${quoteSqlString(sequenceName)}, COALESCE((SELECT MAX("id") FROM ${quotedTable}), 1), COALESCE((SELECT MAX("id") FROM ${quotedTable}), 0) > 0);`;
|
||||
}
|
||||
|
||||
export function nextSequenceIdAfterRestore(maxId: number | null | undefined) {
|
||||
return maxId && maxId > 0 ? maxId + 1 : 1;
|
||||
}
|
||||
|
||||
export async function exportTableSql(client: QueryExecutor, table: string) {
|
||||
const columns = await tableColumns(client, table);
|
||||
|
||||
if (columns.length === 0) {
|
||||
return [`-- Tabelle ${table} existiert nicht im aktuellen Schema.`];
|
||||
}
|
||||
|
||||
const columnNames = columns.map((column) => column.columnName);
|
||||
const quotedColumns = columnNames.map(quoteIdentifier);
|
||||
const rowsResult = await client.query<Record<string, unknown>>(
|
||||
`
|
||||
SELECT ${quotedColumns.join(", ")}
|
||||
FROM ${quoteIdentifier(table)}${orderByClause(columns)};
|
||||
`
|
||||
);
|
||||
|
||||
const lines = [`-- ${table}: ${rowsResult.rows.length} Datensatz/Datensätze`, `-- columns ${table}: ${columnNames.join(",")}`];
|
||||
|
||||
for (const rows of chunk(rowsResult.rows, insertBatchSize)) {
|
||||
if (rows.length === 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
lines.push(
|
||||
[
|
||||
`INSERT INTO ${quoteIdentifier(table)} (${quotedColumns.join(", ")}) VALUES`,
|
||||
rows
|
||||
.map((row) => ` (${columns.map((column) => sqlLiteral(row[column.columnName], column)).join(", ")})`)
|
||||
.join(",\n") + ";"
|
||||
].join("\n")
|
||||
);
|
||||
}
|
||||
|
||||
const sequenceName = await sequenceNameForIdColumn(client, table, columns);
|
||||
|
||||
if (sequenceName) {
|
||||
lines.push(sequenceSetvalSql(table, sequenceName));
|
||||
}
|
||||
|
||||
return lines;
|
||||
}
|
||||
|
||||
export async function currentTableColumns(client: QueryExecutor) {
|
||||
const result = await client.query<{ table_name: string; column_name: string }>(
|
||||
`
|
||||
SELECT table_name, column_name
|
||||
FROM information_schema.columns
|
||||
WHERE table_schema = 'public'
|
||||
AND table_name = ANY($1::text[])
|
||||
ORDER BY table_name, ordinal_position ASC;
|
||||
`,
|
||||
[[...databaseExportTables]]
|
||||
);
|
||||
const columns = new Map<string, string[]>();
|
||||
|
||||
for (const row of result.rows) {
|
||||
columns.set(row.table_name, [...(columns.get(row.table_name) ?? []), row.column_name]);
|
||||
}
|
||||
|
||||
return columns;
|
||||
}
|
||||
|
||||
export function restoreSqlText(sql: string) {
|
||||
const trimmed = sql.replace(/^\uFEFF/, "").trim();
|
||||
|
||||
if (!trimmed.includes("TicketTracker Datenbankexport")) {
|
||||
throw new Error("Nur TicketTracker-SQL-Backups können eingespielt werden");
|
||||
}
|
||||
|
||||
return trimmed
|
||||
.replace(/^\s*BEGIN\s*;\s*$/gim, "")
|
||||
.replace(/^\s*SET\s+LOCAL\s+(?:lock_timeout|statement_timeout)\s*=\s*'[^']*'\s*;\s*$/gim, "")
|
||||
.replace(/^\s*COMMIT\s*;\s*$/gim, "")
|
||||
.replace(/TRUNCATE\s+TABLE[\s\S]*?;\s*/i, "")
|
||||
.trim();
|
||||
}
|
||||
|
||||
function parseDumpColumnList(value: string) {
|
||||
const quoted = [...value.matchAll(/"((?:[^"]|"")+)"/g)].map((match) => match[1].replace(/""/g, '"'));
|
||||
|
||||
if (quoted.length > 0) {
|
||||
return quoted;
|
||||
}
|
||||
|
||||
return value
|
||||
.split(",")
|
||||
.map((column) => column.trim().replace(/^"|"$/g, ""))
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
export type RestoreValidationResult = {
|
||||
valid: boolean;
|
||||
checkedTables: number;
|
||||
issues: string[];
|
||||
};
|
||||
|
||||
export async function validateTicketTrackerBackup(client: QueryExecutor, sql: string): Promise<RestoreValidationResult> {
|
||||
const issues: string[] = [];
|
||||
let body = "";
|
||||
|
||||
try {
|
||||
body = restoreSqlText(sql);
|
||||
} catch (error) {
|
||||
return {
|
||||
valid: false,
|
||||
checkedTables: 0,
|
||||
issues: [error instanceof Error ? error.message : "Backup-Datei konnte nicht gelesen werden"]
|
||||
};
|
||||
}
|
||||
|
||||
const dumpColumns = new Map<string, Set<string>>();
|
||||
|
||||
for (const match of sql.matchAll(/^--\s*columns\s+([a-z_][a-z0-9_]*):\s*(.+)$/gim)) {
|
||||
dumpColumns.set(match[1], new Set(parseDumpColumnList(match[2])));
|
||||
}
|
||||
|
||||
for (const match of body.matchAll(/INSERT\s+INTO\s+"?([a-z_][a-z0-9_]*)"?\s*\(([^)]+)\)\s*VALUES/gi)) {
|
||||
const table = match[1];
|
||||
const columns = dumpColumns.get(table) ?? new Set<string>();
|
||||
|
||||
for (const column of parseDumpColumnList(match[2])) {
|
||||
columns.add(column);
|
||||
}
|
||||
|
||||
dumpColumns.set(table, columns);
|
||||
}
|
||||
|
||||
for (const table of dumpColumns.keys()) {
|
||||
if (!(databaseExportTables as readonly string[]).includes(table)) {
|
||||
issues.push(`Dump enthält unbekannte Tabelle "${table}".`);
|
||||
}
|
||||
}
|
||||
|
||||
const dbColumns = await currentTableColumns(client);
|
||||
|
||||
for (const table of databaseExportTables) {
|
||||
const currentColumns = dbColumns.get(table) ?? [];
|
||||
const backupColumns = dumpColumns.get(table);
|
||||
|
||||
if (currentColumns.length === 0) {
|
||||
issues.push(`Aktuelle Datenbank enthält Tabelle "${table}" nicht.`);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!backupColumns) {
|
||||
issues.push(`Dump enthält keine Strukturinformationen für Tabelle "${table}". Bitte ein aktuelles TicketTracker-Backup verwenden.`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const missingInDump = currentColumns.filter((column) => !backupColumns.has(column));
|
||||
const extraInDump = [...backupColumns].filter((column) => !currentColumns.includes(column));
|
||||
|
||||
if (missingInDump.length > 0) {
|
||||
issues.push(`Dump passt nicht zu Tabelle "${table}": Feld(er) fehlen: ${missingInDump.join(", ")}.`);
|
||||
}
|
||||
|
||||
if (extraInDump.length > 0) {
|
||||
issues.push(`Dump passt nicht zu Tabelle "${table}": unbekannte Feld(er): ${extraInDump.join(", ")}.`);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
valid: issues.length === 0,
|
||||
checkedTables: databaseExportTables.length,
|
||||
issues
|
||||
};
|
||||
}
|
||||
|
||||
export async function exportDatabaseSql(client: QueryExecutor) {
|
||||
const lines = [
|
||||
"-- TicketTracker Datenbankexport",
|
||||
`-- Erstellt: ${new Date().toISOString()}`,
|
||||
"--",
|
||||
"-- Restore:",
|
||||
"-- 1. Frische PostgreSQL-Datenbank erstellen.",
|
||||
"-- 2. TicketTracker-Migrationen ausführen.",
|
||||
"-- 3. TicketTracker-Backend stoppen.",
|
||||
"-- 4. Diese Datei einspielen:",
|
||||
"-- psql -v ON_ERROR_STOP=1 -d DATABASE -f backup.sql",
|
||||
"-- 5. TicketTracker-Backend wieder starten.",
|
||||
"--",
|
||||
"-- Der Restore läuft atomar in einer Transaktion.",
|
||||
"-- ACHTUNG: TRUNCATE benötigt exklusive PostgreSQL-Locks auf den betroffenen Tabellen.",
|
||||
"-- Für den Restore sollte das TicketTracker-Backend gestoppt werden.",
|
||||
"",
|
||||
"BEGIN;",
|
||||
"SET LOCAL lock_timeout = '10s';",
|
||||
"SET LOCAL statement_timeout = '15min';",
|
||||
`TRUNCATE TABLE ${[...databaseExportTables].reverse().map(quoteIdentifier).join(", ")} RESTART IDENTITY CASCADE;`
|
||||
];
|
||||
|
||||
for (const table of databaseExportTables) {
|
||||
lines.push("", ...(await exportTableSql(client, table)));
|
||||
}
|
||||
|
||||
lines.push("", "COMMIT;", "");
|
||||
return lines.join("\n");
|
||||
}
|
||||
+15657
-1007
File diff suppressed because it is too large.
Load diff
+1473
-7
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,72 @@
|
||||
import crypto from "node:crypto";
|
||||
|
||||
const encryptedSecretPrefix = "ttenc:v1:";
|
||||
|
||||
let warnedAboutFallbackKey = false;
|
||||
let cachedKey: Buffer | null = null;
|
||||
|
||||
function getEncryptionMaterial() {
|
||||
const configuredKey = process.env.SECRET_ENCRYPTION_KEY ?? process.env.TICKETTRACKER_SECRET_KEY;
|
||||
|
||||
if (configuredKey) {
|
||||
return configuredKey;
|
||||
}
|
||||
|
||||
if (!process.env.DATABASE_URL) {
|
||||
throw new Error("SECRET_ENCRYPTION_KEY is required to encrypt database secrets");
|
||||
}
|
||||
|
||||
if (!warnedAboutFallbackKey) {
|
||||
console.warn("SECRET_ENCRYPTION_KEY is not set; deriving database secret encryption from DATABASE_URL.");
|
||||
warnedAboutFallbackKey = true;
|
||||
}
|
||||
|
||||
return process.env.DATABASE_URL;
|
||||
}
|
||||
|
||||
function getEncryptionKey() {
|
||||
if (!cachedKey) {
|
||||
cachedKey = crypto.createHash("sha256").update(getEncryptionMaterial()).digest();
|
||||
}
|
||||
|
||||
return cachedKey;
|
||||
}
|
||||
|
||||
export function isEncryptedSecret(value: string | null | undefined) {
|
||||
return typeof value === "string" && value.startsWith(encryptedSecretPrefix);
|
||||
}
|
||||
|
||||
export function encryptSecret(value: string) {
|
||||
if (isEncryptedSecret(value)) {
|
||||
return value;
|
||||
}
|
||||
|
||||
const iv = crypto.randomBytes(12);
|
||||
const cipher = crypto.createCipheriv("aes-256-gcm", getEncryptionKey(), iv);
|
||||
const encrypted = Buffer.concat([cipher.update(value, "utf8"), cipher.final()]);
|
||||
const tag = cipher.getAuthTag();
|
||||
|
||||
return `${encryptedSecretPrefix}${iv.toString("base64url")}.${tag.toString("base64url")}.${encrypted.toString("base64url")}`;
|
||||
}
|
||||
|
||||
export function decryptSecret(value: string | null | undefined) {
|
||||
if (!value) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!isEncryptedSecret(value)) {
|
||||
return value;
|
||||
}
|
||||
|
||||
const parts = value.slice(encryptedSecretPrefix.length).split(".");
|
||||
|
||||
if (parts.length !== 3) {
|
||||
throw new Error("Encrypted database secret has an invalid format");
|
||||
}
|
||||
|
||||
const [ivText, tagText, encryptedText] = parts;
|
||||
const decipher = crypto.createDecipheriv("aes-256-gcm", getEncryptionKey(), Buffer.from(ivText, "base64url"));
|
||||
decipher.setAuthTag(Buffer.from(tagText, "base64url"));
|
||||
|
||||
return Buffer.concat([decipher.update(Buffer.from(encryptedText, "base64url")), decipher.final()]).toString("utf8");
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
export const ticketPattern = /^Ticket#\d{6}$/;
|
||||
export const trackableTicketPattern = /^(Ticket#\d{6}|Fix#\d+)$/;
|
||||
export const monthPattern = /^\d{4}-\d{2}$/;
|
||||
export const weekPattern = /^\d{4}-W\d{2}$/;
|
||||
export const dayPattern = /^\d{4}-\d{2}-\d{2}$/;
|
||||
export const usernamePattern = /^\S{3,40}$/u;
|
||||
|
||||
@@ -87,6 +88,41 @@ export function parseDay(value: string) {
|
||||
};
|
||||
}
|
||||
|
||||
export function parseWeek(value: string) {
|
||||
if (!weekPattern.test(value)) {
|
||||
throw badRequest("week must use YYYY-Www");
|
||||
}
|
||||
|
||||
const year = Number(value.slice(0, 4));
|
||||
const week = Number(value.slice(6, 8));
|
||||
|
||||
if (!Number.isInteger(year) || !Number.isInteger(week) || week < 1 || week > 53) {
|
||||
throw badRequest("week is invalid");
|
||||
}
|
||||
|
||||
const jan4 = new Date(Date.UTC(year, 0, 4));
|
||||
const jan4Weekday = jan4.getUTCDay() || 7;
|
||||
const weekStart = new Date(jan4);
|
||||
weekStart.setUTCDate(jan4.getUTCDate() - jan4Weekday + 1 + (week - 1) * 7);
|
||||
|
||||
const checkThursday = new Date(weekStart);
|
||||
checkThursday.setUTCDate(weekStart.getUTCDate() + 3);
|
||||
|
||||
if (checkThursday.getUTCFullYear() !== year) {
|
||||
throw badRequest("week is invalid");
|
||||
}
|
||||
|
||||
const next = new Date(weekStart);
|
||||
next.setUTCDate(weekStart.getUTCDate() + 7);
|
||||
|
||||
return {
|
||||
label: value,
|
||||
start: weekStart.toISOString().slice(0, 10),
|
||||
startIso: weekStart.toISOString(),
|
||||
endIso: next.toISOString()
|
||||
};
|
||||
}
|
||||
|
||||
export function parsePositiveInteger(value: unknown, field: string) {
|
||||
const number = Number(value);
|
||||
|
||||
@@ -110,8 +146,8 @@ export function parseBillingStatus(value: unknown) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (value !== "billed" && value !== "non_billable") {
|
||||
throw badRequest("billingStatus must be billed, non_billable or null");
|
||||
if (value !== "billable" && value !== "billed" && value !== "non_billable") {
|
||||
throw badRequest("billingStatus must be billable, billed, non_billable or null");
|
||||
}
|
||||
|
||||
return value;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import {
|
||||
databaseExportTables,
|
||||
exportTableSql,
|
||||
nextSequenceIdAfterRestore,
|
||||
restoreSqlText,
|
||||
sequenceSetvalSql,
|
||||
sqlLiteral,
|
||||
type QueryExecutor
|
||||
} from "../src/databaseExport.ts";
|
||||
|
||||
const jsonbColumn = { dataType: "jsonb", udtName: "jsonb" };
|
||||
const bigintArrayColumn = { dataType: "ARRAY", udtName: "_int8" };
|
||||
|
||||
test("sqlLiteral serializes primitive values", () => {
|
||||
assert.equal(sqlLiteral(null), "NULL");
|
||||
assert.equal(sqlLiteral(undefined), "NULL");
|
||||
assert.equal(sqlLiteral("normaler Text"), "'normaler Text'");
|
||||
assert.equal(sqlLiteral("O'Brien"), "'O''Brien'");
|
||||
assert.equal(sqlLiteral("erste Zeile\nzweite Zeile"), "'erste Zeile\nzweite Zeile'");
|
||||
assert.equal(sqlLiteral("Grüße"), "'Grüße'");
|
||||
assert.equal(sqlLiteral(true), "TRUE");
|
||||
assert.equal(sqlLiteral(false), "FALSE");
|
||||
assert.equal(sqlLiteral(0), "0");
|
||||
assert.equal(sqlLiteral(42), "42");
|
||||
assert.equal(sqlLiteral(-10), "-10");
|
||||
assert.equal(sqlLiteral(1.25), "1.25");
|
||||
});
|
||||
|
||||
test("sqlLiteral serializes dates, buffers, objects and arrays", () => {
|
||||
assert.equal(sqlLiteral(new Date("2026-09-11T10:15:30.000Z")), "'2026-09-11T10:15:30.000Z'");
|
||||
assert.equal(sqlLiteral(Buffer.from("abc")), "decode('616263', 'hex')");
|
||||
assert.equal(sqlLiteral({}), "'{}'");
|
||||
assert.equal(sqlLiteral([]), "'[]'");
|
||||
assert.equal(sqlLiteral({ foo: "bar" }), `'{"foo":"bar"}'`);
|
||||
assert.equal(sqlLiteral({ foo: "O'Brien" }), `'{"foo":"O''Brien"}'`);
|
||||
assert.equal(sqlLiteral({ nested: { enabled: true }, items: [{ id: 1 }] }), `'{"nested":{"enabled":true},"items":[{"id":1}]}'`);
|
||||
});
|
||||
|
||||
test("sqlLiteral serializes JSON columns as valid JSON values", () => {
|
||||
assert.equal(sqlLiteral({ foo: "bar", enabled: true }, jsonbColumn), `'{"foo":"bar","enabled":true}'`);
|
||||
assert.equal(sqlLiteral("plain json string", jsonbColumn), `'"plain json string"'`);
|
||||
assert.equal(sqlLiteral(true, jsonbColumn), "'true'");
|
||||
});
|
||||
|
||||
test("sqlLiteral serializes PostgreSQL arrays with an explicit element type", () => {
|
||||
assert.equal(sqlLiteral([], bigintArrayColumn), "ARRAY[]::bigint[]");
|
||||
assert.equal(sqlLiteral(["32349965", "32349982"], bigintArrayColumn), "ARRAY['32349965', '32349982']::bigint[]");
|
||||
assert.throws(
|
||||
() => sqlLiteral(["value"], { dataType: "ARRAY", udtName: "_unsupported" }),
|
||||
/Unsupported PostgreSQL array type/
|
||||
);
|
||||
});
|
||||
|
||||
test("database export inserts assignment rules before tickets that reference them", () => {
|
||||
assert.ok(databaseExportTables.indexOf("organization_attribute_options") < databaseExportTables.indexOf("teamspace_assignment_rules"));
|
||||
assert.ok(databaseExportTables.indexOf("teamspace_assignment_rules") < databaseExportTables.indexOf("tickets"));
|
||||
});
|
||||
|
||||
test("generated table export never contains implicit object stringification", async () => {
|
||||
const client: QueryExecutor = {
|
||||
async query<T extends Record<string, unknown>>(text: string): Promise<{ rows: T[]; rowCount: number | null }> {
|
||||
if (text.includes("information_schema.columns")) {
|
||||
return {
|
||||
rowCount: 3,
|
||||
rows: [
|
||||
{ columnName: "id", dataType: "bigint", udtName: "int8", ordinalPosition: 1, isNullable: "NO" },
|
||||
{ columnName: "value", dataType: "jsonb", udtName: "jsonb", ordinalPosition: 2, isNullable: "NO" },
|
||||
{ columnName: "label", dataType: "text", udtName: "text", ordinalPosition: 3, isNullable: "YES" }
|
||||
] as T[]
|
||||
};
|
||||
}
|
||||
|
||||
if (text.includes("pg_get_serial_sequence")) {
|
||||
return { rowCount: 1, rows: [{ sequenceName: "public.example_id_seq" }] as T[] };
|
||||
}
|
||||
|
||||
return {
|
||||
rowCount: 1,
|
||||
rows: [{ id: 4, value: { foo: "bar" }, label: "O'Brien" }] as T[]
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const sql = (await exportTableSql(client, "example")).join("\n");
|
||||
assert.doesNotMatch(sql, /\[object Object\]/);
|
||||
assert.match(sql, /'\{"foo":"bar"\}'/);
|
||||
});
|
||||
|
||||
test("sequence helper advances after restored max id", () => {
|
||||
assert.equal(nextSequenceIdAfterRestore(4), 5);
|
||||
assert.equal(nextSequenceIdAfterRestore(null), 1);
|
||||
assert.match(sequenceSetvalSql("users", "public.users_id_seq"), /SELECT setval\('public\.users_id_seq'/);
|
||||
assert.match(sequenceSetvalSql("users", "public.users_id_seq"), /MAX\("id"\) FROM "users"/);
|
||||
});
|
||||
|
||||
test("restoreSqlText removes dump transaction wrapper after export header", () => {
|
||||
const body = restoreSqlText(`
|
||||
-- TicketTracker Datenbankexport
|
||||
-- Erstellt: 2026-09-14T10:00:00.000Z
|
||||
|
||||
BEGIN;
|
||||
SET LOCAL lock_timeout = '10s';
|
||||
SET LOCAL statement_timeout = '15min';
|
||||
TRUNCATE TABLE "users", "auth_sessions" RESTART IDENTITY CASCADE;
|
||||
|
||||
INSERT INTO "users" ("id", "username") VALUES
|
||||
(1, 'admin');
|
||||
|
||||
COMMIT;
|
||||
`);
|
||||
|
||||
assert.doesNotMatch(body, /^\s*BEGIN\s*;/im);
|
||||
assert.doesNotMatch(body, /^\s*COMMIT\s*;/im);
|
||||
assert.doesNotMatch(body, /^\s*SET\s+LOCAL/im);
|
||||
assert.doesNotMatch(body, /TRUNCATE\s+TABLE/i);
|
||||
assert.match(body, /INSERT INTO "users"/);
|
||||
});
|
||||
@@ -0,0 +1,26 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import { decryptSecret, encryptSecret, isEncryptedSecret } from "../src/secrets.js";
|
||||
|
||||
test("database secrets are encrypted and can be decrypted", () => {
|
||||
process.env.SECRET_ENCRYPTION_KEY = "test-secret-key";
|
||||
|
||||
const encrypted = encryptSecret("zammad-token-with-specials-!$%&/()=?");
|
||||
|
||||
assert.equal(isEncryptedSecret(encrypted), true);
|
||||
assert.notEqual(encrypted, "zammad-token-with-specials-!$%&/()=?");
|
||||
assert.equal(decryptSecret(encrypted), "zammad-token-with-specials-!$%&/()=?");
|
||||
});
|
||||
|
||||
test("plain legacy secrets are read as-is until migration encrypts them", () => {
|
||||
assert.equal(decryptSecret("legacy-token"), "legacy-token");
|
||||
assert.equal(decryptSecret(null), null);
|
||||
});
|
||||
|
||||
test("already encrypted secrets are not encrypted twice", () => {
|
||||
process.env.SECRET_ENCRYPTION_KEY = "test-secret-key";
|
||||
|
||||
const encrypted = encryptSecret("zammad-token");
|
||||
|
||||
assert.equal(encryptSecret(encrypted), encrypted);
|
||||
});
|
||||
@@ -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/
|
||||
);
|
||||
});
|
||||
@@ -8,7 +8,7 @@ services:
|
||||
POSTGRES_USER: ${POSTGRES_USER:-tickettracker}
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?POSTGRES_PASSWORD muss gesetzt sein}
|
||||
volumes:
|
||||
- tickettracker_postgres_data:/var/lib/postgresql/data
|
||||
- ${POSTGRES_DATA_PATH:-tickettracker_postgres_data}:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U \"$${POSTGRES_USER}\" -d \"$${POSTGRES_DB}\""]
|
||||
interval: 10s
|
||||
@@ -26,6 +26,8 @@ services:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
DATABASE_URL: postgresql://${POSTGRES_USER:-tickettracker}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB:-tickettracker}
|
||||
SECRET_ENCRYPTION_KEY: ${SECRET_ENCRYPTION_KEY:-}
|
||||
ZAMMAD_EMAIL_WEBHOOK_TOKEN: ${ZAMMAD_EMAIL_WEBHOOK_TOKEN:-}
|
||||
PORT: 3000
|
||||
BACKEND_PORT: 3001
|
||||
API_PROXY_TARGET: http://127.0.0.1:3001
|
||||
|
||||
@@ -7,6 +7,8 @@ services:
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
DATABASE_URL: ${DATABASE_URL}
|
||||
SECRET_ENCRYPTION_KEY: ${SECRET_ENCRYPTION_KEY:-}
|
||||
ZAMMAD_EMAIL_WEBHOOK_TOKEN: ${ZAMMAD_EMAIL_WEBHOOK_TOKEN:-}
|
||||
PORT: 3000
|
||||
BACKEND_PORT: 3001
|
||||
API_PROXY_TARGET: http://127.0.0.1:3001
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
# TicketTracker
|
||||
|
||||
Stand: Oktober 2026
|
||||
|
||||
TicketTracker ist ein Arbeits-, Projekt- und Abrechnungscockpit für ticketbasierte Arbeit. Das System ergänzt Zammad und Teamspace: Zammad bleibt das Ticketsystem, Teamspace bleibt die Quelle für Projektbudgets und gebuchte Projektwerte, TicketTracker führt Erfassung, Bewertung, ToDos, Projektüberwachung und Monatsabschluss zusammen.
|
||||
|
||||
Der Kernnutzen: Jeder Benutzer sieht, was er gearbeitet hat, was noch bewertet werden muss, welche Projektbudgets kritisch sind, welche Teamspace-Daten fehlen oder abweichen und wann ein Monat sauber abgeschlossen werden kann.
|
||||
|
||||
## Zielgruppe und Einsatz
|
||||
|
||||
TicketTracker ist für Teams oder Einzelpersonen gedacht, die viel mit Tickets arbeiten und ihre Zeiten zuverlässig nachvollziehen müssen.
|
||||
|
||||
Typische Einsatzfälle:
|
||||
|
||||
- Support- und Consulting-Zeiten per Timer erfassen
|
||||
- Sessions nachträglich ergänzen
|
||||
- Arbeit am Ticket dokumentieren
|
||||
- Sessions als offen, abrechenbar, abgerechnet oder nicht abrechenbar bewerten
|
||||
- Teamspace-Tageswerte für abrechnungsrelevante Projektzeiten pflegen oder Teamspace als Ist-Quelle nutzen
|
||||
- Monatsabschlüsse kontrolliert vorbereiten
|
||||
- Consulting-Projekte mit Teamspace-Budgets, Reststunden und Unterpositionen überwachen
|
||||
- ToDos und Wiedervorlagen direkt am Ticket oder Projekt organisieren
|
||||
- wiederkehrende Leistungen automatisch erzeugen lassen
|
||||
- interne Zammad-Notizen aus TicketTracker heraus erzeugen
|
||||
|
||||
## Grundprinzip
|
||||
|
||||
TicketTracker arbeitet auf drei Ebenen:
|
||||
|
||||
1. **Session-Zeit**
|
||||
Die tatsächlich gemessene oder manuell erfasste Arbeitszeit.
|
||||
2. **Teamspace-/Projekt-Ist**
|
||||
Je nach Profilmodus entweder in TicketTracker gepflegte Teamspace-Tageswerte oder direkt aus Teamspace synchronisierte Ist-Werte.
|
||||
3. **Monatsabschluss**
|
||||
Der kontrollierte Abschluss eines Monats, sobald alle Sessions final bewertet und die benötigten Prüfpunkte erledigt sind.
|
||||
|
||||
Wichtig: Tickets werden nicht abgeschlossen. Tickets dienen als Arbeits- und Auswertungsrahmen. Abgeschlossen wird der Monat.
|
||||
|
||||
## Die wichtigsten Bereiche
|
||||
|
||||
- **Timer und Sessions:** Zeiten erfassen, nachtragen, bewerten und dokumentieren.
|
||||
- **Tickets:** Sessions pro Ticket, Tag und Monat prüfen.
|
||||
- **Projekte:** Consulting-Projekte, Teamspace-Projekte, Budgets, Reststunden und Unterpositionen überwachen.
|
||||
- **Teamspace:** Projekt-IDs, Budgets, Unterpositionen, Monatszeiten und Istwerte synchronisieren.
|
||||
- **Zammad:** Organisationen, Ticketstammdaten, Status, Priorität und interne Notizen anbinden.
|
||||
- **ToDos:** Aufgaben, Termine, Wiedervorlagen und Projektfollow-ups verwalten.
|
||||
- **Dashboard:** Tagessteuerung für offene Bewertungen, ToDos, Teamspace-Nacharbeit und Projektwarnungen.
|
||||
- **Statistiken:** Monatswerte, Organisationen, Tickets, Projektbudgets und Teamspace-Abgleich auswerten.
|
||||
- **Monatsabschluss:** Einen Monat kontrolliert schließen oder wieder öffnen.
|
||||
|
||||
## Unterseiten für Confluence
|
||||
|
||||
Diese Dokumentation ist für den Import als Confluence-Seitenbaum vorbereitet:
|
||||
|
||||
1. `01-zeiterfassung-sessions-monatsabschluss.md`
|
||||
2. `02-projekte-budgets-teamspace.md`
|
||||
3. `03-zammad-sync-und-integration.md`
|
||||
4. `04-dashboard-statistiken-und-widgets.md`
|
||||
5. `05-todos-wiedervorlagen-fixe-abrechnungen.md`
|
||||
6. `06-profil-admin-und-betrieb.md`
|
||||
|
||||
## Typischer Tagesablauf
|
||||
|
||||
1. Ticketnummer eingeben und Timer starten.
|
||||
2. Tätigkeit dokumentieren.
|
||||
3. Timer beenden und Session speichern.
|
||||
4. Bei Bedarf Session nachtragen.
|
||||
5. Sessions im Ticket bewerten.
|
||||
6. Wenn Teamspace-Erfassung in TT aktiv ist: Teamspace-Tageswerte pro Projekt/Tag pflegen.
|
||||
7. ToDos und Wiedervorlagen bearbeiten.
|
||||
8. Dashboard prüfen, ob offene Bewertungen oder Teamspace-Nacharbeiten vorhanden sind.
|
||||
|
||||
## Typischer Monatsablauf
|
||||
|
||||
1. Monat in Tickets, Statistiken oder Monatsabschluss auswählen.
|
||||
2. Offene Sessions prüfen.
|
||||
3. Sessions final als **Abgerechnet** oder **Nicht abrechenbar** bewerten.
|
||||
4. **Abrechenbar**-Zwischenstände klären, weil sie den Abschluss blockieren.
|
||||
5. Bei aktivierter Teamspace-Erfassung: Teamspace-Tageswerte und Differenzen prüfen.
|
||||
6. Im Zammad-Sync bei Bedarf den Teamspace-Monatsabgleich ausführen, um echte TS-Monatszeiten pro Projekt gegen TT zu prüfen.
|
||||
7. Bei deaktivierter Teamspace-Erfassung: Teamspace-Differenzen entfallen; nur Session-Status ist relevant.
|
||||
8. ToDos, Wiedervorlagen und Projektwarnungen prüfen.
|
||||
9. Monat abschließen.
|
||||
|
||||
TicketTracker ist damit das Kontrollzentrum zwischen täglicher Ticketarbeit, Zammad-Dokumentation, Teamspace-Projekten und sauberer Abrechnung.
|
||||
|
||||
Die zentrale Frage bleibt:
|
||||
|
||||
**Ist alles vollständig, bewertet, dokumentiert und abrechnungsbereit?**
|
||||
@@ -0,0 +1,100 @@
|
||||
# Zeiterfassung, Sessions und Monatsabschluss
|
||||
|
||||
Diese Seite beschreibt die tägliche Erfassung, Bewertung und Monatsprüfung in TicketTracker.
|
||||
|
||||
## Timer
|
||||
|
||||
Benutzer starten einen Timer über eine Ticketnummer. Es können mehrere Timer vorbereitet sein, aber immer nur einer läuft aktiv. Wird ein anderer Timer gestartet, pausiert TicketTracker die übrigen Timer automatisch.
|
||||
|
||||
Beim Beenden werden Tätigkeit, Zeit, Ticket, Organisation, Art und optional Bewertung gespeichert. Wenn die Zammad-Notiz im Profil aktiv ist, kann TicketTracker beim Speichern einen internen Zammad-Article erzeugen.
|
||||
|
||||
## Sessions nachtragen
|
||||
|
||||
Vergessene Arbeit kann manuell als Session nachgetragen werden. Dabei werden erfasst:
|
||||
|
||||
- Datum
|
||||
- Start und Ende
|
||||
- Ticket
|
||||
- Organisation
|
||||
- Art, zum Beispiel Support oder Consulting
|
||||
- Tätigkeit
|
||||
- Bewertung
|
||||
- optional interne Zammad-Notiz
|
||||
|
||||
Nachgetragene Sessions laufen genauso in Statistiken, Ticketansichten und Monatsabschluss ein wie per Timer erzeugte Sessions.
|
||||
|
||||
## Session-Bewertungen
|
||||
|
||||
Jede Session kann einen dieser Zustände haben:
|
||||
|
||||
- **Offen**
|
||||
- **Abrechenbar**
|
||||
- **Abgerechnet**
|
||||
- **Nicht abrechenbar**
|
||||
|
||||
Nur **Abgerechnet** und **Nicht abrechenbar** gelten als final. **Abrechenbar** ist ein Zwischenstatus und blockiert weiterhin den Monatsabschluss.
|
||||
|
||||
Ein erneuter Klick auf den bereits gesetzten Status nimmt die Bewertung wieder zurück und macht die Session offen.
|
||||
|
||||
## Ticketdetailansicht
|
||||
|
||||
In der Ticketdetailansicht werden Sessions nach Tagen gruppiert. Dort werden Sessions bewertet, bearbeitet, gelöscht und aufgeklappt.
|
||||
|
||||
Pro Tag sieht man unter anderem:
|
||||
|
||||
- Summe abgerechneter Session-Zeit
|
||||
- Summe abrechenbarer Session-Zeit
|
||||
- Summe nicht abrechenbarer Session-Zeit
|
||||
- offene Session-Zeit
|
||||
- Teamspace-Tageswert, falls die Teamspace-Erfassung in TT aktiv ist
|
||||
|
||||
Die Tageswerte sind pro Benutzer und Projekt/Teamspace-Eintrag gespeichert. Sie sind nicht an einzelne Sessions gebunden.
|
||||
|
||||
## Teamspace-Tageswerte
|
||||
|
||||
Wenn im Profil **Teamspace-Zeiten in TT erfassen** aktiv ist, kann der Benutzer in der Ticketdetailansicht Teamspace-Tageswerte pflegen.
|
||||
|
||||
Die Eingabe erfolgt in Stunden, zum Beispiel:
|
||||
|
||||
- `1`
|
||||
- `1,5`
|
||||
- `2.25`
|
||||
- `0,75`
|
||||
|
||||
Der Wert wird nicht während der Eingabe gespeichert. Sobald sich der Wert ändert, erscheint ein Speichern-Button. Ein leeres Feld entfernt den gespeicherten Teamspace-Wert für diesen Tag.
|
||||
|
||||
Wenn mehrere Teamspace-Projekte an einem Ticket hängen, wird der Tageswert pro Teamspace-Projekt-Eintrag geführt.
|
||||
|
||||
Wenn die Teamspace-Erfassung im Profil deaktiviert ist, sind diese Eingaben ausgeblendet und auch serverseitig gesperrt.
|
||||
|
||||
## Monatsabschluss
|
||||
|
||||
Der Monatsabschluss ist die zentrale Kontrollseite für einen Benutzer und Monat.
|
||||
|
||||
Sie zeigt unter anderem:
|
||||
|
||||
- eine nach Ticket zusammengefuehrte Pruefliste
|
||||
- offene Bewertungen und Sessions mit Zwischenstatus **Abrechenbar**
|
||||
- fehlende oder abweichende Teamspace-Werte als farbige, spaltenweise ausgerichtete Badges; die Tagesdetails stehen am jeweiligen Tag im Ticketdetail
|
||||
- Prüfpunkte vor dem Abschluss
|
||||
- Möglichkeit, den Monat zu schließen oder wieder zu öffnen
|
||||
|
||||
Jedes Ticket erscheint in der Pruefliste nur einmal. Monatsabweichungen, offene Bewertungen und betroffene Tage werden innerhalb dieses Eintrags gebuendelt; Hinweise zum gleichen Tag stehen in einer gemeinsamen Tageszeile.
|
||||
|
||||
Ein TT-Teamspace-Tageswert ist nur konsistent, wenn fuer dasselbe Ticket und denselben Tag mindestens eine Session den Status **Abgerechnet** hat. Bei ausschliesslich abrechenbaren, nicht abrechenbaren oder unbewerteten Sessions wird der hinterlegte Wert als Fehler ausgewiesen; ein zusaetzlicher Unterschied zur TS-API bleibt ebenfalls sichtbar.
|
||||
|
||||
Ein abgeschlossener Monat ist gegen versehentliche Änderungen geschützt. Wird eine Session in einem abgeschlossenen Zeitraum geändert, muss der Zeitraum vorher wieder geöffnet werden.
|
||||
|
||||
## Regeln bei deaktivierter Teamspace-Erfassung
|
||||
|
||||
Der Profil-Schalter **Teamspace-Zeiten in TT erfassen** ist userbezogen.
|
||||
|
||||
Wenn er ausgeschaltet ist:
|
||||
|
||||
- Teamspace-Tageswerte können nicht in TicketTracker gepflegt werden.
|
||||
- Manuelle Projektstunden sind nicht editierbar.
|
||||
- Monatsprüfung und Widgets prüfen nur Session-Status.
|
||||
- Teamspace-Differenzen werden nicht angezeigt.
|
||||
- Projekt-Istwerte kommen aus Teamspace.
|
||||
|
||||
Das ist für Benutzer gedacht, die keine Teamspace-Zeiten in TicketTracker nachpflegen, sondern Teamspace als führende Ist-Quelle verwenden.
|
||||
@@ -0,0 +1,150 @@
|
||||
# Projekte, Budgets und Teamspace
|
||||
|
||||
Diese Seite beschreibt die aktuelle Projekt- und Teamspace-Logik in TicketTracker.
|
||||
|
||||
## Projekte
|
||||
|
||||
Projekte erweitern normale Tickets um Consulting-Projektinformationen.
|
||||
|
||||
Ein Projekt enthält unter anderem:
|
||||
|
||||
- Ticketnummer
|
||||
- Titel und Organisation
|
||||
- Teamspace-Projekt-Einträge
|
||||
- Beschreibung und Ziele
|
||||
- Zeitraum
|
||||
- Projektstatus offen oder geschlossen
|
||||
- ToDos
|
||||
- Teamspace-Budgets und Verbrauchswerte
|
||||
- Teamspace-Unterpositionen
|
||||
|
||||
Projekt-Tickets sind Consulting-Tickets. Wird ein Projekt gelöscht, bleiben Ticket, Sessions und ToDos erhalten. Gelöscht werden nur die Projektinformationen.
|
||||
|
||||
## Teamspace als Budgetquelle
|
||||
|
||||
Teamspace ist die fachliche Quelle für Projektbudgets.
|
||||
|
||||
Aktuelle Budgetregeln:
|
||||
|
||||
- Teamspace ist die Source of Truth für Projektbudgets.
|
||||
- Zammad-Budgets werden nicht mehr als Budgetquelle verwendet.
|
||||
- Das Gesamtbudget eines Projekts kommt aus Teamspace.
|
||||
- Wenn mehrere Teamspace-Projekte an einem Ticket hängen, wird das Gesamtbudget aus diesen Teamspace-Projekten zusammengesetzt.
|
||||
- Unterpositionen zeigen ihre eigenen Teamspace-Budgets und Teamspace-Istwerte.
|
||||
- Stückpositionen werden nicht als Stundenbudget in die Stundenbetrachtung eingerechnet.
|
||||
|
||||
## Verbrauchslogik
|
||||
|
||||
Der Projektverbrauch hängt vom Profil-Schalter **Teamspace-Zeiten in TT erfassen** ab.
|
||||
|
||||
Wenn der Schalter aktiv ist:
|
||||
|
||||
- Projekt-Ist kommt aus Teamspace-Tageswerten, die in TicketTracker gepflegt werden.
|
||||
- Zusätzlich können manuelle Projektstunden erfasst werden.
|
||||
- Progress Bars, Reststunden und Budgetwarnungen verwenden diese Werte.
|
||||
|
||||
Wenn der Schalter deaktiviert ist:
|
||||
|
||||
- Projekt-Ist kommt aus Teamspace.
|
||||
- Manuelle Projektstunden sind ausgeblendet und serverseitig gesperrt.
|
||||
- Teamspace-Tageswerte können nicht in TicketTracker erfasst werden.
|
||||
- Statistiken und Widgets zeigen keine TT/Teamspace-Differenzprüfung für diesen Benutzer.
|
||||
|
||||
## Mehrere Teamspace-Projekte pro Ticket
|
||||
|
||||
Ein Ticket kann mehrere Teamspace-Projekt-IDs haben. TicketTracker speichert dafür mehrere Teamspace-Einträge am Projekt.
|
||||
|
||||
Beispiel:
|
||||
|
||||
- `EP-2025-Comp-009`
|
||||
- `EP-2025-Comp-025`
|
||||
|
||||
Einer der Einträge ist der Standard. Sessions können einem Teamspace-Projekt-Eintrag zugeordnet werden. Diese Zuordnung erfolgt auf Ebene des Teamspace-Projekts, nicht auf Ebene einzelner Unterpositionen.
|
||||
|
||||
## Gemeinsames Teamspace-Budget über mehrere Tickets
|
||||
|
||||
Mehrere unterschiedliche Ticketnummern können dieselbe Teamspace-Projekt-ID verwenden. TicketTracker behandelt diese Teamspace-Nummer dann als gemeinsame Budgetquelle.
|
||||
|
||||
Dabei gilt:
|
||||
|
||||
- Es gibt keine automatische Aufteilung auf die Ticketnummern.
|
||||
- Teamspace-API-ID, Teamspace-API-URL, Budget, Unterpositionen und Teamspace-Monatswerte werden für dieselbe Teamspace-Nummer konsolidiert.
|
||||
- Der Teamspace-Sync zieht gleiche Teamspace-Nummern nach jedem Lauf wieder auf denselben Datenstand.
|
||||
- Die Migration konsolidiert bereits vorhandene doppelte Teamspace-Nummern beim Start.
|
||||
- Ticket-spezifische Felder wie Standard-Eintrag, Reihenfolge, Session-Zuordnung und manuelle Projektstunden bleiben am jeweiligen Ticket.
|
||||
|
||||
## Teamspace-Projekt-ID-Sync
|
||||
|
||||
Der Sync auf der Zammad-Sync-Seite löst offene Consulting-Projekte gegen die Teamspace-API auf.
|
||||
|
||||
Gespeichert werden unter anderem:
|
||||
|
||||
- Teamspace-API-ID des Projekts
|
||||
- Teamspace-API-URL
|
||||
- Teamspace-Gesamtbudget
|
||||
- Teamspace-Unterpositionen
|
||||
- Teamspace-Budgets und Istwerte der Unterpositionen
|
||||
- Stückpositionen und deren Erfüllungsstand
|
||||
- echte Teamspace-Monatszeiten aus `/api/time` für den gewählten Monat
|
||||
|
||||
Die fachliche Teamspace-ID aus Zammad oder Projektinformationen, zum Beispiel `EP-2026-A-035`, ist nicht identisch mit der API-ID für Einzelabfragen. TicketTracker speichert deshalb die interne Teamspace-API-ID, sobald sie gefunden wurde.
|
||||
|
||||
## Teamspace-Monatsabgleich
|
||||
|
||||
Der Teamspace-Projekt-ID-Sync auf der Zammad-Sync-Seite enthält zusätzlich einen Monatsabgleich. Der Benutzer wählt einen Monat aus, TicketTracker ruft für diesen Zeitraum die Teamspace-Zeitbuchungen des angemeldeten Teamspace-Benutzers ab und speichert einen Snapshot pro Teamspace-Projekt-Eintrag.
|
||||
|
||||
Technische Grundlage:
|
||||
|
||||
- API-Endpoint: `/api/time`
|
||||
- Filter: `worker={Teamspace User-ID}` und `date=YYYY-MM-01..YYYY-MM-letzterTag`
|
||||
- gezählt werden Einträge mit `draftStatus.value = NORMAL`
|
||||
- relevante Minuten sind `amountBillable`
|
||||
- `amount` wird als Gesamtzeit mitgeführt, ist aber nicht die Vergleichsgröße
|
||||
|
||||
Die Monatswerte werden pro Benutzer, Teamspace-Projekt-Eintrag und Monat gespeichert. Dadurch bleiben drei Dinge getrennt:
|
||||
|
||||
- TT-Tageswerte, die in TicketTracker gepflegt werden
|
||||
- echte Teamspace-Monatszeiten aus Teamspace
|
||||
- Teamspace-Budget- und Unterpositionswerte aus dem Projektsync
|
||||
|
||||
Im Ergebnis des Syncs sieht man pro Teamspace-Projekt:
|
||||
|
||||
- TT-Monatswert
|
||||
- TS-Monatswert
|
||||
- Differenz
|
||||
- Anzahl der gefundenen TS-Zeitbuchungen
|
||||
- betroffene Haupt- oder Unterpositionen mit TS-Minuten
|
||||
|
||||
Wenn Teamspace-Zeiten auf Unterpositionen gebucht wurden, werden diese ebenfalls dem passenden Teamspace-Projekt-Eintrag zugeordnet. TicketTracker mappt dafür nicht nur die Hauptprojekt-API-ID, sondern auch die synchronisierten Unterpositions-API-IDs. Die Positionsaufschlüsselung wird im Monats-Snapshot gespeichert und kann dadurch später in der Monatsübersicht angezeigt werden.
|
||||
|
||||
## Unterpositionen
|
||||
|
||||
Unterpositionen werden aus Teamspace synchronisiert und im Projektdetail angezeigt. Sie dienen als Teamspace-Anzeige, um Budget, Verbrauch und freie Ressourcen pro Teamspace-Position sichtbar zu machen.
|
||||
|
||||
Wichtig:
|
||||
|
||||
- Zeiten werden nicht auf Unterpositionen in TicketTracker erfasst.
|
||||
- Unterpositionen bekommen ihre Budgets und Istwerte aus Teamspace.
|
||||
- Teamspace-Descriptions werden dekodiert und formatiert ausgegeben.
|
||||
- Unterpositionen können einen Alias in TicketTracker bekommen.
|
||||
- Akkordions der Unterpositionsgruppen merken sich den letzten offenen oder geschlossenen Zustand pro Benutzer und Projekt.
|
||||
- Bei Teamspace-Strukturen mit Überpositionen werden nur echte bebuchbare Unterpositionen angezeigt, nicht reine Summenpositionen.
|
||||
- Positionsnummern werden lesbar dargestellt, zum Beispiel `1` statt `0001` oder `1.1` statt `0001.0001`.
|
||||
|
||||
## Stückpositionen
|
||||
|
||||
Teamspace-Positionen mit `billingType = INVOICEABLE_FIX` sind Stück- oder Fixpositionen.
|
||||
|
||||
Für diese Positionen gelten andere Regeln:
|
||||
|
||||
- Stundenwerte werden nicht als Stundenbudget interpretiert.
|
||||
- TicketTracker zeigt sie als Stückpositionen an.
|
||||
- Der Erfüllungsstand wird über `plannedIncome` gegen `sumIncome` geprüft.
|
||||
- Ist das Soll erreicht, wird die Position als abgerechnet beziehungsweise erfüllt markiert.
|
||||
- Überschreitungen werden mit einem Hinweis-Icon angezeigt.
|
||||
|
||||
## Projektansichten
|
||||
|
||||
Die Projektliste zeigt Gesamtbudget, Verbrauch, Reststunden und Projektwarnungen. Bei mehreren Teamspace-Projekten können Detailbalken pro Teamspace-Projekt angezeigt oder ausgeblendet werden. Der Hauptbalken zeigt weiterhin das Gesamtbudget aller Teamspace-Projekte.
|
||||
|
||||
Im Projektdetail stehen Projektinformationen und ToDos getrennt von den Teamspace-Unterpositionen. Die Unterpositionen liegen in einer eigenen breiten Card unterhalb der Projekt-/ToDo-Informationen.
|
||||
@@ -0,0 +1,82 @@
|
||||
# Zammad Sync und Integration
|
||||
|
||||
TicketTracker nutzt Zammad, ohne Zammad zu ersetzen. Zammad bleibt das führende Ticketsystem für Ticketstammdaten, Organisationen, Status und Prioritäten.
|
||||
|
||||
## Persönlicher Zammad-Token
|
||||
|
||||
Jeder Benutzer kann im Profil einen persönlichen Zammad API-Token speichern. Beim Speichern ruft TicketTracker Zammad ab, prüft den Token und speichert die Zammad User-ID.
|
||||
|
||||
Der Token wird verschlüsselt in der Datenbank gespeichert.
|
||||
|
||||
Der persönliche Token wird unter anderem für benutzerbezogene Zammad-Funktionen verwendet, zum Beispiel interne Notizen oder personenbezogene Synchronisierungen.
|
||||
|
||||
## Admin-Zammad-Zugang
|
||||
|
||||
Im Adminbereich wird der Zammad-Zugang für systemweite Synchronisierungen gepflegt.
|
||||
|
||||
Der Admin kann Organisationen aus Zammad synchronisieren. Dabei werden lokale Organisationen angelegt, aktualisiert oder entfernt beziehungsweise von alten Verknüpfungen gelöst.
|
||||
|
||||
## Ticketdaten aus Zammad
|
||||
|
||||
TicketTracker kann Ticketdaten aus Zammad übernehmen:
|
||||
|
||||
- Ticketnummer
|
||||
- Titel
|
||||
- Organisation
|
||||
- Work-Type, zum Beispiel Support oder Consulting
|
||||
- Status
|
||||
- Priorität
|
||||
- Owner
|
||||
- Teamspace-Projektinformationen
|
||||
|
||||
Zammad-verwaltete Ticketstammdaten bleiben geschützt. Ticketnummer, Titel, Art und Organisation können dann in TicketTracker nicht beliebig überschrieben werden.
|
||||
|
||||
## Consulting-Projekte aus Zammad
|
||||
|
||||
Beim Zammad-Sync oder beim Anlegen eines Projekts können Consulting-Projektinformationen übernommen werden.
|
||||
|
||||
Dazu gehören insbesondere:
|
||||
|
||||
- Teamspace-Projekt-ID oder mehrere Teamspace-Projekt-IDs
|
||||
- Zammad-Owner
|
||||
- Projektstatus-Hinweise, zum Beispiel Review bei Abschlussstatus
|
||||
|
||||
Die Teamspace-Projekt-ID aus Zammad ist die fachliche ID, zum Beispiel `EP-2026-A-035`. Für Teamspace-API-Abfragen speichert TicketTracker zusätzlich die interne Teamspace-API-ID.
|
||||
|
||||
## Teamspace-Projekt-ID-Sync auf der Zammad-Sync-Seite
|
||||
|
||||
Auf der Zammad-Sync-Seite gibt es den Sync für Teamspace-Projekt-IDs, Budgets und Positionen.
|
||||
|
||||
Voraussetzungen:
|
||||
|
||||
- Admin hat die Teamspace-Base-URL gespeichert.
|
||||
- Benutzer hat im Profil Teamspace-Geraete-ID und Geraetepasswort gespeichert.
|
||||
- Die Teamspace User-ID ist bekannt.
|
||||
- Das Projekt hat eine Teamspace-Projekt-ID.
|
||||
|
||||
Der Sync speichert:
|
||||
|
||||
- Teamspace-API-ID
|
||||
- Teamspace-API-URL
|
||||
- Gesamtbudget
|
||||
- Unterpositionen
|
||||
- Budgets und Istwerte der Unterpositionen
|
||||
- Stückpositionen
|
||||
|
||||
## Interne Zammad-Notizen
|
||||
|
||||
Beim Timerabschluss und beim manuellen Nachtragen kann TicketTracker einen internen Zammad-Article erzeugen.
|
||||
|
||||
Der Article enthält die Tätigkeit und optional den `time_unit`-Wert. Im Profil kann eingestellt werden:
|
||||
|
||||
- ob die interne Notiz standardmäßig aktiv ist
|
||||
- welcher Zammad-Zeitbuchungstyp vorausgewählt wird
|
||||
- ob `time_unit` auf 5, 10, 15 oder 30 Minuten aufgerundet wird
|
||||
|
||||
Wenn Zammad nicht erreichbar ist oder die Notiz fehlschlägt, speichert TicketTracker die Session trotzdem. Die fehlgeschlagene Notiz wird geparkt und kann später im Profil erneut gesendet werden.
|
||||
|
||||
## eMail-/Article-Import
|
||||
|
||||
TicketTracker kann Zammad-Articles prüfen und daraus Sessions vorbereiten. Das ist besonders hilfreich, wenn Arbeit per eMail-Import ins Ticket kam.
|
||||
|
||||
Solche Sessions können nachträglich geprüft, zugeordnet und bewertet werden. Wenn ein importierter Eintrag keinem Teamspace-Projekt zugeordnet ist, kann das im Projektdetail sichtbar werden.
|
||||
@@ -0,0 +1,95 @@
|
||||
# Dashboard, Statistiken und Widgets
|
||||
|
||||
Diese Seite beschreibt die Kontroll- und Auswertungsflächen in TicketTracker.
|
||||
|
||||
## Dashboard
|
||||
|
||||
Das Dashboard ist das Tagescockpit. Es zeigt, ob heute oder in der aktuellen Arbeitslage noch etwas offen ist.
|
||||
|
||||
Typische Hinweise:
|
||||
|
||||
- offene Session-Bewertungen
|
||||
- Sessions im Zwischenstatus **Abrechenbar**
|
||||
- heutige oder überfällige ToDos
|
||||
- fällige Wiedervorlagen
|
||||
- fehlende Teamspace-Tageswerte, wenn Teamspace-Erfassung in TT aktiv ist
|
||||
- Projektbudget-Hinweise
|
||||
- Nacharbeit für heutige Tickets
|
||||
|
||||
Widgets können je nach Konfiguration angeordnet und genutzt werden. Favoriten und persönliche Notizen unterstützen die tägliche Arbeit.
|
||||
|
||||
## Teamspace-Verhalten im Dashboard
|
||||
|
||||
Der Profil-Schalter **Teamspace-Zeiten in TT erfassen** beeinflusst die Dashboard-Logik.
|
||||
|
||||
Wenn aktiv:
|
||||
|
||||
- Dashboard kann fehlende Teamspace-Tageswerte anzeigen.
|
||||
- Nacharbeitslisten können Teamspace-Differenzen berücksichtigen.
|
||||
- Projektverbrauch basiert auf TT-Teamspace-Tageswerten plus manuellen Projektstunden.
|
||||
|
||||
Wenn deaktiviert:
|
||||
|
||||
- Teamspace-Erfassung wird als ausgeschaltet angezeigt.
|
||||
- Monatsprüfung läuft statusbasiert.
|
||||
- Teamspace-Differenzhinweise werden ausgeblendet.
|
||||
- Projekt-Istwerte kommen aus Teamspace.
|
||||
|
||||
## Statistiken
|
||||
|
||||
Die Statistikseite betrachtet einen Monat.
|
||||
|
||||
Sie zeigt unter anderem:
|
||||
|
||||
- abgerechnete Zeit
|
||||
- abrechenbare Zwischenstände
|
||||
- nicht abrechenbare Zeit
|
||||
- offene Sessions
|
||||
- Organisationen mit Aufwand
|
||||
- Tickets mit Aufwand
|
||||
- Projektbudget-Hinweise
|
||||
- manuelle und fixe Sessions
|
||||
- Teamspace-Abgleich, wenn Teamspace-Erfassung in TT aktiv ist
|
||||
- Teamspace als Ist-Quelle, wenn die Erfassung in TT deaktiviert ist
|
||||
|
||||
## Abgerechnet, abrechenbar und offen
|
||||
|
||||
Für statistische Auswertungen gilt:
|
||||
|
||||
- **Abgerechnet** zählt in die abgerechnete Session-Zeit.
|
||||
- **Abrechenbar** ist ein Zwischenstatus und bleibt offen für den Monatsabschluss.
|
||||
- **Nicht abrechenbar** ist final, zählt aber nicht als abgerechnete Zeit.
|
||||
- **Offen** blockiert den Monatsabschluss.
|
||||
|
||||
## Teamspace-Abgleich in Statistiken
|
||||
|
||||
Wenn Teamspace-Erfassung in TT aktiv ist, vergleicht TicketTracker:
|
||||
|
||||
`Teamspace-Zeit minus abgerechnete Session-Zeit`
|
||||
|
||||
Bedeutung:
|
||||
|
||||
- Positive Differenz: In Teamspace steht mehr Zeit als abgerechnet wurde.
|
||||
- Negative Differenz: In Teamspace steht weniger Zeit als abgerechnet wurde.
|
||||
- Null: Teamspace und abgerechnete Session-Zeit sind ausgeglichen.
|
||||
|
||||
Wenn Teamspace-Erfassung deaktiviert ist, werden Differenzspalten, Differenzbadges und Teamspace-Linien ausgeblendet oder angepasst. Die Statistik zeigt dann Teamspace als Ist-Quelle und prüft primär die Session-Status.
|
||||
|
||||
## Teamspace-Monatsabgleich aus dem Sync
|
||||
|
||||
Zusätzlich zum Tageswert-Abgleich kann der Zammad-Sync echte Teamspace-Monatszeiten abrufen. Diese Daten kommen aus Teamspace `/api/time` und verwenden `amountBillable` der Projektzeitbuchungen.
|
||||
|
||||
Der Sync speichert die Werte als Monats-Snapshot pro Benutzer und Teamspace-Projekt-Eintrag. Dadurch wird sichtbar, ob der in TicketTracker gepflegte Monatswert zu den echten Teamspace-Buchungen passt und bei welchem Projekt die Abweichung liegt.
|
||||
|
||||
Der Monatsabgleich ist besonders hilfreich, wenn mehrere Teamspace-Projekte an einem Ticket hängen oder wenn Teamspace-Zeiten direkt auf Unterpositionen gebucht wurden. Diese Unterpositionszeiten werden dem passenden Teamspace-Projekt-Eintrag zugerechnet.
|
||||
|
||||
In der Monatsübersicht wird der gespeicherte Snapshot direkt am Ticket ausgegeben: TS-Monatswert, Differenz und die betroffenen Haupt- oder Unterpositionen. So ist nicht nur sichtbar, dass eine Abweichung existiert, sondern auch, auf welcher Teamspace-Position die Zeit gebucht wurde.
|
||||
|
||||
## Projektbudget-Hinweise
|
||||
|
||||
Projektbudget-Hinweise basieren auf den aktuellen Projektregeln:
|
||||
|
||||
- Budget kommt aus Teamspace.
|
||||
- Ist kommt entweder aus TT-Teamspace-Tageswerten plus manuellen Projektstunden oder aus Teamspace, abhängig vom Profil-Schalter.
|
||||
- Überschreitungen werden als Warnung angezeigt.
|
||||
- Stückpositionen werden gesondert bewertet und nicht als Stundenbudget behandelt.
|
||||
@@ -0,0 +1,79 @@
|
||||
# ToDos, Wiedervorlagen und fixe Abrechnungen
|
||||
|
||||
Diese Seite beschreibt die Aufgabenlogik und automatisch erzeugte Sessions.
|
||||
|
||||
## ToDos
|
||||
|
||||
ToDos können direkt an Tickets oder Projekte gehängt werden.
|
||||
|
||||
Ein ToDo enthält:
|
||||
|
||||
- Aufgabe
|
||||
- Priorität
|
||||
- Status offen oder erledigt
|
||||
- optional Umsetzung von
|
||||
- optional Umsetzung bis
|
||||
- optionale Beschreibung
|
||||
|
||||
ToDos können bearbeitet, erledigt, wieder geöffnet oder gelöscht werden.
|
||||
|
||||
## ToDo-Seite
|
||||
|
||||
Die zentrale ToDo-Seite bietet:
|
||||
|
||||
- heutige ToDos
|
||||
- offene, erledigte oder alle ToDos
|
||||
- Suche nach Ticketnummer
|
||||
- Sortierung
|
||||
- Gruppierung
|
||||
- freie Reihenfolge per Drag and Drop
|
||||
- Kalenderansicht
|
||||
|
||||
Überfällige ToDos werden hervorgehoben. ToDos mit Zeitraum zählen an jedem Arbeitstag im Zeitraum. Ein-Tages-ToDos erscheinen einzeln am jeweiligen Tag.
|
||||
|
||||
## Wiedervorlagen
|
||||
|
||||
Für Consulting-Projekte können automatische Wiedervorlagen entstehen, wenn länger keine Aktivität im TicketTracker passiert ist.
|
||||
|
||||
Mögliche Regeln:
|
||||
|
||||
- erste Wiedervorlage nach einer definierten Anzahl Wochen ohne Aktivität
|
||||
- optionale zweite Wiedervorlage nach weiterem Stillstand
|
||||
- automatische Erledigung, wenn später wieder eine Session auf dem Projekt erfasst wird
|
||||
|
||||
Die Einstellungen dafür liegen im Profil.
|
||||
|
||||
## Zammad-Termine
|
||||
|
||||
Aus Zammad-Tickets im Status **Wartend** oder **Abschluss** mit Datum kann TicketTracker Termin-ToDos erzeugen.
|
||||
|
||||
So werden Zammad-Termine direkt im TicketTracker-Dashboard, in der ToDo-Seite und im Kalender sichtbar.
|
||||
|
||||
## Fixe Abrechnungen
|
||||
|
||||
Regelmäßige Leistungen können als fixe Abrechnung angelegt werden.
|
||||
|
||||
Beispiele:
|
||||
|
||||
- jeden Donnerstag 4 Stunden
|
||||
- bestimmte Wochentage mit festen Zeiten
|
||||
- alle 2 Wochen eine Leistung
|
||||
|
||||
TicketTracker erzeugt daraus automatisch Sessions.
|
||||
|
||||
## Feiertage und Ausnahmen
|
||||
|
||||
Bayerische Feiertage werden berücksichtigt. Für Feiertage werden keine fixen Sessions erzeugt.
|
||||
|
||||
Einzelne automatisch erzeugte Sessions können gelöscht werden, zum Beispiel wegen Urlaub, Krankheit oder Verschiebung. Diese Löschung wird als Ausnahme gespeichert, damit die Session beim nächsten Aufruf nicht automatisch wieder erscheint.
|
||||
|
||||
Wenn eine wiederkehrende Leistung an einem anderen Tag erbracht wurde, kann die erzeugte Session bearbeitet oder verschoben werden.
|
||||
|
||||
## Bewertung fixer Sessions
|
||||
|
||||
Fix erzeugte Sessions verhalten sich im Abschluss wie normale Sessions:
|
||||
|
||||
- Sie müssen bewertet werden.
|
||||
- **Abrechenbar** bleibt ein Zwischenstatus.
|
||||
- **Abgerechnet** und **Nicht abrechenbar** sind final.
|
||||
- Offene oder abrechenbare Sessions blockieren den Monatsabschluss.
|
||||
@@ -0,0 +1,107 @@
|
||||
# Profil, Adminbereich und Betrieb
|
||||
|
||||
Diese Seite beschreibt persönliche Einstellungen, Adminfunktionen und betriebliche Hinweise.
|
||||
|
||||
## Profil
|
||||
|
||||
Jeder Benutzer pflegt im Profil seine persönlichen Einstellungen:
|
||||
|
||||
- Benutzername und Anzeigename
|
||||
- Passwort
|
||||
- persönlicher Zammad API-Token
|
||||
- Teamspace-Geraete-ID und Geraetepasswort
|
||||
- angezeigte Zammad User-ID
|
||||
- angezeigte Teamspace User-ID
|
||||
- Bewertungsabfrage beim Timerabschluss
|
||||
- Teamspace-Zeiten in TT erfassen
|
||||
- interne Zammad-Notiz beim Timerabschluss
|
||||
- Standard-Typ für interne Zammad-Notizen
|
||||
- optionale Zammad-Zeitaufrundung für `time_unit`
|
||||
- ATOSS/eMail-Sync-Optionen, sofern aktiviert
|
||||
- Projekt-Wiedervorlagen
|
||||
|
||||
Passwortänderungen erfordern das aktuelle Passwort. Bleiben die Passwortfelder leer, werden nur die übrigen Profildaten gespeichert.
|
||||
|
||||
## Zammad-Zugang im Profil
|
||||
|
||||
Normale Benutzer können im Profil einen persönlichen Zammad API-Token hinterlegen. Beim Speichern eines neuen Tokens ruft TicketTracker Zammad ab und speichert die User-ID, die Zammad für diesen Token zurückliefert.
|
||||
|
||||
Der Token wird verschlüsselt gespeichert. Die User-ID wird danach read-only im Profil angezeigt.
|
||||
|
||||
## Teamspace-Zugang im Profil
|
||||
|
||||
Jeder Benutzer kann seine Teamspace-Geraete-ID und sein Geraetepasswort speichern.
|
||||
|
||||
Beim Speichern werden die Zugangsdaten gegen Teamspace geprüft. Wenn die Prüfung erfolgreich ist, speichert TicketTracker die Teamspace User-ID und zeigt sie im Profil an.
|
||||
|
||||
Geraete-ID und Passwort werden verschlüsselt in der Datenbank gespeichert.
|
||||
|
||||
## Teamspace-Zeiten in TT erfassen
|
||||
|
||||
Der Schalter **Teamspace-Zeiten in TT erfassen** ist userbezogen und steuert, ob ein Benutzer Teamspace-Tageswerte in TicketTracker pflegt.
|
||||
|
||||
**An:**
|
||||
|
||||
- Teamspace-Tageswerte können in TicketTracker erfasst werden.
|
||||
- Manuelle Projektstunden sind editierbar.
|
||||
- Projekt-Ist kommt aus TT-Teamspace-Tageswerten plus manuellen Projektstunden.
|
||||
- Statistiken und Monatsprüfung vergleichen TT gegen Teamspace-Werte.
|
||||
|
||||
**Aus:**
|
||||
|
||||
- Teamspace-Tageswerte können nicht erfasst werden.
|
||||
- Manuelle Projektstunden sind ausgeblendet und serverseitig gesperrt.
|
||||
- Projekt-Ist kommt aus Teamspace.
|
||||
- Monatsprüfung und Widgets prüfen nur Session-Status.
|
||||
- Teamspace-Differenzen werden ausgeblendet.
|
||||
|
||||
Der Schalter ist bewusst userbezogen. Zwei Benutzer können am gleichen Projekt arbeiten und trotzdem unterschiedliche Erfassungs- und Prüflogiken nutzen.
|
||||
|
||||
## Adminbereich
|
||||
|
||||
Der feste Admin ist für Systemverwaltung vorgesehen. Er kann:
|
||||
|
||||
- Benutzer anlegen und bearbeiten
|
||||
- Passwörter für Benutzer setzen
|
||||
- Zammad-Adminzugang für Organisationssync pflegen
|
||||
- Teamspace-Base-URL speichern
|
||||
- Organisationen aus Zammad synchronisieren
|
||||
- SQL-Backups herunterladen
|
||||
- Restore-fähige SQL-Backups wieder einspielen
|
||||
|
||||
Der Admin sieht keine Timer, keine persönlichen Auswertungen, keine Statistiken, keine fixen Abrechnungen und keinen Monatsabschluss. Diese Bereiche sind für normale Benutzer vorgesehen.
|
||||
|
||||
## Teamspace-Base-URL
|
||||
|
||||
Die Teamspace-Base-URL wird im Adminbereich gespeichert. Ohne diese URL können Benutzer ihre Teamspace-Zugangsdaten nicht sinnvoll für API-Syncs nutzen.
|
||||
|
||||
Die persönlichen Teamspace-Zugangsdaten liegen dagegen im Benutzerprofil.
|
||||
|
||||
## Backup und Restore
|
||||
|
||||
Der Adminbereich kann SQL-Backups erzeugen und wieder einspielen.
|
||||
|
||||
Der Export enthält alle Tabellen, die für den Systemstand notwendig sind, unter anderem:
|
||||
|
||||
- Benutzer
|
||||
- Einstellungen
|
||||
- Organisationen
|
||||
- Tickets
|
||||
- Sessions
|
||||
- Teamspace-Werte
|
||||
- Projektinformationen
|
||||
- Unterpositionen
|
||||
- fixe Abrechnungen
|
||||
- Ausnahmen
|
||||
- Monatsabschlüsse
|
||||
|
||||
Ein Restore ersetzt den aktuellen Datenbestand durch den Stand des Backups. Vor dem Restore wird die Struktur geprüft. Nach dem Restore ist eine neue Anmeldung mit den Zugangsdaten aus dem Backup notwendig.
|
||||
|
||||
## Technischer Stack
|
||||
|
||||
- Frontend: Next.js, React, shadcn/ui, Tailwind CSS
|
||||
- Backend: Node.js, Express, TypeScript
|
||||
- Datenbank: PostgreSQL
|
||||
- Deployment: Docker-fähig
|
||||
|
||||
Secrets wie Zammad-Token und Teamspace-Zugangsdaten werden verschlüsselt gespeichert.
|
||||
+60
-24
@@ -21,7 +21,7 @@ Es gibt drei zentrale Zeitebenen:
|
||||
|
||||
- Session-Zeit: Die gemessene oder manuell erfasste Arbeitszeit einer einzelnen Taetigkeit.
|
||||
- Teamspace-Zeit: Die Zeit, die fuer ein Ticket an einem konkreten Tag wirklich im CRM beziehungsweise Teamspace eingetragen wurde.
|
||||
- Monatsabschluss: Der Abschluss eines ganzen Monats, sobald alle Sessions des Monats bewertet sind.
|
||||
- Monatsabschluss: Der Abschluss eines ganzen Monats, sobald alle Sessions des Monats final bewertet sind.
|
||||
|
||||
Wichtig: Tickets werden nicht geschlossen. Tickets dienen nur als Gruppierung fuer Sessions. Abgeschlossen wird ausschliesslich der Monat.
|
||||
|
||||
@@ -71,19 +71,27 @@ Die Seite `Timer` ist fuer laufende Arbeit an Tickets gedacht.
|
||||
1. Ticketnummer im Format `Ticket#XXXXXX` eingeben.
|
||||
2. `Timer starten` klicken.
|
||||
3. Der Timer startet.
|
||||
4. Bei Bedarf kann der Timer pausiert, aktiviert, beendet oder entfernt werden.
|
||||
4. Taetigkeit und optional Bewertung in den Details des ausgewaehlten Timers erfassen.
|
||||
5. Bei Bedarf kann der Timer pausiert, aktiviert, beendet oder entfernt werden.
|
||||
|
||||
Es koennen mehrere Timer vorbereitet sein, aber immer nur ein Timer laeuft aktiv. Wird ein anderer Timer aktiviert, werden alle anderen Timer automatisch pausiert.
|
||||
|
||||
Alle laufenden und pausierten Timer stehen in der Seitenleiste. Dadurch bleiben sie auf allen Unterseiten sichtbar.
|
||||
|
||||
Beim Klick auf `Beenden` stoppt der Timer sofort. Das spaetere Ausfuellen des Modals verlaengert die gemessene Zeit nicht.
|
||||
Die Taetigkeit und Bewertung werden im ausgewaehlten Timer erfasst. Pausierte Timer behalten diese Notizen. Wird ein Timer entfernt, werden auch die noch nicht gespeicherten Details verworfen.
|
||||
|
||||
Beim Klick auf `Beenden` stoppt der Timer sofort. Die vorher im Timer erfasste Taetigkeit wird beim Speichern an die Session gehaengt.
|
||||
|
||||
Die Bewertung hat drei Optionen: `---`, `Abrechenbar` und `Nicht abrechenbar`. `---` ist der Standard und laesst die Session offen. Wenn im Profil die Bewertungsabfrage aktiv ist, fragt TicketTracker beim Beenden eines Timers mit `---` noch einmal nach.
|
||||
|
||||
Im Abschlussdialog kann die Taetigkeit als interner Zammad-Article an das Ticket uebertragen werden. Der Article wird mit `internal: true` angelegt und enthaelt die Taetigkeit sowie den `time_unit`-Wert aus der gemessenen Zeit abzueglich Pausen. Wenn die interne Zammad-Notiz aktiv ist, kann dort auch der Zammad-Zeitbuchungstyp gewaehlt werden.
|
||||
|
||||
Wenn Zammad beim Speichern nicht erreichbar ist, wird die Session trotzdem in TicketTracker gespeichert. Der Zammad-Article wird dann in einer Parktabelle vorgemerkt und kann spaeter im Profil erneut gesendet werden.
|
||||
|
||||
Beim Speichern nach dem Beenden gilt:
|
||||
|
||||
- Existiert das Ticket bereits, werden Organisation und Art vom Ticket uebernommen.
|
||||
- Dann muss nur noch die Taetigkeit eingetragen werden.
|
||||
- Existiert das Ticket noch nicht, muessen Organisation, Taetigkeit und Art ausgefuellt werden.
|
||||
- Existiert das Ticket noch nicht, muessen Organisation und Art ausgefuellt werden.
|
||||
|
||||
Die gemessene Zeit wird auf Minuten gerundet. Pausen werden von der Gesamtdauer abgezogen.
|
||||
|
||||
@@ -101,10 +109,16 @@ Beim Nachtragen werden angegeben:
|
||||
- Datum
|
||||
- Von-Uhrzeit
|
||||
- Bis-Uhrzeit
|
||||
- Bewertung: `---`, `Abrechenbar` oder `Nicht abrechenbar`
|
||||
- Taetigkeit
|
||||
- optional interne Zammad-Notiz
|
||||
|
||||
Das Taetigkeitsfeld ist mehrzeilig. Zeilenumbrueche bleiben in der Auswertung erhalten.
|
||||
|
||||
Mit Bewertung `---` bleibt eine nachgetragene Session offen. `Abrechenbar` und `Nicht abrechenbar` werden direkt an der neuen Session gespeichert.
|
||||
|
||||
Wenn die interne Zammad-Notiz aktiviert ist, wird die Taetigkeit auch beim Nachtragen als interner Zammad-Article uebertragen. Der Zammad-Typ kann dabei angepasst werden. Bei Zammad-Fehlern wird der Eintrag wie beim Timerabschluss geparkt und kann spaeter im Profil gesendet werden.
|
||||
|
||||
Das Ende muss nach dem Beginn liegen. Die Dauer wird automatisch berechnet und auf Minuten gerundet.
|
||||
|
||||
In der Ticketdetailansicht gibt es ebenfalls `Session nachtragen`. Dort wird die neue Session direkt dem geoeffneten Ticket zugeordnet.
|
||||
@@ -145,8 +159,9 @@ Die oberen Karten zeigen:
|
||||
- Tickets: Anzahl der Tickets im Zeitraum
|
||||
- Sessions: Anzahl der Sessions im Zeitraum
|
||||
- Abgerechnet: Summe der Sessions mit Bewertung `Abgerechnet` und, sofern vorhanden, die dazugehoerige Teamspace-Zeit als Vergleichswert
|
||||
- Abrechenbar: Summe der Sessions mit Bewertung `Abrechenbar`, noch ohne Teamspace-Wirkung
|
||||
- Nicht abrechenbar: Summe der Sessions mit Bewertung `Nicht abrechenbar`
|
||||
- Offen: Anzahl unbewerteter Sessions
|
||||
- Offen: Anzahl nicht final bewerteter Sessions, inklusive `Abrechenbar`
|
||||
|
||||
Der Verlauf der abgerechneten Zeit liegt auf der Seite `Statistiken`. Dort wird der Monatsverlauf gemeinsam mit Teamspace und Bewertungsstand betrachtet.
|
||||
|
||||
@@ -170,8 +185,8 @@ Neben der Ticketnummer gibt es ein Copy-Icon. Ein Klick kopiert die Ticketnummer
|
||||
|
||||
Der Status bedeutet:
|
||||
|
||||
- `offen`: Es gibt noch unbewertete Sessions.
|
||||
- `bewertet`: Alle Sessions dieses Tickets im gewaehlten Zeitraum sind bewertet.
|
||||
- `offen`: Es gibt noch nicht final bewertete Sessions oder Sessions mit Status `Abrechenbar`.
|
||||
- `bewertet`: Alle Sessions dieses Tickets im gewaehlten Zeitraum sind `Abgerechnet` oder `Nicht abrechenbar`.
|
||||
|
||||
### Filtern, Gruppieren und Sortieren
|
||||
|
||||
@@ -213,8 +228,11 @@ In der Ticketdetailansicht sieht man:
|
||||
- ToDos zu diesem Ticket oder Projekt
|
||||
- Session-Eintraege nach Tagen gruppiert
|
||||
- Teamspace-Wert pro Ticket und Tag
|
||||
- Moeglichkeit, direkt einen Timer fuer dieses Ticket zu starten
|
||||
- Moeglichkeit, direkt eine Session zu diesem Ticket nachzutragen
|
||||
|
||||
`Timer starten` nutzt dieselbe Timerlogik wie der globale Schnellstart. Existiert bereits ein Timer fuer das Ticket, wird dieser aktiviert.
|
||||
|
||||
Geoeffnete Tickets erscheinen in der Seitenleiste unter `Geoeffnete Tickets`. Die Liste bleibt nach Aktualisierung und Browserneustart erhalten. Mit einem Klick auf die Ticketnummer gelangt man direkt zur Ticketdetailansicht zurueck. Die Reihenfolge bleibt beim Anklicken unveraendert und kann nur per Drag and Drop angepasst werden. Der Schliessen-Button entfernt das Ticket aus der Seitenleiste. Wenn im aktuell geoeffneten Ticket noch ungespeicherte Werte vorhanden sind, muss zuerst gespeichert werden.
|
||||
|
||||
## Projekte und ToDos
|
||||
@@ -306,8 +324,9 @@ Die Sessions sind pro Tag gruppiert. In jeder Tagesgruppe stehen:
|
||||
|
||||
- Tagesdatum
|
||||
- `Tag abgerechnet`: Summe der als `Abgerechnet` bewerteten Session-Zeit dieses Tages
|
||||
- `Abrechenbar`: Summe der als `Abrechenbar` bewerteten Session-Zeit dieses Tages
|
||||
- `Nicht`: Summe der als `Nicht abrechenbar` bewerteten Session-Zeit dieses Tages
|
||||
- `Offen`: Summe der noch unbewerteten Session-Zeit dieses Tages
|
||||
- `Offen`: Summe der noch nicht final bewerteten Session-Zeit dieses Tages, inklusive `Abrechenbar`
|
||||
- `Teamspace`: Stunden, die fuer dieses Ticket an diesem Tag wirklich im CRM eingetragen wurden
|
||||
|
||||
Das Teamspace-Feld wird in Stunden eingetragen, zum Beispiel:
|
||||
@@ -330,11 +349,12 @@ Jede einzelne Session ist innerhalb der Tagesgruppe aufklappbar. In der geschlos
|
||||
Jede Session kann bewertet werden als:
|
||||
|
||||
- Abgerechnet
|
||||
- Abrechenbar
|
||||
- Nicht abrechenbar
|
||||
|
||||
Ein erneuter Klick auf den bereits gewaehlten Zustand nimmt die Bewertung zurueck. Dadurch wird die Session wieder offen.
|
||||
|
||||
Nicht abrechenbare Sessions gelten als bewertet. Sie blockieren den Monatsabschluss nicht.
|
||||
`Abgerechnet` und `Nicht abrechenbar` schliessen die Bewertung ab. `Abrechenbar` ist nur ein Zwischenstatus, gilt fuer Abschluss und offene Bewertungen weiterhin als offen und blockiert den Monatsabschluss. In Teamspace-Statistiken zaehlt erst `Abgerechnet`.
|
||||
|
||||
Es gibt keinen Ticketabschluss. Tickets dienen nur als Gruppierung fuer Sessions. Abgeschlossen wird ausschliesslich der Monat.
|
||||
|
||||
@@ -373,20 +393,21 @@ Der Monatsabschluss ist eine eigene Unterseite.
|
||||
|
||||
Ein Monat kann abgeschlossen werden, wenn:
|
||||
|
||||
- Alle Sessions im Monat bewertet sind.
|
||||
- Keine offenen Bewertungen mehr vorhanden sind. Sessions mit Status `Abrechenbar` gelten dabei weiterhin als offen.
|
||||
|
||||
Einen separaten Tagesabschluss gibt es nicht. Die Tagesansicht ist nur eine gefilterte Kontrolle einzelner Tage. Bewertungen und Teamspace-Tageswerte wirken in Monats- und Tagesansicht auf dieselben Daten, abgeschlossen wird aber ausschliesslich der Monat.
|
||||
|
||||
Die Seite `Monatsabschluss` enthaelt:
|
||||
|
||||
- Checkliste
|
||||
- Offene Bewertungen
|
||||
- Tage ohne Teamspace-Wert
|
||||
- eine gemeinsame Pruefliste nach Ticket
|
||||
- Button zum Monat abschliessen oder wieder oeffnen
|
||||
|
||||
Offene Bewertungen werden nach Ticket gruppiert. Mit `Ticket oeffnen` gelangt man direkt zur Bewertung.
|
||||
Die Pruefliste fuehrt offene Bewertungen, fehlende Teamspace-Tageswerte sowie Monats- und ticketgenaue Tagesabweichungen zusammen. Jedes Ticket erscheint nur einmal. Farbige Badges stehen in festen Spalten fuer die jeweilige Pruefart. Die zugehoerigen Tagesdetails mit TT-Teamspace-Wert, TS-API-Wert und Differenz werden im Ticketdetail am betroffenen Tag angezeigt. Mit `Ticket oeffnen` gelangt man direkt dorthin.
|
||||
|
||||
Tage ohne Teamspace-Wert sind eine Pruefliste. Wenn ein Tag bewusst keinen Teamspace-Wert bekommen soll, kann der Eintrag mit `Zur Kenntnis` abgehakt werden.
|
||||
Wenn ein Tag bewusst keinen Teamspace-Wert bekommen soll, kann er innerhalb des Ticket-Eintrags mit `Zur Kenntnis` abgehakt werden. Trifft fuer denselben Tag zugleich eine TT/TS-Abweichung zu, werden beide Hinweise in einer gemeinsamen Tageszeile dargestellt.
|
||||
|
||||
Ein hinterlegter TT-Teamspace-Wert setzt mindestens eine als `Abgerechnet` bewertete Session desselben Tickets und Tages voraus. Gibt es nur `Abrechenbar`, `Nicht abrechenbar` oder unbewertete Sessions, erscheint der Tageswert als Fehler. Der TT-Teamspace-Wert und eine eventuelle Abweichung zur TS-API werden trotzdem angezeigt.
|
||||
|
||||
Zur Kenntnis genommene Eintraege koennen ueber die Checkbox `Zur Kenntnis anzeigen` wieder eingeblendet werden. Diese Anzeigeeinstellung bleibt im Browser gespeichert. Ein abgehakter Eintrag kann mit `Zuruecknehmen` wieder offen gemacht werden.
|
||||
|
||||
@@ -430,9 +451,9 @@ Die Seite `Statistiken` betrachtet immer einen Monat. Sie dient als Ueberblick f
|
||||
|
||||
### Kennzahlen
|
||||
|
||||
`Abgerechnet` zeigt die Summe aller Sessions mit Bewertung `Abgerechnet`. Nicht abrechenbare und offene Sessions sind darin nicht enthalten. Dieser Wert ist die Basis fuer den Vergleich mit Teamspace.
|
||||
`Abgerechnet` zeigt die Summe aller Sessions mit Bewertung `Abgerechnet`. Abrechenbare, nicht abrechenbare und offene Sessions sind darin nicht enthalten. Dieser Wert ist die Basis fuer den Vergleich mit Teamspace.
|
||||
|
||||
Alle Statistik- und Vergleichswerte verwenden nur abgerechnete Zeiten. Nicht ausgewaehlte/offene und nicht abrechenbare Sessions werden nicht in Relationen, Graphen, Teamspace-Differenzen, Hotspots, Manuell/Fix-Werte oder Durchschnittswerte eingerechnet.
|
||||
Teamspace-Statistiken und -Vergleiche verwenden nur abgerechnete Zeiten. Nicht ausgewaehlte/offene, abrechenbare und nicht abrechenbare Sessions werden nicht in Teamspace-Differenzen, Hotspots, Manuell/Fix-Werte oder Durchschnittswerte eingerechnet.
|
||||
|
||||
`Teamspace` zeigt die Summe aller Teamspace-Tageswerte im Monat. Die Differenz wird immer so berechnet:
|
||||
|
||||
@@ -449,15 +470,16 @@ Das bedeutet:
|
||||
Farben:
|
||||
|
||||
- Abgerechnet: blau
|
||||
- Abrechenbar: grau
|
||||
- Teamspace: fuchsia
|
||||
- Positive oder ausgeglichene Differenz: gruen
|
||||
- Negative Differenz: rot
|
||||
- Nicht abrechenbar: indigo
|
||||
- Offen: gelb/orange
|
||||
|
||||
`Nicht abrechenbar` zeigt Sessions, die bewusst nicht fakturiert werden. In den Statistiken wird dazu kein Stundenwert ausgegeben; diese Sessions werden nicht mit Teamspace verglichen.
|
||||
`Abrechenbar` zeigt Sessions im Zwischenstatus, die noch offen bleiben. `Nicht abrechenbar` zeigt final bewertete Sessions, die nicht als abgerechnet gelten. Beide werden nicht mit Teamspace verglichen.
|
||||
|
||||
`Offen` zeigt Sessions ohne Bewertung. Diese blockieren den Monatsabschluss.
|
||||
`Offen` zeigt Sessions ohne finalen Status, also auch `Abrechenbar`. Diese blockieren den Monatsabschluss.
|
||||
|
||||
### Monatsverlauf und Abrechnungsqualitaet
|
||||
|
||||
@@ -465,7 +487,7 @@ Diese Card kombiniert den Tagesverlauf und die Bewertungsqualitaet.
|
||||
|
||||
- Balken zeigen abgerechnete Session-Zeit.
|
||||
- Die Linie zeigt Teamspace-Zeit.
|
||||
- Der Bewertungsstand trennt abgerechnete, nicht abrechenbare und offene Sessions.
|
||||
- Der Bewertungsstand trennt abgerechnete, abrechenbare, nicht abrechenbare und offene Sessions. Abrechenbar bleibt dabei zusaetzlich offen.
|
||||
- Der Teamspace-Abgleich zeigt die Gesamtdifferenz.
|
||||
- Manuell und Fix trennen manuell erfasste abrechenbare Sessions von automatisch erzeugten abrechenbaren Sessions.
|
||||
|
||||
@@ -477,7 +499,7 @@ Sichtbar sind:
|
||||
|
||||
- Organisation
|
||||
- Sessions
|
||||
- Zeiten: Abgerechnet, nicht abrechenbar und Teamspace
|
||||
- Zeiten: Abgerechnet, abrechenbar, nicht abrechenbar und Teamspace
|
||||
- Differenz
|
||||
- Anteil an der abgerechneten Monatszeit
|
||||
|
||||
@@ -495,7 +517,7 @@ Sie zeigt je Typ:
|
||||
- Nicht abrechenbare Session-Zeit
|
||||
- Teamspace-Zeit
|
||||
- Differenz
|
||||
- Anzahl abgerechnet, nicht abrechenbar und offen
|
||||
- Anzahl abgerechnet, abrechenbar, nicht abrechenbar und offen
|
||||
|
||||
### Ticket-Hotspots
|
||||
|
||||
@@ -511,6 +533,10 @@ Unter `Profil` kann jeder Benutzer seine eigenen Daten pflegen:
|
||||
- Voller Name
|
||||
- Passwort
|
||||
- persoenlicher Zammad API-Token
|
||||
- Bewertungsabfrage beim Timerabschluss
|
||||
- interne Zammad-Notiz beim Timerabschluss
|
||||
- Standard-Typ fuer interne Zammad-Notizen
|
||||
- Zammad-Zeitaufrundung fuer `time_unit`
|
||||
|
||||
Passwortaenderungen erfordern das aktuelle Passwort. Bleiben die Passwortfelder leer, werden nur Benutzername und voller Name gespeichert.
|
||||
|
||||
@@ -520,6 +546,16 @@ Die Pruefung findet nur statt, wenn der Token im Profil neu eingetragen oder ent
|
||||
|
||||
Der persoenliche Zammad Token ist unabhaengig vom Admin-Token fuer den Organisations-Sync. Der gleiche Token kann technisch in beiden Bereichen eingetragen sein, wird aber getrennt gespeichert und getrennt verwendet. Im Admin-Profil gibt es keinen persoenlichen Zammad-Token.
|
||||
|
||||
Die Bewertungsabfrage beim Timerabschluss kann im Profil ein- oder ausgeschaltet werden. Sie erscheint nur, wenn der Timer beim Beenden noch auf Bewertung `---` steht.
|
||||
|
||||
Die interne Zammad-Notiz beim Timerabschluss steuert, ob die Checkbox im Abschlussdialog standardmaessig aktiv ist. Ist sie aktiv, legt TicketTracker beim Speichern einen internen Zammad-Article zum Ticket an. Nur dann werden auch der Standard-Typ und die Zeitaufrundung im Profil angezeigt.
|
||||
|
||||
Der Standard-Typ belegt den Zammad-Zeitbuchungstyp fuer neue interne Notizen vor. Es muss immer eine Kategorie gewaehlt sein; falls noch kein Profilwert gespeichert ist, verwendet TicketTracker `Nicht kategorisiert`. Beim Timerabschluss und beim Nachtragen kann der Typ vor dem Speichern noch geaendert werden.
|
||||
|
||||
Fuer `time_unit` kann optional eine Aufrundung aktiviert werden. Dann wird die tatsaechlich getrackte Zeit immer auf 5, 10, 15 oder 30 Minuten aufgerundet und als Stundenwert an Zammad gesendet. Ohne Rundung wird der konkrete Stundenwert mit zwei Nachkommastellen gesendet.
|
||||
|
||||
Unter `Geparkte Zammad-Notizen` zeigt das Profil interne Notizen, die wegen eines Zammad-Fehlers noch nicht uebertragen wurden. Mit `Senden` werden die offenen Eintraege erneut nach Zammad gepusht.
|
||||
|
||||
## Adminbereich
|
||||
|
||||
Der feste Admin ist nur fuer die Systemverwaltung vorgesehen. Er kann:
|
||||
@@ -562,12 +598,12 @@ Neue Organisationen werden hinzugefuegt, geaenderte Namen aktualisiert und nicht
|
||||
3. Am Monatsende `Tickets` oeffnen.
|
||||
4. Monat auswaehlen.
|
||||
5. Offene Sessions pruefen.
|
||||
6. Pro Ticket jede Session als `Abgerechnet` oder `Nicht abrechenbar` markieren.
|
||||
6. Pro Ticket jede Session final als `Abgerechnet` oder `Nicht abrechenbar` markieren. `Abrechenbar` kann als Zwischenstatus stehen bleiben, blockiert dann aber den Abschluss.
|
||||
7. Pro Tag und Ticket die wirklich in Teamspace eingetragene Zeit im Feld `Teamspace` pflegen.
|
||||
8. `Monatsabschluss` oeffnen.
|
||||
9. Offene Bewertungen abarbeiten.
|
||||
10. Tage ohne Teamspace-Wert pruefen oder zur Kenntnis nehmen.
|
||||
11. Wenn keine Sessions im Monat mehr offen sind, Monat abschliessen.
|
||||
11. Wenn keine Sessions im Monat mehr offen sind und keine Session mehr auf `Abrechenbar` steht, Monat abschliessen.
|
||||
|
||||
## Hinweise
|
||||
|
||||
|
||||
+334
-97
@@ -1,11 +1,12 @@
|
||||
import {
|
||||
AlertCircle,
|
||||
CalendarDays,
|
||||
Building2,
|
||||
CheckCircle2,
|
||||
ClipboardCheck,
|
||||
Command,
|
||||
ChartNoAxesCombined,
|
||||
CircleHelp,
|
||||
DatabaseZap,
|
||||
ExternalLink,
|
||||
FileText,
|
||||
FolderKanban,
|
||||
@@ -27,9 +28,10 @@ import {
|
||||
Trash2,
|
||||
UserCog,
|
||||
UserCircle,
|
||||
FolderGit,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { FormEvent, MouseEvent, useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { FormEvent, MouseEvent, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useShallow } from "zustand/react/shallow";
|
||||
import { toast } from "sonner";
|
||||
|
||||
@@ -65,14 +67,31 @@ import {
|
||||
import { usePreferencesStore } from "@/stores/preferences/preferences-provider";
|
||||
|
||||
import { cn } from "./lib/utils";
|
||||
import { ApiError, deleteTodo, getCurrentUser, getRecurringBillings, getSetupStatus, getTodos, logout, lookupTicket, searchTickets, updateTodo } from "./api";
|
||||
import {
|
||||
ApiError,
|
||||
createActiveTimer,
|
||||
deleteActiveTimer,
|
||||
deleteTodo,
|
||||
getActiveTimers,
|
||||
getCurrentUser,
|
||||
getRecurringBillings,
|
||||
getSetupStatus,
|
||||
getTodos,
|
||||
logout,
|
||||
lookupTicket,
|
||||
searchTickets,
|
||||
updateActiveTimer,
|
||||
updateTodo
|
||||
} from "./api";
|
||||
import { currentDay, formatDateTime, formatMinutes, formatTimer } from "./format";
|
||||
import { isRecurringBillingTicketNumber, ticketHasRecurringBilling } from "./recurring-billings";
|
||||
import { activeElapsedMs, pauseEntry, readStoredTimers, resumeEntry, storageKeyForUser, ticketPattern, type TimerEntry } from "./timers";
|
||||
import { activeElapsedMs, mergeTimerActivityDrafts, readStoredTimers, storageKeyForUser, ticketPattern, type TimerEntry } from "./timers";
|
||||
import { formatTodoPeriod, isTodoActiveOnDay, isTodoFixedOnDay, isTodoOverdue } from "./todos";
|
||||
import { AdminUsersPage } from "./views/AdminUsersPage";
|
||||
import { AdminOrganizationsPage } from "./views/AdminOrganizationsPage";
|
||||
import { AnalysisPage } from "./views/AnalysisPage";
|
||||
import { DashboardPage } from "./views/DashboardPage";
|
||||
import { DashboardTimersPage } from "./views/DashboardTimersPage";
|
||||
import { FaqPage } from "./views/FaqPage";
|
||||
import { LoginPage } from "./views/LoginPage";
|
||||
import { MonthlyClosePage } from "./views/MonthlyClosePage";
|
||||
@@ -84,6 +103,7 @@ import { SetupPage } from "./views/SetupPage";
|
||||
import { TicketDetailPage } from "./views/TicketDetailPage";
|
||||
import { TimerPage } from "./views/TimerPage";
|
||||
import { TodosPage } from "./views/TodosPage";
|
||||
import { ZammadSyncPage } from "./views/ZammadSyncPage";
|
||||
import type { AuthUser, PeriodType, TicketDetailPeriodType, TicketSearchResult, TicketTodo } from "./types";
|
||||
import type { Dispatch, DragEvent, SetStateAction } from "react";
|
||||
|
||||
@@ -119,6 +139,10 @@ function pathWithoutSearch(value: string) {
|
||||
return value.split("?")[0];
|
||||
}
|
||||
|
||||
function openTicketKey(ticketId: string) {
|
||||
return `ticket:${ticketId}`;
|
||||
}
|
||||
|
||||
function readOpenTickets(userId: string): OpenTicketNavItem[] {
|
||||
try {
|
||||
const raw = localStorage.getItem(openTicketsStorageKey(userId));
|
||||
@@ -128,11 +152,15 @@ function readOpenTickets(userId: string): OpenTicketNavItem[] {
|
||||
}
|
||||
|
||||
const parsed = JSON.parse(raw) as Partial<OpenTicketNavItem>[];
|
||||
const tickets: OpenTicketNavItem[] = [];
|
||||
|
||||
return parsed
|
||||
.filter((item) => item.key && item.ticketId && item.ticketNumber && item.path)
|
||||
.map((item) => ({
|
||||
key: String(item.key),
|
||||
for (const item of parsed) {
|
||||
if (!item.ticketId || !item.ticketNumber || !item.path) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const ticket: OpenTicketNavItem = {
|
||||
key: openTicketKey(String(item.ticketId)),
|
||||
ticketId: String(item.ticketId),
|
||||
ticketNumber: String(item.ticketNumber),
|
||||
periodType: item.periodType === "day" ? "day" : item.periodType === "month" ? "month" : "all",
|
||||
@@ -140,7 +168,17 @@ function readOpenTickets(userId: string): OpenTicketNavItem[] {
|
||||
path: String(item.path),
|
||||
returnPath: typeof item.returnPath === "string" ? item.returnPath : undefined,
|
||||
dirty: false
|
||||
}));
|
||||
};
|
||||
const existingIndex = tickets.findIndex((existing) => existing.ticketId === ticket.ticketId);
|
||||
|
||||
if (existingIndex >= 0) {
|
||||
tickets[existingIndex] = ticket;
|
||||
} else {
|
||||
tickets.push(ticket);
|
||||
}
|
||||
}
|
||||
|
||||
return tickets.slice(-12);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
@@ -190,6 +228,10 @@ function routeFromPath(pathWithSearch: string) {
|
||||
}
|
||||
|
||||
if (pathname.startsWith("/dashboard")) {
|
||||
if (pathname.startsWith("/dashboard/timers")) {
|
||||
return { page: "dashboard-timers" as const };
|
||||
}
|
||||
|
||||
return { page: "dashboard" as const };
|
||||
}
|
||||
|
||||
@@ -217,10 +259,18 @@ function routeFromPath(pathWithSearch: string) {
|
||||
return { page: "recurring" as const };
|
||||
}
|
||||
|
||||
if (pathname.startsWith("/zammad-sync")) {
|
||||
return { page: "zammad-sync" as const };
|
||||
}
|
||||
|
||||
if (pathname.startsWith("/admin/users")) {
|
||||
return { page: "admin-users" as const };
|
||||
}
|
||||
|
||||
if (pathname.startsWith("/admin/organizations")) {
|
||||
return { page: "admin-organizations" as const };
|
||||
}
|
||||
|
||||
if (pathname.startsWith("/profile")) {
|
||||
return { page: "profile" as const };
|
||||
}
|
||||
@@ -440,12 +490,12 @@ type SidebarTimersProps = {
|
||||
timers: TimerEntry[];
|
||||
selectedTimerId: string | null;
|
||||
setSelectedTimerId: Dispatch<SetStateAction<string | null>>;
|
||||
setTimers: Dispatch<SetStateAction<TimerEntry[]>>;
|
||||
tick: number;
|
||||
onToggleTimer: (timerId: string) => void;
|
||||
onOpenTimer: () => void;
|
||||
};
|
||||
|
||||
function SidebarTimers({ timers, selectedTimerId, setSelectedTimerId, setTimers, tick, onOpenTimer }: SidebarTimersProps) {
|
||||
function SidebarTimers({ timers, selectedTimerId, setSelectedTimerId, tick, onToggleTimer, onOpenTimer }: SidebarTimersProps) {
|
||||
const sortedTimers = useMemo(
|
||||
() =>
|
||||
[...timers].sort((first, second) => {
|
||||
@@ -466,17 +516,8 @@ function SidebarTimers({ timers, selectedTimerId, setSelectedTimerId, setTimers,
|
||||
return;
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
setSelectedTimerId(timerId);
|
||||
setTimers((current) =>
|
||||
current.map((entry) => {
|
||||
if (entry.id !== timerId) {
|
||||
return pauseEntry(entry, now);
|
||||
}
|
||||
|
||||
return timer.phase === "running" ? pauseEntry(entry, now) : resumeEntry(entry, now);
|
||||
}),
|
||||
);
|
||||
onToggleTimer(timerId);
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -521,9 +562,9 @@ function SidebarTimers({ timers, selectedTimerId, setSelectedTimerId, setTimers,
|
||||
<span className="truncate text-xs font-medium">{timer.ticketNumber}</span>
|
||||
</span>
|
||||
{timer.organizationName ? (
|
||||
<span className="block truncate text-[11px] leading-4 text-muted-foreground">
|
||||
{timer.isProject ? "Projekt · " : ""}
|
||||
{timer.projectName || timer.organizationName}
|
||||
<span className="flex min-w-0 items-center gap-1 truncate text-[11px] leading-4 text-muted-foreground">
|
||||
{timer.isProject ? <FolderGit className="size-3 shrink-0" aria-label="Projekt" /> : null}
|
||||
<span className="truncate">{timer.organizationName}</span>
|
||||
</span>
|
||||
) : null}
|
||||
<span className="block font-mono text-[11px] font-semibold leading-4 tracking-normal text-muted-foreground">
|
||||
@@ -578,20 +619,57 @@ const sidebarTodoCalendarClasses: Record<TicketTodo["priority"], string> = {
|
||||
};
|
||||
|
||||
const sidebarOverdueTodoClass = "border-red-500/60 bg-red-500/15 text-red-100";
|
||||
const sidebarTodoPriorityOrder: Record<TicketTodo["priority"], number> = {
|
||||
urgent: 0,
|
||||
high: 1,
|
||||
medium: 2,
|
||||
low: 3
|
||||
};
|
||||
|
||||
function sortSidebarCurrentTodos(todos: TicketTodo[], today: string) {
|
||||
return [...todos].sort((left, right) => {
|
||||
const leftOverdue = isTodoOverdue(left, today);
|
||||
const rightOverdue = isTodoOverdue(right, today);
|
||||
|
||||
if (leftOverdue !== rightOverdue) {
|
||||
return leftOverdue ? -1 : 1;
|
||||
}
|
||||
|
||||
return sidebarTodoPriorityOrder[left.priority] - sidebarTodoPriorityOrder[right.priority] || left.sort_order - right.sort_order || left.title.localeCompare(right.title, "de-DE", { sensitivity: "base" });
|
||||
});
|
||||
}
|
||||
|
||||
function notifyTodosChanged() {
|
||||
window.dispatchEvent(new Event("tickettracker:todos-changed"));
|
||||
}
|
||||
|
||||
function SidebarTodayTodos({ todos, onNavigate }: SidebarTodayTodosProps) {
|
||||
const [runningTodosOpen, setRunningTodosOpen] = useState(false);
|
||||
const [todosModalOpen, setTodosModalOpen] = useState(false);
|
||||
const [modalBusy, setModalBusy] = useState(false);
|
||||
const today = currentDay();
|
||||
const fixedTodos = todos.filter((todo) => isTodoFixedOnDay(todo, today));
|
||||
const summarizedTodos = todos.filter((todo) => todo.status === "open" && isTodoActiveOnDay(todo, today) && !isTodoFixedOnDay(todo, today));
|
||||
const overdueTodos = todos.filter((todo) => isTodoOverdue(todo, today));
|
||||
const currentTodos = sortSidebarCurrentTodos(
|
||||
[...overdueTodos, ...fixedTodos, ...summarizedTodos].filter((todo, index, list) => list.findIndex((item) => item.id === todo.id) === index),
|
||||
today
|
||||
);
|
||||
const visibleCurrentTodos = currentTodos.slice(0, 6);
|
||||
const additionalTodosCount = Math.max(0, currentTodos.length - visibleCurrentTodos.length);
|
||||
const hasTodos = fixedTodos.length > 0 || summarizedTodos.length > 0 || overdueTodos.length > 0;
|
||||
|
||||
function todoStateLabel(todo: TicketTodo) {
|
||||
if (isTodoOverdue(todo, today)) {
|
||||
return "Überfällig";
|
||||
}
|
||||
|
||||
if (isTodoFixedOnDay(todo, today)) {
|
||||
return "Fester Termin";
|
||||
}
|
||||
|
||||
return "Laufend";
|
||||
}
|
||||
|
||||
async function toggleTodo(todo: TicketTodo) {
|
||||
setModalBusy(true);
|
||||
|
||||
@@ -653,42 +731,30 @@ function SidebarTodayTodos({ todos, onNavigate }: SidebarTodayTodosProps) {
|
||||
|
||||
{hasTodos ? (
|
||||
<div className="space-y-1">
|
||||
{overdueTodos.slice(0, 3).map((todo) => (
|
||||
{visibleCurrentTodos.map((todo) => (
|
||||
<button
|
||||
key={`overdue-${todo.id}`}
|
||||
key={`current-${todo.id}`}
|
||||
type="button"
|
||||
className="block w-full rounded-md border border-red-500/45 bg-red-500/10 px-2 py-1.5 text-left hover:bg-red-500/15"
|
||||
onClick={() => onNavigate(`/tickets/${todo.ticket_id}`)}
|
||||
>
|
||||
<span className="flex min-w-0 items-center gap-1.5 text-[11px] font-semibold text-red-200">
|
||||
<AlertCircle className="size-3.5 shrink-0" />
|
||||
<span className="truncate">{todo.title}</span>
|
||||
</span>
|
||||
<span className="block truncate text-[11px] text-muted-foreground">{todo.ticket_number ?? "Ticket"} · {formatTodoPeriod(todo)}</span>
|
||||
</button>
|
||||
))}
|
||||
{overdueTodos.length > 3 ? <p className="px-1 text-[11px] text-red-200">+{overdueTodos.length - 3} weitere überfällig</p> : null}
|
||||
|
||||
{fixedTodos.slice(0, 3).map((todo) => (
|
||||
<button
|
||||
key={`fixed-${todo.id}`}
|
||||
type="button"
|
||||
className="block w-full rounded-md border bg-background/70 px-2 py-1.5 text-left hover:bg-muted"
|
||||
className={`block w-full rounded-md border px-2 py-1.5 text-left hover:brightness-110 ${isTodoOverdue(todo, today) ? sidebarOverdueTodoClass : sidebarTodoCalendarClasses[todo.priority]}`}
|
||||
onClick={() => onNavigate(`/tickets/${todo.ticket_id}`)}
|
||||
>
|
||||
<span className="block truncate text-[11px] font-semibold">{todo.title}</span>
|
||||
<span className="block truncate text-[11px] text-muted-foreground">{todo.ticket_number ?? "Ticket"} · {todo.organization_name ?? "Keine Organisation"}</span>
|
||||
<span className="block truncate text-[11px] text-muted-foreground">
|
||||
{todo.ticket_number ?? "Ticket"} · {todoStateLabel(todo)}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
{fixedTodos.length > 3 ? <p className="px-1 text-[11px] text-muted-foreground">+{fixedTodos.length - 3} weitere feste Termine</p> : null}
|
||||
|
||||
{summarizedTodos.length > 0 ? (
|
||||
{additionalTodosCount > 0 ? (
|
||||
<button
|
||||
type="button"
|
||||
className="block w-full rounded-md border border-muted-foreground/20 bg-background/60 px-2 py-1.5 text-left text-[11px] font-medium text-muted-foreground hover:bg-muted"
|
||||
onClick={() => setRunningTodosOpen(true)}
|
||||
onClick={() => setTodosModalOpen(true)}
|
||||
>
|
||||
+{summarizedTodos.length} laufende ToDo{summarizedTodos.length === 1 ? "" : "s"}
|
||||
<span className="block">+{additionalTodosCount} weitere aktuelle ToDo{additionalTodosCount === 1 ? "" : "s"}</span>
|
||||
<span className="block truncate font-normal">
|
||||
{overdueTodos.length} überfällig · {summarizedTodos.length} laufend · {fixedTodos.length} feste
|
||||
</span>
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
@@ -699,22 +765,24 @@ function SidebarTodayTodos({ todos, onNavigate }: SidebarTodayTodosProps) {
|
||||
</SidebarGroupContent>
|
||||
</SidebarGroup>
|
||||
|
||||
<Dialog open={runningTodosOpen} onOpenChange={setRunningTodosOpen}>
|
||||
<Dialog open={todosModalOpen} onOpenChange={setTodosModalOpen}>
|
||||
<DialogContent className="max-h-[calc(100dvh-2rem)] overflow-y-auto sm:max-w-xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>ToDos am Tag</DialogTitle>
|
||||
<DialogDescription>Heute · {summarizedTodos.length} laufende ToDo(s)</DialogDescription>
|
||||
<DialogTitle>Aktuelle ToDos</DialogTitle>
|
||||
<DialogDescription>
|
||||
{overdueTodos.length} überfällig · {fixedTodos.length} feste Termine · {summarizedTodos.length} laufende ToDo(s)
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-2">
|
||||
{summarizedTodos.map((todo) => (
|
||||
{currentTodos.map((todo) => (
|
||||
<div key={todo.id} className={`rounded-md border p-2.5 ${isTodoOverdue(todo, today) ? sidebarOverdueTodoClass : sidebarTodoCalendarClasses[todo.priority]}`}>
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="flex min-w-0 flex-wrap items-center gap-2">
|
||||
<span className="min-w-0 truncate text-sm font-semibold">{todo.title}</span>
|
||||
<Badge variant={isTodoOverdue(todo, today) ? "destructive" : isTodoFixedOnDay(todo, today) ? "outline" : "secondary"}>{todoStateLabel(todo)}</Badge>
|
||||
<span className={`shrink-0 rounded-full border px-2 py-0.5 text-xs font-medium ${sidebarTodoPriorityClasses[todo.priority]}`}>{sidebarTodoPriorityLabels[todo.priority]}</span>
|
||||
{isTodoOverdue(todo, today) ? <Badge variant="destructive">überfällig</Badge> : null}
|
||||
</div>
|
||||
<p className="mt-1 truncate text-xs text-muted-foreground" title={todo.organization_name ?? "Keine Organisation"}>
|
||||
{todo.organization_name ?? "Keine Organisation"}
|
||||
@@ -736,7 +804,7 @@ function SidebarTodayTodos({ todos, onNavigate }: SidebarTodayTodosProps) {
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
setRunningTodosOpen(false);
|
||||
setTodosModalOpen(false);
|
||||
onNavigate(`/tickets/${todo.ticket_id}`);
|
||||
}}
|
||||
>
|
||||
@@ -751,8 +819,8 @@ function SidebarTodayTodos({ todos, onNavigate }: SidebarTodayTodosProps) {
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{summarizedTodos.length === 0 ? (
|
||||
<p className="rounded-md border bg-muted/20 p-4 text-center text-sm text-muted-foreground">Keine laufenden ToDos an diesem Tag.</p>
|
||||
{currentTodos.length === 0 ? (
|
||||
<p className="rounded-md border bg-muted/20 p-4 text-center text-sm text-muted-foreground">Keine aktuellen ToDos.</p>
|
||||
) : null}
|
||||
</div>
|
||||
</DialogContent>
|
||||
@@ -770,11 +838,14 @@ export function App() {
|
||||
const [timersLoaded, setTimersLoaded] = useState(false);
|
||||
const [timerOwnerId, setTimerOwnerId] = useState<string | null>(null);
|
||||
const [selectedTimerId, setSelectedTimerId] = useState<string | null>(null);
|
||||
const [finishRequestTimerId, setFinishRequestTimerId] = useState<string | null>(null);
|
||||
const [tick, setTick] = useState(Date.now());
|
||||
const [openTickets, setOpenTickets] = useState<OpenTicketNavItem[]>([]);
|
||||
const [ticketReturnPaths, setTicketReturnPaths] = useState<Record<string, string>>({});
|
||||
const [draggedOpenTicketKey, setDraggedOpenTicketKey] = useState<string | null>(null);
|
||||
const [sidebarTodos, setSidebarTodos] = useState<TicketTodo[]>([]);
|
||||
const openTicketsListRef = useRef<HTMLDivElement | null>(null);
|
||||
const hydratingTimerIds = useRef(new Set<string>());
|
||||
|
||||
const navigate = useCallback(
|
||||
(to: string) => {
|
||||
@@ -852,10 +923,48 @@ export function App() {
|
||||
return;
|
||||
}
|
||||
|
||||
setTimers(readStoredTimers(currentUser.id));
|
||||
let cancelled = false;
|
||||
const userId = currentUser.id;
|
||||
setTimersLoaded(false);
|
||||
setTimers([]);
|
||||
setOpenTickets(readOpenTickets(currentUser.id));
|
||||
setTimerOwnerId(currentUser.id);
|
||||
|
||||
async function loadTimers() {
|
||||
try {
|
||||
let serverTimers = (await getActiveTimers()).timers;
|
||||
const storedTimers = readStoredTimers(userId);
|
||||
|
||||
if (serverTimers.length === 0 && storedTimers.length > 0) {
|
||||
for (const timer of storedTimers) {
|
||||
serverTimers = (await createActiveTimer(timer)).timers;
|
||||
}
|
||||
}
|
||||
|
||||
localStorage.removeItem(storageKeyForUser(userId));
|
||||
|
||||
if (!cancelled) {
|
||||
setTimers(mergeTimerActivityDrafts(userId, serverTimers));
|
||||
}
|
||||
} catch (error) {
|
||||
if (!cancelled) {
|
||||
setTimers(mergeTimerActivityDrafts(userId, readStoredTimers(userId)));
|
||||
toast.error("Timer konnten nicht geladen werden", {
|
||||
description: error instanceof Error ? error.message : "Unbekannter Fehler"
|
||||
});
|
||||
}
|
||||
} finally {
|
||||
if (!cancelled) {
|
||||
setTimersLoaded(true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void loadTimers();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [currentUser]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -869,13 +978,16 @@ export function App() {
|
||||
);
|
||||
}, [currentUser, openTickets]);
|
||||
|
||||
useEffect(() => {
|
||||
const activeTicket = openTicketsListRef.current?.querySelector<HTMLElement>("[data-open-ticket-active='true']");
|
||||
activeTicket?.scrollIntoView({ block: "nearest" });
|
||||
}, [path, openTickets]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!currentUser || currentUser.role === "admin" || !timersLoaded || timerOwnerId !== currentUser.id) {
|
||||
return;
|
||||
}
|
||||
|
||||
localStorage.setItem(storageKeyForUser(currentUser.id), JSON.stringify(timers));
|
||||
|
||||
if (timers.length === 0) {
|
||||
setSelectedTimerId(null);
|
||||
return;
|
||||
@@ -886,6 +998,31 @@ export function App() {
|
||||
}
|
||||
}, [currentUser, timers, selectedTimerId, timersLoaded, timerOwnerId]);
|
||||
|
||||
const refreshActiveTimers = useCallback(async () => {
|
||||
if (!currentUser || currentUser.role === "admin") {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await getActiveTimers();
|
||||
setTimers(mergeTimerActivityDrafts(currentUser.id, result.timers));
|
||||
} catch {
|
||||
// Keep the currently displayed timers if the refresh fails.
|
||||
}
|
||||
}, [currentUser]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!currentUser || currentUser.role === "admin" || !timersLoaded || timerOwnerId !== currentUser.id) {
|
||||
return;
|
||||
}
|
||||
|
||||
window.addEventListener("focus", refreshActiveTimers);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("focus", refreshActiveTimers);
|
||||
};
|
||||
}, [currentUser, refreshActiveTimers, timersLoaded, timerOwnerId]);
|
||||
|
||||
const loadSidebarTodos = useCallback(async () => {
|
||||
if (!currentUser || currentUser.role === "admin") {
|
||||
return;
|
||||
@@ -925,15 +1062,17 @@ export function App() {
|
||||
return;
|
||||
}
|
||||
|
||||
const timersToHydrate = timers.filter((timer) => !timer.ticketLookupDone);
|
||||
const timersToHydrate = timers.filter((timer) => !timer.ticketLookupDone && !hydratingTimerIds.current.has(timer.id));
|
||||
|
||||
if (timersToHydrate.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
timersToHydrate.forEach((timer) => hydratingTimerIds.current.add(timer.id));
|
||||
|
||||
async function hydrateTimers() {
|
||||
try {
|
||||
const results = await Promise.all(
|
||||
timersToHydrate.map(async (timer) => {
|
||||
try {
|
||||
@@ -979,6 +1118,21 @@ export function App() {
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
await Promise.all(
|
||||
results.map((result) =>
|
||||
updateActiveTimer(result.id, {
|
||||
organizationName: result.organizationName,
|
||||
workType: result.workType,
|
||||
isProject: result.isProject,
|
||||
projectName: result.projectName,
|
||||
ticketLookupDone: true
|
||||
}).catch(() => undefined)
|
||||
)
|
||||
);
|
||||
} finally {
|
||||
timersToHydrate.forEach((timer) => hydratingTimerIds.current.delete(timer.id));
|
||||
}
|
||||
}
|
||||
|
||||
void hydrateTimers();
|
||||
@@ -993,12 +1147,32 @@ export function App() {
|
||||
return () => window.clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
async function toggleActiveTimer(timerId: string) {
|
||||
const userId = currentUser?.id;
|
||||
const timer = timers.find((entry) => entry.id === timerId);
|
||||
|
||||
if (!timer || !userId) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await updateActiveTimer(timerId, {
|
||||
phase: timer.phase === "running" ? "paused" : "running"
|
||||
});
|
||||
setTimers(mergeTimerActivityDrafts(userId, result.timers));
|
||||
} catch (error) {
|
||||
toast.error("Timer konnte nicht aktualisiert werden", {
|
||||
description: error instanceof Error ? error.message : "Unbekannter Fehler"
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!currentUser || currentUser.role !== "admin") {
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -1006,6 +1180,12 @@ export function App() {
|
||||
}, [currentUser, route.page]);
|
||||
|
||||
async function startQuickTimer(rawTicketNumber: string) {
|
||||
const userId = currentUser?.id;
|
||||
|
||||
if (!userId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (currentUser?.role === "admin") {
|
||||
toast.error("Admin erfasst keine Sessions", {
|
||||
description: "Der Adminbereich ist nur für die Systemverwaltung vorgesehen."
|
||||
@@ -1028,7 +1208,9 @@ export function App() {
|
||||
]);
|
||||
|
||||
if (ticketHasRecurringBilling(ticketResult.ticket, recurringResult.recurringBillings) || isRecurringBillingTicketNumber(ticketNumber, recurringResult.recurringBillings)) {
|
||||
setTimers((current) => current.filter((timer) => timer.ticketNumber !== ticketNumber));
|
||||
const existingRecurringTimer = timers.find((timer) => timer.ticketNumber === ticketNumber);
|
||||
const result = existingRecurringTimer ? await deleteActiveTimer(existingRecurringTimer.id).catch(() => null) : null;
|
||||
setTimers((current) => result?.timers ?? current.filter((timer) => timer.ticketNumber !== ticketNumber));
|
||||
toast.error("Fixe Abrechnung", {
|
||||
description: "Für fixe Abrechnungen dürfen keine manuellen Sessions gestartet werden. Bitte die Definition bearbeiten."
|
||||
});
|
||||
@@ -1039,18 +1221,24 @@ export function App() {
|
||||
const now = Date.now();
|
||||
|
||||
if (existingTimer) {
|
||||
setTimers((current) =>
|
||||
current.map((timer) => (timer.id === existingTimer.id ? resumeEntry(timer, now) : pauseEntry(timer, now)))
|
||||
);
|
||||
try {
|
||||
const result = await updateActiveTimer(existingTimer.id, { phase: "running" });
|
||||
setTimers(mergeTimerActivityDrafts(userId, result.timers));
|
||||
setSelectedTimerId(existingTimer.id);
|
||||
toast.info("Bestehender Timer aktiviert", {
|
||||
description: ticketNumber
|
||||
});
|
||||
return true;
|
||||
} catch (error) {
|
||||
toast.error("Timer konnte nicht aktiviert werden", {
|
||||
description: error instanceof Error ? error.message : "Unbekannter Fehler"
|
||||
});
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
const newTimer: TimerEntry = {
|
||||
id: crypto.randomUUID(),
|
||||
try {
|
||||
const result = await createActiveTimer({
|
||||
ticketNumber,
|
||||
organizationName: ticketResult.ticket?.organization_name ?? ticketResult.ticket?.customer_name ?? null,
|
||||
workType: ticketResult.ticket?.work_type ?? null,
|
||||
@@ -1060,15 +1248,23 @@ export function App() {
|
||||
startedAt: now,
|
||||
pausedTotalMs: 0,
|
||||
pausedAt: null,
|
||||
activity: "",
|
||||
billingStatus: null,
|
||||
phase: "running"
|
||||
};
|
||||
|
||||
setTimers((current) => [...current.map((timer) => pauseEntry(timer, now)), newTimer]);
|
||||
setSelectedTimerId(newTimer.id);
|
||||
});
|
||||
const timersWithDrafts = mergeTimerActivityDrafts(userId, result.timers);
|
||||
setTimers(timersWithDrafts);
|
||||
setSelectedTimerId(timersWithDrafts.find((timer) => timer.ticketNumber === ticketNumber)?.id ?? null);
|
||||
toast.success("Timer gestartet", {
|
||||
description: ticketResult.ticket?.is_project ? `${ticketNumber} ist ein Projekt` : ticketNumber
|
||||
});
|
||||
return true;
|
||||
} catch (error) {
|
||||
toast.error("Timer konnte nicht gestartet werden", {
|
||||
description: error instanceof Error ? error.message : "Unbekannter Fehler"
|
||||
});
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleLogout() {
|
||||
@@ -1079,13 +1275,31 @@ export function App() {
|
||||
|
||||
const updateOpenTicket = useCallback((ticket: OpenTicketNavItem) => {
|
||||
setOpenTickets((current) => {
|
||||
const existingIndex = current.findIndex((item) => item.key === ticket.key);
|
||||
const normalizedTicket = {
|
||||
...ticket,
|
||||
key: openTicketKey(ticket.ticketId)
|
||||
};
|
||||
const existingIndex = current.findIndex((item) => item.ticketId === normalizedTicket.ticketId || item.key === normalizedTicket.key);
|
||||
|
||||
if (existingIndex >= 0) {
|
||||
return current.map((item, index) => (index === existingIndex ? ticket : item));
|
||||
return current
|
||||
.map((item, index) => {
|
||||
if (item.ticketId === normalizedTicket.ticketId || item.key === normalizedTicket.key) {
|
||||
return index === existingIndex
|
||||
? {
|
||||
...item,
|
||||
...normalizedTicket,
|
||||
returnPath: normalizedTicket.returnPath ?? item.returnPath
|
||||
}
|
||||
: null;
|
||||
}
|
||||
|
||||
return [...current, ticket].slice(-12);
|
||||
return item;
|
||||
})
|
||||
.filter((item): item is OpenTicketNavItem => item !== null);
|
||||
}
|
||||
|
||||
return [...current, normalizedTicket].slice(-12);
|
||||
});
|
||||
}, []);
|
||||
|
||||
@@ -1147,9 +1361,9 @@ export function App() {
|
||||
return;
|
||||
}
|
||||
|
||||
setOpenTickets((items) => items.filter((item) => item.key !== ticket.key));
|
||||
setOpenTickets((items) => items.filter((item) => item.ticketId !== ticket.ticketId && item.key !== ticket.key));
|
||||
|
||||
if (pathWithoutSearch(path) === pathWithoutSearch(ticket.path)) {
|
||||
if (route.page === "ticket" && route.ticketId === ticket.ticketId) {
|
||||
const returnPath = current.returnPath && pathWithoutSearch(current.returnPath) !== pathWithoutSearch(ticket.path) ? current.returnPath : "/analysis";
|
||||
routerNavigate(returnPath);
|
||||
}
|
||||
@@ -1188,13 +1402,15 @@ export function App() {
|
||||
...(currentUser.role === "admin"
|
||||
? [
|
||||
{ type: "item" as const, href: "/admin/users", label: "Administration", icon: Shield, active: path.startsWith("/admin/users") },
|
||||
{ type: "item" as const, href: "/admin/organizations", label: "Organisationen", icon: Building2, active: path.startsWith("/admin/organizations") },
|
||||
{ type: "separator" as const, key: "before-profile" },
|
||||
{ type: "item" as const, href: "/profile", label: "Profil", icon: UserCog, active: path.startsWith("/profile") },
|
||||
{ type: "separator" as const, key: "before-faq" },
|
||||
{ type: "item" as const, href: "/faq", label: "FAQ", icon: CircleHelp, active: path.startsWith("/faq") }
|
||||
]
|
||||
: [
|
||||
{ type: "item" as const, href: "/dashboard", label: "Dashboard", icon: LayoutDashboard, active: path.startsWith("/dashboard") },
|
||||
{ type: "item" as const, href: "/dashboard", label: "Dashboard", icon: LayoutDashboard, active: path === "/dashboard" },
|
||||
{ type: "item" as const, href: "/dashboard/timers", label: "Timer-Übersicht", icon: Timer, active: path.startsWith("/dashboard/timers") },
|
||||
{ type: "separator" as const, key: "after-dashboard" },
|
||||
{ type: "item" as const, href: "/timer", label: "Session starten", icon: Timer, active: path.startsWith("/timer") || path === "/" },
|
||||
{ type: "item" as const, href: "/analysis", label: "Ticket-Sessions", icon: Ticket, active: path.startsWith("/analysis") },
|
||||
@@ -1207,6 +1423,7 @@ export function App() {
|
||||
{ type: "item" as const, href: "/statistics", label: "Statistiken", icon: ChartNoAxesCombined, active: path.startsWith("/statistics") },
|
||||
{ type: "item" as const, href: "/recurring", label: "Fixe Abrechnung", icon: Repeat, active: path.startsWith("/recurring") },
|
||||
{ type: "separator" as const, key: "before-profile" },
|
||||
{ type: "item" as const, href: "/zammad-sync", label: "Zammad-Sync", icon: DatabaseZap, active: path.startsWith("/zammad-sync") },
|
||||
{ type: "item" as const, href: "/profile", label: "Profil", icon: UserCog, active: path.startsWith("/profile") },
|
||||
{ type: "separator" as const, key: "before-faq" },
|
||||
{ type: "item" as const, href: "/faq", label: "FAQ", icon: CircleHelp, active: path.startsWith("/faq") }
|
||||
@@ -1265,8 +1482,8 @@ export function App() {
|
||||
timers={timers}
|
||||
selectedTimerId={selectedTimerId}
|
||||
setSelectedTimerId={setSelectedTimerId}
|
||||
setTimers={setTimers}
|
||||
tick={tick}
|
||||
onToggleTimer={(timerId) => void toggleActiveTimer(timerId)}
|
||||
onOpenTimer={() => navigate("/timer")}
|
||||
/>
|
||||
</>
|
||||
@@ -1299,16 +1516,18 @@ export function App() {
|
||||
{!isAdmin ? <SidebarTodayTodos todos={sidebarTodos} onNavigate={navigate} /> : null}
|
||||
|
||||
{!isAdmin && openTickets.length > 0 ? (
|
||||
<SidebarGroup className="pt-0 group-data-[collapsible=icon]:hidden">
|
||||
<SidebarGroup className="min-h-0 flex-1 pt-0 group-data-[collapsible=icon]:hidden">
|
||||
<SidebarGroupLabel className="h-6 px-2 text-xs">Geöffnete Tickets</SidebarGroupLabel>
|
||||
<SidebarGroupContent>
|
||||
<SidebarGroupContent className="min-h-0 flex-1 overflow-hidden">
|
||||
<div ref={openTicketsListRef} className="max-h-[clamp(8rem,calc(100svh-31rem),24rem)] overflow-y-auto overscroll-contain pr-1 [scrollbar-gutter:stable] [scrollbar-width:thin]">
|
||||
<SidebarMenu>
|
||||
{openTickets.map((ticket) => {
|
||||
const isActive = pathWithoutSearch(path) === pathWithoutSearch(ticket.path);
|
||||
const isActive = route.page === "ticket" && route.ticketId === ticket.ticketId;
|
||||
|
||||
return (
|
||||
<SidebarMenuItem key={ticket.key}>
|
||||
<div
|
||||
data-open-ticket-active={isActive ? "true" : undefined}
|
||||
draggable
|
||||
onDragStart={(event) => startOpenTicketDrag(event, ticket)}
|
||||
onDragOver={(event) => overOpenTicket(event, ticket)}
|
||||
@@ -1350,6 +1569,7 @@ export function App() {
|
||||
);
|
||||
})}
|
||||
</SidebarMenu>
|
||||
</div>
|
||||
</SidebarGroupContent>
|
||||
</SidebarGroup>
|
||||
) : null}
|
||||
@@ -1357,18 +1577,11 @@ export function App() {
|
||||
</SidebarContent>
|
||||
|
||||
<SidebarFooter>
|
||||
{!isAdmin ? <div className="mx-2 rounded-lg border bg-card p-3 text-sm shadow-xs group-data-[collapsible=icon]:hidden">
|
||||
<div className="mb-2 flex items-center gap-2 font-medium">
|
||||
<CheckCircle2 className="size-4 text-muted-foreground" />
|
||||
Abschluss
|
||||
</div>
|
||||
<p className="text-muted-foreground">Offene Bewertungen findest du im Monatsabschluss.</p>
|
||||
</div> : null}
|
||||
<SidebarMenu>
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton tooltip="Worklog">
|
||||
<UserCircle />
|
||||
<span>Worklog lokal</span>
|
||||
<FolderGit />
|
||||
<span className="text-xs text-muted-foreground">v1.2.0-rc1</span>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
</SidebarMenu>
|
||||
@@ -1462,18 +1675,39 @@ export function App() {
|
||||
selectedTimerId={selectedTimerId}
|
||||
setSelectedTimerId={setSelectedTimerId}
|
||||
tick={tick}
|
||||
currentUser={currentUser}
|
||||
finishRequestId={finishRequestTimerId}
|
||||
onFinishRequestHandled={() => setFinishRequestTimerId(null)}
|
||||
/>
|
||||
) : null}
|
||||
{!isAdmin && route.page === "dashboard" ? <DashboardPage onNavigate={navigate} /> : null}
|
||||
{!isAdmin && route.page === "analysis" ? <AnalysisPage onNavigate={navigate} /> : null}
|
||||
{!isAdmin && route.page === "monthly-close" ? <MonthlyClosePage onNavigate={navigate} /> : null}
|
||||
{!isAdmin && route.page === "statistics" ? <StatisticsPage onNavigate={navigate} /> : null}
|
||||
{!isAdmin && route.page === "projects" ? <ProjectsPage onNavigate={navigate} onStartTimer={startQuickTimer} /> : null}
|
||||
{!isAdmin && route.page === "dashboard" ? <DashboardPage currentUser={currentUser} onNavigate={navigate} /> : null}
|
||||
{!isAdmin && route.page === "dashboard-timers" ? (
|
||||
<DashboardTimersPage
|
||||
timers={timers}
|
||||
setTimers={setTimers}
|
||||
selectedTimerId={selectedTimerId}
|
||||
setSelectedTimerId={setSelectedTimerId}
|
||||
tick={tick}
|
||||
currentUser={currentUser}
|
||||
onFinishTimer={(timerId) => {
|
||||
setSelectedTimerId(timerId);
|
||||
setFinishRequestTimerId(timerId);
|
||||
navigate("/timer");
|
||||
}}
|
||||
onOpenActiveTimer={() => navigate("/timer")}
|
||||
/>
|
||||
) : null}
|
||||
{!isAdmin && route.page === "analysis" ? <AnalysisPage currentUser={currentUser} onNavigate={navigate} /> : null}
|
||||
{!isAdmin && route.page === "monthly-close" ? <MonthlyClosePage currentUser={currentUser} onNavigate={navigate} /> : null}
|
||||
{!isAdmin && route.page === "statistics" ? <StatisticsPage currentUser={currentUser} onNavigate={navigate} /> : null}
|
||||
{!isAdmin && route.page === "projects" ? <ProjectsPage currentUser={currentUser} onNavigate={navigate} onStartTimer={startQuickTimer} /> : null}
|
||||
{!isAdmin && route.page === "todos" ? <TodosPage onNavigate={navigate} /> : null}
|
||||
{!isAdmin && route.page === "recurring" ? <RecurringBillingsPage /> : null}
|
||||
{!isAdmin && route.page === "zammad-sync" ? <ZammadSyncPage currentUser={currentUser} /> : null}
|
||||
{route.page === "faq" ? <FaqPage /> : null}
|
||||
{route.page === "profile" ? <ProfilePage currentUser={currentUser} onUserUpdated={setCurrentUser} /> : null}
|
||||
{route.page === "admin-users" && isAdmin ? <AdminUsersPage currentUser={currentUser} /> : null}
|
||||
{route.page === "admin-organizations" && isAdmin ? <AdminOrganizationsPage /> : null}
|
||||
{route.page === "admin-users" && !isAdmin ? (
|
||||
<TimerPage
|
||||
timers={timers}
|
||||
@@ -1481,6 +1715,7 @@ export function App() {
|
||||
selectedTimerId={selectedTimerId}
|
||||
setSelectedTimerId={setSelectedTimerId}
|
||||
tick={tick}
|
||||
currentUser={currentUser}
|
||||
/>
|
||||
) : null}
|
||||
{!isAdmin && route.page === "ticket" ? (
|
||||
@@ -1489,11 +1724,13 @@ export function App() {
|
||||
period={route.period}
|
||||
ticketId={route.ticketId}
|
||||
onNavigate={navigate}
|
||||
onStartTimer={startQuickTimer}
|
||||
currentUser={currentUser}
|
||||
onOpenTicketChange={updateOpenTicket}
|
||||
returnPath={ticketReturnPaths[route.ticketId] ?? openTickets.find((ticket) => ticket.ticketId === route.ticketId)?.returnPath ?? "/analysis"}
|
||||
/>
|
||||
) : null}
|
||||
{isAdmin && route.page !== "admin-users" && route.page !== "profile" && route.page !== "faq" ? <AdminUsersPage currentUser={currentUser} /> : null}
|
||||
{isAdmin && route.page !== "admin-users" && route.page !== "admin-organizations" && route.page !== "profile" && route.page !== "faq" ? <AdminUsersPage currentUser={currentUser} /> : null}
|
||||
</div>
|
||||
</main>
|
||||
</SidebarInset>
|
||||
|
||||
+679
-23
@@ -2,34 +2,62 @@ import type {
|
||||
AdminUser,
|
||||
AuthUser,
|
||||
BillingStatus,
|
||||
DashboardDayTicket,
|
||||
DashboardPreference,
|
||||
Organization,
|
||||
PeriodOverview,
|
||||
PeriodType,
|
||||
ProjectTeamspaceSubentry,
|
||||
ProjectSummary,
|
||||
RecurringBilling,
|
||||
StatisticsOverview,
|
||||
StatisticsProjectFollowupOverview,
|
||||
TicketMeta,
|
||||
TicketPeriod,
|
||||
TicketTodo,
|
||||
TicketSearchResult,
|
||||
TodoPriority,
|
||||
TodoStatus,
|
||||
WorkType
|
||||
WorkType,
|
||||
WorkSegment,
|
||||
ZammadSessionArticleQueueItem
|
||||
} from "./types";
|
||||
import type { TimerEntry, TimerPhase } from "./timers";
|
||||
|
||||
type CreateSessionPayload = {
|
||||
ticketNumber: string;
|
||||
organizationId: string;
|
||||
activity: string;
|
||||
zammadArticleBody?: string | null;
|
||||
workType: WorkType;
|
||||
startedAt: string;
|
||||
endedAt: string;
|
||||
durationSeconds: number;
|
||||
workSegments?: WorkSegment[];
|
||||
zammadLookup?: boolean;
|
||||
projectTeamspaceEntryId?: string | null;
|
||||
billingStatus?: BillingStatus;
|
||||
createZammadArticle?: boolean;
|
||||
zammadArticleDateHeader?: boolean;
|
||||
zammadArticleTypeId?: number;
|
||||
};
|
||||
|
||||
type CreateTimerPayload = Omit<TimerEntry, "id"> & {
|
||||
id?: string;
|
||||
};
|
||||
|
||||
type UpdateTimerPayload = Partial<{
|
||||
organizationName: string | null;
|
||||
workType: TimerEntry["workType"];
|
||||
isProject: boolean;
|
||||
projectName: string | null;
|
||||
ticketLookupDone: boolean;
|
||||
activity: string;
|
||||
zammadArticleBody?: string | null;
|
||||
billingStatus: TimerEntry["billingStatus"];
|
||||
phase: TimerPhase;
|
||||
}>;
|
||||
|
||||
type DeleteSessionResult = {
|
||||
deleted: {
|
||||
id: string;
|
||||
@@ -42,6 +70,161 @@ type DeleteSessionResult = {
|
||||
};
|
||||
};
|
||||
|
||||
export type CreateSessionResult = {
|
||||
zammadArticle?: {
|
||||
requested: boolean;
|
||||
status: "not_requested" | "sent" | "failed";
|
||||
error: string | null;
|
||||
articleId: number | null;
|
||||
};
|
||||
};
|
||||
|
||||
export type ZammadSyncExistingFields = {
|
||||
title: boolean;
|
||||
organization: boolean;
|
||||
type: boolean;
|
||||
projectName: boolean;
|
||||
budget: boolean;
|
||||
overrideBudget: boolean;
|
||||
pendingTodo: boolean;
|
||||
};
|
||||
|
||||
export type ZammadSyncChange = {
|
||||
field: string;
|
||||
from: unknown;
|
||||
to: unknown;
|
||||
};
|
||||
|
||||
export type ZammadSyncItem = {
|
||||
ticketNumber: string;
|
||||
action: "update" | "create" | "skip" | "error";
|
||||
reason: string | null;
|
||||
local: Record<string, unknown> | null;
|
||||
zammad: Record<string, unknown> | null;
|
||||
changes: ZammadSyncChange[];
|
||||
};
|
||||
|
||||
export type ZammadSyncResult = {
|
||||
mode: "preview" | "applied";
|
||||
checked: number;
|
||||
updated: number;
|
||||
created: number;
|
||||
skipped: number;
|
||||
missingInZammad: number;
|
||||
organizationMissing: number;
|
||||
markedForCloseReview: number;
|
||||
convertedToConsulting: number;
|
||||
convertedToSupport: number;
|
||||
errors: Array<{ ticketNumber: string; error: string }>;
|
||||
items: ZammadSyncItem[];
|
||||
};
|
||||
|
||||
export type ZammadSyncJob = {
|
||||
id: string;
|
||||
userId: string;
|
||||
status: "running" | "complete" | "error";
|
||||
result: ZammadSyncResult | null;
|
||||
error: string | null;
|
||||
startedAt: string;
|
||||
finishedAt: string | null;
|
||||
};
|
||||
|
||||
export type TeamspaceProjectIdSyncItem = {
|
||||
entryId: string;
|
||||
projectId: string;
|
||||
ticketNumber: string;
|
||||
teamspaceProject: string;
|
||||
previousApiProjectId: string | null;
|
||||
apiProjectId: string | null;
|
||||
apiProjectUrl: string | null;
|
||||
ttMonthBillableMinutes: number;
|
||||
teamspaceMonthMinutes: number;
|
||||
teamspaceMonthBillableMinutes: number;
|
||||
teamspaceMonthEntryCount: number;
|
||||
teamspaceMonthDeltaMinutes: number;
|
||||
teamspaceMonthPositions: TeamspaceProjectIdSyncPosition[];
|
||||
status: "updated" | "current" | "missing" | "ambiguous" | "excluded";
|
||||
message: string | null;
|
||||
};
|
||||
|
||||
export type TeamspaceProjectIdSyncPosition = {
|
||||
apiProjectId: string;
|
||||
projectId: string | null;
|
||||
label: string;
|
||||
isSubentry: boolean;
|
||||
billingType?: string | null;
|
||||
minutes: number;
|
||||
billableMinutes: number;
|
||||
entryCount: number;
|
||||
days?: Array<{
|
||||
day: string;
|
||||
minutes: number;
|
||||
billableMinutes: number;
|
||||
entryCount: number;
|
||||
}>;
|
||||
};
|
||||
|
||||
export type TeamspaceProjectIdSyncResult = {
|
||||
month: string;
|
||||
checkedEntries: number;
|
||||
matched: number;
|
||||
updated: number;
|
||||
alreadyCurrent: number;
|
||||
missing: number;
|
||||
ambiguous: number;
|
||||
cleared: number;
|
||||
remoteProjectsScanned: number;
|
||||
remoteProjectIds: number;
|
||||
remoteSubprojectsScanned: number;
|
||||
subentriesSynced: number;
|
||||
subentriesRemoved: number;
|
||||
teamspaceTimeEntriesScanned: number;
|
||||
teamspaceTimeEntriesMapped: number;
|
||||
teamspaceTimeBillableMinutes: number;
|
||||
teamspaceTimeDeltaMinutes: number;
|
||||
duplicateRemoteProjectIds: string[];
|
||||
items: TeamspaceProjectIdSyncItem[];
|
||||
};
|
||||
|
||||
export type TeamspaceProjectSubentrySyncResult = {
|
||||
projectId: string;
|
||||
checkedEntries: number;
|
||||
remoteProjectsScanned: number;
|
||||
subentriesSynced: number;
|
||||
subentriesRemoved: number;
|
||||
monthsSynced: string[];
|
||||
teamspaceTimeEntriesScanned: number;
|
||||
teamspaceTimeEntriesMapped: number;
|
||||
teamspaceTimeBillableMinutes: number;
|
||||
};
|
||||
|
||||
export type TeamspaceAllSubentrySyncResult = {
|
||||
checkedProjects: number;
|
||||
checkedEntries: number;
|
||||
remoteProjectsScanned: number;
|
||||
subentriesSynced: number;
|
||||
subentriesRemoved: number;
|
||||
};
|
||||
|
||||
export type TicketZammadMailSessionItem = {
|
||||
articleId: number;
|
||||
ticketNumber: string;
|
||||
subject: string | null;
|
||||
activity: string;
|
||||
createdAt: string;
|
||||
endedAt: string;
|
||||
timeUnit: number;
|
||||
durationSeconds: number;
|
||||
};
|
||||
|
||||
export type TicketZammadMailSessionResult = {
|
||||
checked: number;
|
||||
created: number;
|
||||
skipped: number;
|
||||
items: TicketZammadMailSessionItem[];
|
||||
errors: Array<{ articleId: number; error: string }>;
|
||||
};
|
||||
|
||||
export type RestoreValidationResult = {
|
||||
valid: boolean;
|
||||
checkedTables: number;
|
||||
@@ -58,15 +241,50 @@ export class ApiError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
async function request<T>(url: string, options: RequestInit = {}): Promise<T> {
|
||||
const response = await fetch(url, {
|
||||
type ApiRequestInit = RequestInit & {
|
||||
timeoutMs?: number;
|
||||
};
|
||||
|
||||
async function request<T>(url: string, options: ApiRequestInit = {}): Promise<T> {
|
||||
const { timeoutMs, signal, ...fetchOptions } = options;
|
||||
const controller = timeoutMs ? new AbortController() : null;
|
||||
const timeout = controller
|
||||
? window.setTimeout(() => {
|
||||
controller.abort();
|
||||
}, timeoutMs)
|
||||
: null;
|
||||
|
||||
if (signal && controller) {
|
||||
signal.addEventListener("abort", () => controller.abort(), { once: true });
|
||||
}
|
||||
|
||||
let response: Response;
|
||||
|
||||
try {
|
||||
response = await fetch(url, {
|
||||
signal: controller?.signal ?? signal,
|
||||
...fetchOptions,
|
||||
credentials: "same-origin",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...options.headers
|
||||
},
|
||||
...options
|
||||
...fetchOptions.headers
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof DOMException && error.name === "AbortError") {
|
||||
throw new ApiError("Anfrage hat zu lange gedauert", 408);
|
||||
}
|
||||
|
||||
if (error instanceof TypeError) {
|
||||
throw new ApiError("Verbindung zum Server wurde unterbrochen. Bitte prüfen, ob Backend und Frontend laufen und ob das Backup kleiner als das Upload-Limit ist.", 503);
|
||||
}
|
||||
|
||||
throw error;
|
||||
} finally {
|
||||
if (timeout) {
|
||||
window.clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
const body = await response.json().catch(() => ({}));
|
||||
@@ -76,6 +294,18 @@ async function request<T>(url: string, options: RequestInit = {}): Promise<T> {
|
||||
return response.json() as Promise<T>;
|
||||
}
|
||||
|
||||
async function requestRestoreEndpoint<T>(path: string, options: ApiRequestInit) {
|
||||
return request<T>(path, options);
|
||||
}
|
||||
|
||||
function backupValidationSql(sql: string) {
|
||||
const lines = sql.replace(/^\uFEFF/, "").split(/\r?\n/);
|
||||
const marker = lines.find((line) => line.includes("TicketTracker Datenbankexport")) ?? "-- TicketTracker Datenbankexport";
|
||||
const structuralLines = lines.filter((line) => /^--\s*(?:[a-z_][a-z0-9_]*:\s*\d+\s+Datensatz|columns\s+[a-z_][a-z0-9_]*:)/i.test(line.trim()));
|
||||
|
||||
return [marker, ...structuralLines].join("\n");
|
||||
}
|
||||
|
||||
export function login(payload: { username: string; password: string }) {
|
||||
return request<{ user: AuthUser }>("/api/auth/login", {
|
||||
method: "POST",
|
||||
@@ -99,6 +329,23 @@ export function updateCurrentUser(payload: {
|
||||
currentPassword?: string;
|
||||
newPassword?: string;
|
||||
zammadApiToken?: string;
|
||||
teamspaceDeviceId?: string;
|
||||
teamspaceDevicePassword?: string;
|
||||
zammadEmailSessionWebhookEnabled?: boolean;
|
||||
sessionBillingPromptEnabled?: boolean;
|
||||
sessionWorkSegmentsEnabled?: boolean;
|
||||
teamspaceTimeTrackingEnabled?: boolean;
|
||||
zammadSessionNoteDefaultEnabled?: boolean;
|
||||
zammadSessionNoteDateHeaderEnabled?: boolean;
|
||||
zammadTimeUnitRoundingEnabled?: boolean;
|
||||
zammadTimeUnitRoundingMinutes?: number;
|
||||
zammadAtossEmailSyncEnabled?: boolean;
|
||||
zammadArticleTypeId?: number;
|
||||
projectFollowupEnabled?: boolean;
|
||||
projectFollowupWeeks?: number;
|
||||
projectFollowupSecondEnabled?: boolean;
|
||||
projectFollowupSecondWeeks?: number;
|
||||
projectFollowupCompleteOnSession?: boolean;
|
||||
}) {
|
||||
return request<{ user: AuthUser }>("/api/auth/me", {
|
||||
method: "PATCH",
|
||||
@@ -110,6 +357,10 @@ export function getDashboardPreference() {
|
||||
return request<{ preference: Partial<DashboardPreference> }>("/api/dashboard/preferences");
|
||||
}
|
||||
|
||||
export function getDashboardDayControl() {
|
||||
return request<{ tickets: DashboardDayTicket[] }>("/api/dashboard/day-control");
|
||||
}
|
||||
|
||||
export function saveDashboardPreference(preference: DashboardPreference) {
|
||||
return request<{ preference: Partial<DashboardPreference> }>("/api/dashboard/preferences", {
|
||||
method: "PUT",
|
||||
@@ -142,16 +393,20 @@ export function createSetupAdmin(payload: { username: string; displayName: strin
|
||||
}
|
||||
|
||||
export function restoreSetupBackup(sql: string) {
|
||||
return request<{ ok: boolean }>("/api/setup/restore", {
|
||||
return requestRestoreEndpoint<{ ok: boolean }>("/api/setup/restore", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ sql })
|
||||
timeoutMs: 10 * 60 * 1000,
|
||||
headers: { "Content-Type": "text/plain; charset=utf-8" },
|
||||
body: sql
|
||||
});
|
||||
}
|
||||
|
||||
export function validateSetupBackup(sql: string) {
|
||||
return request<RestoreValidationResult>("/api/setup/restore/validate", {
|
||||
return requestRestoreEndpoint<RestoreValidationResult>("/api/setup/restore/validate", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ sql })
|
||||
timeoutMs: 60_000,
|
||||
headers: { "Content-Type": "text/plain; charset=utf-8" },
|
||||
body: backupValidationSql(sql)
|
||||
});
|
||||
}
|
||||
|
||||
@@ -186,6 +441,37 @@ export function updateAdminUser(
|
||||
});
|
||||
}
|
||||
|
||||
export type TeamspaceInitialActualsSyncResult = {
|
||||
userId: string;
|
||||
fromMonth: string;
|
||||
toMonth: string;
|
||||
checkedEntries: number;
|
||||
matchedEntries: number;
|
||||
updatedProjectIds: number;
|
||||
missingEntries: number;
|
||||
ambiguousEntries: number;
|
||||
monthsChecked: number;
|
||||
monthsSkippedClosed: number;
|
||||
teamspaceTimeEntriesScanned: number;
|
||||
updatedManualEntries: number;
|
||||
importedManualMinutes: number;
|
||||
existingDayBillingMinutes: number;
|
||||
};
|
||||
|
||||
export function syncAdminUserTeamspaceInitialActuals(userId: string, payload: { fromMonth?: string; toMonth?: string }) {
|
||||
return request<{ result: TeamspaceInitialActualsSyncResult }>(`/api/admin/users/${userId}/teamspace-initial-actuals/sync`, {
|
||||
method: "POST",
|
||||
timeoutMs: 10 * 60 * 1000,
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
}
|
||||
|
||||
export function unlockAdminUserTeamspaceInitialActuals(userId: string) {
|
||||
return request<{ user: AdminUser }>(`/api/admin/users/${userId}/teamspace-initial-actuals/unlock`, {
|
||||
method: "POST"
|
||||
});
|
||||
}
|
||||
|
||||
export async function downloadDatabaseExport() {
|
||||
const response = await fetch("/api/admin/export/database", {
|
||||
credentials: "same-origin"
|
||||
@@ -206,16 +492,20 @@ export async function downloadDatabaseExport() {
|
||||
}
|
||||
|
||||
export function restoreDatabaseBackup(sql: string) {
|
||||
return request<{ ok: boolean }>("/api/admin/restore/database", {
|
||||
return requestRestoreEndpoint<{ ok: boolean }>("/api/admin/restore/database", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ sql })
|
||||
timeoutMs: 10 * 60 * 1000,
|
||||
headers: { "Content-Type": "text/plain; charset=utf-8" },
|
||||
body: sql
|
||||
});
|
||||
}
|
||||
|
||||
export function validateDatabaseBackup(sql: string) {
|
||||
return request<RestoreValidationResult>("/api/admin/restore/database/validate", {
|
||||
return requestRestoreEndpoint<RestoreValidationResult>("/api/admin/restore/database/validate", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ sql })
|
||||
timeoutMs: 60_000,
|
||||
headers: { "Content-Type": "text/plain; charset=utf-8" },
|
||||
body: backupValidationSql(sql)
|
||||
});
|
||||
}
|
||||
|
||||
@@ -248,6 +538,33 @@ export function getOrganizations(search = "") {
|
||||
return request<{ organizations: Organization[] }>(`/api/organizations${params.size ? `?${params}` : ""}`);
|
||||
}
|
||||
|
||||
export function getAdminOrganizations(options: {
|
||||
search?: string;
|
||||
page?: number;
|
||||
pageSize?: number | "all";
|
||||
missingAssignment?: boolean;
|
||||
} = {}) {
|
||||
const params = new URLSearchParams();
|
||||
|
||||
if (options.search?.trim()) {
|
||||
params.set("search", options.search.trim());
|
||||
}
|
||||
|
||||
if (options.page) {
|
||||
params.set("page", String(options.page));
|
||||
}
|
||||
|
||||
if (options.pageSize) {
|
||||
params.set("pageSize", String(options.pageSize));
|
||||
}
|
||||
|
||||
if (options.missingAssignment) {
|
||||
params.set("missingAssignment", "true");
|
||||
}
|
||||
|
||||
return request<{ organizations: Organization[]; total: number; page: number; pageSize: number | "all" }>(`/api/admin/organizations${params.size ? `?${params}` : ""}`);
|
||||
}
|
||||
|
||||
export function getZammadSettings() {
|
||||
return request<{ settings: { baseUrl: string; hasApiKey: boolean } }>("/api/admin/zammad/settings");
|
||||
}
|
||||
@@ -259,13 +576,241 @@ export function saveZammadSettings(payload: { baseUrl: string; apiKey?: string }
|
||||
});
|
||||
}
|
||||
|
||||
export function getTeamspaceSettings() {
|
||||
return request<{ settings: { baseUrl: string; credentialsUserId: string | null } }>("/api/admin/teamspace/settings");
|
||||
}
|
||||
|
||||
export function saveTeamspaceSettings(payload: { baseUrl: string; credentialsUserId?: string | null }) {
|
||||
return request<{ settings: { baseUrl: string; credentialsUserId: string | null } }>("/api/admin/teamspace/settings", {
|
||||
method: "PUT",
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
}
|
||||
|
||||
export type TeamspaceProjectExclusion = {
|
||||
teamspace_project: string;
|
||||
teamspace_api_project_id: string | null;
|
||||
teamspace_api_project_ids: string[] | null;
|
||||
teamspace_name: string | null;
|
||||
customer_name: string | null;
|
||||
atoss_support_default: boolean;
|
||||
atoss_support_valid_from: string | null;
|
||||
atoss_support_valid_to: string | null;
|
||||
customer_owner_option_id: string | null;
|
||||
hotline_contract_option_id: string | null;
|
||||
note: string;
|
||||
};
|
||||
|
||||
export type OrganizationAttributeOption = {
|
||||
id: string;
|
||||
zammad_attribute_id: string;
|
||||
attribute_name: "customer_owner" | "hotline_contract";
|
||||
option_key: string;
|
||||
display_name: string;
|
||||
active: boolean;
|
||||
};
|
||||
|
||||
export type TeamspaceAssignmentRule = {
|
||||
id?: string;
|
||||
name: string;
|
||||
active: boolean;
|
||||
priority: number;
|
||||
customer_owner_option_id: string | null;
|
||||
hotline_contract_option_id: string | null;
|
||||
support_enabled: boolean;
|
||||
consulting_enabled: boolean;
|
||||
teamspace_project: string;
|
||||
created_at?: string;
|
||||
updated_at?: string;
|
||||
};
|
||||
|
||||
export function getOrganizationAttributeOptions() {
|
||||
return request<{ options: OrganizationAttributeOption[] }>("/api/admin/zammad/organization-attribute-options");
|
||||
}
|
||||
|
||||
export function getTeamspaceAssignmentRules() {
|
||||
return request<{ rules: TeamspaceAssignmentRule[] }>("/api/admin/teamspace/assignment-rules");
|
||||
}
|
||||
|
||||
export function saveTeamspaceAssignmentRules(rules: TeamspaceAssignmentRule[]) {
|
||||
return request<{ rules: TeamspaceAssignmentRule[] }>("/api/admin/teamspace/assignment-rules", {
|
||||
method: "PUT",
|
||||
body: JSON.stringify({ rules })
|
||||
});
|
||||
}
|
||||
|
||||
export function getTeamspaceProjectExclusions() {
|
||||
return request<{ exclusions: TeamspaceProjectExclusion[] }>("/api/admin/teamspace/project-exclusions");
|
||||
}
|
||||
|
||||
export function saveTeamspaceProjectExclusions(projects: Array<Pick<TeamspaceProjectExclusion, "teamspace_project" | "atoss_support_default" | "atoss_support_valid_from" | "atoss_support_valid_to" | "customer_owner_option_id" | "hotline_contract_option_id">>) {
|
||||
return request<{ exclusions: TeamspaceProjectExclusion[] }>("/api/admin/teamspace/project-exclusions", {
|
||||
method: "PUT",
|
||||
body: JSON.stringify({ projects })
|
||||
});
|
||||
}
|
||||
|
||||
export function syncZammadOrganizations(payload: { baseUrl?: string; apiKey?: string }) {
|
||||
return request<{ synced: number; skipped: number; removed: number; unlinkedTickets: number; unlinkedSessions: number }>("/api/admin/zammad/organizations/sync", {
|
||||
return request<{ synced: number; attributeOptions: number; skipped: number; removed: number; unlinkedTickets: number; unlinkedSessions: number }>("/api/admin/zammad/organizations/sync", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
}
|
||||
|
||||
function wait(milliseconds: number) {
|
||||
return new Promise((resolve) => window.setTimeout(resolve, milliseconds));
|
||||
}
|
||||
|
||||
export async function waitForZammadSyncJob(jobId: string) {
|
||||
const startedAt = Date.now();
|
||||
const timeoutMs = 10 * 60 * 1000;
|
||||
|
||||
for (;;) {
|
||||
if (Date.now() - startedAt > timeoutMs) {
|
||||
throw new ApiError("Zammad-Sync läuft länger als erwartet. Bitte Vorschau neu starten.", 408);
|
||||
}
|
||||
|
||||
const { job } = await request<{ job: ZammadSyncJob }>(`/api/zammad-sync/jobs/${encodeURIComponent(jobId)}`, { timeoutMs: 20_000 });
|
||||
|
||||
if (job.status === "complete" && job.result) {
|
||||
return job.result;
|
||||
}
|
||||
|
||||
if (job.status === "error") {
|
||||
throw new ApiError(job.error ?? "Zammad-Sync fehlgeschlagen", 500);
|
||||
}
|
||||
|
||||
await wait(1200);
|
||||
}
|
||||
}
|
||||
|
||||
async function runZammadSyncJob(url: string, body: Record<string, unknown>, onJobStarted?: (job: ZammadSyncJob) => void) {
|
||||
const { job } = await request<{ job: ZammadSyncJob }>(url, {
|
||||
method: "POST",
|
||||
timeoutMs: 20_000,
|
||||
body: JSON.stringify(body)
|
||||
});
|
||||
onJobStarted?.(job);
|
||||
const result = await waitForZammadSyncJob(job.id);
|
||||
|
||||
return { jobId: job.id, result };
|
||||
}
|
||||
|
||||
export async function syncExistingZammadTickets(fields: ZammadSyncExistingFields, options: { dryRun?: boolean; ticketNumbers?: string[] } = {}, onJobStarted?: (job: ZammadSyncJob) => void) {
|
||||
const { result } = await runZammadSyncJob("/api/zammad-sync/jobs/existing", {
|
||||
fields,
|
||||
dryRun: options.dryRun ?? true,
|
||||
ticketNumbers: options.ticketNumbers
|
||||
}, onJobStarted);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
export async function importOwnZammadConsultingTickets(options: { dryRun?: boolean; ticketNumbers?: string[] } = {}, onJobStarted?: (job: ZammadSyncJob) => void) {
|
||||
const { result } = await runZammadSyncJob("/api/zammad-sync/jobs/consulting-import", {
|
||||
dryRun: options.dryRun ?? true,
|
||||
ticketNumbers: options.ticketNumbers
|
||||
}, onJobStarted);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
export function syncExistingZammadTicketsDirect(fields: ZammadSyncExistingFields, onJobStarted?: (job: ZammadSyncJob) => void) {
|
||||
return runZammadSyncJob("/api/zammad-sync/jobs/existing", {
|
||||
fields,
|
||||
dryRun: false
|
||||
}, onJobStarted);
|
||||
}
|
||||
|
||||
export function importOwnZammadConsultingTicketsDirect(onJobStarted?: (job: ZammadSyncJob) => void) {
|
||||
return runZammadSyncJob("/api/zammad-sync/jobs/consulting-import", {
|
||||
dryRun: false
|
||||
}, onJobStarted);
|
||||
}
|
||||
|
||||
export function syncZammadMailSessionsDirect(daysBack: number, perPage: number, onJobStarted?: (job: ZammadSyncJob) => void) {
|
||||
return runZammadSyncJob("/api/zammad-sync/jobs/mail-sessions", {
|
||||
dryRun: false,
|
||||
daysBack,
|
||||
perPage
|
||||
}, onJobStarted);
|
||||
}
|
||||
|
||||
export function previewExistingZammadTickets(fields: ZammadSyncExistingFields, onJobStarted?: (job: ZammadSyncJob) => void) {
|
||||
return runZammadSyncJob("/api/zammad-sync/jobs/existing", {
|
||||
fields,
|
||||
dryRun: true
|
||||
}, onJobStarted);
|
||||
}
|
||||
|
||||
export function applyExistingZammadTicketsFromPreview(previewJobId: string, fields: ZammadSyncExistingFields, ticketNumbers: string[], onJobStarted?: (job: ZammadSyncJob) => void) {
|
||||
return runZammadSyncJob(`/api/zammad-sync/jobs/${encodeURIComponent(previewJobId)}/apply-existing`, {
|
||||
fields,
|
||||
ticketNumbers
|
||||
}, onJobStarted);
|
||||
}
|
||||
|
||||
export function previewOwnZammadConsultingTickets(onJobStarted?: (job: ZammadSyncJob) => void) {
|
||||
return runZammadSyncJob("/api/zammad-sync/jobs/consulting-import", {
|
||||
dryRun: true
|
||||
}, onJobStarted);
|
||||
}
|
||||
|
||||
export function applyOwnZammadConsultingTicketsFromPreview(previewJobId: string, ticketNumbers: string[], onJobStarted?: (job: ZammadSyncJob) => void) {
|
||||
return runZammadSyncJob(`/api/zammad-sync/jobs/${encodeURIComponent(previewJobId)}/apply-consulting-import`, {
|
||||
ticketNumbers
|
||||
}, onJobStarted);
|
||||
}
|
||||
|
||||
export function previewZammadMailSessions(daysBack: number, perPage: number, onJobStarted?: (job: ZammadSyncJob) => void) {
|
||||
return runZammadSyncJob("/api/zammad-sync/jobs/mail-sessions", {
|
||||
dryRun: true,
|
||||
daysBack,
|
||||
perPage
|
||||
}, onJobStarted);
|
||||
}
|
||||
|
||||
export function applyZammadMailSessionsFromPreview(previewJobId: string, articleIds: number[], onJobStarted?: (job: ZammadSyncJob) => void) {
|
||||
return runZammadSyncJob(`/api/zammad-sync/jobs/${encodeURIComponent(previewJobId)}/apply-mail-sessions`, {
|
||||
articleIds
|
||||
}, onJobStarted);
|
||||
}
|
||||
|
||||
export function syncTeamspaceProjectIds(month?: string) {
|
||||
return request<{ result: TeamspaceProjectIdSyncResult }>("/api/teamspace/project-ids/sync", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(month ? { month } : {}),
|
||||
timeoutMs: 5 * 60 * 1000
|
||||
});
|
||||
}
|
||||
|
||||
export function syncProjectTeamspaceSubentries(projectId: string, months?: string[]) {
|
||||
return request<{ result: TeamspaceProjectSubentrySyncResult }>(`/api/projects/${projectId}/teamspace-subentries/sync`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify(months?.length ? { months } : {}),
|
||||
timeoutMs: 5 * 60 * 1000
|
||||
});
|
||||
}
|
||||
|
||||
export function syncAllTeamspaceSubentries() {
|
||||
return request<{ result: TeamspaceAllSubentrySyncResult }>("/api/teamspace/subentries/sync", {
|
||||
method: "POST",
|
||||
timeoutMs: 5 * 60 * 1000
|
||||
});
|
||||
}
|
||||
|
||||
export function updateProjectTeamspaceSubentry(
|
||||
subentryId: string,
|
||||
payload: {
|
||||
alias: string;
|
||||
}
|
||||
) {
|
||||
return request<{ subentry: ProjectTeamspaceSubentry }>(`/api/project-teamspace-subentries/${subentryId}`, {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
}
|
||||
|
||||
export function getRecurringBillings() {
|
||||
return request<{ recurringBillings: RecurringBilling[] }>("/api/recurring-billings");
|
||||
}
|
||||
@@ -275,6 +820,9 @@ export function createRecurringBilling(payload: {
|
||||
organizationId: string;
|
||||
activity: string;
|
||||
workType: WorkType;
|
||||
teamspaceProject?: string | null;
|
||||
hourBudgetMinutes?: number | null;
|
||||
manualConsumptionMinutes?: number | null;
|
||||
recurrenceType: "weekly" | "every_n_weeks";
|
||||
intervalValue: number;
|
||||
validFrom: string;
|
||||
@@ -300,6 +848,9 @@ export function updateRecurringBilling(
|
||||
organizationId: string;
|
||||
activity: string;
|
||||
workType: WorkType;
|
||||
teamspaceProject?: string | null;
|
||||
hourBudgetMinutes?: number | null;
|
||||
manualConsumptionMinutes?: number | null;
|
||||
recurrenceType: "weekly" | "every_n_weeks";
|
||||
intervalValue: number;
|
||||
validFrom: string;
|
||||
@@ -324,14 +875,18 @@ export function deleteRecurringBilling(billingId: string) {
|
||||
});
|
||||
}
|
||||
|
||||
export function getProjects(includeClosed = false) {
|
||||
export function getProjects(includeClosed = false, hidden = false) {
|
||||
const params = new URLSearchParams();
|
||||
|
||||
if (includeClosed) {
|
||||
params.set("includeClosed", "true");
|
||||
}
|
||||
|
||||
return request<{ projects: ProjectSummary[] }>(`/api/projects${params.size ? `?${params}` : ""}`);
|
||||
if (hidden) {
|
||||
params.set("hidden", "true");
|
||||
}
|
||||
|
||||
return request<{ projects: ProjectSummary[]; hiddenCount: number; totalCount: number }>(`/api/projects${params.size ? `?${params}` : ""}`);
|
||||
}
|
||||
|
||||
export function getProject(projectId: string) {
|
||||
@@ -347,8 +902,10 @@ export function createProject(payload: {
|
||||
teamspaceEntries?: Array<{
|
||||
id?: string | null;
|
||||
teamspaceProject?: string | null;
|
||||
teamspaceDescription?: string | null;
|
||||
isDefault: boolean;
|
||||
hourBudgetMinutes?: number | null;
|
||||
budgetSyncDisabled?: boolean;
|
||||
manualConsumptionMinutes?: number | null;
|
||||
}>;
|
||||
description: string;
|
||||
@@ -374,8 +931,10 @@ export function updateProject(
|
||||
teamspaceEntries?: Array<{
|
||||
id?: string | null;
|
||||
teamspaceProject?: string | null;
|
||||
teamspaceDescription?: string | null;
|
||||
isDefault: boolean;
|
||||
hourBudgetMinutes?: number | null;
|
||||
budgetSyncDisabled?: boolean;
|
||||
manualConsumptionMinutes?: number | null;
|
||||
}>;
|
||||
description: string;
|
||||
@@ -399,12 +958,31 @@ export function updateProjectStatus(projectId: string, closed: boolean) {
|
||||
});
|
||||
}
|
||||
|
||||
export function acknowledgeProjectReview(projectId: string) {
|
||||
return request<{ project: { id: string; ticket_id: string; close_review_required: boolean } }>(`/api/projects/${projectId}/review`, {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify({ acknowledged: true })
|
||||
});
|
||||
}
|
||||
|
||||
export function deleteProject(projectId: string) {
|
||||
return request<{ deleted: { id: string; ticket_id: string } }>(`/api/projects/${projectId}`, {
|
||||
method: "DELETE"
|
||||
});
|
||||
}
|
||||
|
||||
export function hideProject(projectId: string) {
|
||||
return request<{ hidden: { id: string } }>(`/api/projects/${projectId}/hide`, {
|
||||
method: "POST"
|
||||
});
|
||||
}
|
||||
|
||||
export function unhideProject(projectId: string) {
|
||||
return request<{ shown: { id: string } }>(`/api/projects/${projectId}/hide`, {
|
||||
method: "DELETE"
|
||||
});
|
||||
}
|
||||
|
||||
export function getTodos(status?: TodoStatus | "all") {
|
||||
const params = new URLSearchParams();
|
||||
|
||||
@@ -462,12 +1040,58 @@ export function deleteTodo(todoId: string) {
|
||||
}
|
||||
|
||||
export function createSession(payload: CreateSessionPayload) {
|
||||
return request("/api/sessions", {
|
||||
return request<CreateSessionResult>("/api/sessions", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
}
|
||||
|
||||
export function resendSessionZammadArticle(sessionId: string, payload: { zammadArticleTypeId?: number } = {}) {
|
||||
return request<CreateSessionResult>(`/api/sessions/${encodeURIComponent(sessionId)}/zammad-article`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
}
|
||||
|
||||
export function getActiveTimers() {
|
||||
return request<{ timers: TimerEntry[] }>("/api/timers");
|
||||
}
|
||||
|
||||
export function createActiveTimer(payload: CreateTimerPayload) {
|
||||
return request<{ timers: TimerEntry[] }>("/api/timers", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
}
|
||||
|
||||
export function updateActiveTimer(timerId: string, payload: UpdateTimerPayload) {
|
||||
return request<{ timers: TimerEntry[] }>(`/api/timers/${encodeURIComponent(timerId)}`, {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
}
|
||||
|
||||
export function deleteActiveTimer(timerId: string) {
|
||||
return request<{ timers: TimerEntry[] }>(`/api/timers/${encodeURIComponent(timerId)}`, {
|
||||
method: "DELETE"
|
||||
});
|
||||
}
|
||||
|
||||
export function getZammadSessionArticleQueue() {
|
||||
return request<{ queue: ZammadSessionArticleQueueItem[] }>("/api/zammad/session-article-queue");
|
||||
}
|
||||
|
||||
export function retryZammadSessionArticleQueue() {
|
||||
return request<{
|
||||
sent: number;
|
||||
failed: number;
|
||||
queue: ZammadSessionArticleQueueItem[];
|
||||
results: Array<{ id: string; status: "sent" | "failed"; error?: string; zammadArticleId?: number | null }>;
|
||||
}>("/api/zammad/session-article-queue/retry", {
|
||||
method: "POST"
|
||||
});
|
||||
}
|
||||
|
||||
export function lookupTicket(ticketNumber: string, source: "local" | "zammad" = "local") {
|
||||
const params = new URLSearchParams({ ticketNumber });
|
||||
|
||||
@@ -498,11 +1122,19 @@ export function updateTicket(
|
||||
});
|
||||
}
|
||||
|
||||
export function updateTicketAtossSupport(ticketId: string, atossSupport: boolean) {
|
||||
return request<{ ticket: { atoss_support: boolean } }>(`/api/tickets/${ticketId}/atoss-support`, {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify({ atossSupport })
|
||||
});
|
||||
}
|
||||
|
||||
export function updateSessionDetails(
|
||||
sessionId: string,
|
||||
payload: {
|
||||
organizationId: string;
|
||||
activity: string;
|
||||
zammadArticleBody?: string | null;
|
||||
workType: WorkType;
|
||||
removeProjectData?: boolean;
|
||||
projectTeamspaceEntryId?: string | null;
|
||||
@@ -516,10 +1148,18 @@ export function updateSessionDetails(
|
||||
});
|
||||
}
|
||||
|
||||
export function updateTicketDayBilling(ticketId: string, day: string, billedMinutes: number | null) {
|
||||
export function updateTicketDayBilling(
|
||||
ticketId: string,
|
||||
day: string,
|
||||
billedMinutes: number | null,
|
||||
projectTeamspaceEntryId?: string | null
|
||||
) {
|
||||
return request(`/api/tickets/${ticketId}/day-billings/${day}`, {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify({ billedMinutes })
|
||||
body: JSON.stringify({
|
||||
billedMinutes,
|
||||
projectTeamspaceEntryId: projectTeamspaceEntryId ?? null
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
@@ -546,8 +1186,12 @@ export function getPeriodOverview(type: PeriodType, period: string) {
|
||||
return request<PeriodOverview>(`/api/periods/${periodPath(type)}/${period}/overview`);
|
||||
}
|
||||
|
||||
export function getStatisticsOverview(month: string) {
|
||||
return request<StatisticsOverview>(`/api/statistics/months/${month}`);
|
||||
export function getStatisticsOverview(period: string, type: "month" | "week" = "month") {
|
||||
return request<StatisticsOverview>(`/api/statistics/${type === "week" ? "weeks" : "months"}/${period}`);
|
||||
}
|
||||
|
||||
export function getProjectFollowups() {
|
||||
return request<StatisticsProjectFollowupOverview>("/api/statistics/project-followups");
|
||||
}
|
||||
|
||||
export function getTicketPeriod(type: PeriodType, period: string, ticketId: string) {
|
||||
@@ -558,7 +1202,7 @@ export function getTicketDetails(
|
||||
ticketId: string,
|
||||
filters: {
|
||||
month?: string;
|
||||
status?: "all" | "open" | "billed" | "non_billable";
|
||||
status?: "all" | "open" | "billable" | "billed" | "non_billable";
|
||||
source?: "all" | "manual" | "recurring";
|
||||
} = {}
|
||||
) {
|
||||
@@ -579,6 +1223,18 @@ export function getTicketDetails(
|
||||
return request<TicketPeriod>(`/api/tickets/${ticketId}/details${params.size ? `?${params}` : ""}`);
|
||||
}
|
||||
|
||||
export function previewTicketZammadMailSessions(ticketId: string, daysBack: number) {
|
||||
const params = new URLSearchParams({ daysBack: String(daysBack) });
|
||||
return request<TicketZammadMailSessionResult>(`/api/tickets/${ticketId}/zammad-mail-sessions?${params}`);
|
||||
}
|
||||
|
||||
export function importTicketZammadMailSessions(ticketId: string, daysBack: number, articleIds: number[]) {
|
||||
return request<TicketZammadMailSessionResult>(`/api/tickets/${ticketId}/zammad-mail-sessions`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ daysBack, articleIds })
|
||||
});
|
||||
}
|
||||
|
||||
export function updateSessionBilling(sessionId: string, billingStatus: BillingStatus) {
|
||||
return request(`/api/sessions/${sessionId}/billing`, {
|
||||
method: "PATCH",
|
||||
|
||||
@@ -53,7 +53,8 @@
|
||||
--font-heading: var(--font-sans);
|
||||
--font-sans: var(--font-sans);
|
||||
--breakpoint-xxl: 108.125rem;
|
||||
|
||||
--breakpoint-details: 85rem;
|
||||
--text-xxs: 0.65rem;
|
||||
}
|
||||
|
||||
/* Default theme styles (used when no data-theme-preset is set or when 'default' is selected).
|
||||
@@ -165,6 +166,11 @@ These serve as the fallback; there is no separate default.css file. */
|
||||
}
|
||||
|
||||
@layer utilities {
|
||||
.text-xxs {
|
||||
font-size: var(--text-xxs);
|
||||
line-height: 0.65rem;
|
||||
}
|
||||
|
||||
[data-theme-preset]:not([data-theme-preset="default"]) .shadow-2xs {
|
||||
box-shadow: var(--shadow-2xs);
|
||||
}
|
||||
|
||||
@@ -8,9 +8,32 @@ type CopyTicketButtonProps = {
|
||||
};
|
||||
|
||||
export function CopyTicketButton({ ticketNumber }: CopyTicketButtonProps) {
|
||||
async function writeToClipboard(value: string) {
|
||||
if (navigator.clipboard?.writeText) {
|
||||
await navigator.clipboard.writeText(value);
|
||||
return;
|
||||
}
|
||||
|
||||
const textarea = document.createElement("textarea");
|
||||
textarea.value = value;
|
||||
textarea.style.position = "fixed";
|
||||
textarea.style.left = "-9999px";
|
||||
textarea.setAttribute("readonly", "");
|
||||
document.body.appendChild(textarea);
|
||||
textarea.select();
|
||||
|
||||
try {
|
||||
if (!document.execCommand("copy")) {
|
||||
throw new Error("Zwischenablage nicht verfügbar");
|
||||
}
|
||||
} finally {
|
||||
document.body.removeChild(textarea);
|
||||
}
|
||||
}
|
||||
|
||||
async function copyTicketNumber() {
|
||||
try {
|
||||
await navigator.clipboard.writeText(ticketNumber);
|
||||
await writeToClipboard(ticketNumber);
|
||||
toast.success("Ticketnummer kopiert", {
|
||||
description: ticketNumber
|
||||
});
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
import { ChevronsUpDown } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
|
||||
export type MultiFilterOption = { value: string; label: string };
|
||||
|
||||
type MultiFilterDropdownProps = {
|
||||
values: string[];
|
||||
options: MultiFilterOption[];
|
||||
onChange: (values: string[]) => void;
|
||||
placeholder?: string;
|
||||
countLabel?: string;
|
||||
emptyLabel?: string;
|
||||
};
|
||||
|
||||
export function MultiFilterDropdown({
|
||||
values,
|
||||
options,
|
||||
onChange,
|
||||
placeholder = "Alle Filter",
|
||||
countLabel = "Filter",
|
||||
emptyLabel = "Keine Filter."
|
||||
}: MultiFilterDropdownProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [search, setSearch] = useState("");
|
||||
const normalizedSearch = search.trim().toLocaleLowerCase("de-DE");
|
||||
const visibleOptions = normalizedSearch
|
||||
? options.filter((option) => option.label.toLocaleLowerCase("de-DE").includes(normalizedSearch) || option.value.toLocaleLowerCase("de-DE").includes(normalizedSearch))
|
||||
: options;
|
||||
const activeLabels = values.map((value) => options.find((option) => option.value === value)?.label).filter(Boolean);
|
||||
|
||||
function toggle(value: string) {
|
||||
onChange(values.includes(value) ? values.filter((filter) => filter !== value) : [...values, value]);
|
||||
}
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button type="button" variant="outline" role="combobox" aria-expanded={open} className="h-8 w-full justify-between px-2 text-xs font-normal">
|
||||
<span className={cn("truncate", values.length === 0 && "text-muted-foreground")}>
|
||||
{activeLabels.length === 0 ? placeholder : activeLabels.length === 1 ? activeLabels[0] : `${activeLabels.length} ${countLabel}`}
|
||||
</span>
|
||||
<ChevronsUpDown className="size-3.5 opacity-60" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align="start" className="w-[--radix-popover-trigger-width] gap-1 p-1.5">
|
||||
<Input className="h-7 text-xs" value={search} placeholder="Suchen..." onChange={(event) => setSearch(event.currentTarget.value)} />
|
||||
<div className="max-h-64 overflow-y-auto pr-1">
|
||||
{visibleOptions.length === 0 ? (
|
||||
<p className="px-2 py-3 text-center text-xs text-muted-foreground">{emptyLabel}</p>
|
||||
) : (
|
||||
<div className="space-y-0.5">
|
||||
{visibleOptions.map((option) => {
|
||||
const checked = values.includes(option.value);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={option.value}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-pressed={checked}
|
||||
className="flex h-7 w-full min-w-0 cursor-default items-center gap-2 rounded-md px-2 text-left text-xs outline-none hover:bg-muted focus-visible:bg-muted"
|
||||
onClick={() => toggle(option.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter" || event.key === " ") {
|
||||
event.preventDefault();
|
||||
toggle(option.value);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Checkbox checked={checked} tabIndex={-1} className="pointer-events-none" />
|
||||
<span className="truncate">{option.label}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{values.length > 0 ? (
|
||||
<Button type="button" size="sm" variant="ghost" className="h-6 justify-start px-2 text-[11px]" onClick={() => onChange([])}>
|
||||
Zurücksetzen
|
||||
</Button>
|
||||
) : null}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,412 @@
|
||||
import { Bold, Heading1, Heading2, Heading3, Italic, List, ListOrdered, RemoveFormatting, Strikethrough, Underline } from "lucide-react";
|
||||
import { type ClipboardEvent, type KeyboardEvent, useEffect, useRef, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type RichActivityEditorProps = {
|
||||
id: string;
|
||||
value: string;
|
||||
htmlValue: string;
|
||||
disabled?: boolean;
|
||||
placeholder?: string;
|
||||
onChange: (value: string, htmlValue: string) => void;
|
||||
};
|
||||
|
||||
function escapeHtml(value: string) {
|
||||
return value
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """);
|
||||
}
|
||||
|
||||
function escapeAttribute(value: string) {
|
||||
return escapeHtml(value).replace(/'/g, "'");
|
||||
}
|
||||
|
||||
function escapeHtmlText(value: string) {
|
||||
return escapeHtml(value)
|
||||
.replace(/\t/g, " ")
|
||||
.replace(/ /g, " ")
|
||||
.replace(/\r\n|\r|\n/g, "<br>");
|
||||
}
|
||||
|
||||
function textToEditorHtml(value: string) {
|
||||
return value
|
||||
.split(/\r?\n/)
|
||||
.map((line) => `<div>${line ? escapeHtml(line) : "<br>"}</div>`)
|
||||
.join("");
|
||||
}
|
||||
|
||||
function fileToDataUrl(file: File) {
|
||||
return new Promise<string>((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.addEventListener("load", () => resolve(String(reader.result ?? "")));
|
||||
reader.addEventListener("error", () => reject(reader.error ?? new Error("Bild konnte nicht gelesen werden")));
|
||||
reader.readAsDataURL(file);
|
||||
});
|
||||
}
|
||||
|
||||
function currentSelectionRange() {
|
||||
const selection = window.getSelection();
|
||||
return selection && selection.rangeCount > 0 ? selection.getRangeAt(0).cloneRange() : null;
|
||||
}
|
||||
|
||||
function restoreSelectionRange(range: Range | null) {
|
||||
if (!range) {
|
||||
return;
|
||||
}
|
||||
|
||||
const selection = window.getSelection();
|
||||
selection?.removeAllRanges();
|
||||
selection?.addRange(range);
|
||||
}
|
||||
|
||||
function editorPlainText(element: HTMLElement) {
|
||||
return element.innerText.replace(/\u00a0/g, " ");
|
||||
}
|
||||
|
||||
function hasInlineImage(element: HTMLElement) {
|
||||
return Boolean(element.querySelector("img[src^='data:image/']"));
|
||||
}
|
||||
|
||||
function textStyleTagsFromElement(element: HTMLElement) {
|
||||
const style = element.getAttribute("style")?.toLowerCase() ?? "";
|
||||
const tags: Array<"b" | "i" | "u" | "strike"> = [];
|
||||
|
||||
if (/font-weight\s*:\s*(bold|[7-9]00)\b/.test(style)) {
|
||||
tags.push("b");
|
||||
}
|
||||
|
||||
if (/font-style\s*:\s*italic\b/.test(style)) {
|
||||
tags.push("i");
|
||||
}
|
||||
|
||||
if (/text-decoration(?:-line)?\s*:[^;]*underline/.test(style)) {
|
||||
tags.push("u");
|
||||
}
|
||||
|
||||
if (/text-decoration(?:-line)?\s*:[^;]*(line-through|strike)/.test(style)) {
|
||||
tags.push("strike");
|
||||
}
|
||||
|
||||
return tags;
|
||||
}
|
||||
|
||||
function wrapHtml(html: string, tags: string[]) {
|
||||
return tags.reduceRight((current, tag) => `<${tag}>${current}</${tag}>`, html);
|
||||
}
|
||||
|
||||
function serializeEditorChildren(element: Element | DocumentFragment) {
|
||||
return Array.from(element.childNodes).map(serializeEditorNode).join("");
|
||||
}
|
||||
|
||||
function serializeEditorNode(node: ChildNode): string {
|
||||
if (node.nodeType === Node.TEXT_NODE) {
|
||||
return escapeHtmlText(node.textContent ?? "");
|
||||
}
|
||||
|
||||
if (!(node instanceof HTMLElement)) {
|
||||
return "";
|
||||
}
|
||||
|
||||
const tagName = node.tagName.toLowerCase();
|
||||
const children = serializeEditorChildren(node);
|
||||
|
||||
if (tagName === "br") {
|
||||
return "<br>";
|
||||
}
|
||||
|
||||
if (tagName === "img") {
|
||||
const src = node.getAttribute("src") ?? "";
|
||||
return src.startsWith("data:image/") ? `<img src="${escapeAttribute(src)}" style="max-width:100%;">` : "";
|
||||
}
|
||||
|
||||
if (tagName === "b" || tagName === "strong") {
|
||||
return `<b>${children}</b>`;
|
||||
}
|
||||
|
||||
if (tagName === "i" || tagName === "em") {
|
||||
return `<i>${children}</i>`;
|
||||
}
|
||||
|
||||
if (tagName === "u") {
|
||||
return `<u>${children}</u>`;
|
||||
}
|
||||
|
||||
if (tagName === "s" || tagName === "strike") {
|
||||
return `<strike>${children}</strike>`;
|
||||
}
|
||||
|
||||
if (tagName === "span" || tagName === "font") {
|
||||
return wrapHtml(children, textStyleTagsFromElement(node));
|
||||
}
|
||||
|
||||
if (tagName === "div" || tagName === "p" || tagName === "blockquote" || tagName === "pre" || tagName === "code") {
|
||||
return `<${tagName}>${children || "<br>"}</${tagName}>`;
|
||||
}
|
||||
|
||||
if (tagName === "h1" || tagName === "h2" || tagName === "h3" || tagName === "ul" || tagName === "ol" || tagName === "li") {
|
||||
return `<${tagName}>${children}</${tagName}>`;
|
||||
}
|
||||
|
||||
return children;
|
||||
}
|
||||
|
||||
function editorHtml(element: HTMLElement) {
|
||||
return serializeEditorChildren(element);
|
||||
}
|
||||
|
||||
function normalizedEditorHtml(element: HTMLElement) {
|
||||
const html = editorHtml(element);
|
||||
return html && (editorPlainText(element).trim() || hasInlineImage(element)) ? html : "";
|
||||
}
|
||||
|
||||
export function readRichActivityEditorValue(id: string) {
|
||||
const element = typeof document === "undefined" ? null : document.getElementById(id);
|
||||
|
||||
if (!(element instanceof HTMLElement)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
activity: editorPlainText(element),
|
||||
zammadArticleBody: normalizedEditorHtml(element)
|
||||
};
|
||||
}
|
||||
|
||||
function editorButtonClass(active = false) {
|
||||
return cn(
|
||||
"inline-flex size-8 items-center justify-center rounded-sm text-sm font-semibold text-zinc-100 outline-none transition-colors hover:bg-zinc-700 focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50",
|
||||
active && "bg-zinc-700"
|
||||
);
|
||||
}
|
||||
|
||||
export function RichActivityEditor({
|
||||
id,
|
||||
value,
|
||||
htmlValue,
|
||||
disabled = false,
|
||||
placeholder = "Tätigkeit beschreiben, Screenshot mit Strg+V einfügen",
|
||||
onChange
|
||||
}: RichActivityEditorProps) {
|
||||
const editorRef = useRef<HTMLDivElement | null>(null);
|
||||
const lastEmittedTextRef = useRef("");
|
||||
const lastEmittedHtmlRef = useRef("");
|
||||
const [selectedImage, setSelectedImage] = useState<HTMLImageElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const editor = editorRef.current;
|
||||
|
||||
if (!editor) {
|
||||
return;
|
||||
}
|
||||
|
||||
const nextHtml = htmlValue || (value ? textToEditorHtml(value) : "");
|
||||
const currentHtml = normalizedEditorHtml(editor);
|
||||
|
||||
if (htmlValue === lastEmittedHtmlRef.current && value === lastEmittedTextRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!htmlValue && value === lastEmittedTextRef.current && currentHtml === lastEmittedHtmlRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (editor.innerHTML !== nextHtml) {
|
||||
editor.innerHTML = nextHtml;
|
||||
}
|
||||
|
||||
lastEmittedTextRef.current = editorPlainText(editor);
|
||||
lastEmittedHtmlRef.current = normalizedEditorHtml(editor);
|
||||
}, [htmlValue, value]);
|
||||
|
||||
function clearSelectedImage() {
|
||||
selectedImage?.classList.remove("ring-2", "ring-ring", "ring-offset-2");
|
||||
setSelectedImage(null);
|
||||
}
|
||||
|
||||
function emitChange() {
|
||||
const editor = editorRef.current;
|
||||
|
||||
if (!editor) {
|
||||
return;
|
||||
}
|
||||
|
||||
const text = editorPlainText(editor);
|
||||
const html = normalizedEditorHtml(editor);
|
||||
lastEmittedTextRef.current = text;
|
||||
lastEmittedHtmlRef.current = html;
|
||||
onChange(text, html);
|
||||
}
|
||||
|
||||
function focusEditor() {
|
||||
editorRef.current?.focus();
|
||||
}
|
||||
|
||||
function runCommand(command: string, value?: string) {
|
||||
if (disabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
focusEditor();
|
||||
document.execCommand("styleWithCSS", false, "false");
|
||||
document.execCommand(command, false, value);
|
||||
emitChange();
|
||||
}
|
||||
|
||||
function selectImage(image: HTMLImageElement) {
|
||||
clearSelectedImage();
|
||||
image.classList.add("ring-2", "ring-ring", "ring-offset-2");
|
||||
setSelectedImage(image);
|
||||
|
||||
const range = document.createRange();
|
||||
range.selectNode(image);
|
||||
const selection = window.getSelection();
|
||||
selection?.removeAllRanges();
|
||||
selection?.addRange(range);
|
||||
}
|
||||
|
||||
function removeSelectedImage() {
|
||||
const image = selectedImage;
|
||||
|
||||
if (!image) {
|
||||
return;
|
||||
}
|
||||
|
||||
image.remove();
|
||||
setSelectedImage(null);
|
||||
emitChange();
|
||||
}
|
||||
|
||||
function handleKeyDown(event: KeyboardEvent<HTMLDivElement>) {
|
||||
if ((event.key === "Delete" || event.key === "Backspace") && selectedImage) {
|
||||
event.preventDefault();
|
||||
removeSelectedImage();
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.key === "Tab" && !disabled) {
|
||||
event.preventDefault();
|
||||
document.execCommand("insertText", false, " ");
|
||||
emitChange();
|
||||
}
|
||||
}
|
||||
|
||||
async function handlePaste(event: ClipboardEvent<HTMLDivElement>) {
|
||||
if (disabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
const imageFiles = Array.from(event.clipboardData.items)
|
||||
.filter((item) => item.kind === "file" && item.type.startsWith("image/"))
|
||||
.map((item) => item.getAsFile())
|
||||
.filter((file): file is File => Boolean(file));
|
||||
|
||||
if (imageFiles.length === 0) {
|
||||
const text = event.clipboardData.getData("text/plain");
|
||||
|
||||
if (text) {
|
||||
event.preventDefault();
|
||||
document.execCommand("styleWithCSS", false, "false");
|
||||
document.execCommand("insertText", false, text);
|
||||
emitChange();
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
clearSelectedImage();
|
||||
|
||||
let insertionRange = currentSelectionRange();
|
||||
const text = event.clipboardData.getData("text/plain");
|
||||
if (text) {
|
||||
document.execCommand("styleWithCSS", false, "false");
|
||||
document.execCommand("insertText", false, `${text}\n`);
|
||||
insertionRange = currentSelectionRange();
|
||||
}
|
||||
|
||||
try {
|
||||
for (const file of imageFiles) {
|
||||
const dataUrl = await fileToDataUrl(file);
|
||||
restoreSelectionRange(insertionRange);
|
||||
document.execCommand("insertHTML", false, `<img src="${dataUrl}" style="display:block;max-width:100%;height:auto;margin-top:0.5rem;border-radius:0.375rem;"><br>`);
|
||||
insertionRange = currentSelectionRange();
|
||||
}
|
||||
emitChange();
|
||||
} catch (error) {
|
||||
toast.error("Screenshot konnte nicht eingefügt werden", {
|
||||
description: error instanceof Error ? error.message : "Unbekannter Fehler"
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const toolbarDisabled = disabled;
|
||||
|
||||
return (
|
||||
<div className={cn("min-w-0 max-w-full overflow-hidden rounded-lg border border-input bg-transparent", disabled && "opacity-60")}>
|
||||
<div className="flex min-w-0 flex-wrap items-center gap-1 border-b border-zinc-700 bg-zinc-900 px-2 py-1">
|
||||
<button type="button" className={editorButtonClass()} disabled={toolbarDisabled} title="Fett" onMouseDown={(event) => event.preventDefault()} onClick={() => runCommand("bold")}>
|
||||
<Bold className="size-4" />
|
||||
</button>
|
||||
<button type="button" className={editorButtonClass()} disabled={toolbarDisabled} title="Kursiv" onMouseDown={(event) => event.preventDefault()} onClick={() => runCommand("italic")}>
|
||||
<Italic className="size-4" />
|
||||
</button>
|
||||
<button type="button" className={editorButtonClass()} disabled={toolbarDisabled} title="Unterstrichen" onMouseDown={(event) => event.preventDefault()} onClick={() => runCommand("underline")}>
|
||||
<Underline className="size-4" />
|
||||
</button>
|
||||
<button type="button" className={editorButtonClass()} disabled={toolbarDisabled} title="Durchgestrichen" onMouseDown={(event) => event.preventDefault()} onClick={() => runCommand("strikeThrough")}>
|
||||
<Strikethrough className="size-4" />
|
||||
</button>
|
||||
<button type="button" className={editorButtonClass()} disabled={toolbarDisabled} title="Formatierung entfernen" onMouseDown={(event) => event.preventDefault()} onClick={() => runCommand("removeFormat")}>
|
||||
<RemoveFormatting className="size-4" />
|
||||
</button>
|
||||
<span className="mx-1 h-6 w-px bg-zinc-700" aria-hidden="true" />
|
||||
<button type="button" className={editorButtonClass()} disabled={toolbarDisabled} title="Überschrift 1" onMouseDown={(event) => event.preventDefault()} onClick={() => runCommand("formatBlock", "h1")}>
|
||||
<Heading1 className="size-4" />
|
||||
</button>
|
||||
<button type="button" className={editorButtonClass()} disabled={toolbarDisabled} title="Überschrift 2" onMouseDown={(event) => event.preventDefault()} onClick={() => runCommand("formatBlock", "h2")}>
|
||||
<Heading2 className="size-4" />
|
||||
</button>
|
||||
<button type="button" className={editorButtonClass()} disabled={toolbarDisabled} title="Überschrift 3" onMouseDown={(event) => event.preventDefault()} onClick={() => runCommand("formatBlock", "h3")}>
|
||||
<Heading3 className="size-4" />
|
||||
</button>
|
||||
<span className="mx-1 h-6 w-px bg-zinc-700" aria-hidden="true" />
|
||||
<button type="button" className={editorButtonClass()} disabled={toolbarDisabled} title="Nummerierte Liste" onMouseDown={(event) => event.preventDefault()} onClick={() => runCommand("insertOrderedList")}>
|
||||
<ListOrdered className="size-4" />
|
||||
</button>
|
||||
<button type="button" className={editorButtonClass()} disabled={toolbarDisabled} title="Liste" onMouseDown={(event) => event.preventDefault()} onClick={() => runCommand("insertUnorderedList")}>
|
||||
<List className="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
id={id}
|
||||
ref={editorRef}
|
||||
role="textbox"
|
||||
aria-multiline="true"
|
||||
contentEditable={!disabled}
|
||||
suppressContentEditableWarning
|
||||
className="min-h-24 w-full min-w-0 overflow-hidden break-words bg-transparent px-2.5 py-2 text-base outline-none transition-colors [overflow-wrap:anywhere] empty:before:text-muted-foreground empty:before:content-[attr(data-placeholder)] focus-visible:ring-3 focus-visible:ring-ring/50 md:text-sm [&_h1]:text-2xl [&_h1]:font-semibold [&_h2]:text-xl [&_h2]:font-semibold [&_h3]:text-lg [&_h3]:font-semibold [&_img]:my-2 [&_img]:max-h-[28rem] [&_img]:max-w-full [&_img]:rounded-md [&_ol]:ml-5 [&_ol]:list-decimal [&_ul]:ml-5 [&_ul]:list-disc"
|
||||
data-placeholder={placeholder}
|
||||
onInput={emitChange}
|
||||
onBlur={() => {
|
||||
clearSelectedImage();
|
||||
emitChange();
|
||||
}}
|
||||
onClick={(event) => {
|
||||
const target = event.target;
|
||||
|
||||
if (target instanceof HTMLImageElement) {
|
||||
selectImage(target);
|
||||
return;
|
||||
}
|
||||
|
||||
clearSelectedImage();
|
||||
}}
|
||||
onKeyDown={handleKeyDown}
|
||||
onPaste={(event) => void handlePaste(event)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { CalendarDays, CheckCircle2, Circle, GripVertical, Pencil, Plus, Save, Trash2, X } from "lucide-react";
|
||||
import { FormEvent, useState } from "react";
|
||||
import { type FormEvent, type ReactNode, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
@@ -40,9 +40,11 @@ type TodoPanelProps = {
|
||||
onNavigate?: (to: string) => void;
|
||||
reorderEnabled?: boolean;
|
||||
onReorder?: (draggedTodoId: string, targetTodoId: string) => void;
|
||||
toolbar?: ReactNode;
|
||||
compact?: boolean;
|
||||
};
|
||||
|
||||
export function TodoPanel({ ticketId, todos, onChanged, showCreate = true, showTicketMeta = false, onNavigate, reorderEnabled = false, onReorder }: TodoPanelProps) {
|
||||
export function TodoPanel({ ticketId, todos, onChanged, showCreate = true, showTicketMeta = false, onNavigate, reorderEnabled = false, onReorder, toolbar, compact = false }: TodoPanelProps) {
|
||||
const [title, setTitle] = useState("");
|
||||
const [description, setDescription] = useState("");
|
||||
const [priority, setPriority] = useState<TodoPriority>("medium");
|
||||
@@ -156,9 +158,11 @@ export function TodoPanel({ ticketId, todos, onChanged, showCreate = true, showT
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className={compact ? "space-y-2" : "space-y-3"}>
|
||||
{toolbar || (showCreate && ticketId) ? (
|
||||
<div className={cn("flex flex-wrap items-center gap-2", toolbar ? "justify-start" : "justify-end")}>
|
||||
<div className="min-w-0">{toolbar}</div>
|
||||
{showCreate && ticketId ? (
|
||||
<div className="flex justify-end">
|
||||
<Dialog open={createOpen} onOpenChange={setCreateOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button type="button" size="sm">
|
||||
@@ -243,10 +247,11 @@ export function TodoPanel({ ticketId, todos, onChanged, showCreate = true, showT
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className={compact ? "space-y-1.5" : "space-y-2"}>
|
||||
{todos.map((todo) => {
|
||||
const isEditing = editingId === todo.id;
|
||||
const periodLabel = formatTodoPeriod(todo);
|
||||
@@ -256,7 +261,8 @@ export function TodoPanel({ ticketId, todos, onChanged, showCreate = true, showT
|
||||
<div
|
||||
key={todo.id}
|
||||
className={cn(
|
||||
"rounded-md border bg-card/50 p-3 transition-colors",
|
||||
"rounded-md border bg-card/50 transition-colors",
|
||||
compact ? "p-2" : "p-3",
|
||||
overdue && "border-red-500/50 bg-red-500/10",
|
||||
todo.status === "done" && "opacity-70",
|
||||
reorderEnabled && draggingId === todo.id && "border-primary/50 bg-primary/5"
|
||||
@@ -393,7 +399,7 @@ export function TodoPanel({ ticketId, todos, onChanged, showCreate = true, showT
|
||||
})}
|
||||
</div>
|
||||
|
||||
{todos.length === 0 ? <p className="rounded-md border bg-muted/20 p-4 text-center text-sm text-muted-foreground">Keine ToDos vorhanden.</p> : null}
|
||||
{todos.length === 0 ? <p className={cn("rounded-md border bg-muted/20 text-center text-sm text-muted-foreground", compact ? "p-3" : "p-4")}>Keine ToDos vorhanden.</p> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { Icon, type IconNode, type LucideProps } from "lucide-react";
|
||||
|
||||
const listClockIconNode: IconNode = [
|
||||
["path", { d: "M3 6h.01", key: "1es8zl" }],
|
||||
["path", { d: "M3 12h.01", key: "1v72e3" }],
|
||||
["path", { d: "M3 18h.01", key: "1h3o6d" }],
|
||||
["path", { d: "M7 6h10", key: "1b9f9n" }],
|
||||
["path", { d: "M7 12h5", key: "1f8yah" }],
|
||||
["path", { d: "M7 18h3", key: "1ul03r" }],
|
||||
["circle", { cx: "17", cy: "17", r: "4", key: "y9qv7f" }],
|
||||
["path", { d: "M17 15v2l1.5 1.5", key: "d4d9bm" }]
|
||||
];
|
||||
|
||||
export function ListClockIcon(props: LucideProps) {
|
||||
return <Icon iconNode={listClockIconNode} {...props} />;
|
||||
}
|
||||
@@ -68,7 +68,7 @@ function AccordionContent({
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"h-(--radix-accordion-content-height) pt-0 pb-2.5 [&_a]:underline [&_a]:underline-offset-3 [&_a]:hover:text-foreground [&_p:not(:last-child)]:mb-4",
|
||||
"pt-0 pb-2.5 [&_a]:underline [&_a]:underline-offset-3 [&_a]:hover:text-foreground [&_p:not(:last-child)]:mb-4",
|
||||
className
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -53,6 +53,17 @@ export function currentMonth() {
|
||||
return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
export function currentWeek() {
|
||||
const now = new Date();
|
||||
const date = new Date(Date.UTC(now.getFullYear(), now.getMonth(), now.getDate()));
|
||||
const weekday = date.getUTCDay() || 7;
|
||||
date.setUTCDate(date.getUTCDate() + 4 - weekday);
|
||||
const yearStart = new Date(Date.UTC(date.getUTCFullYear(), 0, 1));
|
||||
const week = Math.ceil(((date.getTime() - yearStart.getTime()) / 86_400_000 + 1) / 7);
|
||||
|
||||
return `${date.getUTCFullYear()}-W${String(week).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
export function currentDay() {
|
||||
const now = new Date();
|
||||
return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}-${String(now.getDate()).padStart(2, "0")}`;
|
||||
|
||||
@@ -16,6 +16,7 @@ export const helpSections: HelpSection[] = [
|
||||
quickItems: [
|
||||
"Sessions sind die getrackten oder nachgetragenen Arbeitszeiten.",
|
||||
"Teamspace-Werte sind Tageswerte pro Ticket, nicht einzelne Sessions.",
|
||||
"Projekt-Sessions können einem Teamspace-Eintrag zugeordnet sein.",
|
||||
"Abgeschlossen wird nur der Monat."
|
||||
],
|
||||
details: [
|
||||
@@ -23,7 +24,8 @@ export const helpSections: HelpSection[] = [
|
||||
"TicketTracker ersetzt auch nicht das CRM. Die CRM- beziehungsweise Teamspace-Zeit wird als Kontrollwert gepflegt, damit abgerechnete Zeiten und eingetragene Abrechnung gegenübergestellt werden können.",
|
||||
"Eine Session ist immer einem Benutzer zugeordnet. In den normalen Auswertungen sieht jeder Benutzer nur seine eigenen Sessions.",
|
||||
"Ticketdaten wie Organisation und Art können aus bestehenden Tickets übernommen werden, auch wenn ein anderer Benutzer das Ticket schon bearbeitet hat.",
|
||||
"Tickets werden nicht abgeschlossen. Sie gruppieren Sessions. Relevant für den Abschluss ist nur, ob alle Sessions eines Monats bewertet sind.",
|
||||
"Consulting-Projekte können einen oder mehrere Teamspace-Einträge haben. Einer davon ist immer der Standard-Eintrag.",
|
||||
"Tickets werden nicht abgeschlossen. Sie gruppieren Sessions. Relevant für den Abschluss ist nur, ob alle Sessions eines Monats final bewertet sind.",
|
||||
"Der Monatsabschluss sperrt den Monat gegen versehentliche Änderungen. Muss doch noch etwas korrigiert werden, kann der Monat wieder geöffnet werden."
|
||||
]
|
||||
},
|
||||
@@ -32,31 +34,33 @@ export const helpSections: HelpSection[] = [
|
||||
title: "Monatsworkflow",
|
||||
description: "Der empfohlene Ablauf vom Start einer Arbeitssession bis zum sauberen Monatsabschluss.",
|
||||
quickItems: [
|
||||
"Während des Monats: Ticketnummer eingeben, Timer starten, Session beenden und Tätigkeit dokumentieren.",
|
||||
"Während des Monats: Ticketnummer eingeben, Timer starten, Tätigkeit dokumentieren und Session beenden.",
|
||||
"Vergessene Zeiten: Session manuell auf dem Ticket oder als neues Ticket nachtragen.",
|
||||
"Am Monatsende: offene Bewertungen bearbeiten, Teamspace-Tageswerte pflegen, Kontrolle durchführen und Monat schließen."
|
||||
],
|
||||
details: [
|
||||
"Der normale Einstieg ist das Ticketfeld im Kopfbereich oder die Seite Session starten. Dort gibst du die Ticketnummer ein und startest den Timer mit Timer starten.",
|
||||
"Während der Arbeit läuft genau ein Timer aktiv. Wechselst du zu einem anderen Timer, pausiert TicketTracker die übrigen Timer automatisch.",
|
||||
"Wenn die Arbeit erledigt ist, beendest du den Timer. Die Zeit stoppt sofort beim Klick auf Beenden; das anschließende Ausfüllen der Tätigkeit zählt nicht mehr zur Sessionzeit.",
|
||||
"Im Beenden-Dialog trägst du die Tätigkeit ein. Wenn das Ticket bereits bekannt ist, übernimmt TicketTracker Organisation und Art automatisch. Bei neuen Tickets wählst du Organisation und Art zusätzlich aus.",
|
||||
"Während der Timer läuft oder pausiert ist, trägst du die Tätigkeit direkt in den Timer-Details ein. Pausierte Timer behalten diese Notizen.",
|
||||
"Wenn die Arbeit erledigt ist, beendest du den Timer. Die Zeit stoppt sofort beim Klick auf Beenden; die bereits erfasste Tätigkeit wird mit der Session gespeichert.",
|
||||
"Wenn das Ticket bereits bekannt ist, übernimmt TicketTracker Organisation und Art automatisch. Bei neuen Tickets wählst du Organisation und Art beim Speichern zusätzlich aus.",
|
||||
"Hat ein Consulting-Projekt mehrere Teamspace-Einträge, wählst du beim Speichern der Session den passenden Eintrag aus. Bei genau einem Teamspace-Eintrag ordnet TicketTracker automatisch zu.",
|
||||
"Vergessene Sessions trägst du auf der Seite Session starten im Bereich Session nachtragen nach. Wenn das Ticket bereits in der Ticketliste geöffnet ist, nutzt du in der Ticketdetailansicht ebenfalls Session nachtragen; dann ist die Zuordnung zum Ticket bereits vorbelegt.",
|
||||
"Unter Tickets wählst du Monat oder Tag, öffnest ein Ticket über Öffnen und siehst dort alle Sessions nach Tag gruppiert.",
|
||||
"Im Ticket bewertest du jede Session mit Abgerechnet oder Nicht abrechenbar. Ein erneuter Klick auf den aktiven Status nimmt die Bewertung zurück.",
|
||||
"Im Ticket setzt du je Session den Status Abrechenbar, Abgerechnet oder Nicht abrechenbar. Abrechenbar bleibt eine offene Bewertung, bis daraus Abgerechnet oder Nicht abrechenbar wird.",
|
||||
"Im Ticket steht pro Tag das Feld Teamspace. Dort trägst du die Stunden ein, die du für genau dieses Ticket an genau diesem Tag wirklich im CRM beziehungsweise Teamspace erfasst hast. Nach einer Änderung erscheint der Speichern-Button.",
|
||||
"Die Teamspace-Zeit ist ein Tageswert pro Ticket und Benutzer. Monats- und Tagesansicht zeigen denselben Wert; er muss also nur einmal gepflegt werden.",
|
||||
"Am Monatsende öffnest du Monatsabschluss. Dort findest du offene Bewertungen gruppiert nach Ticket, Tage ohne Teamspace-Wert und die Schaltfläche Monat abschließen.",
|
||||
"Offene Bewertungen sind Pflicht: Öffne das Ticket aus der Liste, bewerte die Sessions und kehre zum Monatsabschluss zurück.",
|
||||
"Tage ohne Teamspace-Wert sind eine Prüfliste. Wenn die Arbeit wirklich nicht in Teamspace eingetragen werden soll, nutzt du Zur Kenntnis. Mit Zur Kenntnis anzeigen blendest du solche Einträge wieder ein und kannst die Kenntnisnahme zurücknehmen.",
|
||||
"Kontrolliere vor dem Abschluss die Kennzahl Teamspace im Monatsabschluss. Idealerweise entspricht dieser Wert der Summe, die du im CRM beziehungsweise Teamspace für den Monat eingetragen hast.",
|
||||
"Wenn alle Sessions bewertet sind und die Teamspace-Kontrolle passt, schließt du den Monat mit Monat abschließen. Danach sind Bewertungen und Änderungen für den Monat geschützt, bis der Monat wieder geöffnet wird."
|
||||
"Wenn keine offenen Bewertungen mehr vorhanden sind und die Teamspace-Kontrolle passt, schließt du den Monat mit Monat abschließen. Danach sind Bewertungen und Änderungen für den Monat geschützt, bis der Monat wieder geöffnet wird."
|
||||
],
|
||||
workflow: [
|
||||
"Ticketfeld im Kopfbereich oder Session starten öffnen.",
|
||||
"Ticketnummer eingeben und Timer starten.",
|
||||
"Arbeit erledigen; bei Bedarf zwischen vorbereiteten Timern wechseln.",
|
||||
"Timer beenden und Tätigkeit speichern.",
|
||||
"Tätigkeit im Timer erfassen und Timer beenden.",
|
||||
"Vergessene Zeiten über Session nachtragen ergänzen.",
|
||||
"Tickets öffnen, Monat wählen und Einträge prüfen.",
|
||||
"Ticket öffnen, Sessions pro Tag bewerten und Teamspace-Tageswerte speichern.",
|
||||
@@ -68,8 +72,8 @@ export const helpSections: HelpSection[] = [
|
||||
"Ticketnummer eingeben",
|
||||
"Timer starten",
|
||||
"Arbeit am Ticket erledigen",
|
||||
"Tätigkeit im Timer eintragen",
|
||||
"Timer beenden",
|
||||
"Tätigkeit eintragen",
|
||||
"Session wird dem Ticket und Benutzer gespeichert",
|
||||
"Optional: vergessene Session nachtragen",
|
||||
"Tickets öffnen und Ticket prüfen",
|
||||
@@ -108,15 +112,21 @@ export const helpSections: HelpSection[] = [
|
||||
quickItems: [
|
||||
"Ticketnummer eingeben und Timer starten.",
|
||||
"Aktivieren eines Timers pausiert alle anderen Timer automatisch.",
|
||||
"Beim Beenden stoppt die Zeit sofort; Details werden danach gespeichert."
|
||||
"Die Tätigkeit wird im ausgewählten Timer erfasst und beim Beenden gespeichert."
|
||||
],
|
||||
details: [
|
||||
"Neue Timer werden mit einer Ticketnummer im Format Ticket#XXXXXX gestartet.",
|
||||
"Alle laufenden und pausierten Timer bleiben in der Seitenleiste sichtbar, damit du auf jeder Unterseite sehen kannst, was vorbereitet oder aktiv ist.",
|
||||
"Wenn ein anderer Timer aktiviert wird, pausiert TicketTracker die übrigen Timer automatisch. Dadurch kann nie versehentlich auf zwei Tickets gleichzeitig Zeit laufen.",
|
||||
"Beim Klick auf Beenden wird die Stoppuhr sofort angehalten. Das Speichern der Tätigkeit findet danach statt und verlängert die gemessene Zeit nicht.",
|
||||
"Existiert das Ticket bereits, übernimmt TicketTracker Organisation und Art. Dann muss beim Beenden nur noch die Tätigkeit eingetragen werden.",
|
||||
"Existiert das Ticket noch nicht, müssen Organisation, Tätigkeit und Art angegeben werden.",
|
||||
"Wenn du einen Timer auswählst, zeigt die Timerseite dessen Details inklusive gemessener Zeit und Tätigkeitsfeld.",
|
||||
"Pausierte Timer behalten ihre Tätigkeit. Entfernst du einen Timer, wird auch die noch nicht gespeicherte Tätigkeit verworfen.",
|
||||
"Beim Klick auf Beenden wird die Stoppuhr sofort angehalten. Die bereits im Timer erfasste Tätigkeit wird beim Speichern an die Session gehängt.",
|
||||
"Im Abschlussdialog kann die Tätigkeit als interne Zammad-Notiz übertragen werden. Der Article wird intern erstellt und bekommt die getrackte Zeit als time_unit sowie den gewählten Zammad-Typ.",
|
||||
"Wenn Zammad beim Speichern nicht erreichbar ist, bleibt die Session trotzdem gespeichert. Die interne Notiz wird geparkt und kann später im Profil erneut gesendet werden.",
|
||||
"Existiert das Ticket bereits, übernimmt TicketTracker Organisation und Art.",
|
||||
"Bei Projekt-Tickets mit mehreren Teamspace-Einträgen zeigt der Beenden-Dialog eine Auswahl. Der Standard-Eintrag ist dort mit Standard gekennzeichnet.",
|
||||
"Bei Projekt-Tickets mit genau einem Teamspace-Eintrag wird die Session automatisch diesem Eintrag zugeordnet.",
|
||||
"Existiert das Ticket noch nicht, müssen Organisation und Art angegeben werden.",
|
||||
"Pausen werden von der Gesamtdauer abgezogen. Die gespeicherte Zeit wird auf volle Minuten gerundet.",
|
||||
"Der Schnellstart im Kopfbereich funktioniert auf allen Unterseiten und nutzt dieselbe Timerlogik."
|
||||
]
|
||||
@@ -126,7 +136,7 @@ export const helpSections: HelpSection[] = [
|
||||
title: "Session Nachtragen",
|
||||
description: "Nachtragen ist für vergessene Zeiten gedacht, wenn kein Timer gelaufen ist.",
|
||||
quickItems: [
|
||||
"Datum, Von, Bis und Tätigkeit eintragen.",
|
||||
"Datum, Von, Bis, Bewertung und Tätigkeit eintragen.",
|
||||
"Benutzer tragen eigene Sessions nach.",
|
||||
"Tätigkeiten sind mehrzeilig."
|
||||
],
|
||||
@@ -135,8 +145,10 @@ export const helpSections: HelpSection[] = [
|
||||
"Das Ende muss nach dem Beginn liegen. Übernacht-Sessions sind aktuell nicht vorgesehen und sollten als getrennte Einträge gepflegt werden.",
|
||||
"Die Organisation muss aus der synchronisierten Organisationsliste gewählt werden. Freitext ist absichtlich gesperrt.",
|
||||
"Die Tätigkeit ist eine Textarea. Zeilenumbrüche bleiben erhalten und werden in der Ticketdetailansicht wieder ausgegeben.",
|
||||
"Nachgetragene Sessions starten immer offen. Sie müssen später als Abgerechnet oder Nicht abrechenbar bewertet werden.",
|
||||
"In der Ticketdetailansicht kann eine Session direkt zum geöffneten Ticket nachgetragen werden. Ticketnummer und Stammdaten sind dort schon vorbelegt beziehungsweise aus dem Ticket abgeleitet."
|
||||
"Mit Bewertung --- bleibt die Session offen. Abrechenbar oder Nicht abrechenbar werden direkt an der neuen Session gespeichert.",
|
||||
"Die interne Zammad-Notiz kann auch beim Nachtragen gesetzt werden. Ist sie aktiv, kann der Zammad-Typ angepasst werden. Bei Zammad-Fehlern wird sie geparkt und kann später im Profil gesendet werden.",
|
||||
"In der Ticketdetailansicht kann eine Session direkt zum geöffneten Ticket nachgetragen werden. Ticketnummer und Stammdaten sind dort schon vorbelegt beziehungsweise aus dem Ticket abgeleitet.",
|
||||
"Bei Consulting-Projekten mit mehreren Teamspace-Einträgen ist der passende Teamspace-Eintrag Pflicht. Bei nur einem Eintrag wird er automatisch gesetzt."
|
||||
]
|
||||
},
|
||||
{
|
||||
@@ -146,12 +158,16 @@ export const helpSections: HelpSection[] = [
|
||||
quickItems: [
|
||||
"Ticketnummer, Organisation und Art sind bearbeitbar.",
|
||||
"Organisationen kommen aus der lokalen Zammad-Synchronisation.",
|
||||
"Atoss Support ist ein bewusster Fallback am gesamten Support-Ticket.",
|
||||
"Die Art wiederkehrender Sessions kommt aus der Regel."
|
||||
],
|
||||
details: [
|
||||
"Ticketnummern können korrigiert werden, falls beim Starten oder Nachtragen ein falscher Bezug gewählt wurde.",
|
||||
"Die Organisation wird immer über die Suchliste gewählt. Dadurch sind spätere Auswertungen pro Organisation zuverlässig.",
|
||||
"Änderungen an Organisation und Art werden auf vorhandene manuelle Sessions dieses Tickets übernommen.",
|
||||
"Zammad-Verwaltung und automatische Teamspace-Zuordnungen werden kompakt als Badges in den Ticketdaten angezeigt.",
|
||||
"Die automatische TS-Zuordnung prüft zuerst ein am Ticket hinterlegtes Projekt und danach die Zuordnungsregeln aus dem Adminbereich. Der Haken Atoss Support greift nur, wenn beides fehlt.",
|
||||
"Die Auswahl Atoss Support wird nur bei Support-Tickets ohne Projekt- oder Regelzuordnung angezeigt. Ist sie aktiviert, werden alle Sessions des Tickets anhand ihres Datums dem dafür im Adminbereich gültigen Atoss-Support-Projekt zugewiesen. Ohne Haken bleibt das Ticket bewusst unzugeordnet und muss manuell geprüft werden.",
|
||||
"Bei wiederkehrenden Sessions ist die Art nicht direkt an der Session änderbar. Sie wird ausschließlich über die fixe Abrechnungsregel gesteuert.",
|
||||
"Wenn ein Ticket bereits durch einen anderen Benutzer bekannt ist, können dessen Ticketdaten beim Beenden eines Timers übernommen werden. Die Session selbst bleibt trotzdem deinem Benutzer zugeordnet."
|
||||
]
|
||||
@@ -207,12 +223,15 @@ export const helpSections: HelpSection[] = [
|
||||
],
|
||||
details: [
|
||||
"Oben steht die Ticketnummer mit Copy-Icon, damit sie schnell in andere Systeme übernommen werden kann.",
|
||||
"Über Timer starten kann direkt für dieses Ticket ein Timer gestartet oder ein vorhandener Timer aktiviert werden.",
|
||||
"Die Session-Einträge sind nach Kalendertagen gruppiert. Pro Tagesgruppe steht die Summe der abgerechneten Zeit.",
|
||||
"Nicht abrechenbare und offene Zeiten werden neben der abgerechneten Tageszeit separat angezeigt.",
|
||||
"Abrechenbare, nicht abrechenbare und offene Zeiten werden neben der abgerechneten Tageszeit separat angezeigt.",
|
||||
"Bei Projekt-Tickets mit mehreren Teamspace-Einträgen zeigt jede zugeordnete Session den gewählten Teamspace-Eintrag. Nicht zugeordnete Sessions werden hervorgehoben.",
|
||||
"Das Teamspace-Feld wird als Stundenwert gepflegt, zum Beispiel 1, 1,5 oder 2.25.",
|
||||
"Während der Eingabe wird der Teamspace-Wert nicht sofort gespeichert. Sobald eine Änderung vorliegt, erscheint ein Speichern-Button.",
|
||||
"Ein leeres Teamspace-Feld entfernt den gespeicherten Teamspace-Wert für diesen Ticket-Tag.",
|
||||
"Der Teamspace-Wert hängt am Ticket, Benutzer und Tag. Deshalb ist er in Monats- und Tagesansicht identisch.",
|
||||
"Im Projektbereich der Ticketdetailansicht werden einzelne Teamspace-Budgetzeilen nur angezeigt, wenn mehrere Teamspace-Einträge tatsächlich ein Budget haben. Ein einzelnes Budget bleibt im Hauptbalken.",
|
||||
"Geöffnete Tickets werden in der Seitenleiste unter Geöffnete Tickets abgelegt und bleiben nach Refresh oder Browserneustart erhalten.",
|
||||
"Die Reihenfolge bleibt beim Anklicken stabil und kann nur per Drag and Drop geändert werden.",
|
||||
"Der Schließen-Button in der Seitenleiste entfernt ein Ticket nur, wenn im aktuell geöffneten Ticket keine ungespeicherten Werte mehr vorhanden sind.",
|
||||
@@ -222,17 +241,18 @@ export const helpSections: HelpSection[] = [
|
||||
{
|
||||
id: "sessions-bewerten",
|
||||
title: "Sessions Bewerten",
|
||||
description: "Bewertungen bestimmen, ob eine Session abgerechnet wurde oder bewusst nicht abrechenbar ist.",
|
||||
description: "Bewertungen bestimmen, ob eine Session offen, abrechenbar, abgerechnet oder bewusst nicht abrechenbar ist.",
|
||||
quickItems: [
|
||||
"Jede Session braucht genau eine Bewertung.",
|
||||
"Abgerechnet und Nicht abrechenbar können durch erneuten Klick zurückgenommen werden.",
|
||||
"Jede Session braucht vor dem Monatsabschluss einen finalen Status.",
|
||||
"Abrechenbar, Abgerechnet und Nicht abrechenbar können durch erneuten Klick zurückgenommen werden.",
|
||||
"Offene Bewertungen blockieren den Monatsabschluss."
|
||||
],
|
||||
details: [
|
||||
"Abrechenbar bedeutet: Die Session ist als potenziell abrechenbar markiert, bleibt aber für Abschluss und offene Bewertungen noch offen.",
|
||||
"Abgerechnet bedeutet: Die Session wurde für die Abrechnung beziehungsweise im CRM berücksichtigt.",
|
||||
"Nicht abrechenbar bedeutet: Die Session gehört zum Arbeitsnachweis, wird aber nicht abgerechnet.",
|
||||
"Ein erneuter Klick auf den aktiven Bewertungsbutton entfernt die Bewertung und macht die Session wieder offen.",
|
||||
"Nicht abrechenbare Sessions zählen als bewertet. Sie blockieren den Monatsabschluss nicht.",
|
||||
"Nur Abgerechnet und Nicht abrechenbar schließen die Bewertung ab. Abrechenbar blockiert den Monatsabschluss weiterhin.",
|
||||
"Wenn ein Monat abgeschlossen ist, sind Bewertungen gesperrt. Öffne den Monat zuerst wieder, wenn du etwas korrigieren musst.",
|
||||
"Die offenen Bewertungen findest du gebündelt im Monatsabschluss."
|
||||
]
|
||||
@@ -244,6 +264,7 @@ export const helpSections: HelpSection[] = [
|
||||
quickItems: [
|
||||
"Projekte werden über eine Ticketnummer angelegt.",
|
||||
"Vorhandene Ticketdaten werden automatisch übernommen.",
|
||||
"Ein Projekt kann mehrere Teamspace-Einträge haben.",
|
||||
"Projekt-Sessions sind immer Consulting."
|
||||
],
|
||||
details: [
|
||||
@@ -251,11 +272,18 @@ export const helpSections: HelpSection[] = [
|
||||
"Beim Anlegen gibst du die Ticketnummer ein. Existiert das Ticket bereits, übernimmt TicketTracker Organisation und Stammdaten automatisch.",
|
||||
"Existiert das Ticket noch nicht, muss eine Organisation aus der synchronisierten Organisationsliste gewählt werden.",
|
||||
"Ein Projekt-Ticket bleibt ein normales Ticket. Es kann über Suche, Tickets, geöffnete Tickets und ToDos geöffnet werden.",
|
||||
"Zusätzlich speicherst du Teamspace-Projekt, Beschreibung/Ziele, ungefähren Zeitraum und gesamtes Stundenkontingent.",
|
||||
"Der Projektverbrauch wird nur aus gepflegten Teamspace-Tageswerten und einem optionalen manuellen Projektverbrauch berechnet. Getrackte Sessionzeiten zählen nicht in den Projektverbrauch.",
|
||||
"Zusätzlich speicherst du einen oder mehrere Teamspace-Einträge, Beschreibung/Ziele, ungefähren Zeitraum und Budgets.",
|
||||
"Der erste Teamspace-Eintrag ist der Standard. Er bekommt die aus Teamspace gebuchten Tageswerte und das Budget aus Zammad, solange kein Budgetschutz greift.",
|
||||
"Hat ein Projekt genau einen Teamspace-Eintrag, ordnet TicketTracker Consulting-Sessions automatisch diesem Eintrag zu. Bei mehreren Einträgen muss der passende Eintrag beim Speichern oder Bearbeiten der Session gewählt werden.",
|
||||
"Die Auswahl zeigt den Standard-Eintrag ausdrücklich mit Standard an, auch wenn der Eintrag eine normale Teamspace-ID hat.",
|
||||
"Der Projektverbrauch wird aus gepflegten Teamspace-Tageswerten und optionalem manuellem Verbrauch je Teamspace-Eintrag berechnet. Getrackte Sessionzeiten zählen nicht in den Projektverbrauch.",
|
||||
"Der manuelle Projektverbrauch ist für Altbestände oder einmalige Vorbuchungen gedacht und wirkt ausschließlich im Projektverbrauch. Er erscheint nicht in Auswertungen, Statistiken, CSV-Export oder Monatsabschluss.",
|
||||
"Manuelle Änderungen am Budget setzen den Budgetschutz des betroffenen Teamspace-Eintrags automatisch. Der Haken kann im Projekt wieder entfernt werden, wenn das Budget künftig wieder aus Zammad synchronisiert werden soll.",
|
||||
"Projekte können offen oder geschlossen sein. Standardmäßig zeigt die Projektseite offene Projekte; geschlossene können zusätzlich eingeblendet werden.",
|
||||
"Auf der Projektseite kannst du Projekte nach Freitext, Status, Organisation, Kontingentstatus und ToDos filtern sowie nach Ticket, Titel, Organisation, Teamspace-ID, Verbrauch, Reststunden, Kontingent oder ToDos sortieren. Diese Einstellungen bleiben im Browser gespeichert.",
|
||||
"Auf der Projektseite gibt es ein gemeinsames Filter-Dropdown. Filter derselben Gruppe wirken als Oder, unterschiedliche Gruppen werden kombiniert.",
|
||||
"Filterbar sind unter anderem TicketTracker-Status, Zammad-Status, Zammad-Priorität, Organisation, Kontingentstatus und Nur Std.-Budget.",
|
||||
"Nur Std.-Budget zeigt Projekte, bei denen ein Budget vorhanden ist und dieses ausschließlich auf dem Standard-Teamspace-Eintrag liegt.",
|
||||
"In der Projektliste und im Budget-Widget werden einzelne Teamspace-Budgetzeilen nur angezeigt, wenn mehrere Teamspace-Einträge ein Budget haben. Nicht budgetierte Einträge werden dort ausgeblendet.",
|
||||
"Reststunden werden aus dem Stundenkontingent minus Projektverbrauch berechnet. Gibt es kein Kontingent, kann TicketTracker keinen offenen Rest berechnen.",
|
||||
"Offene Projekte stehen immer vor geschlossenen Projekten. Der Projektstatus ändert nicht die Anzeige der Sessions im Ticket.",
|
||||
"Projekt-Tickets sind immer Consulting. Auch wenn Sessions später über den normalen Timer oder das Nachtragen erfasst werden, erkennt TicketTracker das Projekt und übernimmt Consulting.",
|
||||
@@ -282,6 +310,7 @@ export const helpSections: HelpSection[] = [
|
||||
"In der linken Navigation zeigt die Kachel Termine die festen heutigen ToDos, laufende ToDos als Summe und überfällige Aufgaben.",
|
||||
"Unter der Aufgabenliste zeigt der ToDo-Kalender offene ToDos im Monatsüberblick. Erledigte ToDos werden dort ausgeblendet; Wochenenden und bayerische Feiertage bleiben ohne ToDo-Einträge.",
|
||||
"Ein-Tages-ToDos werden einzeln und farblich nach Priorität angezeigt. Zeiträume, offene Von-Daten und Bis-Daten werden pro Tag nur als Summe angezeigt.",
|
||||
"Wenn der Zammad-Sync entsprechend aktiviert ist, kann aus Zammad-Wartedaten automatisch ein ToDo als Erinnerung oder Abschlussprüfung erzeugt werden.",
|
||||
"Bei Nur-Von läuft die Summe ab diesem Datum weiter, bis das ToDo erledigt, gelöscht oder mit einem Bis-Datum begrenzt wird. Bei Nur-Bis zählt der Kalender ab heute bis zum Bis-Datum.",
|
||||
"Ein offenes ToDo ist überfällig, wenn Umsetzung bis vor heute liegt. Im Kalender bleibt es ab dem Tag nach der Deadline an jedem Arbeitstag als roter +X üf-Badge sichtbar, bis es erledigt oder gelöscht wird.",
|
||||
"Ein Klick auf ein einzelnes ToDo öffnet die Bearbeitung. Eine Tagessumme öffnet eine kompakte Tagesliste; daraus kannst du ein ToDo separat öffnen, erledigen, löschen oder das Ticket öffnen.",
|
||||
@@ -316,17 +345,23 @@ export const helpSections: HelpSection[] = [
|
||||
description: "Der Monatsabschluss ist die Kontrollseite für offene Bewertungen und Teamspace-Punkte vor dem Abschluss.",
|
||||
quickItems: [
|
||||
"Bewertungen sind Pflicht für den Abschluss.",
|
||||
"Tage ohne Teamspace-Wert können zur Kenntnis genommen werden.",
|
||||
"Alle Prüfpunkte werden pro Ticket in einem Eintrag zusammengeführt.",
|
||||
"Tage ohne Teamspace-Wert können innerhalb des Tickets zur Kenntnis genommen werden.",
|
||||
"Support und ausgenommene Consulting-Projekte werden über Ticket#XXXXXX pro Tag mit der TS-API abgeglichen.",
|
||||
"Der Monat kann wieder geöffnet werden."
|
||||
],
|
||||
details: [
|
||||
"Ein Monat kann abgeschlossen werden, sobald alle Sessions dieses Monats bewertet sind.",
|
||||
"Ein Monat kann abgeschlossen werden, sobald keine offenen Bewertungen mehr vorhanden sind.",
|
||||
"Tage ohne Teamspace-Wert blockieren den Abschluss nicht automatisch. Sie sind eine Prüfliste für CRM-Abgleich und Nacharbeit.",
|
||||
"Wenn eine Session bewusst nicht in Teamspace eingetragen wird, kann der fehlende Tageswert zur Kenntnis genommen 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 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."
|
||||
]
|
||||
},
|
||||
@@ -361,12 +396,12 @@ export const helpSections: HelpSection[] = [
|
||||
],
|
||||
details: [
|
||||
"Die Statistikseite betrachtet immer einen Monat.",
|
||||
"Abgerechnet zeigt nur Sessions mit der Bewertung Abgerechnet. Offene Zeiten sind dort nicht enthalten.",
|
||||
"Alle Statistik- und Vergleichswerte nutzen nur abgerechnete Zeiten.",
|
||||
"Teamspace zeigt, wie viel Zeit für dieselben abrechenbaren Ticket-Tage im CRM eingetragen wurde.",
|
||||
"Abgerechnet zeigt nur Sessions mit der Bewertung Abgerechnet. Abrechenbare, nicht abrechenbare und offene Zeiten sind dort nicht enthalten.",
|
||||
"Teamspace-Vergleiche nutzen nur abgerechnete Zeiten.",
|
||||
"Teamspace zeigt, wie viel Zeit für dieselben abgerechneten Ticket-Tage im CRM eingetragen wurde.",
|
||||
"Eine positive Differenz bedeutet: In Teamspace steht mehr Zeit als abgerechnet wurde.",
|
||||
"Eine negative Differenz bedeutet: In Teamspace steht weniger Zeit als abgerechnet wurde.",
|
||||
"Offene Sessions werden nicht in Relationen, Graphen, Teamspace-Differenzen, Hotspots, Manuell/Fix-Werte oder Durchschnittswerte eingerechnet.",
|
||||
"Abrechenbare, nicht abrechenbare und offene Sessions werden nicht in Teamspace-Differenzen, Hotspots, Manuell/Fix-Werte oder Durchschnittswerte eingerechnet.",
|
||||
"Der Monatsverlauf zeigt abgerechnete Session-Zeit als Balken und Teamspace als Linie.",
|
||||
"Organisationen, Typen und Ticket-Hotspots helfen dabei, Auffälligkeiten im Monat schnell zu finden."
|
||||
]
|
||||
@@ -374,7 +409,7 @@ export const helpSections: HelpSection[] = [
|
||||
{
|
||||
id: "statistik-getrackt",
|
||||
title: "Statistik: Abgerechnet",
|
||||
description: "Diese Kennzahl zeigt die abrechenbare Session-Zeit im gewählten Monat.",
|
||||
description: "Diese Kennzahl zeigt die abgerechnete Session-Zeit im gewählten Monat.",
|
||||
quickItems: [
|
||||
"Summe aller Sessions mit Bewertung Abgerechnet.",
|
||||
"Zeigt zusätzlich die abgerechnete Sessionanzahl.",
|
||||
@@ -382,7 +417,7 @@ export const helpSections: HelpSection[] = [
|
||||
],
|
||||
details: [
|
||||
"Abgerechnet umfasst nur Sessions des angemeldeten Benutzers, die aktiv als Abgerechnet markiert wurden.",
|
||||
"Offene Sessions werden aus diesem Wert herausgehalten.",
|
||||
"Abrechenbare, nicht abrechenbare und offene Sessions werden aus diesem Wert herausgehalten.",
|
||||
"Auch automatisch erzeugte Sessions aus fixen Abrechnungen zählen hier mit, sobald sie als Abgerechnet markiert wurden.",
|
||||
"Es werden keine offenen Zeiten in diesen Wert eingerechnet.",
|
||||
"Dieser Wert ist die Ausgangsbasis für Teamspace-Differenzen: Teamspace minus abgerechnete Session-Zeit ergibt die Differenz."
|
||||
@@ -402,7 +437,7 @@ export const helpSections: HelpSection[] = [
|
||||
"Die Statistik summiert alle Teamspace-Werte des Monats für den angemeldeten Benutzer.",
|
||||
"Eine positive Differenz bedeutet, dass in Teamspace mehr Zeit steht als abgerechnet wurde.",
|
||||
"Eine negative Differenz bedeutet, dass in Teamspace weniger Zeit steht als abgerechnet wurde.",
|
||||
"Offene Sessions fließen nicht in diese Differenz ein.",
|
||||
"Abrechenbare, nicht abrechenbare und offene Sessions fließen nicht in diese Differenz ein.",
|
||||
"Fehlende Teamspace-Werte können im Monatsabschluss geprüft und bei Bedarf zur Kenntnis genommen werden."
|
||||
]
|
||||
},
|
||||
@@ -416,9 +451,9 @@ export const helpSections: HelpSection[] = [
|
||||
"Die Klärung erfolgt in der Ticketdetailansicht."
|
||||
],
|
||||
details: [
|
||||
"Offen umfasst alle Sessions des Monats ohne Bewertung.",
|
||||
"Diese Sessions müssen vor dem Monatsabschluss bewertet werden.",
|
||||
"Die offene Zeit zeigt, wie viele Minuten noch bewertet werden müssen.",
|
||||
"Offen umfasst alle Sessions des Monats ohne finalen Status sowie Sessions mit dem Zwischenstatus Abrechenbar.",
|
||||
"Diese Sessions müssen vor dem Monatsabschluss auf Abgerechnet oder Nicht abrechenbar gesetzt werden.",
|
||||
"Die offene Zeit zeigt, wie viele Minuten noch final bewertet werden müssen.",
|
||||
"Der Monatsabschluss listet offene Bewertungen gruppiert nach Ticket, damit du direkt in die passende Ticketdetailansicht springen kannst.",
|
||||
"Wird eine Bewertung zurückgenommen, steigt die offene Anzahl wieder."
|
||||
]
|
||||
@@ -436,7 +471,7 @@ export const helpSections: HelpSection[] = [
|
||||
"Der Monatsverlauf zeigt jeden Tag des Monats. Tage ohne Sessions bleiben leer, damit die Monatsstruktur sichtbar bleibt.",
|
||||
"Die blauen Balken stehen für abgerechnete Session-Zeit.",
|
||||
"Die fuchsiafarbene Linie steht für Teamspace-Zeit, sofern für den Tag Werte gepflegt wurden.",
|
||||
"Der Bewertungsstand weist Abgerechnet und Offen getrennt aus.",
|
||||
"Der Bewertungsstand weist Abgerechnet, Abrechenbar, Nicht abrechenbar und Offen getrennt aus.",
|
||||
"Der Teamspace-Abgleich zeigt die Gesamtdifferenz zwischen CRM-Werten und abgerechneter Session-Zeit.",
|
||||
"Manuell und Fix trennen selbst erfasste abrechenbare Sessions von automatisch erzeugten abrechenbaren Sessions aus fixen Abrechnungen."
|
||||
]
|
||||
@@ -473,7 +508,7 @@ export const helpSections: HelpSection[] = [
|
||||
"Support und Consulting werden getrennt summiert.",
|
||||
"Die Zeitwerte zeigen abgerechnete Sessions und Teamspace für den jeweiligen Typ.",
|
||||
"Die Differenz folgt derselben Logik wie überall: Teamspace minus abgerechnete Session-Zeit.",
|
||||
"Die Zeile Abr. und Offen zeigt, wie viele Sessions dieses Typs bereits abgerechnet oder noch offen sind."
|
||||
"Die Zeile Abr., Abrechenbar, Nicht abrechenbar und Offen zeigt, wie viele Sessions dieses Typs bereits abgerechnet, als abrechenbar markiert, bewusst nicht abrechenbar oder noch offen sind."
|
||||
]
|
||||
},
|
||||
{
|
||||
@@ -512,7 +547,12 @@ export const helpSections: HelpSection[] = [
|
||||
"Beim Speichern eines neuen Tokens fragt TicketTracker Zammad über /api/v1/users/me ab und speichert die zurückgelieferte Zammad User-ID.",
|
||||
"Die gespeicherte Zammad User-ID wird danach read-only im Profil angezeigt.",
|
||||
"Die Token-Prüfung läuft nur, wenn der Token im Profil geändert oder entfernt wird. Normale Profiländerungen prüfen Zammad nicht erneut.",
|
||||
"Der persönliche Zammad Token ist vom Admin-Token für den Organisations-Sync getrennt. Es kann zufällig derselbe Token sein, wird aber separat gespeichert und verwendet.",
|
||||
"Der persönliche Zammad Token ist vom Admin-Token für den Organisations-Sync getrennt. Er wird für ticketbezogene Zammad-Abgleiche, Consulting-Importe und die Zuordnung zum Zammad-Owner verwendet.",
|
||||
"Die Bewertungsabfrage beim Timerabschluss kann im Profil ein- oder ausgeschaltet werden. Sie erscheint nur, wenn der Timer noch auf --- steht.",
|
||||
"Die interne Zammad-Notiz beim Timerabschluss steuert, ob die Checkbox im Abschlussdialog standardmäßig aktiv ist. Nur dann werden Standard-Typ und Zeitaufrundung angezeigt.",
|
||||
"Der Standard-Typ belegt die Zammad-Zeitbuchungskategorie für interne Notizen vor. Es gibt keine leere Auswahl; ohne gespeicherten Profilwert wird Nicht kategorisiert verwendet.",
|
||||
"Für die an Zammad gesendete time_unit kann optional eine Aufrundung auf 5, 10, 15 oder 30 Minuten aktiviert werden. Ohne Rundung wird die konkrete Zeit mit zwei Nachkommastellen gesendet.",
|
||||
"Geparkte Zammad-Notizen listet interne Notizen, die wegen eines Zammad-Fehlers noch nicht übertragen wurden. Über Senden werden sie gesammelt nach Zammad gepusht.",
|
||||
"Im Admin-Profil wird kein persönlicher Zammad-Token angezeigt.",
|
||||
"Admins können Benutzer ebenfalls verwalten, aber jeder Benutzer kann die eigenen Daten jederzeit selbst pflegen."
|
||||
]
|
||||
@@ -531,6 +571,14 @@ export const helpSections: HelpSection[] = [
|
||||
"Admins können bestehende Benutzer bearbeiten, Passwörter setzen und Benutzer deaktivieren.",
|
||||
"Der feste Admin-Zugang kann nicht deaktiviert oder zur normalen User-Rolle herabgestuft werden.",
|
||||
"Der Data-Sync speichert Zammad-Zugangsdaten und synchronisiert Organisationen in die lokale Datenbank.",
|
||||
"Beim Organisationsabgleich werden zuerst die Zammad-Auswahloptionen für Zugeordnet und Hotline synchronisiert. In den globalen Teamspace-Projektausnahmen können diese Optionen anschließend pro Projekt ausgewählt werden.",
|
||||
"Automatische TS-Zuordnungsregeln kombinieren optional Zugeordnet und Hotline mit Support und/oder Consulting sowie einem Zielprojekt. Sind beide Attribute gesetzt, müssen beide passen; bei mehreren Treffern gewinnt die kleinste Priorität.",
|
||||
"Beim Zammad-Sync gilt die Reihenfolge: Ein konkretes TS-Projekt hat Vorrang. Genau ein Projekt wird allen Sessions automatisch zugeordnet; bei mehreren Projekten erfolgt die Auswahl beim Speichern einer Session manuell. Nur ohne konkretes Projekt wird eine passende Regel angewendet und allen Sessions des Tickets zugeordnet.",
|
||||
"Die im Sync ermittelte Regelzuordnung wird im Ticketdetail angezeigt. Greift keine Regel, bleibt das Ticket ohne automatische TS-Zuordnung.",
|
||||
"Der Zammad-Ticket-Sync aktualisiert Titel, Organisation, Typ, Zammad-Status, Zammad-Priorität sowie Teamspace-Projekte und Budgets je nach gewählter Option.",
|
||||
"Zammad-Projektwerte können mehrere Teamspace-IDs enthalten. TicketTracker erkennt zusammenhängende IDs und legt entsprechend mehrere Teamspace-Einträge an.",
|
||||
"Beim Budget-Sync bleiben geschützte TicketTracker-Budgets unverändert. Die Option Budget-Schutz im Sync ignorieren überschreibt auch geschützte Budgets mit Zammad-Werten.",
|
||||
"Optional kann der Ticket-Sync bei Zammad-Status Wartend oder Abschluss und vorhandenem Wartedatum ein hoch priorisiertes ToDo für dieses Datum erzeugen.",
|
||||
"Der Datenbankexport erzeugt eine SQL-Datei mit Benutzern, Einstellungen, Organisationen, Tickets, Sessions, Teamspace-Werten, fixen Abrechnungen und Abschlüssen.",
|
||||
"Der Restore im Adminbereich prüft zuerst, ob die Dump-Struktur zur aktuellen Datenbank passt.",
|
||||
"Der Restore-Button wird erst nach erfolgreicher Prüfung aktiv.",
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
Datensatz aus api/v1/ticket_articles/
|
||||
|
||||
{
|
||||
"id": 2,
|
||||
"ticket_id": 2,
|
||||
"type_id": 1,
|
||||
"sender_id": 1,
|
||||
"from": "",
|
||||
"to": "",
|
||||
"cc": null,
|
||||
"subject": "Test Bestellung",
|
||||
"reply_to": null,
|
||||
"message_id": "",
|
||||
"message_id_md5": "",
|
||||
"in_reply_to": null,
|
||||
"content_type": "text/html",
|
||||
"body": "",
|
||||
"internal": false,
|
||||
"preferences": {
|
||||
"send-auto-response": true,
|
||||
"is-auto-response": false
|
||||
},
|
||||
"updated_by_id": 3,
|
||||
"created_by_id": 3,
|
||||
"origin_by_id": null,
|
||||
"created_at": "2023-01-06T11:17:03.919Z",
|
||||
"updated_at": "2023-01-06T11:17:03.919Z",
|
||||
"detected_language": null,
|
||||
"ai_stored_result_ids": [],
|
||||
"attachments": [
|
||||
{
|
||||
"id": 3,
|
||||
"store_file_id": 3,
|
||||
"filename": "message.html",
|
||||
"size": "5733",
|
||||
"preferences": {
|
||||
"content-alternative": true,
|
||||
"original-format": true,
|
||||
"Mime-Type": "text/html",
|
||||
"Charset": "utf-8"
|
||||
}
|
||||
}
|
||||
],
|
||||
"body_rendering_error": false
|
||||
}
|
||||
|
||||
|
||||
Datensatz aus api/v1/ticket_articles/{id]}
|
||||
{
|
||||
"id": 2,
|
||||
"ticket_id": 2,
|
||||
"type_id": 1,
|
||||
"sender_id": 1,
|
||||
"from": "",
|
||||
"to": "",
|
||||
"cc": null,
|
||||
"subject": "",
|
||||
"reply_to": null,
|
||||
"message_id": "",
|
||||
"message_id_md5": "a9cc698b80f75fe08ca6d2dc6dec69ae",
|
||||
"in_reply_to": null,
|
||||
"content_type": "text/html",
|
||||
"body": "",
|
||||
"internal": false,
|
||||
"preferences": {
|
||||
"send-auto-response": true,
|
||||
"is-auto-response": false
|
||||
},
|
||||
"updated_by_id": 3,
|
||||
"created_by_id": 3,
|
||||
"origin_by_id": null,
|
||||
"created_at": "2023-01-06T11:17:03.919Z",
|
||||
"updated_at": "2023-01-06T11:17:03.919Z",
|
||||
"detected_language": null,
|
||||
"ai_stored_result_ids": [],
|
||||
"attachments": [
|
||||
{
|
||||
"id": 3,
|
||||
"store_file_id": 3,
|
||||
"filename": "message.html",
|
||||
"size": "5733",
|
||||
"preferences": {
|
||||
"content-alternative": true,
|
||||
"original-format": true,
|
||||
"Mime-Type": "text/html",
|
||||
"Charset": "utf-8"
|
||||
}
|
||||
}
|
||||
],
|
||||
"body_rendering_error": false,
|
||||
"created_by": "",
|
||||
"updated_by": "",
|
||||
"type": "email",
|
||||
"sender": "Agent",
|
||||
"ai_stored_results": [],
|
||||
"time_unit": null
|
||||
}
|
||||
+130
-6
@@ -1,3 +1,5 @@
|
||||
import type { BillingStatus, WorkSegment } from "./types";
|
||||
|
||||
export type TimerPhase = "running" | "paused";
|
||||
|
||||
export type TimerEntry = {
|
||||
@@ -8,17 +10,30 @@ export type TimerEntry = {
|
||||
isProject?: boolean;
|
||||
projectName?: string | null;
|
||||
ticketLookupDone?: boolean;
|
||||
activity: string;
|
||||
zammadArticleBody?: string;
|
||||
billingStatus: BillingStatus;
|
||||
startedAt: number;
|
||||
pausedTotalMs: number;
|
||||
pausedAt: number | null;
|
||||
workSegments?: WorkSegment[];
|
||||
phase: TimerPhase;
|
||||
};
|
||||
|
||||
const storageKeyPrefix = "tickettracker.timers";
|
||||
const legacyStorageKey = "tickettracker.activeTimer";
|
||||
const activityDraftStorageKeyPrefix = "tickettracker.timerActivityDrafts";
|
||||
|
||||
export const ticketPattern = /^Ticket#\d{6}$/;
|
||||
|
||||
export function createClientId(prefix = "client") {
|
||||
if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") {
|
||||
return crypto.randomUUID();
|
||||
}
|
||||
|
||||
return `${prefix}-${Date.now()}-${Math.random().toString(16).slice(2)}`;
|
||||
}
|
||||
|
||||
export function pauseEntry(timer: TimerEntry, now = Date.now()): TimerEntry {
|
||||
if (timer.phase === "paused") {
|
||||
return timer;
|
||||
@@ -44,6 +59,34 @@ export function resumeEntry(timer: TimerEntry, now = Date.now()): TimerEntry {
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeWorkSegments(value: unknown): WorkSegment[] {
|
||||
if (!Array.isArray(value)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return value
|
||||
.map((segment) => {
|
||||
if (!segment || typeof segment !== "object") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const raw = segment as Partial<WorkSegment>;
|
||||
if (typeof raw.startedAt !== "string" || Number.isNaN(new Date(raw.startedAt).getTime())) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (raw.endedAt !== null && raw.endedAt !== undefined && (typeof raw.endedAt !== "string" || Number.isNaN(new Date(raw.endedAt).getTime()))) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
startedAt: new Date(raw.startedAt).toISOString(),
|
||||
endedAt: raw.endedAt ? new Date(raw.endedAt).toISOString() : null,
|
||||
};
|
||||
})
|
||||
.filter((segment): segment is WorkSegment => Boolean(segment));
|
||||
}
|
||||
|
||||
export function normalizeTimers(timers: TimerEntry[]) {
|
||||
let runningSeen = false;
|
||||
const now = Date.now();
|
||||
@@ -51,23 +94,31 @@ export function normalizeTimers(timers: TimerEntry[]) {
|
||||
return timers
|
||||
.filter((timer) => ticketPattern.test(timer.ticketNumber))
|
||||
.map((timer) => {
|
||||
const normalizedTimer = {
|
||||
...timer,
|
||||
activity: typeof timer.activity === "string" ? timer.activity : "",
|
||||
zammadArticleBody: typeof timer.zammadArticleBody === "string" ? timer.zammadArticleBody : "",
|
||||
billingStatus: timer.billingStatus === "billed" ? "billable" : timer.billingStatus === "billable" || timer.billingStatus === "non_billable" ? timer.billingStatus : null,
|
||||
workSegments: normalizeWorkSegments(timer.workSegments),
|
||||
};
|
||||
|
||||
if (timer.phase !== "running") {
|
||||
return {
|
||||
...timer,
|
||||
...normalizedTimer,
|
||||
phase: "paused" as const,
|
||||
pausedAt: timer.pausedAt ?? now,
|
||||
pausedAt: normalizedTimer.pausedAt ?? now,
|
||||
};
|
||||
}
|
||||
|
||||
if (!runningSeen) {
|
||||
runningSeen = true;
|
||||
return {
|
||||
...timer,
|
||||
...normalizedTimer,
|
||||
pausedAt: null,
|
||||
};
|
||||
}
|
||||
|
||||
return pauseEntry(timer, now);
|
||||
return pauseEntry(normalizedTimer, now);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -75,6 +126,77 @@ export function storageKeyForUser(userId: string) {
|
||||
return `${storageKeyPrefix}.${userId}`;
|
||||
}
|
||||
|
||||
function activityDraftStorageKeyForUser(userId: string) {
|
||||
return `${activityDraftStorageKeyPrefix}.${userId}`;
|
||||
}
|
||||
|
||||
type TimerActivityDraft = {
|
||||
activity: string;
|
||||
zammadArticleBody: string;
|
||||
updatedAt: number;
|
||||
};
|
||||
|
||||
function readTimerActivityDraftMap(userId: string) {
|
||||
if (typeof window === "undefined") {
|
||||
return {} as Record<string, TimerActivityDraft>;
|
||||
}
|
||||
|
||||
const raw = sessionStorage.getItem(activityDraftStorageKeyForUser(userId));
|
||||
|
||||
if (!raw) {
|
||||
return {} as Record<string, TimerActivityDraft>;
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(raw) as Record<string, TimerActivityDraft>;
|
||||
return Object.fromEntries(
|
||||
Object.entries(parsed).filter(([, draft]) =>
|
||||
typeof draft.activity === "string" &&
|
||||
typeof draft.zammadArticleBody === "string" &&
|
||||
typeof draft.updatedAt === "number"
|
||||
)
|
||||
);
|
||||
} catch {
|
||||
return {} as Record<string, TimerActivityDraft>;
|
||||
}
|
||||
}
|
||||
|
||||
function writeTimerActivityDraftMap(userId: string, drafts: Record<string, TimerActivityDraft>) {
|
||||
if (typeof window === "undefined") {
|
||||
return;
|
||||
}
|
||||
|
||||
sessionStorage.setItem(activityDraftStorageKeyForUser(userId), JSON.stringify(drafts));
|
||||
}
|
||||
|
||||
export function writeTimerActivityDraft(userId: string, timerId: string, draft: Omit<TimerActivityDraft, "updatedAt">) {
|
||||
const drafts = readTimerActivityDraftMap(userId);
|
||||
drafts[timerId] = { ...draft, updatedAt: Date.now() };
|
||||
writeTimerActivityDraftMap(userId, drafts);
|
||||
}
|
||||
|
||||
export function removeTimerActivityDraft(userId: string, timerId: string) {
|
||||
const drafts = readTimerActivityDraftMap(userId);
|
||||
delete drafts[timerId];
|
||||
writeTimerActivityDraftMap(userId, drafts);
|
||||
}
|
||||
|
||||
export function mergeTimerActivityDrafts(userId: string, timers: TimerEntry[]) {
|
||||
const drafts = readTimerActivityDraftMap(userId);
|
||||
const timerIds = new Set(timers.map((timer) => timer.id));
|
||||
const keptDrafts = Object.fromEntries(Object.entries(drafts).filter(([timerId]) => timerIds.has(timerId)));
|
||||
|
||||
if (Object.keys(keptDrafts).length !== Object.keys(drafts).length) {
|
||||
writeTimerActivityDraftMap(userId, keptDrafts);
|
||||
}
|
||||
|
||||
return timers.map((timer) => {
|
||||
const draft = keptDrafts[timer.id];
|
||||
|
||||
return draft ? { ...timer, activity: draft.activity, zammadArticleBody: draft.zammadArticleBody } : timer;
|
||||
});
|
||||
}
|
||||
|
||||
export function readStoredTimers(userId: string): TimerEntry[] {
|
||||
if (typeof window === "undefined") {
|
||||
return [];
|
||||
@@ -98,13 +220,15 @@ export function readStoredTimers(userId: string): TimerEntry[] {
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(legacy) as Omit<TimerEntry, "id">;
|
||||
const parsed = JSON.parse(legacy) as Omit<TimerEntry, "id" | "activity" | "billingStatus"> & { activity?: string; billingStatus?: BillingStatus };
|
||||
localStorage.removeItem(legacyStorageKey);
|
||||
|
||||
return normalizeTimers([
|
||||
{
|
||||
...parsed,
|
||||
id: crypto.randomUUID(),
|
||||
id: createClientId("timer"),
|
||||
activity: typeof parsed.activity === "string" ? parsed.activity : "",
|
||||
billingStatus: parsed.billingStatus === "billed" ? "billable" : parsed.billingStatus === "billable" || parsed.billingStatus === "non_billable" ? parsed.billingStatus : null,
|
||||
},
|
||||
]);
|
||||
} catch {
|
||||
|
||||
+296
-4
@@ -1,5 +1,5 @@
|
||||
export type WorkType = "support" | "consulting";
|
||||
export type BillingStatus = "billed" | "non_billable" | null;
|
||||
export type BillingStatus = "billable" | "billed" | "non_billable" | null;
|
||||
export type PeriodType = "month" | "day";
|
||||
export type TicketDetailPeriodType = PeriodType | "all";
|
||||
export type UserRole = "admin" | "user";
|
||||
@@ -12,9 +12,26 @@ export type AuthUser = {
|
||||
active: boolean;
|
||||
zammad_user_id: string | null;
|
||||
has_zammad_api_token: boolean;
|
||||
teamspace_user_id: string | null;
|
||||
has_teamspace_credentials: boolean;
|
||||
zammad_email_session_webhook_enabled: boolean;
|
||||
session_billing_prompt_enabled: boolean;
|
||||
session_work_segments_enabled: boolean;
|
||||
teamspace_time_tracking_enabled: boolean;
|
||||
zammad_session_note_default_enabled: boolean;
|
||||
zammad_session_note_date_header_enabled: boolean;
|
||||
zammad_time_unit_rounding_enabled: boolean;
|
||||
zammad_time_unit_rounding_minutes: number;
|
||||
zammad_atoss_email_sync_enabled: boolean;
|
||||
zammad_article_type_id: number;
|
||||
project_followup_enabled: boolean;
|
||||
project_followup_weeks: number;
|
||||
project_followup_second_enabled: boolean;
|
||||
project_followup_second_weeks: number;
|
||||
project_followup_complete_on_session: boolean;
|
||||
};
|
||||
|
||||
export type DashboardWidgetType = "today" | "month" | "openRatings" | "projectBudgets" | "todos" | "teamspace" | "favorites" | "divider" | "statistic" | "notes";
|
||||
export type DashboardWidgetType = "dayControl" | "today" | "month" | "openRatings" | "projectBudgets" | "todos" | "teamspace" | "favorites" | "divider" | "statistic" | "notes";
|
||||
export type DashboardSortDirection = "asc" | "desc";
|
||||
|
||||
export type DashboardFavorite = {
|
||||
@@ -40,16 +57,67 @@ export type DashboardPreference = {
|
||||
favorites: DashboardFavorite[];
|
||||
};
|
||||
|
||||
export type DashboardDayTicket = {
|
||||
ticket_id: string;
|
||||
ticket_number: string;
|
||||
title: string | null;
|
||||
organization_name: string | null;
|
||||
work_type: WorkType | null;
|
||||
zammad_priority_id: string | null;
|
||||
zammad_state_id: string | null;
|
||||
zammad_pending_time: string | null;
|
||||
latest_started_at: string | null;
|
||||
has_project: boolean;
|
||||
own_session_count: number;
|
||||
open_session_count: number;
|
||||
open_todo_count: number;
|
||||
due_todo_count: number;
|
||||
};
|
||||
|
||||
export type AdminUser = AuthUser & {
|
||||
teamspace_initial_actuals_synced_at: string | null;
|
||||
teamspace_initial_actuals_synced_by: string | null;
|
||||
teamspace_initial_actuals_unlocked_at: string | null;
|
||||
teamspace_initial_actuals_unlocked_by: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
};
|
||||
|
||||
export type WorkSegment = {
|
||||
startedAt: string;
|
||||
endedAt: string | null;
|
||||
};
|
||||
|
||||
export type ZammadSessionArticleQueueItem = {
|
||||
id: string;
|
||||
session_id: string;
|
||||
ticket_id: string;
|
||||
ticket_number: string;
|
||||
zammad_ticket_id: string | null;
|
||||
activity: string;
|
||||
time_unit: number;
|
||||
zammad_article_type_id: number;
|
||||
attempt_count: number;
|
||||
last_error: string | null;
|
||||
last_attempt_at: string | null;
|
||||
created_at: string;
|
||||
started_at: string;
|
||||
rounded_minutes: number;
|
||||
};
|
||||
|
||||
export type Organization = {
|
||||
id: string;
|
||||
zammad_id: string;
|
||||
name: string;
|
||||
customer_owner_option_id?: string | null;
|
||||
customer_owner?: string | null;
|
||||
customer_owner_display_name?: string | null;
|
||||
hotline_contract_option_id?: string | null;
|
||||
hotline_contract?: string | null;
|
||||
hotline_contract_display_name?: string | null;
|
||||
synced_at: string;
|
||||
created_at?: string;
|
||||
updated_at?: string;
|
||||
};
|
||||
|
||||
export type RecurringBillingSlot = {
|
||||
@@ -70,6 +138,9 @@ export type RecurringBilling = {
|
||||
organization_name: string;
|
||||
activity: string;
|
||||
work_type: WorkType;
|
||||
teamspace_project: string | null;
|
||||
hour_budget_minutes: number | null;
|
||||
manual_consumption_minutes: number;
|
||||
recurrence_type: "weekly" | "every_n_weeks";
|
||||
interval_value: number;
|
||||
valid_from: string;
|
||||
@@ -89,11 +160,22 @@ export type TicketMeta = {
|
||||
organization_name: string | null;
|
||||
customer_name: string | null;
|
||||
work_type: WorkType | null;
|
||||
atoss_support?: boolean;
|
||||
can_manage_atoss_support?: boolean;
|
||||
teamspace_assignment_rule_id?: string | null;
|
||||
teamspace_assignment_rule_name?: string | null;
|
||||
teamspace_assignment_project?: string | null;
|
||||
teamspace_assignment_synced_at?: string | null;
|
||||
zammad_ticket_id?: string | null;
|
||||
zammad_group_id?: string | null;
|
||||
zammad_priority_id?: string | null;
|
||||
zammad_state_id?: string | null;
|
||||
zammad_owner_id?: string | null;
|
||||
zammad_owner_user_id?: string | null;
|
||||
zammad_owner_display_name?: string | null;
|
||||
zammad_owner_username?: string | null;
|
||||
is_foreign_zammad_owner?: boolean;
|
||||
can_edit_ticket_data?: boolean;
|
||||
project_id?: string | null;
|
||||
is_project?: boolean;
|
||||
project_name?: string | null;
|
||||
@@ -136,12 +218,21 @@ export type ProjectSummary = {
|
||||
organization_name: string | null;
|
||||
customer_name: string | null;
|
||||
work_type: WorkType | null;
|
||||
zammad_priority_id?: string | null;
|
||||
zammad_state_id?: string | null;
|
||||
zammad_ticket_id?: string | null;
|
||||
zammad_owner_id?: string | null;
|
||||
ticket_zammad_owner_id?: string | null;
|
||||
zammad_pending_time?: string | null;
|
||||
zammad_merged_from_ticket_id?: string | null;
|
||||
zammad_merged_from_ticket_number?: string | null;
|
||||
teamspace_project: string | null;
|
||||
description: string;
|
||||
goals: string;
|
||||
starts_on: string | null;
|
||||
ends_on: string | null;
|
||||
hour_budget_minutes: number | null;
|
||||
teamspace_budget_excluded?: boolean;
|
||||
manual_consumption_minutes: number;
|
||||
teamspace_consumption_minutes?: number;
|
||||
consumption_minutes?: number;
|
||||
@@ -159,20 +250,50 @@ export type ProjectSummary = {
|
||||
teamspace_entry_count?: number;
|
||||
teamspace_entries?: ProjectTeamspaceEntry[];
|
||||
unassigned_teamspace_session_count?: number;
|
||||
unassigned_teamspace_subentry_count?: number;
|
||||
close_review_required?: boolean;
|
||||
hidden_for_user?: boolean;
|
||||
};
|
||||
|
||||
export type ProjectTeamspaceEntry = {
|
||||
id: string;
|
||||
project_id?: string;
|
||||
teamspace_project: string | null;
|
||||
teamspace_api_project_id?: string | null;
|
||||
teamspace_api_project_url?: string | null;
|
||||
teamspace_description?: string | null;
|
||||
subentries?: ProjectTeamspaceSubentry[];
|
||||
is_default: boolean;
|
||||
hour_budget_minutes: number | null;
|
||||
teamspace_budget_excluded?: boolean;
|
||||
budget_sync_disabled?: boolean;
|
||||
manual_consumption_minutes: number;
|
||||
teamspace_consumption_minutes?: number;
|
||||
consumption_minutes?: number;
|
||||
display_order?: number;
|
||||
};
|
||||
|
||||
export type ProjectTeamspaceSubentry = {
|
||||
id: string;
|
||||
project_teamspace_entry_id?: string;
|
||||
teamspace_api_project_id: string;
|
||||
teamspace_api_project_url: string | null;
|
||||
teamspace_project_id: string | null;
|
||||
billing_type: string | null;
|
||||
name: string | null;
|
||||
alias: string;
|
||||
description: string;
|
||||
progress: number | null;
|
||||
planned_minutes: number | null;
|
||||
sum_minutes: number | null;
|
||||
planned_minutes_billable: number | null;
|
||||
sum_minutes_billable: number | null;
|
||||
planned_income: number | string | null;
|
||||
sum_income: number | string | null;
|
||||
consumption_minutes?: number;
|
||||
display_order: number;
|
||||
};
|
||||
|
||||
export type TodoPriority = "low" | "medium" | "high" | "urgent";
|
||||
export type TodoStatus = "open" | "done";
|
||||
|
||||
@@ -206,10 +327,13 @@ export type TicketSummary = {
|
||||
project_id: string | null;
|
||||
session_count: number;
|
||||
total_minutes: number;
|
||||
crm_billed_minutes: number;
|
||||
billable_minutes: number;
|
||||
non_billable_minutes: number;
|
||||
open_minutes: number;
|
||||
open_count: number;
|
||||
ticket_open_count: number;
|
||||
billable_count: number;
|
||||
billed_count: number;
|
||||
non_billable_count: number;
|
||||
recurring_session_count: number;
|
||||
@@ -217,6 +341,44 @@ export type TicketSummary = {
|
||||
requires_closure: boolean;
|
||||
closed: boolean;
|
||||
closed_at: string | null;
|
||||
teamspace_month_billable_minutes?: number;
|
||||
teamspace_month_delta_minutes?: number;
|
||||
teamspace_month_actuals?: TeamspaceMonthActual[];
|
||||
};
|
||||
|
||||
export type TeamspaceMonthActual = {
|
||||
entry_id: string;
|
||||
teamspace_project: string | null;
|
||||
teamspace_description: string | null;
|
||||
teamspace_api_project_id: string | null;
|
||||
teamspace_minutes: number;
|
||||
teamspace_billable_minutes: number;
|
||||
teamspace_time_entry_count: number;
|
||||
delta_minutes: number;
|
||||
positions: TeamspaceMonthActualPosition[];
|
||||
};
|
||||
|
||||
export type TeamspaceMonthActualPosition = {
|
||||
apiProjectId: string;
|
||||
projectId: string | null;
|
||||
mainProjectId?: string | null;
|
||||
label: string;
|
||||
description?: string | null;
|
||||
ticketNumber?: string | null;
|
||||
customerName?: string | null;
|
||||
isSubentry: boolean;
|
||||
billingType?: string | null;
|
||||
minutes: number;
|
||||
billableMinutes: number;
|
||||
entryCount: number;
|
||||
days?: TeamspaceMonthActualPositionDay[];
|
||||
};
|
||||
|
||||
export type TeamspaceMonthActualPositionDay = {
|
||||
day: string;
|
||||
minutes: number;
|
||||
billableMinutes: number;
|
||||
entryCount: number;
|
||||
};
|
||||
|
||||
export type OpenSession = {
|
||||
@@ -252,6 +414,7 @@ export type PeriodOverview = {
|
||||
sessions: number;
|
||||
minutes: number;
|
||||
crmBilledMinutes: number;
|
||||
billableMinutes: number;
|
||||
nonBillableMinutes: number;
|
||||
openMinutes: number;
|
||||
openSessions: number;
|
||||
@@ -263,9 +426,31 @@ export type PeriodOverview = {
|
||||
|
||||
export type TicketDayBilling = {
|
||||
day: string;
|
||||
project_teamspace_entry_id?: string | null;
|
||||
billed_minutes: number;
|
||||
};
|
||||
|
||||
export type TicketTeamspaceDay = {
|
||||
day: string;
|
||||
tracked_minutes: number;
|
||||
crm_billed_minutes: number;
|
||||
teamspace_entry_count: number;
|
||||
delta_minutes: number;
|
||||
session_count: number;
|
||||
billed_session_count: number;
|
||||
billable_session_count: number;
|
||||
non_billable_session_count: number;
|
||||
unrated_session_count: number;
|
||||
has_tt_value: boolean;
|
||||
missing_tt_value: boolean;
|
||||
tt_value_without_billed_session: boolean;
|
||||
acknowledged: boolean;
|
||||
acknowledged_at: string | null;
|
||||
comparison_enabled: boolean;
|
||||
teamspace_excluded: boolean;
|
||||
work_type: WorkType | null;
|
||||
};
|
||||
|
||||
export type TicketAvailableMonth = {
|
||||
month: string;
|
||||
session_count: number;
|
||||
@@ -277,6 +462,7 @@ export type SessionEntry = {
|
||||
organization_name: string | null;
|
||||
customer_name: string;
|
||||
activity: string;
|
||||
zammad_article_body?: string | null;
|
||||
work_type: WorkType;
|
||||
project_teamspace_entry_id?: string | null;
|
||||
project_teamspace_project?: string | null;
|
||||
@@ -284,12 +470,17 @@ export type SessionEntry = {
|
||||
ended_at: string;
|
||||
duration_seconds: number;
|
||||
rounded_minutes: number;
|
||||
work_segments: WorkSegment[];
|
||||
billing_status: BillingStatus;
|
||||
billing_updated_at: string | null;
|
||||
created_at: string;
|
||||
recurring_billing_id: string | null;
|
||||
recurring_billing_slot_id: string | null;
|
||||
recurring_occurrence_date: string | null;
|
||||
zammad_source_article_id?: string | null;
|
||||
zammad_article_status?: "pending" | "sent" | null;
|
||||
zammad_article_id?: string | null;
|
||||
zammad_article_type_id?: number;
|
||||
};
|
||||
|
||||
export type TicketPeriod = {
|
||||
@@ -299,7 +490,7 @@ export type TicketPeriod = {
|
||||
closedAt: string | null;
|
||||
filters?: {
|
||||
month: string;
|
||||
status: "all" | "open" | "billed" | "non_billable";
|
||||
status: "all" | "open" | "billable" | "billed" | "non_billable";
|
||||
source: "all" | "manual" | "recurring";
|
||||
};
|
||||
availableMonths?: TicketAvailableMonth[];
|
||||
@@ -311,6 +502,22 @@ export type TicketPeriod = {
|
||||
organization_name: string | null;
|
||||
customer_name: string | null;
|
||||
work_type: WorkType | null;
|
||||
atoss_support?: boolean;
|
||||
can_manage_atoss_support?: boolean;
|
||||
teamspace_assignment_rule_id?: string | null;
|
||||
teamspace_assignment_rule_name?: string | null;
|
||||
teamspace_assignment_project?: string | null;
|
||||
teamspace_assignment_synced_at?: string | null;
|
||||
zammad_ticket_id?: string | null;
|
||||
zammad_group_id?: string | null;
|
||||
zammad_priority_id?: string | null;
|
||||
zammad_state_id?: string | null;
|
||||
zammad_owner_id?: string | null;
|
||||
zammad_owner_user_id?: string | null;
|
||||
zammad_owner_display_name?: string | null;
|
||||
zammad_owner_username?: string | null;
|
||||
is_foreign_zammad_owner?: boolean;
|
||||
can_edit_ticket_data?: boolean;
|
||||
closed_at: string | null;
|
||||
requires_closure: boolean;
|
||||
recurring_session_count: number;
|
||||
@@ -319,8 +526,12 @@ export type TicketPeriod = {
|
||||
};
|
||||
sessions: SessionEntry[];
|
||||
dayBillings: TicketDayBilling[];
|
||||
ticketTeamspaceDays?: TicketTeamspaceDay[];
|
||||
project?: ProjectSummary | null;
|
||||
todos?: TicketTodo[];
|
||||
teamspaceMonthActuals?: TeamspaceMonthActual[];
|
||||
teamspaceMonthBillableMinutes?: number;
|
||||
teamspaceMonthDeltaMinutes?: number;
|
||||
canClose: boolean;
|
||||
openCount: number;
|
||||
ticketOpenCount: number;
|
||||
@@ -334,9 +545,11 @@ export type StatisticsDailyBucket = {
|
||||
sessions: number;
|
||||
total_minutes: number;
|
||||
billed_minutes: number;
|
||||
billable_minutes: number;
|
||||
non_billable_minutes: number;
|
||||
open_minutes: number;
|
||||
billed_sessions: number;
|
||||
billable_sessions: number;
|
||||
non_billable_sessions: number;
|
||||
open_sessions: number;
|
||||
crm_billed_minutes: number;
|
||||
@@ -350,13 +563,18 @@ export type StatisticsGroup = {
|
||||
sessions: number;
|
||||
total_minutes: number;
|
||||
billed_minutes: number;
|
||||
billable_minutes: number;
|
||||
non_billable_minutes: number;
|
||||
open_minutes: number;
|
||||
billed_sessions: number;
|
||||
billable_sessions: number;
|
||||
non_billable_sessions: number;
|
||||
open_sessions: number;
|
||||
crm_billed_minutes: number;
|
||||
crm_delta_minutes: number;
|
||||
teamspace_month_billable_minutes?: number;
|
||||
teamspace_month_delta_minutes?: number;
|
||||
teamspace_month_actuals?: TeamspaceMonthActual[];
|
||||
};
|
||||
|
||||
export type StatisticsTicket = {
|
||||
@@ -368,13 +586,18 @@ export type StatisticsTicket = {
|
||||
sessions: number;
|
||||
total_minutes: number;
|
||||
billed_minutes: number;
|
||||
billable_minutes: number;
|
||||
non_billable_minutes: number;
|
||||
open_minutes: number;
|
||||
billed_sessions: number;
|
||||
billable_sessions: number;
|
||||
non_billable_sessions: number;
|
||||
open_sessions: number;
|
||||
crm_billed_minutes: number;
|
||||
crm_delta_minutes: number;
|
||||
teamspace_month_billable_minutes?: number;
|
||||
teamspace_month_delta_minutes?: number;
|
||||
teamspace_month_actuals?: TeamspaceMonthActual[];
|
||||
};
|
||||
|
||||
export type StatisticsOpenSession = {
|
||||
@@ -399,8 +622,70 @@ export type StatisticsCrmDay = {
|
||||
acknowledged_at?: string | null;
|
||||
};
|
||||
|
||||
export type StatisticsTeamspaceDay = {
|
||||
ticket_id: string | null;
|
||||
ticket_number: string | null;
|
||||
organization_name: string;
|
||||
day: string;
|
||||
tracked_minutes: number;
|
||||
crm_billed_minutes: number;
|
||||
teamspace_entry_count: number;
|
||||
delta_minutes: number;
|
||||
session_count: number;
|
||||
project_teamspace_project: string | null;
|
||||
api_project_id?: string | null;
|
||||
teamspace_project_id?: string | null;
|
||||
teamspace_excluded?: boolean;
|
||||
work_type?: WorkType | null;
|
||||
tt_value_without_billed_session?: boolean;
|
||||
billed_session_count?: number;
|
||||
billable_session_count?: number;
|
||||
non_billable_session_count?: number;
|
||||
unrated_session_count?: number;
|
||||
};
|
||||
|
||||
export type StatisticsTeamspaceMonthMismatch = {
|
||||
ticket_id: string;
|
||||
ticket_number: string;
|
||||
organization_name: string;
|
||||
crm_billed_minutes: number;
|
||||
teamspace_month_billable_minutes: number;
|
||||
teamspace_month_delta_minutes: number;
|
||||
teamspace_month_actuals: TeamspaceMonthActual[];
|
||||
};
|
||||
|
||||
export type StatisticsProjectFollowup = {
|
||||
project_id: string;
|
||||
ticket_id: string;
|
||||
ticket_number: string;
|
||||
title: string | null;
|
||||
organization_name: string | null;
|
||||
teamspace_project: string | null;
|
||||
hour_budget_minutes: number | null;
|
||||
teamspace_budget_excluded?: boolean;
|
||||
consumption_minutes: number;
|
||||
remaining_minutes: number | null;
|
||||
latest_tt_update: string;
|
||||
last_session_at: string | null;
|
||||
project_updated_at: string;
|
||||
open_followup_todo_id: string | null;
|
||||
followup_state: "open" | "done" | "dismissed" | null;
|
||||
open_todos: number;
|
||||
overdue_todos: number;
|
||||
};
|
||||
|
||||
export type StatisticsProjectFollowupOverview = {
|
||||
enabled: boolean;
|
||||
weeks: number;
|
||||
secondEnabled: boolean;
|
||||
secondWeeks: number;
|
||||
createdTodos: number;
|
||||
updatedTodos: number;
|
||||
projects: StatisticsProjectFollowup[];
|
||||
};
|
||||
|
||||
export type StatisticsOverview = {
|
||||
periodType: "month";
|
||||
periodType: "month" | "week";
|
||||
period: string;
|
||||
closed: boolean;
|
||||
closedAt: string | null;
|
||||
@@ -409,9 +694,11 @@ export type StatisticsOverview = {
|
||||
sessions: number;
|
||||
minutes: number;
|
||||
billedMinutes: number;
|
||||
billableMinutes: number;
|
||||
nonBillableMinutes: number;
|
||||
openMinutes: number;
|
||||
billedSessions: number;
|
||||
billableSessions: number;
|
||||
nonBillableSessions: number;
|
||||
openSessions: number;
|
||||
recurringMinutes: number;
|
||||
@@ -422,6 +709,8 @@ export type StatisticsOverview = {
|
||||
activeDays: number;
|
||||
crmBilledMinutes: number;
|
||||
crmDeltaMinutes: number;
|
||||
teamspaceApiBillableMinutes?: number;
|
||||
teamspaceApiDeltaMinutes?: number;
|
||||
};
|
||||
dailySeries: StatisticsDailyBucket[];
|
||||
organizations: StatisticsGroup[];
|
||||
@@ -432,5 +721,8 @@ export type StatisticsOverview = {
|
||||
missingCrmDays: StatisticsCrmDay[];
|
||||
acknowledgedMissingCrmDays: StatisticsCrmDay[];
|
||||
crmMismatches: StatisticsCrmDay[];
|
||||
teamspaceMonthMismatches: StatisticsTeamspaceMonthMismatch[];
|
||||
ticketTeamspaceMismatches: StatisticsTeamspaceDay[];
|
||||
};
|
||||
teamspaceDays: StatisticsTeamspaceDay[];
|
||||
};
|
||||
@@ -0,0 +1,330 @@
|
||||
import { FormEvent, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Building2, DatabaseZap, RefreshCcw, Save, Search } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { NativeSelect, NativeSelectOption } from "@/components/ui/native-select";
|
||||
import { Pagination, PaginationContent, PaginationItem, PaginationNext, PaginationPrevious } from "@/components/ui/pagination";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||
import { HelpLink } from "@/components/HelpLink";
|
||||
import { getAdminOrganizations, getZammadSettings, saveZammadSettings, syncZammadOrganizations } from "../api";
|
||||
import { formatDateTime } from "../format";
|
||||
import type { Organization } from "../types";
|
||||
|
||||
type PageSize = 10 | 20 | 50 | 100 | "all";
|
||||
|
||||
const pageSizeOptions: PageSize[] = [10, 20, 50, 100, "all"];
|
||||
|
||||
function pageSizeFromValue(value: string): PageSize {
|
||||
if (value === "all") {
|
||||
return "all";
|
||||
}
|
||||
|
||||
const parsed = Number(value);
|
||||
return pageSizeOptions.includes(parsed as PageSize) ? parsed as PageSize : 20;
|
||||
}
|
||||
|
||||
function emptyText(value: string | null | undefined) {
|
||||
return value?.trim() || "leer";
|
||||
}
|
||||
|
||||
export function AdminOrganizationsPage() {
|
||||
const [organizations, setOrganizations] = useState<Organization[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [page, setPage] = useState(1);
|
||||
const [pageSize, setPageSize] = useState<PageSize>(20);
|
||||
const [search, setSearch] = useState("");
|
||||
const [missingAssignment, setMissingAssignment] = useState(false);
|
||||
const [zammadBaseUrl, setZammadBaseUrl] = useState("");
|
||||
const [zammadApiKey, setZammadApiKey] = useState("");
|
||||
const [hasZammadApiKey, setHasZammadApiKey] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [savingZammadSettings, setSavingZammadSettings] = useState(false);
|
||||
const [syncingOrganizations, setSyncingOrganizations] = useState(false);
|
||||
const loadRequestId = useRef(0);
|
||||
|
||||
const pageCount = pageSize === "all" ? 1 : Math.max(1, Math.ceil(total / pageSize));
|
||||
const safePage = Math.min(page, pageCount);
|
||||
const rangeStart = total === 0 ? 0 : pageSize === "all" ? 1 : (safePage - 1) * pageSize + 1;
|
||||
const rangeEnd = pageSize === "all" ? total : Math.min(safePage * pageSize, total);
|
||||
const activeFilterCount = useMemo(
|
||||
() => [search.trim(), missingAssignment].filter(Boolean).length,
|
||||
[missingAssignment, search]
|
||||
);
|
||||
|
||||
async function loadSettings() {
|
||||
const result = await getZammadSettings();
|
||||
setZammadBaseUrl(result.settings.baseUrl);
|
||||
setHasZammadApiKey(result.settings.hasApiKey);
|
||||
}
|
||||
|
||||
async function loadOrganizations() {
|
||||
const requestId = loadRequestId.current + 1;
|
||||
loadRequestId.current = requestId;
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
const result = await getAdminOrganizations({
|
||||
search,
|
||||
page: safePage,
|
||||
pageSize,
|
||||
missingAssignment
|
||||
});
|
||||
|
||||
if (requestId === loadRequestId.current) {
|
||||
setOrganizations(result.organizations);
|
||||
setTotal(result.total);
|
||||
}
|
||||
} catch (error) {
|
||||
if (requestId === loadRequestId.current) {
|
||||
toast.error("Organisationen konnten nicht geladen werden", {
|
||||
description: error instanceof Error ? error.message : "Unbekannter Fehler"
|
||||
});
|
||||
}
|
||||
} finally {
|
||||
if (requestId === loadRequestId.current) {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
void loadSettings();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
setPage(1);
|
||||
}, [search, pageSize, missingAssignment]);
|
||||
|
||||
useEffect(() => {
|
||||
void loadOrganizations();
|
||||
}, [safePage, pageSize, search, missingAssignment]);
|
||||
|
||||
async function saveZammadAccess() {
|
||||
setSavingZammadSettings(true);
|
||||
|
||||
try {
|
||||
const result = await saveZammadSettings({
|
||||
baseUrl: zammadBaseUrl,
|
||||
apiKey: zammadApiKey.trim() || undefined
|
||||
});
|
||||
setZammadBaseUrl(result.settings.baseUrl);
|
||||
setHasZammadApiKey(result.settings.hasApiKey);
|
||||
setZammadApiKey("");
|
||||
toast.success("Zammad-Zugang gespeichert", {
|
||||
description: result.settings.hasApiKey ? "URL und API-Key sind hinterlegt." : "URL wurde gespeichert."
|
||||
});
|
||||
} catch (error) {
|
||||
toast.error("Zammad-Zugang konnte nicht gespeichert werden", {
|
||||
description: error instanceof Error ? error.message : "Unbekannter Fehler"
|
||||
});
|
||||
} finally {
|
||||
setSavingZammadSettings(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function syncOrganizations(event: FormEvent) {
|
||||
event.preventDefault();
|
||||
setSyncingOrganizations(true);
|
||||
|
||||
try {
|
||||
const result = await syncZammadOrganizations({
|
||||
baseUrl: zammadBaseUrl,
|
||||
apiKey: zammadApiKey.trim() || undefined
|
||||
});
|
||||
toast.success("Organisationen synchronisiert", {
|
||||
description: `${result.attributeOptions} Attributoptionen und ${result.synced} Organisationen gespeichert, ${result.removed} Organisationen entfernt, ${result.unlinkedTickets + result.unlinkedSessions} Verknüpfung(en) gelöst.`
|
||||
});
|
||||
setHasZammadApiKey(true);
|
||||
setZammadApiKey("");
|
||||
await loadOrganizations();
|
||||
} catch (error) {
|
||||
toast.error("Zammad-Sync fehlgeschlagen", {
|
||||
description: error instanceof Error ? error.message : "Unbekannter Fehler"
|
||||
});
|
||||
} finally {
|
||||
setSyncingOrganizations(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-col gap-3 lg:flex-row lg:items-end lg:justify-between">
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold tracking-normal sm:text-2xl">Organisationsabgleich</h2>
|
||||
<p className="text-sm text-muted-foreground">Zammad-Organisationen synchronisieren, prüfen und nach fehlenden Zuordnungen filtern.</p>
|
||||
</div>
|
||||
<Badge variant={activeFilterCount > 0 ? "secondary" : "outline"}>{activeFilterCount} Filter aktiv</Badge>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="gap-3 p-4 sm:flex-row sm:items-start sm:justify-between sm:space-y-0">
|
||||
<div>
|
||||
<CardTitle>Zammad-Sync</CardTitle>
|
||||
<CardDescription>Zugang speichern und Organisationen aus Zammad in die lokale Datenbank übernehmen.</CardDescription>
|
||||
</div>
|
||||
<HelpLink anchor="organisationen" label="Hilfe zum Organisationsabgleich" />
|
||||
</CardHeader>
|
||||
<CardContent className="px-4 pb-4">
|
||||
<form className="grid gap-3 lg:grid-cols-[minmax(220px,1fr)_minmax(220px,1fr)_auto_auto] lg:items-end" onSubmit={syncOrganizations}>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium" htmlFor="zammad-base-url">Zammad URL</label>
|
||||
<Input
|
||||
id="zammad-base-url"
|
||||
placeholder="https://zammad.example.de"
|
||||
value={zammadBaseUrl}
|
||||
onChange={(event) => setZammadBaseUrl(event.currentTarget.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium" htmlFor="zammad-api-key">API-Key</label>
|
||||
<Input
|
||||
id="zammad-api-key"
|
||||
type="password"
|
||||
placeholder={hasZammadApiKey ? "••••••••••••••••" : ""}
|
||||
value={zammadApiKey}
|
||||
onChange={(event) => setZammadApiKey(event.currentTarget.value)}
|
||||
/>
|
||||
</div>
|
||||
<Button type="button" variant="secondary" disabled={savingZammadSettings} onClick={() => void saveZammadAccess()}>
|
||||
<Save className="size-4" />
|
||||
{savingZammadSettings ? "Speichert..." : "Zugang speichern"}
|
||||
</Button>
|
||||
<Button type="submit" disabled={syncingOrganizations}>
|
||||
<DatabaseZap className="size-4" />
|
||||
{syncingOrganizations ? "Synchronisiert..." : "Synchronisieren"}
|
||||
</Button>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="gap-3 p-4 xl:flex-row xl:items-end xl:justify-between xl:space-y-0">
|
||||
<div>
|
||||
<CardTitle>Organisationen</CardTitle>
|
||||
<CardDescription>{loading ? "Lädt..." : `${rangeStart}-${rangeEnd} von ${total} Organisation(en)`}</CardDescription>
|
||||
</div>
|
||||
<div className="grid gap-2 md:grid-cols-[minmax(220px,1fr)_auto_auto] md:items-end">
|
||||
<div className="space-y-1">
|
||||
<label className="text-xs font-medium text-muted-foreground" htmlFor="organization-search">Suche</label>
|
||||
<div className="relative">
|
||||
<Search className="pointer-events-none absolute left-2.5 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
id="organization-search"
|
||||
className="pl-8"
|
||||
placeholder="Name, ID, Zuordnung, Hotline..."
|
||||
value={search}
|
||||
onChange={(event) => setSearch(event.currentTarget.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<label className="flex h-9 items-center gap-2 rounded-md border bg-background px-3 text-sm">
|
||||
<Checkbox checked={missingAssignment} onCheckedChange={(checked) => setMissingAssignment(checked === true)} />
|
||||
Zuordnung fehlt
|
||||
</label>
|
||||
<div className="space-y-1">
|
||||
<label className="text-xs font-medium text-muted-foreground" htmlFor="organization-page-size">Anzeigen</label>
|
||||
<NativeSelect
|
||||
id="organization-page-size"
|
||||
className="w-28"
|
||||
value={String(pageSize)}
|
||||
onChange={(event) => setPageSize(pageSizeFromValue(event.currentTarget.value))}
|
||||
>
|
||||
{pageSizeOptions.map((option) => (
|
||||
<NativeSelectOption key={String(option)} value={String(option)}>
|
||||
{option === "all" ? "Alle" : option}
|
||||
</NativeSelectOption>
|
||||
))}
|
||||
</NativeSelect>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3 px-4 pb-4">
|
||||
<div className="overflow-x-auto rounded-md border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="min-w-20">Zammad-ID</TableHead>
|
||||
<TableHead className="min-w-[260px]">Organisation</TableHead>
|
||||
<TableHead className="min-w-[180px]">Zugeordnet</TableHead>
|
||||
<TableHead className="min-w-[160px]">Hotline</TableHead>
|
||||
<TableHead className="min-w-[150px]">Synchronisiert</TableHead>
|
||||
<TableHead className="min-w-[150px]">Geändert</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{organizations.map((organization) => (
|
||||
<TableRow key={organization.zammad_id}>
|
||||
<TableCell className="font-mono text-xs">{organization.zammad_id}</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<Building2 className="size-4 shrink-0 text-muted-foreground" />
|
||||
<span className="truncate font-medium">{organization.name}</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className={organization.customer_owner ? "" : "text-muted-foreground"}>{emptyText(organization.customer_owner)}</TableCell>
|
||||
<TableCell className={organization.hotline_contract ? "" : "text-muted-foreground"}>{emptyText(organization.hotline_contract)}</TableCell>
|
||||
<TableCell className="text-xs text-muted-foreground">{formatDateTime(organization.synced_at)}</TableCell>
|
||||
<TableCell className="text-xs text-muted-foreground">{organization.updated_at ? formatDateTime(organization.updated_at) : "-"}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
{organizations.length === 0 ? (
|
||||
<div className="grid place-items-center gap-2 px-4 py-10 text-center text-sm text-muted-foreground">
|
||||
<Building2 className="size-8" />
|
||||
Keine Organisationen für die aktuellen Filter.
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{pageSize !== "all" ? (
|
||||
<Pagination className="justify-end">
|
||||
<PaginationContent>
|
||||
<PaginationItem>
|
||||
<PaginationPrevious
|
||||
href="#"
|
||||
text="Zurück"
|
||||
aria-disabled={safePage <= 1}
|
||||
className={safePage <= 1 ? "pointer-events-none opacity-50" : undefined}
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
setPage((current) => Math.max(1, current - 1));
|
||||
}}
|
||||
/>
|
||||
</PaginationItem>
|
||||
<PaginationItem>
|
||||
<span className="flex h-9 items-center px-3 text-sm text-muted-foreground">
|
||||
Seite {safePage} von {pageCount}
|
||||
</span>
|
||||
</PaginationItem>
|
||||
<PaginationItem>
|
||||
<PaginationNext
|
||||
href="#"
|
||||
text="Weiter"
|
||||
aria-disabled={safePage >= pageCount}
|
||||
className={safePage >= pageCount ? "pointer-events-none opacity-50" : undefined}
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
setPage((current) => Math.min(pageCount, current + 1));
|
||||
}}
|
||||
/>
|
||||
</PaginationItem>
|
||||
</PaginationContent>
|
||||
</Pagination>
|
||||
) : null}
|
||||
|
||||
<Button type="button" variant="secondary" size="sm" disabled={loading} onClick={() => void loadOrganizations()}>
|
||||
<RefreshCcw className="size-4" />
|
||||
Aktualisieren
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { ChangeEvent, FormEvent, useEffect, useState } from "react";
|
||||
import { CheckCircle2, DatabaseBackup, DatabaseZap, Save, Shield, Upload, UserPlus } from "lucide-react";
|
||||
import { CheckCircle2, DatabaseBackup, Plus, RotateCcw, Save, Shield, TimerReset, Trash2, Unplug, Upload, UserPlus } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||
@@ -14,12 +14,21 @@ import {
|
||||
createAdminUser,
|
||||
downloadDatabaseExport,
|
||||
getAdminUsers,
|
||||
getZammadSettings,
|
||||
getOrganizationAttributeOptions,
|
||||
getTeamspaceAssignmentRules,
|
||||
getTeamspaceSettings,
|
||||
getTeamspaceProjectExclusions,
|
||||
restoreDatabaseBackup,
|
||||
saveZammadSettings,
|
||||
syncZammadOrganizations,
|
||||
saveTeamspaceSettings,
|
||||
saveTeamspaceAssignmentRules,
|
||||
saveTeamspaceProjectExclusions,
|
||||
syncAdminUserTeamspaceInitialActuals,
|
||||
unlockAdminUserTeamspaceInitialActuals,
|
||||
updateAdminUser,
|
||||
validateDatabaseBackup,
|
||||
type TeamspaceProjectExclusion,
|
||||
type TeamspaceAssignmentRule,
|
||||
type OrganizationAttributeOption,
|
||||
type RestoreValidationResult
|
||||
} from "../api";
|
||||
import type { AdminUser, AuthUser } from "../types";
|
||||
@@ -51,11 +60,15 @@ export function AdminUsersPage({ currentUser }: { currentUser: AuthUser }) {
|
||||
const [users, setUsers] = useState<AdminUser[]>([]);
|
||||
const [forms, setForms] = useState<Record<string, UserFormState>>({});
|
||||
const [newUser, setNewUser] = useState<UserFormState>(emptyForm);
|
||||
const [zammadBaseUrl, setZammadBaseUrl] = useState("");
|
||||
const [zammadApiKey, setZammadApiKey] = useState("");
|
||||
const [hasZammadApiKey, setHasZammadApiKey] = useState(false);
|
||||
const [syncingOrganizations, setSyncingOrganizations] = useState(false);
|
||||
const [savingZammadSettings, setSavingZammadSettings] = useState(false);
|
||||
const [teamspaceBaseUrl, setTeamspaceBaseUrl] = useState("");
|
||||
const [teamspaceCredentialsUserId, setTeamspaceCredentialsUserId] = useState("");
|
||||
const [teamspaceProjectExclusions, setTeamspaceProjectExclusions] = useState<TeamspaceProjectExclusion[]>([]);
|
||||
const [organizationAttributeOptions, setOrganizationAttributeOptions] = useState<OrganizationAttributeOption[]>([]);
|
||||
const [teamspaceAssignmentRules, setTeamspaceAssignmentRules] = useState<TeamspaceAssignmentRule[]>([]);
|
||||
const [newTeamspaceProjectExclusion, setNewTeamspaceProjectExclusion] = useState("");
|
||||
const [savingTeamspaceSettings, setSavingTeamspaceSettings] = useState(false);
|
||||
const [savingTeamspaceProjectExclusions, setSavingTeamspaceProjectExclusions] = useState(false);
|
||||
const [savingTeamspaceAssignmentRules, setSavingTeamspaceAssignmentRules] = useState(false);
|
||||
const [exportingDatabase, setExportingDatabase] = useState(false);
|
||||
const [restoreSql, setRestoreSql] = useState("");
|
||||
const [restoreValidation, setRestoreValidation] = useState<RestoreValidationResult | null>(null);
|
||||
@@ -63,17 +76,29 @@ export function AdminUsersPage({ currentUser }: { currentUser: AuthUser }) {
|
||||
const [restoringDatabase, setRestoringDatabase] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [savingId, setSavingId] = useState<string | null>(null);
|
||||
const [syncingInitialActualsId, setSyncingInitialActualsId] = useState<string | null>(null);
|
||||
const [unlockingInitialActualsId, setUnlockingInitialActualsId] = useState<string | null>(null);
|
||||
const [initialActualsFromMonthByUser, setInitialActualsFromMonthByUser] = useState<Record<string, string>>({});
|
||||
const [creating, setCreating] = useState(false);
|
||||
|
||||
async function load(options: { preserveEdits?: boolean } = {}) {
|
||||
setLoading(true);
|
||||
try {
|
||||
const [usersResult, zammadSettingsResult] = await Promise.all([getAdminUsers(), getZammadSettings()]);
|
||||
const [usersResult, teamspaceSettingsResult, teamspaceProjectExclusionsResult, organizationAttributeOptionsResult, teamspaceAssignmentRulesResult] = await Promise.all([
|
||||
getAdminUsers(),
|
||||
getTeamspaceSettings(),
|
||||
getTeamspaceProjectExclusions(),
|
||||
getOrganizationAttributeOptions(),
|
||||
getTeamspaceAssignmentRules()
|
||||
]);
|
||||
setUsers(usersResult.users);
|
||||
|
||||
if (!options.preserveEdits) {
|
||||
setZammadBaseUrl(zammadSettingsResult.settings.baseUrl);
|
||||
setHasZammadApiKey(zammadSettingsResult.settings.hasApiKey);
|
||||
setTeamspaceBaseUrl(teamspaceSettingsResult.settings.baseUrl);
|
||||
setTeamspaceCredentialsUserId(teamspaceSettingsResult.settings.credentialsUserId ?? "");
|
||||
setTeamspaceProjectExclusions(teamspaceProjectExclusionsResult.exclusions);
|
||||
setOrganizationAttributeOptions(organizationAttributeOptionsResult.options);
|
||||
setTeamspaceAssignmentRules(teamspaceAssignmentRulesResult.rules);
|
||||
}
|
||||
|
||||
setForms((current) =>
|
||||
@@ -166,49 +191,211 @@ export function AdminUsersPage({ currentUser }: { currentUser: AuthUser }) {
|
||||
}
|
||||
}
|
||||
|
||||
async function syncOrganizations(event: FormEvent) {
|
||||
event.preventDefault();
|
||||
setSyncingOrganizations(true);
|
||||
function initialActualsLocked(user: AdminUser) {
|
||||
return Boolean(
|
||||
user.teamspace_initial_actuals_synced_at &&
|
||||
(!user.teamspace_initial_actuals_unlocked_at || user.teamspace_initial_actuals_unlocked_at <= user.teamspace_initial_actuals_synced_at)
|
||||
);
|
||||
}
|
||||
|
||||
function initialActualsStatus(user: AdminUser) {
|
||||
if (initialActualsLocked(user)) {
|
||||
return `ausgeführt ${new Date(user.teamspace_initial_actuals_synced_at as string).toLocaleDateString("de-DE")}`;
|
||||
}
|
||||
|
||||
if (user.teamspace_initial_actuals_unlocked_at) {
|
||||
return "entsperrt";
|
||||
}
|
||||
|
||||
return "bereit";
|
||||
}
|
||||
|
||||
async function syncInitialActuals(user: AdminUser) {
|
||||
if (!window.confirm(`Teamspace-Istzeiten für ${user.display_name} initial als manuelle TS-Zeiten übernehmen? Vorhandene Tageswerte bleiben unverändert.`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
setSyncingInitialActualsId(user.id);
|
||||
try {
|
||||
const result = await syncZammadOrganizations({
|
||||
baseUrl: zammadBaseUrl,
|
||||
apiKey: zammadApiKey.trim() || undefined
|
||||
const result = await syncAdminUserTeamspaceInitialActuals(user.id, {
|
||||
fromMonth: initialActualsFromMonthByUser[user.id] || undefined
|
||||
});
|
||||
toast.success("Organisationen synchronisiert", {
|
||||
description: `${result.synced} gespeichert, ${result.removed} entfernt, ${result.unlinkedTickets + result.unlinkedSessions} Verknüpfung(en) gelöst.`
|
||||
toast.success("TS-Istzeiten übernommen", {
|
||||
description: `${result.result.updatedManualEntries} TS-Eintrag(e), ${Math.round(result.result.importedManualMinutes / 60 * 100) / 100} Std., Zeitraum ${result.result.fromMonth} bis ${result.result.toMonth}.`
|
||||
});
|
||||
setHasZammadApiKey(true);
|
||||
setZammadApiKey("");
|
||||
await load();
|
||||
} catch (error) {
|
||||
toast.error("Zammad-Sync fehlgeschlagen", {
|
||||
toast.error("TS-Istzeiten konnten nicht übernommen werden", {
|
||||
description: error instanceof Error ? error.message : "Unbekannter Fehler"
|
||||
});
|
||||
} finally {
|
||||
setSyncingOrganizations(false);
|
||||
setSyncingInitialActualsId(null);
|
||||
}
|
||||
}
|
||||
|
||||
async function saveZammadAccess() {
|
||||
setSavingZammadSettings(true);
|
||||
async function unlockInitialActuals(user: AdminUser) {
|
||||
if (!window.confirm(`Initialen Teamspace-Istzeiten-Sync für ${user.display_name} wieder freigeben?`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
setUnlockingInitialActualsId(user.id);
|
||||
try {
|
||||
const result = await saveZammadSettings({
|
||||
baseUrl: zammadBaseUrl,
|
||||
apiKey: zammadApiKey.trim() || undefined
|
||||
});
|
||||
setZammadBaseUrl(result.settings.baseUrl);
|
||||
setHasZammadApiKey(result.settings.hasApiKey);
|
||||
setZammadApiKey("");
|
||||
toast.success("Zammad-Zugang gespeichert", {
|
||||
description: result.settings.hasApiKey ? "URL und API-Key sind hinterlegt." : "URL wurde gespeichert."
|
||||
});
|
||||
await unlockAdminUserTeamspaceInitialActuals(user.id);
|
||||
toast.success("Initialsync entsperrt");
|
||||
await load({ preserveEdits: true });
|
||||
} catch (error) {
|
||||
toast.error("Zammad-Zugang konnte nicht gespeichert werden", {
|
||||
toast.error("Initialsync konnte nicht entsperrt werden", {
|
||||
description: error instanceof Error ? error.message : "Unbekannter Fehler"
|
||||
});
|
||||
} finally {
|
||||
setSavingZammadSettings(false);
|
||||
setUnlockingInitialActualsId(null);
|
||||
}
|
||||
}
|
||||
|
||||
async function saveTeamspaceAccess() {
|
||||
setSavingTeamspaceSettings(true);
|
||||
|
||||
try {
|
||||
const result = await saveTeamspaceSettings({
|
||||
baseUrl: teamspaceBaseUrl,
|
||||
credentialsUserId: teamspaceCredentialsUserId || null
|
||||
});
|
||||
setTeamspaceBaseUrl(result.settings.baseUrl);
|
||||
setTeamspaceCredentialsUserId(result.settings.credentialsUserId ?? "");
|
||||
toast.success("Teamspace-Zugang gespeichert", {
|
||||
description: "URL ist hinterlegt."
|
||||
});
|
||||
} catch (error) {
|
||||
toast.error("Teamspace-Zugang konnte nicht gespeichert werden", {
|
||||
description: error instanceof Error ? error.message : "Unbekannter Fehler"
|
||||
});
|
||||
} finally {
|
||||
setSavingTeamspaceSettings(false);
|
||||
}
|
||||
}
|
||||
|
||||
function addTeamspaceExclusion() {
|
||||
const project = newTeamspaceProjectExclusion.trim();
|
||||
|
||||
if (!project) {
|
||||
return;
|
||||
}
|
||||
|
||||
const exists = teamspaceProjectExclusions.some((entry) => entry.teamspace_project.toLocaleLowerCase("de") === project.toLocaleLowerCase("de"));
|
||||
|
||||
if (exists) {
|
||||
toast.info("Teamspace-Projekt ist bereits in der Liste");
|
||||
setNewTeamspaceProjectExclusion("");
|
||||
return;
|
||||
}
|
||||
|
||||
setTeamspaceProjectExclusions((current) => [
|
||||
...current,
|
||||
{
|
||||
teamspace_project: project,
|
||||
teamspace_name: null,
|
||||
customer_name: null,
|
||||
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: ""
|
||||
}
|
||||
]);
|
||||
setNewTeamspaceProjectExclusion("");
|
||||
}
|
||||
|
||||
function removeTeamspaceExclusion(project: string) {
|
||||
setTeamspaceProjectExclusions((current) => current.filter((entry) => entry.teamspace_project !== project));
|
||||
}
|
||||
|
||||
function updateTeamspaceExclusion(project: string, patch: Partial<TeamspaceProjectExclusion>) {
|
||||
setTeamspaceProjectExclusions((current) =>
|
||||
current.map((entry) => entry.teamspace_project === project ? { ...entry, ...patch } : entry)
|
||||
);
|
||||
}
|
||||
|
||||
async function saveTeamspaceExclusions() {
|
||||
setSavingTeamspaceProjectExclusions(true);
|
||||
|
||||
try {
|
||||
const projects = teamspaceProjectExclusions
|
||||
.map((entry) => ({
|
||||
teamspace_project: entry.teamspace_project.trim(),
|
||||
atoss_support_default: entry.atoss_support_default,
|
||||
atoss_support_valid_from: entry.atoss_support_valid_from || null,
|
||||
atoss_support_valid_to: entry.atoss_support_valid_to || null,
|
||||
customer_owner_option_id: entry.customer_owner_option_id || null,
|
||||
hotline_contract_option_id: entry.hotline_contract_option_id || null
|
||||
}))
|
||||
.filter((entry) => entry.teamspace_project);
|
||||
const result = await saveTeamspaceProjectExclusions(projects);
|
||||
setTeamspaceProjectExclusions(result.exclusions);
|
||||
toast.success("Teamspace-Ausnahmen gespeichert", {
|
||||
description: `${result.exclusions.length} Projektnummer(n) geprüft und gespeichert.`
|
||||
});
|
||||
} catch (error) {
|
||||
toast.error("Teamspace-Ausnahmen konnten nicht gespeichert werden", {
|
||||
description: error instanceof Error ? error.message : "Unbekannter Fehler"
|
||||
});
|
||||
} finally {
|
||||
setSavingTeamspaceProjectExclusions(false);
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -317,6 +504,16 @@ export function AdminUsersPage({ currentUser }: { currentUser: AuthUser }) {
|
||||
}
|
||||
|
||||
const regularUserCount = users.filter((user) => user.role === "user").length;
|
||||
const customerOwnerOptions = organizationAttributeOptions.filter((option) => option.attribute_name === "customer_owner");
|
||||
const hotlineContractOptions = organizationAttributeOptions.filter((option) => option.attribute_name === "hotline_contract");
|
||||
|
||||
function organizationAttributeOptionLabel(option: OrganizationAttributeOption) {
|
||||
const label = option.display_name === option.option_key
|
||||
? option.display_name
|
||||
: `${option.display_name} (${option.option_key})`;
|
||||
|
||||
return option.active ? label : `${label} · nicht mehr in Zammad`;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
@@ -325,51 +522,268 @@ export function AdminUsersPage({ currentUser }: { currentUser: AuthUser }) {
|
||||
<p className="text-sm text-muted-foreground">Systemverwaltung für Benutzer, Zammad-Organisationen und restore-fähige Backups.</p>
|
||||
</div>
|
||||
|
||||
<div className="grid min-w-0 gap-4 2xl:grid-cols-[minmax(0,1.2fr)_minmax(340px,0.8fr)]">
|
||||
<div className="grid min-w-0 gap-4">
|
||||
<Card className="min-w-0">
|
||||
<CardHeader className="p-4">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<DatabaseZap className="size-5 text-muted-foreground" />
|
||||
<CardTitle>Data-Sync</CardTitle>
|
||||
<Unplug className="size-5 text-muted-foreground" />
|
||||
<CardTitle>Teamspace API</CardTitle>
|
||||
</div>
|
||||
<HelpLink anchor="organisationen" label="Hilfe zum Zammad-Sync" />
|
||||
<HelpLink anchor="admin" label="Hilfe zu Teamspace API" />
|
||||
</div>
|
||||
<CardDescription>Zammad-Zugang speichern und Organisationen in die lokale TicketTracker-Datenbank übernehmen.</CardDescription>
|
||||
<CardDescription>Teamspace-Zugang für Adminabfragen und globale Projekt-Ausnahmen verwalten.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="px-4 pb-4">
|
||||
<form className="grid gap-3 xl:grid-cols-2" onSubmit={syncOrganizations}>
|
||||
<div className="space-y-4">
|
||||
<div className="grid gap-3 xl:grid-cols-[minmax(220px,1fr)_minmax(220px,1fr)_auto] xl:items-end">
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium" htmlFor="zammad-base-url">Zammad URL</label>
|
||||
<label className="text-sm font-medium" htmlFor="teamspace-base-url">Teamspace URL</label>
|
||||
<Input
|
||||
id="zammad-base-url"
|
||||
placeholder="https://zammad.example.de"
|
||||
value={zammadBaseUrl}
|
||||
onChange={(event) => setZammadBaseUrl(event.currentTarget.value)}
|
||||
required
|
||||
id="teamspace-base-url"
|
||||
placeholder="https://app1.teamspace.de"
|
||||
value={teamspaceBaseUrl}
|
||||
onChange={(event) => setTeamspaceBaseUrl(event.currentTarget.value)}
|
||||
/>
|
||||
</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)}
|
||||
/>
|
||||
<label className="text-sm font-medium" htmlFor="teamspace-credentials-user">Credentials für Adminabfragen</label>
|
||||
<select
|
||||
id="teamspace-credentials-user"
|
||||
className="h-10 w-full rounded-md border border-input bg-background px-3 text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
value={teamspaceCredentialsUserId}
|
||||
onChange={(event) => setTeamspaceCredentialsUserId(event.currentTarget.value)}
|
||||
>
|
||||
<option value="">Bitte wählen</option>
|
||||
{users.filter((user) => user.active && user.has_teamspace_credentials).map((user) => (
|
||||
<option key={user.id} value={user.id}>
|
||||
{user.display_name} ({user.username})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<p className="text-xs text-muted-foreground">Dieser Benutzer wird für Admin-Checks gegen Teamspace verwendet.</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2 xl:col-span-2">
|
||||
<Button type="button" variant="secondary" disabled={savingZammadSettings} onClick={() => void saveZammadAccess()}>
|
||||
<Button type="button" variant="secondary" disabled={savingTeamspaceSettings} onClick={() => void saveTeamspaceAccess()}>
|
||||
<Save className="size-4" />
|
||||
{savingZammadSettings ? "Speichert..." : "Zugang speichern"}
|
||||
</Button>
|
||||
<Button type="submit" disabled={syncingOrganizations}>
|
||||
<DatabaseZap className="size-4" />
|
||||
{syncingOrganizations ? "Synchronisiert..." : "Organisationen synchronisieren"}
|
||||
{savingTeamspaceSettings ? "Speichert..." : "Einstellungen speichern"}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
<div className="mt-4 space-y-3 rounded-md border bg-muted/20 p-3">
|
||||
<div>
|
||||
<p className="text-sm font-medium">TS-Projekte ohne Differenz-/Budgetprüfung</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Sync und Gesamtverbrauch laufen weiter, aber TS/TT-Differenzen, Budgetbalken und Budgetverbrauch werden für diese TS-Projekte ausgeblendet. Zugeordnet und Hotline stammen aus den Zammad-Organisationsattributen.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2 sm:flex-row">
|
||||
<Input
|
||||
id="teamspace-project-exclusion-new"
|
||||
placeholder="BP-2026-A-023"
|
||||
value={newTeamspaceProjectExclusion}
|
||||
onChange={(event) => setNewTeamspaceProjectExclusion(event.currentTarget.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter") {
|
||||
event.preventDefault();
|
||||
addTeamspaceExclusion();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Button type="button" variant="secondary" onClick={addTeamspaceExclusion}>
|
||||
<Plus className="size-4" />
|
||||
Hinzufügen
|
||||
</Button>
|
||||
</div>
|
||||
<div className="overflow-x-auto rounded-md border bg-background">
|
||||
{teamspaceProjectExclusions.length === 0 ? (
|
||||
<p className="px-3 py-4 text-sm text-muted-foreground">Noch keine Teamspace-Projekte ausgenommen.</p>
|
||||
) : (
|
||||
<div className="min-w-[1120px] divide-y">
|
||||
{teamspaceProjectExclusions.map((entry) => (
|
||||
<div key={entry.teamspace_project} className="grid grid-cols-[180px_minmax(280px,1fr)_minmax(240px,0.9fr)_210px_210px_2.5rem] items-center gap-4 px-4 py-3">
|
||||
<div className="font-mono text-sm font-semibold">{entry.teamspace_project}</div>
|
||||
<div className="min-w-0">
|
||||
<p className="truncate text-sm font-medium">{entry.teamspace_name ?? "Name wird beim Speichern aus Teamspace geladen"}</p>
|
||||
{entry.teamspace_api_project_ids?.length ? <p className="text-xs text-muted-foreground">{entry.teamspace_api_project_ids.length} API-ID(s)</p> : null}
|
||||
</div>
|
||||
<div className="min-w-0 text-sm text-muted-foreground">
|
||||
<p className="truncate">{entry.customer_name ?? "Kunde wird beim Speichern aus Teamspace geladen"}</p>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<label className="text-xs text-muted-foreground" htmlFor={`customer-owner-${entry.teamspace_project}`}>Zugeordnet</label>
|
||||
<select
|
||||
id={`customer-owner-${entry.teamspace_project}`}
|
||||
className="h-9 w-full rounded-md border border-input bg-background px-3 text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
value={entry.customer_owner_option_id ?? ""}
|
||||
onChange={(event) => updateTeamspaceExclusion(entry.teamspace_project, { customer_owner_option_id: event.currentTarget.value || null })}
|
||||
>
|
||||
<option value="">Nicht zugeordnet</option>
|
||||
{customerOwnerOptions.map((option) => (
|
||||
<option key={option.id} value={option.id} disabled={!option.active && option.id !== entry.customer_owner_option_id}>
|
||||
{organizationAttributeOptionLabel(option)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<label className="text-xs text-muted-foreground" htmlFor={`hotline-contract-${entry.teamspace_project}`}>Hotline</label>
|
||||
<select
|
||||
id={`hotline-contract-${entry.teamspace_project}`}
|
||||
className="h-9 w-full rounded-md border border-input bg-background px-3 text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
value={entry.hotline_contract_option_id ?? ""}
|
||||
onChange={(event) => updateTeamspaceExclusion(entry.teamspace_project, { hotline_contract_option_id: event.currentTarget.value || null })}
|
||||
>
|
||||
<option value="">Nicht zugeordnet</option>
|
||||
{hotlineContractOptions.map((option) => (
|
||||
<option key={option.id} value={option.id} disabled={!option.active && option.id !== entry.hotline_contract_option_id}>
|
||||
{organizationAttributeOptionLabel(option)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<Button type="button" variant="ghost" size="icon" className="justify-self-end" onClick={() => removeTeamspaceExclusion(entry.teamspace_project)}>
|
||||
<Trash2 className="size-4" />
|
||||
<span className="sr-only">Teamspace-Projekt entfernen</span>
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<Button type="button" variant="secondary" disabled={savingTeamspaceProjectExclusions} onClick={() => void saveTeamspaceExclusions()}>
|
||||
<Save className="size-4" />
|
||||
{savingTeamspaceProjectExclusions ? "Speichert..." : "Ausnahmen speichern"}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="space-y-3 rounded-md border bg-muted/20 p-3">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium">Automatische TS-Zuordnung</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Angegebene Bedingungen innerhalb einer Regel gelten gemeinsam. Beim Auswählen des Zielprojekts werden dessen hinterlegte Zugeordnet-/Hotline-Werte übernommen und können danach angepasst werden. Die erste passende aktive Regel gewinnt. Regeln werden beim Zammad-Sync nur angewendet, wenn das Ticket keine konkrete TS-Projektnummer besitzt; anschließend werden alle Sessions dem Zielprojekt zugeordnet.
|
||||
</p>
|
||||
</div>
|
||||
<Button type="button" variant="secondary" onClick={addTeamspaceAssignmentRule}>
|
||||
<Plus className="size-4" />
|
||||
Regel hinzufügen
|
||||
</Button>
|
||||
</div>
|
||||
<div className="overflow-x-auto rounded-md border bg-background">
|
||||
{teamspaceAssignmentRules.length === 0 ? (
|
||||
<p className="px-3 py-4 text-sm text-muted-foreground">Noch keine automatische Zuordnungsregel angelegt.</p>
|
||||
) : (
|
||||
<div className="min-w-[1580px] divide-y">
|
||||
{teamspaceAssignmentRules.map((rule, index) => {
|
||||
const targetExists = teamspaceProjectExclusions.some((entry) => entry.teamspace_project === rule.teamspace_project);
|
||||
|
||||
return (
|
||||
<div key={rule.id ?? `new-${index}`} className="grid grid-cols-[230px_100px_220px_220px_300px_240px_110px_2.5rem] items-end gap-3 px-4 py-3">
|
||||
<div className="space-y-1">
|
||||
<label className="text-xs text-muted-foreground" htmlFor={`assignment-rule-name-${rule.id ?? index}`}>Regelname</label>
|
||||
<Input
|
||||
id={`assignment-rule-name-${rule.id ?? index}`}
|
||||
className="h-9"
|
||||
value={rule.name}
|
||||
onChange={(event) => updateTeamspaceAssignmentRule(index, { name: event.currentTarget.value })}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<label className="text-xs text-muted-foreground" htmlFor={`assignment-rule-priority-${rule.id ?? index}`}>Priorität</label>
|
||||
<Input
|
||||
id={`assignment-rule-priority-${rule.id ?? index}`}
|
||||
className="h-9"
|
||||
type="number"
|
||||
min={0}
|
||||
max={100000}
|
||||
value={rule.priority}
|
||||
onChange={(event) => updateTeamspaceAssignmentRule(index, { priority: Number(event.currentTarget.value) || 0 })}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<label className="text-xs text-muted-foreground" htmlFor={`assignment-rule-owner-${rule.id ?? index}`}>Zugeordnet ist</label>
|
||||
<select
|
||||
id={`assignment-rule-owner-${rule.id ?? index}`}
|
||||
className="h-9 w-full rounded-md border border-input bg-background px-3 text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
value={rule.customer_owner_option_id ?? ""}
|
||||
onChange={(event) => updateTeamspaceAssignmentRule(index, { customer_owner_option_id: event.currentTarget.value || null })}
|
||||
>
|
||||
<option value="">Bedingung nicht verwenden</option>
|
||||
{customerOwnerOptions.map((option) => (
|
||||
<option key={option.id} value={option.id} disabled={!option.active && option.id !== rule.customer_owner_option_id}>
|
||||
{organizationAttributeOptionLabel(option)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<label className="text-xs text-muted-foreground" htmlFor={`assignment-rule-hotline-${rule.id ?? index}`}>Hotline ist</label>
|
||||
<select
|
||||
id={`assignment-rule-hotline-${rule.id ?? index}`}
|
||||
className="h-9 w-full rounded-md border border-input bg-background px-3 text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
value={rule.hotline_contract_option_id ?? ""}
|
||||
onChange={(event) => updateTeamspaceAssignmentRule(index, { hotline_contract_option_id: event.currentTarget.value || null })}
|
||||
>
|
||||
<option value="">Bedingung nicht verwenden</option>
|
||||
{hotlineContractOptions.map((option) => (
|
||||
<option key={option.id} value={option.id} disabled={!option.active && option.id !== rule.hotline_contract_option_id}>
|
||||
{organizationAttributeOptionLabel(option)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="flex h-9 items-center gap-4 rounded-md border bg-background px-3">
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<Switch
|
||||
checked={rule.support_enabled}
|
||||
onCheckedChange={(checked) => updateTeamspaceAssignmentRule(index, { support_enabled: checked })}
|
||||
/>
|
||||
Support
|
||||
</label>
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<Switch
|
||||
checked={rule.consulting_enabled}
|
||||
onCheckedChange={(checked) => updateTeamspaceAssignmentRule(index, { consulting_enabled: checked })}
|
||||
/>
|
||||
Consulting
|
||||
</label>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<label className="text-xs text-muted-foreground" htmlFor={`assignment-rule-target-${rule.id ?? index}`}>Ziel-TS-Projekt</label>
|
||||
<select
|
||||
id={`assignment-rule-target-${rule.id ?? index}`}
|
||||
className="h-9 w-full rounded-md border border-input bg-background px-3 font-mono text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
value={rule.teamspace_project}
|
||||
onChange={(event) => updateTeamspaceAssignmentRuleTarget(index, event.currentTarget.value)}
|
||||
>
|
||||
<option value="">Bitte wählen</option>
|
||||
{!targetExists && rule.teamspace_project ? <option value={rule.teamspace_project}>{rule.teamspace_project} · nicht mehr vorhanden</option> : null}
|
||||
{teamspaceProjectExclusions.map((entry) => (
|
||||
<option key={entry.teamspace_project} value={entry.teamspace_project}>{entry.teamspace_project}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<label className="flex h-9 items-center gap-2 text-sm">
|
||||
<Switch checked={rule.active} onCheckedChange={(checked) => updateTeamspaceAssignmentRule(index, { active: checked })} />
|
||||
Aktiv
|
||||
</label>
|
||||
<Button type="button" variant="ghost" size="icon" onClick={() => removeTeamspaceAssignmentRule(index)}>
|
||||
<Trash2 className="size-4" />
|
||||
<span className="sr-only">Zuordnungsregel entfernen</span>
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<Button type="button" variant="secondary" disabled={savingTeamspaceAssignmentRules} onClick={() => void saveAssignmentRules()}>
|
||||
<Save className="size-4" />
|
||||
{savingTeamspaceAssignmentRules ? "Speichert..." : "Zuordnungsregeln speichern"}
|
||||
</Button>
|
||||
<p className="text-xs text-muted-foreground">Zielprojekte müssen zuvor in der Ausnahmen-Liste gespeichert worden sein.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -488,6 +902,7 @@ export function AdminUsersPage({ currentUser }: { currentUser: AuthUser }) {
|
||||
<TableHead>Rolle</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead>Neues Passwort</TableHead>
|
||||
<TableHead>TS-Istzeiten</TableHead>
|
||||
<TableHead />
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
@@ -522,6 +937,45 @@ export function AdminUsersPage({ currentUser }: { currentUser: AuthUser }) {
|
||||
onChange={(event) => updateForm(user.id, { password: event.currentTarget.value })}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{isFixedAdmin ? (
|
||||
<span className="text-sm text-muted-foreground">-</span>
|
||||
) : (
|
||||
<div className="grid min-w-[280px] gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant={initialActualsLocked(user) ? "secondary" : "outline"}>{initialActualsStatus(user)}</Badge>
|
||||
{!user.has_teamspace_credentials || !user.teamspace_user_id ? <Badge variant="warning">TS-Zugang fehlt</Badge> : null}
|
||||
</div>
|
||||
<div className="grid grid-cols-[minmax(120px,1fr)_auto_auto] gap-2">
|
||||
<Input
|
||||
className="h-8"
|
||||
type="month"
|
||||
value={initialActualsFromMonthByUser[user.id] ?? ""}
|
||||
onChange={(event) => setInitialActualsFromMonthByUser((current) => ({ ...current, [user.id]: event.currentTarget.value }))}
|
||||
disabled={initialActualsLocked(user) || syncingInitialActualsId === user.id}
|
||||
/>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
disabled={initialActualsLocked(user) || !user.has_teamspace_credentials || !user.teamspace_user_id || syncingInitialActualsId === user.id}
|
||||
onClick={() => void syncInitialActuals(user)}
|
||||
>
|
||||
<TimerReset className="size-4" />
|
||||
{syncingInitialActualsId === user.id ? "Läuft..." : "Sync"}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
disabled={!initialActualsLocked(user) || unlockingInitialActualsId === user.id}
|
||||
onClick={() => void unlockInitialActuals(user)}
|
||||
>
|
||||
<RotateCcw className="size-4" />
|
||||
Entsperren
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<Button size="sm" onClick={() => void saveUser(user)} disabled={savingId === user.id}>
|
||||
<Save className="size-4" />
|
||||
@@ -557,6 +1011,41 @@ export function AdminUsersPage({ currentUser }: { currentUser: AuthUser }) {
|
||||
value={form.password}
|
||||
onChange={(event) => updateForm(user.id, { password: event.currentTarget.value })}
|
||||
/>
|
||||
{!isFixedAdmin ? (
|
||||
<div className="space-y-2 rounded-md border bg-muted/20 p-2">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="text-sm font-medium">TS-Istzeiten</span>
|
||||
<Badge variant={initialActualsLocked(user) ? "secondary" : "outline"}>{initialActualsStatus(user)}</Badge>
|
||||
{!user.has_teamspace_credentials || !user.teamspace_user_id ? <Badge variant="warning">TS-Zugang fehlt</Badge> : null}
|
||||
</div>
|
||||
<div className="grid gap-2 sm:grid-cols-[minmax(140px,1fr)_auto_auto]">
|
||||
<Input
|
||||
type="month"
|
||||
value={initialActualsFromMonthByUser[user.id] ?? ""}
|
||||
onChange={(event) => setInitialActualsFromMonthByUser((current) => ({ ...current, [user.id]: event.currentTarget.value }))}
|
||||
disabled={initialActualsLocked(user) || syncingInitialActualsId === user.id}
|
||||
/>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
disabled={initialActualsLocked(user) || !user.has_teamspace_credentials || !user.teamspace_user_id || syncingInitialActualsId === user.id}
|
||||
onClick={() => void syncInitialActuals(user)}
|
||||
>
|
||||
<TimerReset className="size-4" />
|
||||
{syncingInitialActualsId === user.id ? "Läuft..." : "Sync"}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
disabled={!initialActualsLocked(user) || unlockingInitialActualsId === user.id}
|
||||
onClick={() => void unlockInitialActuals(user)}
|
||||
>
|
||||
<RotateCcw className="size-4" />
|
||||
Entsperren
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<Switch checked={form.active} disabled={isFixedAdmin} onCheckedChange={(active) => updateForm(user.id, { active })} />
|
||||
|
||||
+261
-125
@@ -1,4 +1,4 @@
|
||||
import { ChevronLeft, ChevronRight, Clock3, Download, ExternalLink, RotateCcw, SlidersHorizontal, Ticket, UserCheck, Users } from "lucide-react";
|
||||
import { AlertCircle, ArrowDown, ArrowUp, ChevronLeft, ChevronRight, Clock3, Download, ExternalLink, RotateCcw, SlidersHorizontal, Ticket, UserCheck, Users } from "lucide-react";
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
import { Fragment, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
@@ -11,28 +11,28 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||
import { CopyTicketButton } from "@/components/CopyTicketButton";
|
||||
import { HelpLink } from "@/components/HelpLink";
|
||||
import { MultiFilterDropdown, type MultiFilterOption } from "@/components/MultiFilterDropdown";
|
||||
import { downloadMonthExport, getPeriodOverview } from "../api";
|
||||
import { currentDay, currentMonth, formatDateTime, formatMinutes } from "../format";
|
||||
import type { PeriodOverview, PeriodType, TicketSummary } from "../types";
|
||||
import type { AuthUser, PeriodOverview, PeriodType, TicketSummary } from "../types";
|
||||
|
||||
type AnalysisPageProps = {
|
||||
currentUser: AuthUser;
|
||||
onNavigate: (to: string) => void;
|
||||
};
|
||||
|
||||
type TicketGroupBy = "none" | "workType" | "organization" | "status";
|
||||
type TicketSortBy = "ticket" | "workType" | "organization" | "status";
|
||||
type TicketSortDirection = "asc" | "desc";
|
||||
type TicketStatusFilter = "all" | "open" | "evaluated";
|
||||
type TicketWorkTypeFilter = "all" | "support" | "consulting";
|
||||
type TicketFilterOption = MultiFilterOption;
|
||||
|
||||
type AnalysisTicketViewSettings = {
|
||||
groupBy: TicketGroupBy;
|
||||
sortBy: TicketSortBy;
|
||||
sortDirection: TicketSortDirection;
|
||||
query: string;
|
||||
workType: TicketWorkTypeFilter;
|
||||
organization: string;
|
||||
status: TicketStatusFilter;
|
||||
filterValues: string[];
|
||||
organizationValues: string[];
|
||||
};
|
||||
|
||||
type TicketSection = {
|
||||
@@ -51,11 +51,19 @@ const defaultTicketViewSettings: AnalysisTicketViewSettings = {
|
||||
sortBy: "ticket",
|
||||
sortDirection: "asc",
|
||||
query: "",
|
||||
workType: "all",
|
||||
organization: "all",
|
||||
status: "all"
|
||||
filterValues: [],
|
||||
organizationValues: []
|
||||
};
|
||||
|
||||
const ticketStaticFilterOptions: TicketFilterOption[] = [
|
||||
{ value: "workType:support", label: "Support" },
|
||||
{ value: "workType:consulting", label: "Consulting" },
|
||||
{ value: "status:open", label: "Offen" },
|
||||
{ value: "status:evaluated", label: "Bewertet" }
|
||||
];
|
||||
|
||||
const ticketStaticFilterValues = new Set(ticketStaticFilterOptions.map((option) => option.value));
|
||||
|
||||
const groupByOptions: Array<{ value: TicketGroupBy; label: string }> = [
|
||||
{ value: "none", label: "Keine" },
|
||||
{ value: "workType", label: "Typ" },
|
||||
@@ -70,6 +78,56 @@ const sortByOptions: Array<{ value: TicketSortBy; label: string }> = [
|
||||
{ value: "status", label: "Status" }
|
||||
];
|
||||
|
||||
function isKnownTicketFilterValue(value: unknown): value is string {
|
||||
if (typeof value !== "string") {
|
||||
return false;
|
||||
}
|
||||
|
||||
return ticketStaticFilterValues.has(value);
|
||||
}
|
||||
|
||||
function normalizedTicketFilterValues(values: unknown) {
|
||||
if (!Array.isArray(values)) {
|
||||
return defaultTicketViewSettings.filterValues;
|
||||
}
|
||||
|
||||
return Array.from(new Set(values.filter(isKnownTicketFilterValue)));
|
||||
}
|
||||
|
||||
function normalizedTicketOrganizationValues(values: unknown) {
|
||||
if (!Array.isArray(values)) {
|
||||
return defaultTicketViewSettings.organizationValues;
|
||||
}
|
||||
|
||||
return Array.from(new Set(values.filter((value): value is string => typeof value === "string" && value.trim() !== "")));
|
||||
}
|
||||
|
||||
function legacyTicketFilterValues(parsed: Partial<AnalysisTicketViewSettings> & Record<string, unknown>) {
|
||||
const filters: string[] = [];
|
||||
|
||||
if (parsed.workType === "support" || parsed.workType === "consulting") {
|
||||
filters.push(`workType:${parsed.workType}`);
|
||||
}
|
||||
|
||||
if (parsed.status === "open" || parsed.status === "evaluated") {
|
||||
filters.push(`status:${parsed.status}`);
|
||||
}
|
||||
|
||||
return filters;
|
||||
}
|
||||
|
||||
function legacyTicketOrganizationValues(parsed: Partial<AnalysisTicketViewSettings> & Record<string, unknown>) {
|
||||
if (Array.isArray(parsed.organizationValues)) {
|
||||
return normalizedTicketOrganizationValues(parsed.organizationValues);
|
||||
}
|
||||
|
||||
if (typeof parsed.organization === "string" && parsed.organization !== "all") {
|
||||
return [parsed.organization];
|
||||
}
|
||||
|
||||
return defaultTicketViewSettings.organizationValues;
|
||||
}
|
||||
|
||||
function readStoredTicketViewSettings(): AnalysisTicketViewSettings {
|
||||
if (typeof window === "undefined") {
|
||||
return defaultTicketViewSettings;
|
||||
@@ -83,15 +141,17 @@ function readStoredTicketViewSettings(): AnalysisTicketViewSettings {
|
||||
}
|
||||
|
||||
const parsed = JSON.parse(raw) as Partial<AnalysisTicketViewSettings>;
|
||||
const filterValues = Array.isArray(parsed.filterValues)
|
||||
? normalizedTicketFilterValues(parsed.filterValues)
|
||||
: legacyTicketFilterValues(parsed as Partial<AnalysisTicketViewSettings> & Record<string, unknown>);
|
||||
|
||||
return {
|
||||
groupBy: groupByOptions.find((option) => option.value === parsed.groupBy)?.value ?? defaultTicketViewSettings.groupBy,
|
||||
sortBy: sortByOptions.find((option) => option.value === parsed.sortBy)?.value ?? defaultTicketViewSettings.sortBy,
|
||||
sortDirection: parsed.sortDirection === "desc" ? "desc" : defaultTicketViewSettings.sortDirection,
|
||||
query: typeof parsed.query === "string" ? parsed.query : defaultTicketViewSettings.query,
|
||||
workType: parsed.workType === "support" || parsed.workType === "consulting" ? parsed.workType : defaultTicketViewSettings.workType,
|
||||
organization: typeof parsed.organization === "string" ? parsed.organization : defaultTicketViewSettings.organization,
|
||||
status: parsed.status === "open" || parsed.status === "evaluated" ? parsed.status : defaultTicketViewSettings.status
|
||||
filterValues,
|
||||
organizationValues: legacyTicketOrganizationValues(parsed as Partial<AnalysisTicketViewSettings> & Record<string, unknown>)
|
||||
};
|
||||
} catch {
|
||||
return defaultTicketViewSettings;
|
||||
@@ -108,6 +168,41 @@ function TicketStatus({ ticket }: { ticket: TicketSummary }) {
|
||||
return <Badge variant="success">bewertet</Badge>;
|
||||
}
|
||||
|
||||
function formatSignedMinutes(minutes: number) {
|
||||
if (minutes === 0) {
|
||||
return formatMinutes(0);
|
||||
}
|
||||
|
||||
return `${minutes > 0 ? "+" : "-"}${formatMinutes(Math.abs(minutes))}`;
|
||||
}
|
||||
|
||||
function teamspaceMonthDeltaMinutes(ticket: TicketSummary) {
|
||||
const actuals = ticket.teamspace_month_actuals ?? [];
|
||||
|
||||
if (actuals.length === 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const tsMinutes = ticket.teamspace_month_billable_minutes ?? actuals.reduce((sum, actual) => sum + actual.teamspace_billable_minutes, 0);
|
||||
return ticket.teamspace_month_delta_minutes ?? tsMinutes - ticket.crm_billed_minutes;
|
||||
}
|
||||
|
||||
function TeamspaceDeviationIcon({ ticket }: { ticket: TicketSummary }) {
|
||||
const deltaMinutes = teamspaceMonthDeltaMinutes(ticket);
|
||||
|
||||
if (deltaMinutes === 0) {
|
||||
return <span className="inline-block size-4" aria-hidden="true" />;
|
||||
}
|
||||
|
||||
const title = deltaMinutes > 0 ? `${formatMinutes(deltaMinutes)} mehr in TS API` : `${formatMinutes(Math.abs(deltaMinutes))} mehr in TT-Teamspace`;
|
||||
|
||||
return (
|
||||
<span title={`${formatSignedMinutes(deltaMinutes)} · ${title}`} aria-label={`Teamspace-Abweichung: ${title}`}>
|
||||
<AlertCircle className="mx-auto size-4 text-amber-500" />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function ticketStatusKey(ticket: TicketSummary): "open" | "evaluated" {
|
||||
return (ticket.ticket_open_count ?? ticket.open_count) > 0 ? "open" : "evaluated";
|
||||
}
|
||||
@@ -136,6 +231,57 @@ function organizationKey(ticket: TicketSummary) {
|
||||
return ticket.organization_id ? `id:${ticket.organization_id}` : `name:${organizationLabel(ticket)}`;
|
||||
}
|
||||
|
||||
function groupedTicketFilterMatch(filters: string[], groupForFilter: (filter: string) => string, matchesFilter: (filter: string) => boolean) {
|
||||
if (filters.length === 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const groups = new Map<string, string[]>();
|
||||
|
||||
for (const filter of filters) {
|
||||
const group = groupForFilter(filter);
|
||||
groups.set(group, [...(groups.get(group) ?? []), filter]);
|
||||
}
|
||||
|
||||
return Array.from(groups.values()).every((groupFilters) => groupFilters.some((filter) => matchesFilter(filter)));
|
||||
}
|
||||
|
||||
function ticketFilterGroup(filter: string) {
|
||||
if (filter.startsWith("workType:")) {
|
||||
return "workType";
|
||||
}
|
||||
|
||||
if (filter.startsWith("status:")) {
|
||||
return "status";
|
||||
}
|
||||
|
||||
return "misc";
|
||||
}
|
||||
|
||||
function matchesSingleTicketFilter(ticket: TicketSummary, filter: string) {
|
||||
if (filter.startsWith("workType:")) {
|
||||
const workType = filter.slice("workType:".length);
|
||||
|
||||
return ticket.work_type === workType;
|
||||
}
|
||||
|
||||
if (filter.startsWith("status:")) {
|
||||
const status = filter.slice("status:".length);
|
||||
|
||||
return ticketStatusKey(ticket) === status;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
function matchesTicketFilters(ticket: TicketSummary, filters: string[], organizationValues: string[]) {
|
||||
if (organizationValues.length > 0 && !organizationValues.includes(organizationKey(ticket))) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return groupedTicketFilterMatch(filters, ticketFilterGroup, (filter) => matchesSingleTicketFilter(ticket, filter));
|
||||
}
|
||||
|
||||
function compareText(left: string, right: string) {
|
||||
return left.localeCompare(right, "de", { numeric: true, sensitivity: "base" });
|
||||
}
|
||||
@@ -217,7 +363,7 @@ function groupTicketSections(tickets: TicketSummary[], groupBy: TicketGroupBy):
|
||||
});
|
||||
}
|
||||
|
||||
export function AnalysisPage({ onNavigate }: AnalysisPageProps) {
|
||||
export function AnalysisPage({ currentUser, onNavigate }: AnalysisPageProps) {
|
||||
const [periodType, setPeriodType] = useState<PeriodType>("month");
|
||||
const [period, setPeriod] = useState(currentMonth());
|
||||
const [overview, setOverview] = useState<PeriodOverview | null>(null);
|
||||
@@ -328,12 +474,18 @@ export function AnalysisPage({ onNavigate }: AnalysisPageProps) {
|
||||
}
|
||||
|
||||
const totals = overview?.totals;
|
||||
const teamspaceTimeTrackingEnabled = currentUser.teamspace_time_tracking_enabled ?? true;
|
||||
const periodLabel = periodType === "month" ? "Monat" : "Tag";
|
||||
const metricCards: Array<{ label: string; value: string | number; detail?: string; icon: LucideIcon }> = [
|
||||
{ label: "Tickets", value: totals?.tickets ?? 0, icon: Ticket },
|
||||
{ label: "Sessions", value: totals?.sessions ?? 0, icon: Users },
|
||||
{ label: "Abgerechnet", value: formatMinutes(totals?.minutes ?? 0), detail: `Teamspace ${formatMinutes(totals?.crmBilledMinutes ?? 0)}`, icon: Clock3 },
|
||||
{ label: "Nicht abrechenbar", value: formatMinutes(totals?.nonBillableMinutes ?? 0), icon: UserCheck },
|
||||
{
|
||||
label: "Abgerechnet",
|
||||
value: formatMinutes(totals?.minutes ?? 0),
|
||||
detail: teamspaceTimeTrackingEnabled ? `Teamspace ${formatMinutes(totals?.crmBilledMinutes ?? 0)}` : "Ist aus Teamspace",
|
||||
icon: Clock3
|
||||
},
|
||||
{ label: "Abrechenbar", value: formatMinutes(totals?.billableMinutes ?? 0), icon: UserCheck },
|
||||
{ label: "Offen", value: totals?.openSessions ?? 0, detail: formatMinutes(totals?.openMinutes ?? 0), icon: UserCheck }
|
||||
];
|
||||
const tickets = overview?.tickets ?? [];
|
||||
@@ -356,24 +508,20 @@ export function AnalysisPage({ onNavigate }: AnalysisPageProps) {
|
||||
ticket.ticket_number.toLocaleLowerCase("de").includes(normalizedQuery) ||
|
||||
(ticket.title ?? "").toLocaleLowerCase("de").includes(normalizedQuery) ||
|
||||
organizationLabel(ticket).toLocaleLowerCase("de").includes(normalizedQuery);
|
||||
const matchesWorkType = ticketViewSettings.workType === "all" || ticket.work_type === ticketViewSettings.workType;
|
||||
const matchesOrganization = ticketViewSettings.organization === "all" || organizationKey(ticket) === ticketViewSettings.organization;
|
||||
const matchesStatus = ticketViewSettings.status === "all" || ticketStatusKey(ticket) === ticketViewSettings.status;
|
||||
|
||||
return matchesQuery && matchesWorkType && matchesOrganization && matchesStatus;
|
||||
return matchesQuery && matchesTicketFilters(ticket, ticketViewSettings.filterValues, ticketViewSettings.organizationValues);
|
||||
});
|
||||
|
||||
return sortTickets(filtered, ticketViewSettings.sortBy, ticketViewSettings.sortDirection);
|
||||
}, [tickets, ticketViewSettings]);
|
||||
const ticketSections = useMemo(() => groupTicketSections(visibleTickets, ticketViewSettings.groupBy), [visibleTickets, ticketViewSettings.groupBy]);
|
||||
const activeFilterCount = [
|
||||
ticketViewSettings.query.trim(),
|
||||
ticketViewSettings.workType !== "all",
|
||||
ticketViewSettings.organization !== "all",
|
||||
ticketViewSettings.status !== "all",
|
||||
ticketViewSettings.groupBy !== "none",
|
||||
ticketViewSettings.sortBy !== "ticket",
|
||||
ticketViewSettings.sortDirection !== "asc"
|
||||
ticketViewSettings.query.trim() !== "",
|
||||
JSON.stringify(ticketViewSettings.filterValues) !== JSON.stringify(defaultTicketViewSettings.filterValues),
|
||||
ticketViewSettings.organizationValues.length > 0,
|
||||
ticketViewSettings.groupBy !== defaultTicketViewSettings.groupBy,
|
||||
ticketViewSettings.sortBy !== defaultTicketViewSettings.sortBy,
|
||||
ticketViewSettings.sortDirection !== defaultTicketViewSettings.sortDirection
|
||||
].filter(Boolean).length;
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
@@ -449,102 +597,61 @@ export function AnalysisPage({ onNavigate }: AnalysisPageProps) {
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="gap-3 p-4 sm:flex-row sm:items-center sm:justify-between sm:space-y-0">
|
||||
<CardHeader className="p-4">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<CardTitle>Tickets im Zeitraum</CardTitle>
|
||||
<CardDescription>
|
||||
{periodType === "month" ? "Sessions prüfen, bewerten und bei Bedarf Tickets öffnen." : "Tagesansicht ohne eigenen Abschluss."}
|
||||
</CardDescription>
|
||||
</div>
|
||||
<HelpLink anchor="auswertung" label="Hilfe zu Tickets im Zeitraum" />
|
||||
</CardHeader>
|
||||
<CardContent className="px-4 pb-4">
|
||||
<div className="mb-4 rounded-md border bg-muted/20 p-3">
|
||||
<div className="mb-3 flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
<SlidersHorizontal className="size-4 text-muted-foreground" />
|
||||
<div>
|
||||
<p className="text-sm font-medium">Ansicht</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{visibleTickets.length} von {tickets.length} Ticket(s)
|
||||
</p>
|
||||
Filter und Sortierung
|
||||
{activeFilterCount > 0 ? <Badge variant="secondary">{activeFilterCount}</Badge> : null}
|
||||
</CardTitle>
|
||||
<CardDescription>{visibleTickets.length} von {tickets.length} Ticket(s) sichtbar.</CardDescription>
|
||||
</div>
|
||||
{activeFilterCount > 0 ? <Badge variant="outline">{activeFilterCount} aktiv</Badge> : null}
|
||||
<div className="flex items-center gap-2">
|
||||
<HelpLink anchor="auswertung" label="Hilfe zu Filtern und Sortierung" />
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
disabled={activeFilterCount === 0}
|
||||
onClick={() => setTicketViewSettings(defaultTicketViewSettings)}
|
||||
>
|
||||
<RotateCcw className="size-3.5" />
|
||||
<Button type="button" size="sm" variant="ghost" disabled={activeFilterCount === 0} onClick={() => setTicketViewSettings(defaultTicketViewSettings)}>
|
||||
<RotateCcw className="size-4" />
|
||||
Zurücksetzen
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2 md:grid-cols-2 xl:grid-cols-[minmax(180px,1fr)_150px_190px_140px_140px_140px_120px]">
|
||||
<div className="space-y-1">
|
||||
<label className="text-xs font-medium text-muted-foreground" htmlFor="ticket-filter-query">Suche</label>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="grid gap-3 px-4 pb-4 md:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-5">
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs font-medium text-muted-foreground" htmlFor="ticket-filter-query">
|
||||
Suche
|
||||
</label>
|
||||
<Input
|
||||
id="ticket-filter-query"
|
||||
className="h-8"
|
||||
value={ticketViewSettings.query}
|
||||
placeholder="Ticket oder Organisation"
|
||||
placeholder="Ticket, Titel, Organisation..."
|
||||
onChange={(event) => {
|
||||
const query = event.currentTarget.value;
|
||||
setTicketViewSettings((current) => ({ ...current, query }));
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<label className="text-xs font-medium text-muted-foreground">Typ</label>
|
||||
<Select value={ticketViewSettings.workType} onValueChange={(value) => setTicketViewSettings((current) => ({ ...current, workType: value as TicketWorkTypeFilter }))}>
|
||||
<SelectTrigger size="sm" className="w-full">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">Alle Typen</SelectItem>
|
||||
<SelectItem value="support">Support</SelectItem>
|
||||
<SelectItem value="consulting">Consulting</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs font-medium text-muted-foreground">Filter</label>
|
||||
<MultiFilterDropdown
|
||||
values={ticketViewSettings.filterValues}
|
||||
options={ticketStaticFilterOptions}
|
||||
onChange={(filterValues) => setTicketViewSettings((current) => ({ ...current, filterValues }))}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<label className="text-xs font-medium text-muted-foreground">Organisation</label>
|
||||
<Select value={ticketViewSettings.organization} onValueChange={(value) => setTicketViewSettings((current) => ({ ...current, organization: value }))}>
|
||||
<SelectTrigger size="sm" className="w-full">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">Alle Organisationen</SelectItem>
|
||||
{organizationOptions.map((organization) => (
|
||||
<SelectItem key={organization.value} value={organization.value}>
|
||||
{organization.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs font-medium text-muted-foreground">Organisationen</label>
|
||||
<MultiFilterDropdown
|
||||
values={ticketViewSettings.organizationValues}
|
||||
options={organizationOptions}
|
||||
onChange={(organizationValues) => setTicketViewSettings((current) => ({ ...current, organizationValues }))}
|
||||
placeholder="Alle Org."
|
||||
countLabel="Org."
|
||||
emptyLabel="Keine Org."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<label className="text-xs font-medium text-muted-foreground">Status</label>
|
||||
<Select value={ticketViewSettings.status} onValueChange={(value) => setTicketViewSettings((current) => ({ ...current, status: value as TicketStatusFilter }))}>
|
||||
<SelectTrigger size="sm" className="w-full">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">Alle Status</SelectItem>
|
||||
<SelectItem value="open">Offen</SelectItem>
|
||||
<SelectItem value="evaluated">Bewertet</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs font-medium text-muted-foreground">Gruppierung</label>
|
||||
<Select value={ticketViewSettings.groupBy} onValueChange={(value) => setTicketViewSettings((current) => ({ ...current, groupBy: value as TicketGroupBy }))}>
|
||||
<SelectTrigger size="sm" className="w-full">
|
||||
@@ -559,8 +666,7 @@ export function AnalysisPage({ onNavigate }: AnalysisPageProps) {
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs font-medium text-muted-foreground">Sortierung</label>
|
||||
<Select value={ticketViewSettings.sortBy} onValueChange={(value) => setTicketViewSettings((current) => ({ ...current, sortBy: value as TicketSortBy }))}>
|
||||
<SelectTrigger size="sm" className="w-full">
|
||||
@@ -575,32 +681,57 @@ export function AnalysisPage({ onNavigate }: AnalysisPageProps) {
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs font-medium text-muted-foreground">Richtung</label>
|
||||
<Select value={ticketViewSettings.sortDirection} onValueChange={(value) => setTicketViewSettings((current) => ({ ...current, sortDirection: value as TicketSortDirection }))}>
|
||||
<SelectTrigger size="sm" className="w-full">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="asc">Aufsteigend</SelectItem>
|
||||
<SelectItem value="desc">Absteigend</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 rounded-md border bg-background p-0.5">
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant={ticketViewSettings.sortDirection === "asc" ? "secondary" : "ghost"}
|
||||
className="h-7 px-2"
|
||||
onClick={() => setTicketViewSettings((current) => ({ ...current, sortDirection: "asc" }))}
|
||||
>
|
||||
<ArrowUp className="size-3.5" />
|
||||
Auf
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant={ticketViewSettings.sortDirection === "desc" ? "secondary" : "ghost"}
|
||||
className="h-7 px-2"
|
||||
onClick={() => setTicketViewSettings((current) => ({ ...current, sortDirection: "desc" }))}
|
||||
>
|
||||
<ArrowDown className="size-3.5" />
|
||||
Ab
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="gap-3 p-4 sm:flex-row sm:items-center sm:justify-between sm:space-y-0">
|
||||
<div>
|
||||
<CardTitle>Sessions im Zeitraum</CardTitle>
|
||||
<CardDescription>
|
||||
{periodType === "month" ? "Sessions prüfen, bewerten und bei Bedarf Tickets öffnen." : "Tagesansicht ohne eigenen Abschluss."}
|
||||
</CardDescription>
|
||||
</div>
|
||||
<HelpLink anchor="auswertung" label="Hilfe zu Sessions im Zeitraum" />
|
||||
</CardHeader>
|
||||
<CardContent className="px-4 pb-4">
|
||||
<div className="hidden md:block">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Ticket</TableHead>
|
||||
<TableHead className="w-10 text-center">
|
||||
<span className="sr-only">Teamspace-Abweichung</span>
|
||||
</TableHead>
|
||||
<TableHead>Organisation</TableHead>
|
||||
<TableHead>Art</TableHead>
|
||||
<TableHead>Sessions</TableHead>
|
||||
<TableHead>Zeit abgerechnet</TableHead>
|
||||
<TableHead>Sessions abgerechnet</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead />
|
||||
</TableRow>
|
||||
@@ -617,7 +748,7 @@ export function AnalysisPage({ onNavigate }: AnalysisPageProps) {
|
||||
<span className="truncate text-base font-semibold">{section.label}</span>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Badge variant="outline">{section.ticketCount} Ticket(s)</Badge>
|
||||
<Badge variant="outline">{section.ticketCount} Session(s)</Badge>
|
||||
<Badge variant="outline">{formatMinutes(section.totalMinutes)}</Badge>
|
||||
{section.openCount > 0 ? <Badge variant="warning">{section.openCount} offen</Badge> : <Badge variant="success">bewertet</Badge>}
|
||||
</div>
|
||||
@@ -637,11 +768,13 @@ export function AnalysisPage({ onNavigate }: AnalysisPageProps) {
|
||||
</div>
|
||||
{ticket.title ? <p className="max-w-[220px] truncate text-xs text-muted-foreground">{ticket.title}</p> : null}
|
||||
</TableCell>
|
||||
<TableCell className="text-center">
|
||||
<TeamspaceDeviationIcon ticket={ticket} />
|
||||
</TableCell>
|
||||
<TableCell>{ticket.customer_name ?? "-"}</TableCell>
|
||||
<TableCell>{workTypeLabel(ticket.work_type)}</TableCell>
|
||||
<TableCell>{ticket.session_count}</TableCell>
|
||||
<TableCell>{formatMinutes(ticket.total_minutes)}</TableCell>
|
||||
<TableCell>{ticket.billed_count}</TableCell>
|
||||
<TableCell>
|
||||
<TicketStatus ticket={ticket} />
|
||||
</TableCell>
|
||||
@@ -673,7 +806,7 @@ export function AnalysisPage({ onNavigate }: AnalysisPageProps) {
|
||||
<span className="truncate text-base font-semibold">{section.label}</span>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Badge variant="outline">{section.ticketCount} Ticket(s)</Badge>
|
||||
<Badge variant="outline">{section.ticketCount} Session(s)</Badge>
|
||||
<Badge variant="outline">{formatMinutes(section.totalMinutes)}</Badge>
|
||||
{section.openCount > 0 ? <Badge variant="warning">{section.openCount} offen</Badge> : <Badge variant="success">bewertet</Badge>}
|
||||
</div>
|
||||
@@ -693,16 +826,19 @@ export function AnalysisPage({ onNavigate }: AnalysisPageProps) {
|
||||
{ticket.title ? <p className="truncate text-xs text-muted-foreground">{ticket.title}</p> : null}
|
||||
<p className="text-sm text-muted-foreground">{ticket.customer_name ?? "Keine Organisation"} · {workTypeLabel(ticket.work_type)} · {ticket.session_count} Session(s)</p>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
<TeamspaceDeviationIcon ticket={ticket} />
|
||||
<TicketStatus ticket={ticket} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-2 text-sm">
|
||||
<div className="rounded-md bg-muted/50 p-2">
|
||||
<p className="text-muted-foreground">Abr.</p>
|
||||
<p className="font-medium">{formatMinutes(ticket.total_minutes)}</p>
|
||||
</div>
|
||||
<div className="rounded-md bg-muted/50 p-2">
|
||||
<p className="text-muted-foreground">Sessions</p>
|
||||
<p className="font-medium">{ticket.billed_count}</p>
|
||||
<p className="text-muted-foreground">Abrechenbar</p>
|
||||
<p className="font-medium">{ticket.billable_count}</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
@@ -723,7 +859,7 @@ export function AnalysisPage({ onNavigate }: AnalysisPageProps) {
|
||||
{tickets.length === 0 ? (
|
||||
<p className="py-8 text-center text-sm text-muted-foreground">Für diesen Zeitraum sind noch keine Sessions vorhanden.</p>
|
||||
) : visibleTickets.length === 0 ? (
|
||||
<p className="py-8 text-center text-sm text-muted-foreground">Keine Tickets passend zu den aktuellen Filtern.</p>
|
||||
<p className="py-8 text-center text-sm text-muted-foreground">Keine Sessions passend zu den aktuellen Filtern.</p>
|
||||
) : null}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -21,7 +21,7 @@ export function LoginPage({ onLogin }: LoginPageProps) {
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
const result = await login({ username, password });
|
||||
const result = await login({ username: username.trim(), password });
|
||||
onLogin(result.user);
|
||||
} catch (error) {
|
||||
toast.error("Login fehlgeschlagen", {
|
||||
|
||||
@@ -7,7 +7,6 @@ import {
|
||||
FileWarning,
|
||||
Lock,
|
||||
LockOpen,
|
||||
Sparkles,
|
||||
} from "lucide-react";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
@@ -20,28 +19,38 @@ import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { CopyTicketButton } from "@/components/CopyTicketButton";
|
||||
import { HelpLink } from "@/components/HelpLink";
|
||||
import { acknowledgeMissingTicketDayBilling, closePeriod, getStatisticsOverview, reopenPeriod } from "../api";
|
||||
import { currentMonth, formatDate, formatDateTime, formatMinutes } from "../format";
|
||||
import type { StatisticsCrmDay, StatisticsOpenSession, StatisticsOverview } from "../types";
|
||||
import { closePeriod, getStatisticsOverview, reopenPeriod } from "../api";
|
||||
import { currentMonth, currentWeek, formatDateTime, formatMinutes } from "../format";
|
||||
import type { AuthUser, StatisticsCrmDay, StatisticsOpenSession, StatisticsOverview, StatisticsTeamspaceDay, StatisticsTeamspaceMonthMismatch } from "../types";
|
||||
|
||||
type MonthlyClosePageProps = {
|
||||
currentUser: AuthUser;
|
||||
onNavigate: (to: string) => void;
|
||||
};
|
||||
|
||||
const trackedTextClass = "text-sky-700 dark:text-sky-300";
|
||||
const teamspaceTextClass = "text-emerald-700 dark:text-emerald-300";
|
||||
const showAcknowledgedMissingCrmStorageKey = "tickettracker.monthlyClose.showAcknowledgedMissingCrm";
|
||||
const monthlyCloseViewModeStorageKey = "tickettracker.monthlyClose.viewMode";
|
||||
|
||||
function formatSignedMinutes(minutes: number) {
|
||||
type MonthlyCloseViewMode = "month" | "week";
|
||||
|
||||
function readMonthlyCloseViewMode(): MonthlyCloseViewMode {
|
||||
if (typeof window === "undefined") {
|
||||
return "month";
|
||||
}
|
||||
|
||||
return window.localStorage.getItem(monthlyCloseViewModeStorageKey) === "week" ? "week" : "month";
|
||||
}
|
||||
|
||||
function formatMissingTarget(minutes: number, teamspaceTimeTrackingEnabled: boolean) {
|
||||
if (minutes === 0) {
|
||||
return "ausgeglichen";
|
||||
}
|
||||
|
||||
return `${minutes > 0 ? "+" : "-"}${formatMinutes(Math.abs(minutes))}`;
|
||||
if (minutes > 0) {
|
||||
return `${formatMinutes(minutes)} mehr in TS API`;
|
||||
}
|
||||
|
||||
function formatTeamspaceDelta(minutes: number) {
|
||||
return formatSignedMinutes(minutes);
|
||||
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" }) {
|
||||
@@ -52,58 +61,177 @@ function StatusCard({ label, value, detail, tone }: { label: string; value: stri
|
||||
<CardContent className="p-3">
|
||||
<p className="text-xs font-medium text-muted-foreground">{label}</p>
|
||||
<p className={`text-xl font-semibold tracking-normal ${valueClassName}`}>{value}</p>
|
||||
<p className="text-xs text-muted-foreground">{detail}</p>
|
||||
<p className="text-xs text-muted-foreground whitespace-pre-line">{detail}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
type OpenTicketGroup = {
|
||||
type MonthlyCloseTicketDay = {
|
||||
day: string;
|
||||
missingCrm: StatisticsCrmDay | null;
|
||||
ticketMismatch: StatisticsTeamspaceDay | null;
|
||||
};
|
||||
|
||||
type MonthlyCloseTicketGroup = {
|
||||
ticketId: string;
|
||||
ticketNumber: string;
|
||||
organizationName: string;
|
||||
month: string;
|
||||
openCount: number;
|
||||
totalMinutes: number;
|
||||
mismatch: StatisticsTeamspaceMonthMismatch | null;
|
||||
days: MonthlyCloseTicketDay[];
|
||||
};
|
||||
|
||||
function groupOpenSessions(sessions: StatisticsOpenSession[]) {
|
||||
const groups = new Map<string, OpenTicketGroup>();
|
||||
type MutableMonthlyCloseTicketGroup = Omit<MonthlyCloseTicketGroup, "days"> & {
|
||||
days: Map<string, MonthlyCloseTicketDay>;
|
||||
};
|
||||
|
||||
for (const session of sessions) {
|
||||
const existing = groups.get(session.ticket_id);
|
||||
const monthlyCloseTicketGridClassName =
|
||||
"xl:grid-cols-[minmax(10rem,1fr)_minmax(4.25rem,7rem)_minmax(4.25rem,7rem)_minmax(4.25rem,7rem)_minmax(4.25rem,7rem)_minmax(4.25rem,7rem)_11.5rem]";
|
||||
|
||||
function buildMonthlyCloseTicketGroups(
|
||||
sessions: StatisticsOpenSession[],
|
||||
monthMismatches: StatisticsTeamspaceMonthMismatch[],
|
||||
ticketDayMismatches: StatisticsTeamspaceDay[],
|
||||
missingCrmDays: StatisticsCrmDay[],
|
||||
fallbackMonth: string
|
||||
) {
|
||||
const groups = new Map<string, MutableMonthlyCloseTicketGroup>();
|
||||
|
||||
function ensureGroup(ticketId: string, ticketNumber: string, organizationName: string, month: string) {
|
||||
const existing = groups.get(ticketId);
|
||||
|
||||
if (existing) {
|
||||
existing.openCount += 1;
|
||||
existing.totalMinutes += session.rounded_minutes;
|
||||
if (!existing.organizationName && organizationName) {
|
||||
existing.organizationName = organizationName;
|
||||
}
|
||||
|
||||
return existing;
|
||||
}
|
||||
|
||||
const group: MutableMonthlyCloseTicketGroup = {
|
||||
ticketId,
|
||||
ticketNumber,
|
||||
organizationName,
|
||||
month,
|
||||
openCount: 0,
|
||||
totalMinutes: 0,
|
||||
mismatch: null,
|
||||
days: new Map()
|
||||
};
|
||||
groups.set(ticketId, group);
|
||||
return group;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
groups.set(session.ticket_id, {
|
||||
ticketId: session.ticket_id,
|
||||
ticketNumber: session.ticket_number,
|
||||
organizationName: session.organization_name,
|
||||
openCount: 1,
|
||||
totalMinutes: session.rounded_minutes
|
||||
});
|
||||
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);
|
||||
}
|
||||
|
||||
return Array.from(groups.values()).sort((left, right) => left.ticketNumber.localeCompare(right.ticketNumber, "de", { numeric: true, sensitivity: "base" }));
|
||||
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);
|
||||
}
|
||||
|
||||
function OpenTicketItem({ group, month, onNavigate }: { group: OpenTicketGroup; month: string; onNavigate: (to: string) => void }) {
|
||||
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 MonthlyCloseTicketItem({
|
||||
group,
|
||||
onNavigate
|
||||
}: {
|
||||
group: MonthlyCloseTicketGroup;
|
||||
onNavigate: (to: string) => void;
|
||||
}) {
|
||||
const missingDayCount = group.days.filter((day) => day.missingCrm && !day.missingCrm.acknowledged).length;
|
||||
const acknowledgedDayCount = group.days.filter((day) => day.missingCrm?.acknowledged).length;
|
||||
const mismatchDayCount = group.days.filter((day) => day.ticketMismatch && day.ticketMismatch.delta_minutes !== 0).length;
|
||||
const invalidTeamspaceValueDayCount = group.days.filter((day) => day.ticketMismatch?.tt_value_without_billed_session).length;
|
||||
const monthDeltaMinutes = group.mismatch?.teamspace_month_delta_minutes ?? 0;
|
||||
const monthDeltaLabel = monthDeltaMinutes > 0
|
||||
? `TS +${formatMinutes(monthDeltaMinutes)}`
|
||||
: `TT +${formatMinutes(Math.abs(monthDeltaMinutes))}`;
|
||||
const metricClassName = (visible: boolean) => `${visible ? "flex" : "hidden xl:flex"} min-h-7 items-center justify-between gap-2 rounded-md bg-muted/30 px-2 xl:min-h-5 xl:justify-center xl:rounded-none xl:bg-transparent xl:px-0`;
|
||||
|
||||
return (
|
||||
<div className="flex min-h-36 flex-col justify-between gap-3 rounded-md border bg-background p-3">
|
||||
<div className="min-w-0">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="font-medium">{group.ticketNumber}</span>
|
||||
<div className={`grid grid-cols-1 items-center gap-1.5 rounded-md border bg-background px-3 py-2.5 sm:grid-cols-2 xl:gap-2 ${monthlyCloseTicketGridClassName}`}>
|
||||
<div className="min-w-0 sm:col-span-2 xl:col-span-1">
|
||||
<p className="font-medium">{group.ticketNumber}</p>
|
||||
<p className="truncate text-xs text-muted-foreground">{group.organizationName}</p>
|
||||
</div>
|
||||
<div className={metricClassName(group.openCount > 0)}>
|
||||
<span className="text-[10px] font-medium uppercase tracking-wide text-muted-foreground xl:hidden">Offene Bewertungen</span>
|
||||
{group.openCount > 0 ? (
|
||||
<Badge className="bg-amber-500/12 text-amber-800 dark:bg-amber-500/20 dark:text-amber-200" title={`${group.openCount} offene Bewertung(en) · ${formatMinutes(group.totalMinutes)}`}>
|
||||
{group.openCount}
|
||||
</Badge>
|
||||
) : null}
|
||||
</div>
|
||||
<div className={metricClassName(Boolean(group.mismatch))}>
|
||||
<span className="text-[10px] font-medium uppercase tracking-wide text-muted-foreground xl:hidden">Monatsabweichung</span>
|
||||
{group.mismatch ? (
|
||||
<Badge className="bg-orange-500/12 text-orange-800 dark:bg-orange-500/20 dark:text-orange-200" title="Monatsabweichung zwischen TT-Teamspace und TS-API">
|
||||
{monthDeltaLabel}
|
||||
</Badge>
|
||||
) : null}
|
||||
</div>
|
||||
<div className={metricClassName(missingDayCount > 0 || acknowledgedDayCount > 0)}>
|
||||
<span className="text-[10px] font-medium uppercase tracking-wide text-muted-foreground xl:hidden">Fehlender TT-Wert</span>
|
||||
<span className="flex flex-wrap items-center justify-end gap-1 xl:flex-col xl:justify-center">
|
||||
{missingDayCount > 0 ? (
|
||||
<Badge className="bg-sky-500/12 text-sky-800 dark:bg-sky-500/20 dark:text-sky-200" title={`${missingDayCount} Tag(e) ohne TT-Teamspace-Wert`}>
|
||||
TT {missingDayCount}
|
||||
</Badge>
|
||||
) : null}
|
||||
{acknowledgedDayCount > 0 ? (
|
||||
<Badge className="bg-emerald-500/12 text-emerald-800 dark:bg-emerald-500/20 dark:text-emerald-200" title={`${acknowledgedDayCount} zur Kenntnis genommene(r) Tag(e)`}>
|
||||
✓ {acknowledgedDayCount}
|
||||
</Badge>
|
||||
) : null}
|
||||
</span>
|
||||
</div>
|
||||
<div className={metricClassName(mismatchDayCount > 0)}>
|
||||
<span className="text-[10px] font-medium uppercase tracking-wide text-muted-foreground xl:hidden">Tagesabweichung</span>
|
||||
{mismatchDayCount > 0 ? (
|
||||
<Badge className="bg-violet-500/12 text-violet-800 dark:bg-violet-500/20 dark:text-violet-200" title={`${mismatchDayCount} Tag(e) mit Abweichung zwischen TT und TS`}>
|
||||
{mismatchDayCount}
|
||||
</Badge>
|
||||
) : null}
|
||||
</div>
|
||||
<div className={metricClassName(invalidTeamspaceValueDayCount > 0)}>
|
||||
<span className="text-[10px] font-medium uppercase tracking-wide text-muted-foreground xl:hidden">TT-Wert ohne Abrechnung</span>
|
||||
{invalidTeamspaceValueDayCount > 0 ? (
|
||||
<Badge variant="destructive" title={`${invalidTeamspaceValueDayCount} Tag(e) mit TT-Teamspace-Wert, aber ohne abgerechnete Session`}>{invalidTeamspaceValueDayCount}</Badge>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center justify-end gap-1.5 pt-0.5 sm:col-span-2 xl:col-span-1 xl:pt-0">
|
||||
<CopyTicketButton ticketNumber={group.ticketNumber} />
|
||||
<Badge variant="warning">{group.openCount} offen</Badge>
|
||||
</div>
|
||||
<p className="truncate text-sm text-muted-foreground">{group.organizationName}</p>
|
||||
<p className={`text-sm font-medium ${trackedTextClass}`}>{formatMinutes(group.totalMinutes)} offen zu bewerten</p>
|
||||
</div>
|
||||
<div className="flex justify-end">
|
||||
<Button variant="secondary" size="sm" onClick={() => onNavigate(`/tickets/${group.ticketId}?month=${month}`)}>
|
||||
<Button className="h-8" variant="secondary" size="sm" onClick={() => onNavigate(`/tickets/${group.ticketId}?month=${group.month}`)}>
|
||||
Ticket öffnen
|
||||
<ExternalLink className="size-4" />
|
||||
</Button>
|
||||
@@ -112,58 +240,23 @@ function OpenTicketItem({ group, month, onNavigate }: { group: OpenTicketGroup;
|
||||
);
|
||||
}
|
||||
|
||||
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={`flex min-h-36 flex-col justify-between gap-3 rounded-md border bg-background p-3 ${isAcknowledged ? "opacity-75" : ""}`}>
|
||||
<div className="min-w-0">
|
||||
<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 flex-wrap justify-end gap-2">
|
||||
<Button variant={isAcknowledged ? "secondary" : "ghost"} size="sm" disabled={busy} onClick={() => onSetAcknowledgement(item, !isAcknowledged)}>
|
||||
{isAcknowledged ? "Zurücknehmen" : "Zur Kenntnis"}
|
||||
</Button>
|
||||
<Button 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>
|
||||
);
|
||||
}
|
||||
|
||||
export function MonthlyClosePage({ onNavigate }: MonthlyClosePageProps) {
|
||||
export function MonthlyClosePage({ currentUser, onNavigate }: MonthlyClosePageProps) {
|
||||
const [viewMode, setViewMode] = useState<MonthlyCloseViewMode>(() => readMonthlyCloseViewMode());
|
||||
const [month, setMonth] = useState(currentMonth());
|
||||
const [week, setWeek] = useState(currentWeek());
|
||||
const [stats, setStats] = useState<StatisticsOverview | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [closing, setClosing] = useState(false);
|
||||
const [reopening, setReopening] = useState(false);
|
||||
const [acknowledgingKeys, setAcknowledgingKeys] = useState<Set<string>>(() => new Set());
|
||||
const [showAcknowledgedMissingCrm, setShowAcknowledgedMissingCrm] = useState(false);
|
||||
const [showAcknowledgedMissingCrmLoaded, setShowAcknowledgedMissingCrmLoaded] = useState(false);
|
||||
const loadRequestId = useRef(0);
|
||||
const period = viewMode === "month" ? month : week;
|
||||
const periodLabel = viewMode === "month" ? "Monat" : "Woche";
|
||||
const periodAttentionLabel = viewMode === "month" ? "diesen Monat" : "diese Woche";
|
||||
const periodInLabel = viewMode === "month" ? "diesem Monat" : "dieser Woche";
|
||||
const isMonthView = viewMode === "month";
|
||||
const teamspaceTimeTrackingEnabled = currentUser.teamspace_time_tracking_enabled ?? true;
|
||||
|
||||
async function load() {
|
||||
const requestId = loadRequestId.current + 1;
|
||||
@@ -171,14 +264,14 @@ export function MonthlyClosePage({ onNavigate }: MonthlyClosePageProps) {
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
const result = await getStatisticsOverview(month);
|
||||
const result = await getStatisticsOverview(period, viewMode);
|
||||
|
||||
if (requestId === loadRequestId.current) {
|
||||
setStats(result);
|
||||
}
|
||||
} catch (error) {
|
||||
if (requestId === loadRequestId.current) {
|
||||
toast.error("Monatsabschluss konnte nicht geladen werden", {
|
||||
toast.error("Abschlussansicht konnte nicht geladen werden", {
|
||||
description: error instanceof Error ? error.message : "Unbekannter Fehler"
|
||||
});
|
||||
}
|
||||
@@ -191,7 +284,11 @@ export function MonthlyClosePage({ onNavigate }: MonthlyClosePageProps) {
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
}, [month]);
|
||||
}, [period, viewMode]);
|
||||
|
||||
useEffect(() => {
|
||||
window.localStorage.setItem(monthlyCloseViewModeStorageKey, viewMode);
|
||||
}, [viewMode]);
|
||||
|
||||
useEffect(() => {
|
||||
setShowAcknowledgedMissingCrm(window.localStorage.getItem(showAcknowledgedMissingCrmStorageKey) === "true");
|
||||
@@ -211,6 +308,22 @@ export function MonthlyClosePage({ onNavigate }: MonthlyClosePageProps) {
|
||||
setMonth(`${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}`);
|
||||
}
|
||||
|
||||
function changeWeekBy(delta: number) {
|
||||
const match = /^(\d{4})-W(\d{2})$/.exec(week);
|
||||
const year = match ? Number(match[1]) : new Date().getFullYear();
|
||||
const weekNumber = match ? Number(match[2]) : 1;
|
||||
const jan4 = new Date(Date.UTC(year, 0, 4));
|
||||
const jan4Weekday = jan4.getUTCDay() || 7;
|
||||
const date = new Date(jan4);
|
||||
date.setUTCDate(jan4.getUTCDate() - jan4Weekday + 1 + (weekNumber - 1 + delta) * 7);
|
||||
const weekday = date.getUTCDay() || 7;
|
||||
date.setUTCDate(date.getUTCDate() + 4 - weekday);
|
||||
const yearStart = new Date(Date.UTC(date.getUTCFullYear(), 0, 1));
|
||||
const nextWeek = Math.ceil(((date.getTime() - yearStart.getTime()) / 86_400_000 + 1) / 7);
|
||||
|
||||
setWeek(`${date.getUTCFullYear()}-W${String(nextWeek).padStart(2, "0")}`);
|
||||
}
|
||||
|
||||
async function finishMonth() {
|
||||
setClosing(true);
|
||||
try {
|
||||
@@ -246,60 +359,56 @@ export function MonthlyClosePage({ onNavigate }: MonthlyClosePageProps) {
|
||||
}
|
||||
}
|
||||
|
||||
async function setMissingCrmAcknowledgement(item: StatisticsCrmDay, acknowledged: boolean) {
|
||||
const key = `${item.ticket_id}:${item.day}`;
|
||||
setAcknowledgingKeys((current) => new Set(current).add(key));
|
||||
|
||||
try {
|
||||
await acknowledgeMissingTicketDayBilling(item.ticket_id, item.day, acknowledged);
|
||||
toast.success(acknowledged ? "Teamspace-Prüfung abgehakt" : "Kenntnisnahme zurückgenommen", {
|
||||
description: `${item.ticket_number} am ${formatDate(`${item.day}T00:00:00`)}`
|
||||
});
|
||||
await load();
|
||||
} catch (error) {
|
||||
toast.error(acknowledged ? "Konnte nicht abgehakt werden" : "Konnte nicht zurückgenommen werden", {
|
||||
description: error instanceof Error ? error.message : "Unbekannter Fehler"
|
||||
});
|
||||
} finally {
|
||||
setAcknowledgingKeys((current) => {
|
||||
const next = new Set(current);
|
||||
next.delete(key);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const openSessions = stats?.attention.openSessions ?? [];
|
||||
const missingCrmDays = stats?.attention.missingCrmDays ?? [];
|
||||
const acknowledgedMissingCrmDays = stats?.attention.acknowledgedMissingCrmDays ?? [];
|
||||
const openTicketGroups = useMemo(() => groupOpenSessions(openSessions), [openSessions]);
|
||||
const teamspaceMonthMismatches = stats?.attention.teamspaceMonthMismatches ?? [];
|
||||
const ticketTeamspaceMismatches = stats?.attention.ticketTeamspaceMismatches ?? [];
|
||||
const billedMinutes = stats?.totals.billedMinutes ?? 0;
|
||||
const ttTeamspaceMinutes = stats?.totals.crmBilledMinutes ?? 0;
|
||||
const teamspaceApiBillableMinutes = stats?.totals.teamspaceApiBillableMinutes ?? 0;
|
||||
const teamspaceApiDeltaMinutes = stats?.totals.teamspaceApiDeltaMinutes ?? 0;
|
||||
const visibleMissingCrmDays = useMemo(() => {
|
||||
const days = showAcknowledgedMissingCrm ? [...missingCrmDays, ...acknowledgedMissingCrmDays] : [...missingCrmDays];
|
||||
return days.sort((left, right) => left.day.localeCompare(right.day) || left.ticket_number.localeCompare(right.ticket_number, "de", { numeric: true, sensitivity: "base" }));
|
||||
}, [acknowledgedMissingCrmDays, missingCrmDays, showAcknowledgedMissingCrm]);
|
||||
const canClose = Boolean(stats && !stats.closed && stats.totals.sessions > 0 && openSessions.length === 0);
|
||||
const activeReviewTicketGroups = useMemo(
|
||||
() => buildMonthlyCloseTicketGroups(openSessions, teamspaceMonthMismatches, ticketTeamspaceMismatches, missingCrmDays, month),
|
||||
[missingCrmDays, month, openSessions, teamspaceMonthMismatches, ticketTeamspaceMismatches]
|
||||
);
|
||||
const visibleReviewTicketGroups = useMemo(
|
||||
() => buildMonthlyCloseTicketGroups(openSessions, teamspaceMonthMismatches, ticketTeamspaceMismatches, visibleMissingCrmDays, month),
|
||||
[month, openSessions, teamspaceMonthMismatches, ticketTeamspaceMismatches, visibleMissingCrmDays]
|
||||
);
|
||||
const teamspaceAttentionTicketCount = activeReviewTicketGroups.filter((group) => group.mismatch || group.days.length > 0).length;
|
||||
const missingCrmTicketCount = new Set(missingCrmDays.map((item) => item.ticket_id)).size;
|
||||
const ticketTeamspaceDeviationCount = ticketTeamspaceMismatches.filter((item) => item.delta_minutes !== 0).length;
|
||||
const invalidTeamspaceValueDayCount = ticketTeamspaceMismatches.filter((item) => item.tt_value_without_billed_session).length;
|
||||
const canClose = Boolean(isMonthView && stats && !stats.closed && stats.totals.sessions > 0 && openSessions.length === 0);
|
||||
const checklist = useMemo(
|
||||
() => [
|
||||
{
|
||||
label: "Offene Bewertungen",
|
||||
ok: openSessions.length === 0,
|
||||
value: openSessions.length,
|
||||
detail: openSessions.length === 0 ? "Alle Sessions sind bewertet." : `${openTicketGroups.length} Ticket(s) bearbeiten`
|
||||
label: "Tickets zu prüfen",
|
||||
ok: activeReviewTicketGroups.length === 0,
|
||||
value: activeReviewTicketGroups.length,
|
||||
detail: activeReviewTicketGroups.length === 0 ? "Keine offenen Prüfpunkte." : `${activeReviewTicketGroups.length} Ticket(s) mit mindestens einem Prüfpunkt`
|
||||
},
|
||||
{
|
||||
label: "Teamspace geprüft",
|
||||
ok: missingCrmDays.length === 0,
|
||||
value: missingCrmDays.length,
|
||||
detail: missingCrmDays.length === 0 ? "Keine offenen Teamspace-Prüfpunkte." : `${acknowledgedMissingCrmDays.length} zur Kenntnis genommen`
|
||||
ok: teamspaceAttentionTicketCount === 0,
|
||||
value: teamspaceAttentionTicketCount,
|
||||
detail: teamspaceAttentionTicketCount === 0
|
||||
? "Keine offenen Teamspace-Prüfpunkte."
|
||||
: `${missingCrmDays.length} fehlende Tageswerte, ${ticketTeamspaceDeviationCount} Tagesabweichungen, ${invalidTeamspaceValueDayCount} TS-Werte ohne Abrechnung, ${teamspaceMonthMismatches.length} Monatsabweichungen`
|
||||
},
|
||||
{
|
||||
label: "Monat abschließbar",
|
||||
ok: Boolean(stats?.closed || canClose),
|
||||
value: stats?.closed ? "geschlossen" : canClose ? "bereit" : "offen",
|
||||
detail: stats?.closed ? "Monat ist gesperrt." : canClose ? "Kann abgeschlossen werden." : "Bewertungen prüfen."
|
||||
label: isMonthView ? "Monat abschließbar" : "Wochenansicht",
|
||||
ok: isMonthView ? Boolean(stats?.closed || canClose) : true,
|
||||
value: isMonthView ? (stats?.closed ? "geschlossen" : canClose ? "bereit" : "offen") : "Ansicht",
|
||||
detail: isMonthView ? (stats?.closed ? "Monat ist gesperrt." : canClose ? "Kann abgeschlossen werden." : "Bewertungen prüfen.") : "Abschluss bleibt monatlich."
|
||||
}
|
||||
],
|
||||
[acknowledgedMissingCrmDays.length, canClose, openSessions.length, openTicketGroups.length, missingCrmDays.length, stats?.closed]
|
||||
[activeReviewTicketGroups.length, canClose, invalidTeamspaceValueDayCount, isMonthView, missingCrmDays.length, stats?.closed, teamspaceAttentionTicketCount, teamspaceMonthMismatches.length, ticketTeamspaceDeviationCount]
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -307,25 +416,46 @@ export function MonthlyClosePage({ onNavigate }: MonthlyClosePageProps) {
|
||||
<div className="flex flex-col gap-3 xl:flex-row xl:items-end xl:justify-between">
|
||||
<div>
|
||||
<h2 className="text-2xl font-semibold tracking-normal">Monatsabschluss</h2>
|
||||
<p className="text-sm text-muted-foreground">Offene Bewertungen, Teamspace-Abgleich und der eigentliche Monatsabschluss.</p>
|
||||
<p className="text-sm text-muted-foreground">Offene Bewertungen und TS-API-Abgleich für {periodAttentionLabel}; der Abschluss selbst bleibt monatlich.</p>
|
||||
</div>
|
||||
<div className="grid gap-2 sm:grid-cols-[auto_minmax(220px,300px)_auto] sm:items-end">
|
||||
<div className="space-y-1">
|
||||
<span className="text-xs font-medium text-muted-foreground">Ansicht</span>
|
||||
<div className="inline-flex h-9 rounded-md border bg-muted/20 p-1">
|
||||
<Button type="button" size="sm" variant={viewMode === "week" ? "default" : "ghost"} className="h-7 px-3" onClick={() => setViewMode("week")}>
|
||||
Woche
|
||||
</Button>
|
||||
<Button type="button" size="sm" variant={viewMode === "month" ? "default" : "ghost"} className="h-7 px-3" onClick={() => setViewMode("month")}>
|
||||
Monat
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid gap-2 sm:grid-cols-[minmax(220px,300px)_auto] sm:items-end">
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<label className="text-xs font-medium text-muted-foreground" htmlFor="close-month">Monat</label>
|
||||
<label className="text-xs font-medium text-muted-foreground" htmlFor="close-period">{periodLabel}</label>
|
||||
{loading ? <span className="text-xs text-muted-foreground">lädt...</span> : null}
|
||||
</div>
|
||||
<div className="grid grid-cols-[2.25rem_minmax(0,1fr)_2.25rem] gap-1">
|
||||
<Button type="button" size="icon" variant="secondary" className="h-9 w-9" onClick={() => changeMonthBy(-1)} aria-label="Monat zurück">
|
||||
<Button type="button" size="icon" variant="secondary" className="h-9 w-9" onClick={() => (isMonthView ? changeMonthBy(-1) : changeWeekBy(-1))} aria-label={`${periodLabel} zurück`}>
|
||||
<ChevronLeft className="size-4" />
|
||||
</Button>
|
||||
<Input id="close-month" className="h-9" type="month" value={month} onChange={(event) => setMonth(event.currentTarget.value)} />
|
||||
<Button type="button" size="icon" variant="secondary" className="h-9 w-9" onClick={() => changeMonthBy(1)} aria-label="Monat vor">
|
||||
<Input
|
||||
id="close-period"
|
||||
className="h-9"
|
||||
type={isMonthView ? "month" : "week"}
|
||||
value={period}
|
||||
onChange={(event) => (isMonthView ? setMonth(event.currentTarget.value) : setWeek(event.currentTarget.value))}
|
||||
/>
|
||||
<Button type="button" size="icon" variant="secondary" className="h-9 w-9" onClick={() => (isMonthView ? changeMonthBy(1) : changeWeekBy(1))} aria-label={`${periodLabel} vor`}>
|
||||
<ChevronRight className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{stats?.closed ? (
|
||||
{!isMonthView ? (
|
||||
<Badge variant="outline" className="h-9 justify-center px-3">
|
||||
Abschluss nur monatlich
|
||||
</Badge>
|
||||
) : stats?.closed ? (
|
||||
<Button size="sm" variant="secondary" disabled={reopening} onClick={reopenMonth}>
|
||||
<LockOpen className="size-4" />
|
||||
{reopening ? "Öffnet..." : "Monat öffnen"}
|
||||
@@ -339,7 +469,7 @@ export function MonthlyClosePage({ onNavigate }: MonthlyClosePageProps) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{stats?.closed ? (
|
||||
{isMonthView && stats?.closed ? (
|
||||
<Alert variant="success" className="flex items-center gap-2 py-3">
|
||||
<CheckCircle2 className="size-4 shrink-0" />
|
||||
<span>Dieser Monat wurde am {formatDateTime(stats.closedAt!)} abgeschlossen.</span>
|
||||
@@ -347,11 +477,35 @@ export function MonthlyClosePage({ onNavigate }: MonthlyClosePageProps) {
|
||||
) : null}
|
||||
|
||||
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-5">
|
||||
<StatusCard label="Sessions" value={stats?.totals.sessions ?? 0} detail={`${formatMinutes(stats?.totals.minutes ?? 0)} abgerechnet`} />
|
||||
<StatusCard label="Teamspace" value={formatMinutes(stats?.totals.crmBilledMinutes ?? 0)} detail={`Differenz ${formatTeamspaceDelta(stats?.totals.crmDeltaMinutes ?? 0)}`} />
|
||||
<StatusCard label="Offene Bewertungen" value={openSessions.length} detail={`${openTicketGroups.length} Ticket(s)`} tone={openSessions.length > 0 ? "warn" : "ok"} />
|
||||
<StatusCard label="Ohne Teamspace" value={missingCrmDays.length} detail={`${acknowledgedMissingCrmDays.length} zur Kenntnis genommen`} tone={missingCrmDays.length > 0 ? "warn" : "ok"} />
|
||||
<StatusCard label="Status" value={stats?.closed ? "geschlossen" : canClose ? "bereit" : "offen"} detail={stats?.closed ? "Monat ist gesperrt" : canClose ? "kann abgeschlossen werden" : "Prüfpunkte bearbeiten"} tone={stats?.closed || canClose ? "ok" : "warn"} />
|
||||
<StatusCard
|
||||
label="Tickettracker-Werte"
|
||||
value={stats?.totals.sessions ?? 0}
|
||||
detail={`${formatMinutes(stats?.totals.minutes ?? 0)} abgerechnet`}
|
||||
/>
|
||||
<StatusCard
|
||||
label="TT-Teamspace"
|
||||
value={formatMinutes(ttTeamspaceMinutes)}
|
||||
detail={`aus TS API ${formatMinutes(teamspaceApiBillableMinutes)}\n${formatMissingTarget(teamspaceApiDeltaMinutes, teamspaceTimeTrackingEnabled)}`}
|
||||
tone={teamspaceApiDeltaMinutes !== 0 ? "warn" : "ok"}
|
||||
/>
|
||||
<StatusCard
|
||||
label="Tickets zu prüfen"
|
||||
value={activeReviewTicketGroups.length}
|
||||
detail={`${openSessions.length} offene Bewertung(en)\n${teamspaceAttentionTicketCount} Ticket(s) mit TS-Prüfpunkten`}
|
||||
tone={activeReviewTicketGroups.length > 0 ? "warn" : "ok"}
|
||||
/>
|
||||
<StatusCard
|
||||
label="Ohne TT-Teamspace"
|
||||
value={missingCrmTicketCount}
|
||||
detail={`${missingCrmDays.length} Tag(e) · ${acknowledgedMissingCrmDays.length} zur Kenntnis`}
|
||||
tone={missingCrmDays.length > 0 ? "warn" : "ok"}
|
||||
/>
|
||||
<StatusCard
|
||||
label="Status"
|
||||
value={isMonthView ? (stats?.closed ? "geschlossen" : canClose ? "bereit" : "offen") : "Ansicht"}
|
||||
detail={isMonthView ? (stats?.closed ? "Monat ist gesperrt" : canClose ? "kann abgeschlossen werden" : "Prüfpunkte bearbeiten") : "Abschluss bleibt monatlich"}
|
||||
tone={isMonthView ? (stats?.closed || canClose ? "ok" : "warn") : "neutral"}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
@@ -381,8 +535,11 @@ export function MonthlyClosePage({ onNavigate }: MonthlyClosePageProps) {
|
||||
<CardHeader className="p-4">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div>
|
||||
<CardTitle>Aufräumen</CardTitle>
|
||||
<CardDescription>Alles, was für diesen Monat noch Aufmerksamkeit braucht.</CardDescription>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<FileWarning className="size-4 text-muted-foreground" />
|
||||
Prüfliste nach Ticket
|
||||
</CardTitle>
|
||||
<CardDescription>Offene Bewertungen, fehlende Tageswerte sowie Monats- und Tagesabweichungen – je Ticket einmal in festen Statusspalten.</CardDescription>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<div className="inline-flex h-8 items-center gap-2 rounded-md border bg-background px-3 text-sm">
|
||||
@@ -397,38 +554,37 @@ export function MonthlyClosePage({ onNavigate }: MonthlyClosePageProps) {
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="grid gap-4 px-4 pb-4 xl:grid-cols-2">
|
||||
<section className="space-y-2">
|
||||
<div className="flex min-h-8 items-center gap-2 text-sm font-medium">
|
||||
<FileWarning className="size-4 text-muted-foreground" />
|
||||
Offene Bewertungen
|
||||
<CardContent className="space-y-3 px-4 pb-4">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2 text-xs text-muted-foreground">
|
||||
<span>{visibleReviewTicketGroups.length} Ticket(s) angezeigt</span>
|
||||
<span>Tagesdetails stehen im jeweiligen Ticket</span>
|
||||
</div>
|
||||
{openTicketGroups.map((group) => (
|
||||
<OpenTicketItem key={group.ticketId} group={group} month={month} onNavigate={onNavigate} />
|
||||
))}
|
||||
{openTicketGroups.length === 0 ? <p className="rounded-md border bg-muted/20 p-3 text-sm text-muted-foreground">Keine offenen Bewertungen.</p> : null}
|
||||
</section>
|
||||
|
||||
<section className="space-y-2">
|
||||
<div className="flex min-h-8 items-center gap-2 text-sm font-medium">
|
||||
<Sparkles className="size-4 text-muted-foreground" />
|
||||
Tage ohne Teamspace-Wert
|
||||
{visibleReviewTicketGroups.length > 0 ? (
|
||||
<div className="space-y-2">
|
||||
<div className={`hidden items-end gap-2 border border-transparent px-3 text-[11px] font-medium leading-tight uppercase tracking-wide text-muted-foreground xl:grid ${monthlyCloseTicketGridClassName}`}>
|
||||
<span>Ticket</span>
|
||||
<span className="text-center">Offene<br />Bewertungen</span>
|
||||
<span className="text-center">Monatsabweichung<br />TT / TS</span>
|
||||
<span className="text-center">Fehlender<br />TT-Wert</span>
|
||||
<span className="text-center">Tagesabweichung<br />TT / TS</span>
|
||||
<span className="text-center">TT-Wert ohne<br />Abrechnung</span>
|
||||
<span className="sr-only">Aktionen</span>
|
||||
</div>
|
||||
{visibleMissingCrmDays.map((item) => (
|
||||
<CrmDayItem
|
||||
key={`${item.ticket_id}-${item.day}-missing`}
|
||||
item={item}
|
||||
busy={acknowledgingKeys.has(`${item.ticket_id}:${item.day}`)}
|
||||
onSetAcknowledgement={setMissingCrmAcknowledgement}
|
||||
{visibleReviewTicketGroups.map((group) => (
|
||||
<MonthlyCloseTicketItem
|
||||
key={group.ticketId}
|
||||
group={group}
|
||||
onNavigate={onNavigate}
|
||||
/>
|
||||
))}
|
||||
{visibleMissingCrmDays.length === 0 ? (
|
||||
</div>
|
||||
) : (
|
||||
<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."}
|
||||
{acknowledgedMissingCrmDays.length > 0 && !showAcknowledgedMissingCrm
|
||||
? "Keine offenen Prüfpunkte. Zur Kenntnis genommene Tageswerte können eingeblendet werden."
|
||||
: "Keine offenen Prüfpunkte für diesen Zeitraum."}
|
||||
</p>
|
||||
) : null}
|
||||
</section>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -437,8 +593,8 @@ export function MonthlyClosePage({ onNavigate }: MonthlyClosePageProps) {
|
||||
<CardContent className="grid place-items-center gap-2 p-8 text-center">
|
||||
<Clock3 className="size-8 text-muted-foreground" />
|
||||
<div>
|
||||
<p className="font-medium">Keine Sessions in diesem Monat</p>
|
||||
<p className="text-sm text-muted-foreground">Ein Monatsabschluss ist erst sinnvoll, wenn Sessions vorhanden sind.</p>
|
||||
<p className="font-medium">Keine Sessions in {periodInLabel}</p>
|
||||
<p className="text-sm text-muted-foreground">Die Kontrollansicht ist erst sinnvoll, wenn Sessions vorhanden sind.</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -1,12 +1,16 @@
|
||||
import { FormEvent, useEffect, useState } from "react";
|
||||
import { KeyRound, Save, Unplug, UserCircle, X } from "lucide-react";
|
||||
import { Clock3, ClipboardCheck, FileText, KeyRound, MailPlus, RefreshCw, Save, Send, Unplug, UserCircle, X } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { HelpLink } from "@/components/HelpLink";
|
||||
import { updateCurrentUser } from "../api";
|
||||
import type { AuthUser } from "../types";
|
||||
import { ListClockIcon } from "@/components/icons/ListClockIcon";
|
||||
import { getZammadSessionArticleQueue, retryZammadSessionArticleQueue, updateCurrentUser } from "../api";
|
||||
import { formatDateTime, formatMinutes } from "../format";
|
||||
import type { AuthUser, ZammadSessionArticleQueueItem } from "../types";
|
||||
import { zammadArticleTypeFromValue, zammadArticleTypeLabel, zammadArticleTypeOptions, zammadArticleTypeValue, type ZammadArticleTypeId } from "../zammad-article-types";
|
||||
|
||||
type ProfilePageProps = {
|
||||
currentUser: AuthUser;
|
||||
@@ -21,6 +25,27 @@ export function ProfilePage({ currentUser, onUserUpdated }: ProfilePageProps) {
|
||||
const [confirmPassword, setConfirmPassword] = useState("");
|
||||
const [zammadApiToken, setZammadApiToken] = useState("");
|
||||
const [zammadTokenDirty, setZammadTokenDirty] = useState(false);
|
||||
const [teamspaceDeviceId, setTeamspaceDeviceId] = useState("");
|
||||
const [teamspaceDevicePassword, setTeamspaceDevicePassword] = useState("");
|
||||
const [teamspaceCredentialsDirty, setTeamspaceCredentialsDirty] = useState(false);
|
||||
const [zammadEmailSessionWebhookEnabled, setZammadEmailSessionWebhookEnabled] = useState(currentUser.zammad_email_session_webhook_enabled);
|
||||
const [zammadAtossEmailSyncEnabled, setZammadAtossEmailSyncEnabled] = useState(currentUser.zammad_atoss_email_sync_enabled);
|
||||
const [sessionBillingPromptEnabled, setSessionBillingPromptEnabled] = useState(currentUser.session_billing_prompt_enabled);
|
||||
const [sessionWorkSegmentsEnabled, setSessionWorkSegmentsEnabled] = useState(currentUser.session_work_segments_enabled);
|
||||
const [teamspaceTimeTrackingEnabled, setTeamspaceTimeTrackingEnabled] = useState(currentUser.teamspace_time_tracking_enabled ?? true);
|
||||
const [zammadSessionNoteDefaultEnabled, setZammadSessionNoteDefaultEnabled] = useState(currentUser.zammad_session_note_default_enabled);
|
||||
const [zammadSessionNoteDateHeaderEnabled, setZammadSessionNoteDateHeaderEnabled] = useState(currentUser.zammad_session_note_date_header_enabled);
|
||||
const [zammadTimeUnitRoundingEnabled, setZammadTimeUnitRoundingEnabled] = useState(currentUser.zammad_time_unit_rounding_enabled);
|
||||
const [zammadTimeUnitRoundingMinutes, setZammadTimeUnitRoundingMinutes] = useState(currentUser.zammad_time_unit_rounding_minutes);
|
||||
const [zammadArticleTypeId, setZammadArticleTypeId] = useState<ZammadArticleTypeId>(currentUser.zammad_article_type_id ?? 6);
|
||||
const [projectFollowupEnabled, setProjectFollowupEnabled] = useState(currentUser.project_followup_enabled ?? true);
|
||||
const [projectFollowupWeeks, setProjectFollowupWeeks] = useState(currentUser.project_followup_weeks ?? 4);
|
||||
const [projectFollowupSecondEnabled, setProjectFollowupSecondEnabled] = useState(currentUser.project_followup_second_enabled ?? true);
|
||||
const [projectFollowupSecondWeeks, setProjectFollowupSecondWeeks] = useState(currentUser.project_followup_second_weeks ?? 2);
|
||||
const [projectFollowupCompleteOnSession, setProjectFollowupCompleteOnSession] = useState(currentUser.project_followup_complete_on_session ?? true);
|
||||
const [zammadQueue, setZammadQueue] = useState<ZammadSessionArticleQueueItem[]>([]);
|
||||
const [loadingZammadQueue, setLoadingZammadQueue] = useState(false);
|
||||
const [retryingZammadQueue, setRetryingZammadQueue] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -28,8 +53,75 @@ export function ProfilePage({ currentUser, onUserUpdated }: ProfilePageProps) {
|
||||
setDisplayName(currentUser.display_name);
|
||||
setZammadApiToken("");
|
||||
setZammadTokenDirty(false);
|
||||
setTeamspaceDeviceId("");
|
||||
setTeamspaceDevicePassword("");
|
||||
setTeamspaceCredentialsDirty(false);
|
||||
setZammadEmailSessionWebhookEnabled(currentUser.zammad_email_session_webhook_enabled);
|
||||
setZammadAtossEmailSyncEnabled(currentUser.zammad_atoss_email_sync_enabled);
|
||||
setSessionBillingPromptEnabled(currentUser.session_billing_prompt_enabled);
|
||||
setSessionWorkSegmentsEnabled(currentUser.session_work_segments_enabled);
|
||||
setTeamspaceTimeTrackingEnabled(currentUser.teamspace_time_tracking_enabled ?? true);
|
||||
setZammadSessionNoteDefaultEnabled(currentUser.zammad_session_note_default_enabled);
|
||||
setZammadSessionNoteDateHeaderEnabled(currentUser.zammad_session_note_date_header_enabled);
|
||||
setZammadTimeUnitRoundingEnabled(currentUser.zammad_time_unit_rounding_enabled);
|
||||
setZammadTimeUnitRoundingMinutes(currentUser.zammad_time_unit_rounding_minutes);
|
||||
setZammadArticleTypeId(currentUser.zammad_article_type_id ?? 6);
|
||||
setProjectFollowupEnabled(currentUser.project_followup_enabled ?? true);
|
||||
setProjectFollowupWeeks(currentUser.project_followup_weeks ?? 4);
|
||||
setProjectFollowupSecondEnabled(currentUser.project_followup_second_enabled ?? true);
|
||||
setProjectFollowupSecondWeeks(currentUser.project_followup_second_weeks ?? 2);
|
||||
setProjectFollowupCompleteOnSession(currentUser.project_followup_complete_on_session ?? true);
|
||||
}, [currentUser]);
|
||||
|
||||
async function loadZammadQueue() {
|
||||
if (currentUser.role !== "user") {
|
||||
setZammadQueue([]);
|
||||
return;
|
||||
}
|
||||
|
||||
setLoadingZammadQueue(true);
|
||||
|
||||
try {
|
||||
const result = await getZammadSessionArticleQueue();
|
||||
setZammadQueue(result.queue);
|
||||
} catch (error) {
|
||||
toast.error("Zammad-Parktabelle konnte nicht geladen werden", {
|
||||
description: error instanceof Error ? error.message : "Unbekannter Fehler"
|
||||
});
|
||||
} finally {
|
||||
setLoadingZammadQueue(false);
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
void loadZammadQueue();
|
||||
}, [currentUser.role]);
|
||||
|
||||
async function retryZammadQueue() {
|
||||
setRetryingZammadQueue(true);
|
||||
|
||||
try {
|
||||
const result = await retryZammadSessionArticleQueue();
|
||||
setZammadQueue(result.queue);
|
||||
|
||||
if (result.failed > 0) {
|
||||
toast.warning("Zammad-Notizen teilweise gesendet", {
|
||||
description: `${result.sent} gesendet, ${result.failed} weiter geparkt.`
|
||||
});
|
||||
} else {
|
||||
toast.success("Zammad-Notizen gesendet", {
|
||||
description: `${result.sent} Eintrag/Einträge erfolgreich übertragen.`
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error("Zammad-Notizen konnten nicht gesendet werden", {
|
||||
description: error instanceof Error ? error.message : "Unbekannter Fehler"
|
||||
});
|
||||
} finally {
|
||||
setRetryingZammadQueue(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function submit(event: FormEvent) {
|
||||
event.preventDefault();
|
||||
|
||||
@@ -54,7 +146,27 @@ export function ProfilePage({ currentUser, onUserUpdated }: ProfilePageProps) {
|
||||
displayName,
|
||||
currentPassword: wantsPasswordChange ? currentPassword : undefined,
|
||||
newPassword: wantsPasswordChange ? newPassword : undefined,
|
||||
...(currentUser.role === "user" && zammadTokenDirty ? { zammadApiToken } : {})
|
||||
...(currentUser.role === "user" && zammadTokenDirty ? { zammadApiToken } : {}),
|
||||
...(currentUser.role === "user" && teamspaceCredentialsDirty ? { teamspaceDeviceId, teamspaceDevicePassword } : {}),
|
||||
...(currentUser.role === "user"
|
||||
? {
|
||||
zammadEmailSessionWebhookEnabled,
|
||||
zammadAtossEmailSyncEnabled,
|
||||
sessionBillingPromptEnabled,
|
||||
sessionWorkSegmentsEnabled,
|
||||
teamspaceTimeTrackingEnabled,
|
||||
zammadSessionNoteDefaultEnabled,
|
||||
zammadSessionNoteDateHeaderEnabled,
|
||||
zammadTimeUnitRoundingEnabled,
|
||||
zammadTimeUnitRoundingMinutes,
|
||||
zammadArticleTypeId,
|
||||
projectFollowupEnabled,
|
||||
projectFollowupWeeks,
|
||||
projectFollowupSecondEnabled,
|
||||
projectFollowupSecondWeeks,
|
||||
projectFollowupCompleteOnSession
|
||||
}
|
||||
: {})
|
||||
};
|
||||
|
||||
try {
|
||||
@@ -65,6 +177,9 @@ export function ProfilePage({ currentUser, onUserUpdated }: ProfilePageProps) {
|
||||
setConfirmPassword("");
|
||||
setZammadApiToken("");
|
||||
setZammadTokenDirty(false);
|
||||
setTeamspaceDeviceId("");
|
||||
setTeamspaceDevicePassword("");
|
||||
setTeamspaceCredentialsDirty(false);
|
||||
toast.success("Profil gespeichert");
|
||||
} catch (error) {
|
||||
toast.error("Profil konnte nicht gespeichert werden", {
|
||||
@@ -181,7 +296,7 @@ export function ProfilePage({ currentUser, onUserUpdated }: ProfilePageProps) {
|
||||
}}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Die Zammad-User-ID wird nur geprüft, wenn du hier einen neuen Token speicherst.
|
||||
Die Zammad-ID wird nur geprüft, wenn du hier einen neuen Token speicherst.
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
@@ -206,6 +321,371 @@ export function ProfilePage({ currentUser, onUserUpdated }: ProfilePageProps) {
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4 rounded-md border bg-background p-3">
|
||||
<div className="mb-3 flex items-center gap-2">
|
||||
<Unplug className="size-4 text-muted-foreground" />
|
||||
<p className="text-sm font-medium">Teamspace API</p>
|
||||
</div>
|
||||
<div className="grid gap-3 md:grid-cols-[minmax(180px,220px)_minmax(220px,1fr)_180px]">
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium" htmlFor="profile-teamspace-device-id">
|
||||
Geräte-ID
|
||||
</label>
|
||||
<Input
|
||||
id="profile-teamspace-device-id"
|
||||
value={teamspaceDeviceId}
|
||||
placeholder={currentUser.has_teamspace_credentials ? "hinterlegt" : "Geräte-ID"}
|
||||
autoComplete="off"
|
||||
onChange={(event) => {
|
||||
setTeamspaceDeviceId(event.currentTarget.value);
|
||||
setTeamspaceCredentialsDirty(true);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium" htmlFor="profile-teamspace-device-password">
|
||||
Gerätepasswort
|
||||
</label>
|
||||
<Input
|
||||
id="profile-teamspace-device-password"
|
||||
type="password"
|
||||
value={teamspaceDevicePassword}
|
||||
placeholder={currentUser.has_teamspace_credentials ? "••••••••••••••••" : "Token aus Teamspace einfügen"}
|
||||
autoComplete="off"
|
||||
onChange={(event) => {
|
||||
setTeamspaceDevicePassword(event.currentTarget.value);
|
||||
setTeamspaceCredentialsDirty(true);
|
||||
}}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">Die Teamspace-ID wird nur geprüft, wenn du neue Zugangsdaten speicherst.</p>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium" htmlFor="profile-teamspace-user-id">
|
||||
Teamspace User-ID
|
||||
</label>
|
||||
<Input id="profile-teamspace-user-id" value={currentUser.teamspace_user_id ?? "nicht verknüpft"} readOnly />
|
||||
{currentUser.has_teamspace_credentials ? (
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
className="w-full"
|
||||
onClick={() => {
|
||||
setTeamspaceDeviceId("");
|
||||
setTeamspaceDevicePassword("");
|
||||
setTeamspaceCredentialsDirty(true);
|
||||
}}
|
||||
>
|
||||
<X className="size-4" />
|
||||
Zugang entfernen
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3 flex items-center justify-between gap-3 rounded-md border bg-background p-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Clock3 className="size-4 text-muted-foreground" />
|
||||
<div>
|
||||
<label className="text-sm font-medium" htmlFor="profile-teamspace-time-tracking">
|
||||
Teamspace-Zeiten in TT erfassen
|
||||
</label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Wenn aus, werden keine Teamspace-Tageswerte in TT erfasst; Monatsprüfung und Abschluss laufen nur über Session-Status.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Switch
|
||||
id="profile-teamspace-time-tracking"
|
||||
checked={teamspaceTimeTrackingEnabled}
|
||||
onCheckedChange={setTeamspaceTimeTrackingEnabled}
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-4 flex items-center justify-between gap-3 rounded-md border bg-background p-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<MailPlus className="size-4 text-muted-foreground" />
|
||||
<div>
|
||||
<label className="text-sm font-medium" htmlFor="profile-zammad-email-session-webhook">
|
||||
eMail-Sessions aus Zammad
|
||||
</label>
|
||||
<p className="text-xs text-muted-foreground">Ausgehende Zammad-eMails automatisch als Session übernehmen.</p>
|
||||
</div>
|
||||
</div>
|
||||
<Switch
|
||||
id="profile-zammad-email-session-webhook"
|
||||
checked={zammadEmailSessionWebhookEnabled}
|
||||
onCheckedChange={setZammadEmailSessionWebhookEnabled}
|
||||
/>
|
||||
</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">
|
||||
<MailPlus className="size-4 text-muted-foreground" />
|
||||
<div>
|
||||
<label className="text-sm font-medium" htmlFor="profile-zammad-atoss-email-sync">
|
||||
ATOSS eMails syncen
|
||||
</label>
|
||||
<p className="text-xs text-muted-foreground">Wenn aus, werden eMails an support.atc@atoss.com nicht übernommen.</p>
|
||||
</div>
|
||||
</div>
|
||||
<Switch
|
||||
id="profile-zammad-atoss-email-sync"
|
||||
checked={zammadAtossEmailSyncEnabled}
|
||||
onCheckedChange={setZammadAtossEmailSyncEnabled}
|
||||
/>
|
||||
</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">
|
||||
<ClipboardCheck className="size-4 text-muted-foreground" />
|
||||
<div>
|
||||
<label className="text-sm font-medium" htmlFor="profile-session-billing-prompt">
|
||||
Bewertungsabfrage beim Timerabschluss
|
||||
</label>
|
||||
<p className="text-xs text-muted-foreground">Bei Bewertung --- vor dem Speichern noch einmal nachfragen.</p>
|
||||
</div>
|
||||
</div>
|
||||
<Switch
|
||||
id="profile-session-billing-prompt"
|
||||
checked={sessionBillingPromptEnabled}
|
||||
onCheckedChange={setSessionBillingPromptEnabled}
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-3 flex items-center justify-between gap-3 rounded-md border bg-background p-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<ListClockIcon className="size-4 text-muted-foreground" />
|
||||
<div>
|
||||
<label className="text-sm font-medium" htmlFor="profile-session-work-segments">
|
||||
Arbeitsbereiche bei Pausen speichern
|
||||
</label>
|
||||
<p className="text-xs text-muted-foreground">Beim Pausieren und Fortsetzen die konkreten Von-bis-Zeiten an der Session merken.</p>
|
||||
</div>
|
||||
</div>
|
||||
<Switch
|
||||
id="profile-session-work-segments"
|
||||
checked={sessionWorkSegmentsEnabled}
|
||||
onCheckedChange={setSessionWorkSegmentsEnabled}
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-3 space-y-3 rounded-md border bg-background p-3">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<label className="text-sm font-medium" htmlFor="profile-project-followup-enabled">
|
||||
Automatische Wiedervorlagen
|
||||
</label>
|
||||
<p className="text-xs text-muted-foreground">Consulting-Projekte automatisch auf Wiedervorlage setzen.</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="profile-project-followup-enabled"
|
||||
checked={projectFollowupEnabled}
|
||||
onCheckedChange={setProjectFollowupEnabled}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{projectFollowupEnabled ? (
|
||||
<div className="space-y-3 border-t pt-3">
|
||||
<div>
|
||||
<p className="text-sm font-medium">1. Wiedervorlage aktiv</p>
|
||||
<p className="text-xs text-muted-foreground">Wenn automatische Wiedervorlagen aktiv sind, wird die erste WVL immer geprüft.</p>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2 sm:max-w-xs">
|
||||
<label className="text-sm font-medium" htmlFor="profile-project-followup-weeks">
|
||||
1. Wiedervorlage nach Wochen
|
||||
</label>
|
||||
<Input
|
||||
id="profile-project-followup-weeks"
|
||||
type="number"
|
||||
min={1}
|
||||
max={52}
|
||||
value={projectFollowupWeeks}
|
||||
onChange={(event) => setProjectFollowupWeeks(Math.max(1, Math.min(52, Number(event.currentTarget.value) || 1)))}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<label className="text-sm font-medium" htmlFor="profile-project-followup-second-enabled">
|
||||
2. Wiedervorlage
|
||||
</label>
|
||||
<p className="text-xs text-muted-foreground">Nur anlegen, wenn die erste WVL erledigt oder gelöscht wurde und keine Session entstanden ist.</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="profile-project-followup-second-enabled"
|
||||
checked={projectFollowupSecondEnabled}
|
||||
onCheckedChange={setProjectFollowupSecondEnabled}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{projectFollowupSecondEnabled ? (
|
||||
<div className="grid gap-2 sm:max-w-xs">
|
||||
<label className="text-sm font-medium" htmlFor="profile-project-followup-second-weeks">
|
||||
2. Wiedervorlage nach weiteren Wochen
|
||||
</label>
|
||||
<Input
|
||||
id="profile-project-followup-second-weeks"
|
||||
type="number"
|
||||
min={1}
|
||||
max={52}
|
||||
value={projectFollowupSecondWeeks}
|
||||
onChange={(event) => setProjectFollowupSecondWeeks(Math.max(1, Math.min(52, Number(event.currentTarget.value) || 1)))}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<label className="text-sm font-medium" htmlFor="profile-project-followup-complete-on-session">
|
||||
Automatische Wiedervorlagen durch Session erledigen
|
||||
</label>
|
||||
<p className="text-xs text-muted-foreground">Gilt für beide Zeiträume. Betrifft nur automatisch erzeugte Wiedervorlagen, niemals manuelle ToDos.</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="profile-project-followup-complete-on-session"
|
||||
checked={projectFollowupCompleteOnSession}
|
||||
onCheckedChange={setProjectFollowupCompleteOnSession}
|
||||
/>
|
||||
</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">
|
||||
<FileText className="size-4 text-muted-foreground" />
|
||||
<div>
|
||||
<label className="text-sm font-medium" htmlFor="profile-zammad-session-note">
|
||||
Interne Zammad-Notiz beim Timerabschluss
|
||||
</label>
|
||||
<p className="text-xs text-muted-foreground">Checkbox im Speichern-Dialog standardmäßig aktivieren.</p>
|
||||
</div>
|
||||
</div>
|
||||
<Switch
|
||||
id="profile-zammad-session-note"
|
||||
checked={zammadSessionNoteDefaultEnabled}
|
||||
onCheckedChange={(checked) => {
|
||||
setZammadSessionNoteDefaultEnabled(checked);
|
||||
|
||||
if (!checked) {
|
||||
setZammadTimeUnitRoundingEnabled(false);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-3 rounded-md border bg-background p-3">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<FileText className="size-4 text-muted-foreground" />
|
||||
<div>
|
||||
<label className="text-sm font-medium" htmlFor="profile-zammad-date-header">
|
||||
Datum bei nachgetragenen Sessions voranstellen
|
||||
</label>
|
||||
<p className="text-xs text-muted-foreground">Bei Einträgen vor heute wird nur in der Zammad-Notiz das Datum mit Leerzeile eingefügt.</p>
|
||||
</div>
|
||||
</div>
|
||||
<Switch
|
||||
id="profile-zammad-date-header"
|
||||
checked={zammadSessionNoteDateHeaderEnabled}
|
||||
onCheckedChange={setZammadSessionNoteDateHeaderEnabled}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{zammadSessionNoteDefaultEnabled ? (
|
||||
<div className="mt-3 space-y-3">
|
||||
<div className="rounded-md border bg-background p-3">
|
||||
<div className="grid gap-2 sm:max-w-lg">
|
||||
<label className="text-sm font-medium" htmlFor="profile-zammad-article-type">
|
||||
Standard-Typ
|
||||
</label>
|
||||
<select
|
||||
id="profile-zammad-article-type"
|
||||
className="h-9 rounded-md border border-input bg-background px-3 text-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
value={zammadArticleTypeValue(zammadArticleTypeId)}
|
||||
onChange={(event) => setZammadArticleTypeId(zammadArticleTypeFromValue(event.currentTarget.value))}
|
||||
>
|
||||
{zammadArticleTypeOptions.map((option) => (
|
||||
<option key={option.value || "empty"} value={option.value}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<p className="text-xs text-muted-foreground">Wird beim Erstellen interner Zammad-Notizen vorbelegt.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-md border bg-background p-3">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Clock3 className="size-4 text-muted-foreground" />
|
||||
<div>
|
||||
<label className="text-sm font-medium" htmlFor="profile-zammad-time-rounding">
|
||||
Zammad-Zeit aufrunden
|
||||
</label>
|
||||
<p className="text-xs text-muted-foreground">Ohne Rundung wird die konkrete Zeit als Stundenwert mit 2 Nachkommastellen gesendet.</p>
|
||||
</div>
|
||||
</div>
|
||||
<Switch
|
||||
id="profile-zammad-time-rounding"
|
||||
checked={zammadTimeUnitRoundingEnabled}
|
||||
onCheckedChange={setZammadTimeUnitRoundingEnabled}
|
||||
/>
|
||||
</div>
|
||||
{zammadTimeUnitRoundingEnabled ? (
|
||||
<div className="mt-3 grid gap-2 sm:w-56">
|
||||
<label className="text-xs font-medium text-muted-foreground" htmlFor="profile-zammad-rounding-minutes">
|
||||
Rundungsintervall
|
||||
</label>
|
||||
<select
|
||||
id="profile-zammad-rounding-minutes"
|
||||
className="h-9 rounded-md border border-input bg-background px-3 text-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
value={zammadTimeUnitRoundingMinutes}
|
||||
onChange={(event) => setZammadTimeUnitRoundingMinutes(Number(event.currentTarget.value))}
|
||||
>
|
||||
<option value={5}>5 Minuten</option>
|
||||
<option value={10}>10 Minuten</option>
|
||||
<option value={15}>15 Minuten</option>
|
||||
<option value={30}>30 Minuten</option>
|
||||
</select>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="mt-3 rounded-md border bg-background p-3">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div className="flex items-start gap-2">
|
||||
<Send className="mt-0.5 size-4 text-muted-foreground" />
|
||||
<div>
|
||||
<p className="text-sm font-medium">Geparkte Zammad-Notizen</p>
|
||||
<p className="text-xs text-muted-foreground">Nicht übertragene interne Notizen nachträglich senden.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button type="button" size="sm" variant="secondary" disabled={loadingZammadQueue} onClick={() => void loadZammadQueue()}>
|
||||
<RefreshCw className="size-3.5" />
|
||||
Prüfen
|
||||
</Button>
|
||||
<Button type="button" size="sm" disabled={zammadQueue.length === 0 || retryingZammadQueue} onClick={() => void retryZammadQueue()}>
|
||||
<Send className="size-3.5" />
|
||||
{retryingZammadQueue ? "Sendet..." : "Senden"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3 space-y-2">
|
||||
{loadingZammadQueue ? <p className="text-xs text-muted-foreground">Lädt...</p> : null}
|
||||
{!loadingZammadQueue && zammadQueue.length === 0 ? <p className="text-xs text-muted-foreground">Keine geparkten Zammad-Notizen.</p> : null}
|
||||
{zammadQueue.slice(0, 5).map((item) => (
|
||||
<div key={item.id} className="rounded-md border bg-muted/30 p-2 text-xs">
|
||||
<div className="flex flex-wrap items-center gap-x-2 gap-y-1">
|
||||
<span className="font-medium">{item.ticket_number}</span>
|
||||
<span className="text-muted-foreground">{formatDateTime(item.started_at)}</span>
|
||||
<span className="text-muted-foreground">{formatMinutes(item.rounded_minutes)}</span>
|
||||
<span className="text-muted-foreground">time_unit {item.time_unit}</span>
|
||||
<span className="text-muted-foreground">{zammadArticleTypeLabel(item.zammad_article_type_id)}</span>
|
||||
</div>
|
||||
{item.last_error ? <p className="mt-1 line-clamp-2 text-destructive">{item.last_error}</p> : null}
|
||||
</div>
|
||||
))}
|
||||
{zammadQueue.length > 5 ? <p className="text-xs text-muted-foreground">Weitere {zammadQueue.length - 5} Einträge werden beim Senden ebenfalls berücksichtigt.</p> : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
|
||||
+820
-253
File diff suppressed because it is too large.
Load diff
@@ -26,6 +26,9 @@ type RecurringFormState = {
|
||||
organizationName: string | null;
|
||||
activity: string;
|
||||
workType: WorkType;
|
||||
teamspaceProject: string;
|
||||
hourBudgetHours: string;
|
||||
manualConsumptionHours: string;
|
||||
recurrenceType: "weekly" | "every_n_weeks";
|
||||
intervalValue: number;
|
||||
validFrom: string;
|
||||
@@ -50,6 +53,9 @@ function emptyRecurringForm(): RecurringFormState {
|
||||
organizationName: null,
|
||||
activity: "",
|
||||
workType: "support",
|
||||
teamspaceProject: "",
|
||||
hourBudgetHours: "",
|
||||
manualConsumptionHours: "",
|
||||
recurrenceType: "weekly",
|
||||
intervalValue: 1,
|
||||
validFrom: currentDay(),
|
||||
@@ -118,6 +124,9 @@ export function RecurringBillingsPage() {
|
||||
organizationName: billing.organization_name,
|
||||
activity: billing.activity,
|
||||
workType: billing.work_type,
|
||||
teamspaceProject: billing.teamspace_project ?? "",
|
||||
hourBudgetHours: billing.hour_budget_minutes === null ? "" : durationHoursLabel(billing.hour_budget_minutes),
|
||||
manualConsumptionHours: billing.manual_consumption_minutes > 0 ? durationHoursLabel(billing.manual_consumption_minutes) : "",
|
||||
recurrenceType: billing.recurrence_type,
|
||||
intervalValue: billing.recurrence_type === "weekly" ? 1 : billing.interval_value,
|
||||
validFrom: billing.valid_from,
|
||||
@@ -162,8 +171,26 @@ export function RecurringBillingsPage() {
|
||||
}
|
||||
|
||||
let slots: Array<{ id?: string; weekday: number | null; startTime: string; durationMinutes: number }>;
|
||||
let hourBudgetMinutes: number | null;
|
||||
let manualConsumptionMinutes: number;
|
||||
|
||||
try {
|
||||
const parseOptionalHours = (value: string, label: string) => {
|
||||
if (!value.trim()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const hours = Number(value.replace(",", "."));
|
||||
|
||||
if (!Number.isFinite(hours) || hours < 0) {
|
||||
throw new Error(`${label} prüfen`);
|
||||
}
|
||||
|
||||
return Math.round(hours * 60);
|
||||
};
|
||||
|
||||
hourBudgetMinutes = parseOptionalHours(recurringForm.hourBudgetHours, "Budget");
|
||||
manualConsumptionMinutes = parseOptionalHours(recurringForm.manualConsumptionHours, "Manuelle Stunden") ?? 0;
|
||||
slots = recurringForm.slots.map((slot) => {
|
||||
const hours = Number(slot.durationHours.replace(",", "."));
|
||||
|
||||
@@ -191,6 +218,9 @@ export function RecurringBillingsPage() {
|
||||
organizationId: recurringForm.organizationId,
|
||||
activity: recurringForm.activity,
|
||||
workType: recurringForm.workType,
|
||||
teamspaceProject: recurringForm.teamspaceProject.trim() || null,
|
||||
hourBudgetMinutes,
|
||||
manualConsumptionMinutes,
|
||||
recurrenceType: recurringForm.recurrenceType,
|
||||
intervalValue: recurringForm.recurrenceType === "weekly" ? 1 : recurringForm.intervalValue,
|
||||
validFrom: recurringForm.validFrom,
|
||||
@@ -355,6 +385,44 @@ export function RecurringBillingsPage() {
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3 lg:grid-cols-[minmax(220px,1fr)_180px_180px]">
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium" htmlFor="recurring-teamspace-project">
|
||||
TS-Projekt (optional)
|
||||
</label>
|
||||
<Input
|
||||
id="recurring-teamspace-project"
|
||||
value={recurringForm.teamspaceProject}
|
||||
onChange={(event) => setRecurringForm({ ...recurringForm, teamspaceProject: event.currentTarget.value })}
|
||||
placeholder="Teamspace-Projekt"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium" htmlFor="recurring-budget-hours">
|
||||
Budget in Stunden (optional)
|
||||
</label>
|
||||
<Input
|
||||
id="recurring-budget-hours"
|
||||
inputMode="decimal"
|
||||
value={recurringForm.hourBudgetHours}
|
||||
onChange={(event) => setRecurringForm({ ...recurringForm, hourBudgetHours: event.currentTarget.value })}
|
||||
placeholder="z. B. 20"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium" htmlFor="recurring-manual-hours">
|
||||
Manuelle Stunden (optional)
|
||||
</label>
|
||||
<Input
|
||||
id="recurring-manual-hours"
|
||||
inputMode="decimal"
|
||||
value={recurringForm.manualConsumptionHours}
|
||||
onChange={(event) => setRecurringForm({ ...recurringForm, manualConsumptionHours: event.currentTarget.value })}
|
||||
placeholder="z. B. 2,5"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium" htmlFor="recurring-activity">
|
||||
Tätigkeit
|
||||
@@ -473,6 +541,9 @@ export function RecurringBillingsPage() {
|
||||
{billing.valid_until ? ` bis ${formatDateLabel(billing.valid_until)}` : ""}
|
||||
</p>
|
||||
<div className="mt-1 flex flex-wrap gap-1">
|
||||
{billing.teamspace_project ? <Badge variant="secondary">TS {billing.teamspace_project}</Badge> : null}
|
||||
{billing.hour_budget_minutes !== null ? <Badge variant="outline">Budget {formatMinutes(billing.hour_budget_minutes)}</Badge> : null}
|
||||
{billing.manual_consumption_minutes > 0 ? <Badge variant="outline">Manuell {formatMinutes(billing.manual_consumption_minutes)}</Badge> : null}
|
||||
{billing.slots.map((slot) => (
|
||||
<Badge key={slot.id} variant="outline">
|
||||
{slot.weekday === null ? "Starttag" : weekdays.find((weekday) => weekday.value === slot.weekday)?.label} · {slot.start_time?.slice(0, 5)} · {formatMinutes(slot.duration_minutes)}
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
import {
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
BellPlus,
|
||||
CircleAlert,
|
||||
Clock3,
|
||||
ExternalLink,
|
||||
ListChecks,
|
||||
RotateCcw,
|
||||
Ticket,
|
||||
TrendingUp,
|
||||
UserCheck,
|
||||
} from "lucide-react";
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
@@ -19,13 +22,15 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/com
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Pagination, PaginationContent, PaginationItem, PaginationNext, PaginationPrevious } from "@/components/ui/pagination";
|
||||
import { Progress } from "@/components/ui/progress";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||
import { HelpLink } from "@/components/HelpLink";
|
||||
import { getStatisticsOverview } from "../api";
|
||||
import { currentMonth, formatDate, formatMinutes } from "../format";
|
||||
import type { StatisticsDailyBucket, StatisticsGroup, StatisticsOverview, StatisticsTicket, WorkType } from "../types";
|
||||
import { getProjectFollowups, getStatisticsOverview } from "../api";
|
||||
import { currentMonth, formatDate, formatDateTime, formatMinutes } from "../format";
|
||||
import type { AuthUser, StatisticsDailyBucket, StatisticsGroup, StatisticsOverview, StatisticsProjectFollowup, StatisticsProjectFollowupOverview, StatisticsTicket, TeamspaceMonthActual, WorkType } from "../types";
|
||||
|
||||
type StatisticsPageProps = {
|
||||
currentUser: AuthUser;
|
||||
onNavigate: (to: string) => void;
|
||||
};
|
||||
|
||||
@@ -36,6 +41,7 @@ const teamspaceStrokeClass = "text-fuchsia-600 dark:text-fuchsia-300";
|
||||
const trackedDotClass = "bg-sky-500 dark:bg-sky-400";
|
||||
const teamspaceDotClass = "bg-fuchsia-500 dark:bg-fuchsia-400";
|
||||
const openTextClass = "text-amber-700 dark:text-amber-300";
|
||||
const nonBillableTextClass = "text-indigo-700 dark:text-indigo-300";
|
||||
const organizationPageSize = 8;
|
||||
|
||||
function workTypeLabel(workType: WorkType | undefined) {
|
||||
@@ -70,6 +76,63 @@ function formatTeamspaceDelta(minutes: number) {
|
||||
return formatSignedMinutes(minutes);
|
||||
}
|
||||
|
||||
function teamspaceActualLabel(actual: TeamspaceMonthActual) {
|
||||
return actual.teamspace_description?.trim() || actual.teamspace_project || actual.teamspace_api_project_id || "Teamspace";
|
||||
}
|
||||
|
||||
function TeamspaceMonthActualsLine({
|
||||
actuals,
|
||||
tsMinutes,
|
||||
deltaMinutes
|
||||
}: {
|
||||
actuals: TeamspaceMonthActual[] | undefined;
|
||||
tsMinutes: number | undefined;
|
||||
deltaMinutes: number | undefined;
|
||||
}) {
|
||||
const visibleActuals = actuals ?? [];
|
||||
|
||||
if (visibleActuals.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const effectiveTsMinutes = tsMinutes ?? visibleActuals.reduce((sum, actual) => sum + actual.teamspace_billable_minutes, 0);
|
||||
const effectiveDeltaMinutes = deltaMinutes ?? visibleActuals.reduce((sum, actual) => sum + actual.delta_minutes, 0);
|
||||
const positions = visibleActuals.flatMap((actual) =>
|
||||
(actual.positions ?? []).map((position) => ({
|
||||
...position,
|
||||
entryLabel: teamspaceActualLabel(actual)
|
||||
}))
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="mt-1 space-y-1 text-[11px] text-muted-foreground">
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
<Badge variant={effectiveDeltaMinutes === 0 ? "success" : "warning"} className="h-5 px-1.5 text-[10px]">
|
||||
TS API {formatMinutes(effectiveTsMinutes)} · Diff {formatTeamspaceDelta(effectiveDeltaMinutes)}
|
||||
</Badge>
|
||||
{visibleActuals
|
||||
.filter((actual) => actual.delta_minutes !== 0)
|
||||
.slice(0, 3)
|
||||
.map((actual) => (
|
||||
<span key={actual.entry_id} className="rounded-sm bg-muted px-1.5 py-0.5">
|
||||
{teamspaceActualLabel(actual)}: {formatTeamspaceDelta(actual.delta_minutes)}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
{positions.length > 0 ? (
|
||||
<div className="flex flex-wrap gap-x-2 gap-y-0.5">
|
||||
{positions.slice(0, 4).map((position) => (
|
||||
<span key={`${position.entryLabel}-${position.apiProjectId}`} title={`${position.entryLabel} · API ${position.apiProjectId}`}>
|
||||
{position.isSubentry ? `Pos. ${position.projectId ?? position.apiProjectId}` : "Hauptprojekt"}: {position.label} · {formatMinutes(position.billableMinutes)}
|
||||
</span>
|
||||
))}
|
||||
{positions.length > 4 ? <span>+{positions.length - 4} weitere</span> : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function deltaTextClass(minutes: number) {
|
||||
return minutes < 0 ? "text-red-700 dark:text-red-300" : "text-emerald-700 dark:text-emerald-300";
|
||||
}
|
||||
@@ -84,27 +147,27 @@ function DeltaBadge({ minutes }: { minutes: number }) {
|
||||
|
||||
function deltaDescription(minutes: number) {
|
||||
if (minutes > 0) {
|
||||
return "Teamspace liegt über der abgerechneten Zeit";
|
||||
return "TT-Teamspace liegt über der Session-Zeit";
|
||||
}
|
||||
|
||||
if (minutes < 0) {
|
||||
return "Teamspace liegt unter der abgerechneten Zeit";
|
||||
return "TT-Teamspace liegt unter der Session-Zeit";
|
||||
}
|
||||
|
||||
return "Teamspace und abgerechnete Zeit sind ausgeglichen";
|
||||
return "TT-Teamspace und Session-Zeit sind ausgeglichen";
|
||||
}
|
||||
|
||||
function TimeComparison({ trackedMinutes, crmMinutes, sessions }: { trackedMinutes: number; crmMinutes: number; sessions?: number }) {
|
||||
return (
|
||||
<div className="space-y-0.5 text-sm">
|
||||
<div className={`font-semibold ${trackedTextClass}`}>Abgerechnet {formatMinutes(trackedMinutes)}</div>
|
||||
<div className={`font-semibold ${teamspaceTextClass}`}>Teamspace {formatMinutes(crmMinutes)}</div>
|
||||
<div className={`font-semibold ${trackedTextClass}`}>Session-Zeit {formatMinutes(trackedMinutes)}</div>
|
||||
<div className={`font-semibold ${teamspaceTextClass}`}>TT-Teamspace {formatMinutes(crmMinutes)}</div>
|
||||
{typeof sessions === "number" ? <div className="text-xs text-muted-foreground">{sessions} Session(s)</div> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ColorLegend() {
|
||||
function ColorLegend({ showDifference = true }: { showDifference?: boolean }) {
|
||||
return (
|
||||
<div className="flex flex-wrap gap-x-4 gap-y-2 rounded-md border bg-muted/20 p-3 text-xs text-muted-foreground">
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
@@ -113,8 +176,18 @@ function ColorLegend() {
|
||||
</span>
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<span className={`h-0.5 w-4 ${teamspaceDotClass}`} />
|
||||
<span className={teamspaceTextClass}>Teamspace</span>
|
||||
<span className={teamspaceTextClass}>TT-Teamspace</span>
|
||||
</span>
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<span className="size-2 rounded-sm bg-slate-500 dark:bg-slate-400" />
|
||||
<span>Abrechenbar</span>
|
||||
</span>
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<span className="size-2 rounded-sm bg-indigo-500 dark:bg-indigo-400" />
|
||||
<span className={nonBillableTextClass}>Nicht abrechenbar</span>
|
||||
</span>
|
||||
{showDifference ? (
|
||||
<>
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<span className="size-2 rounded-full bg-emerald-500 dark:bg-emerald-400" />
|
||||
<span className="text-emerald-700 dark:text-emerald-300">Differenz plus oder ausgeglichen</span>
|
||||
@@ -123,6 +196,8 @@ function ColorLegend() {
|
||||
<span className="size-2 rounded-full bg-red-500 dark:bg-red-400" />
|
||||
<span className="text-red-700 dark:text-red-300">Differenz minus</span>
|
||||
</span>
|
||||
</>
|
||||
) : null}
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<span className="size-2 rounded-full bg-amber-500 dark:bg-amber-400" />
|
||||
<span className={openTextClass}>offen</span>
|
||||
@@ -197,14 +272,14 @@ function buildMonthSeries(month: string, buckets: StatisticsDailyBucket[]) {
|
||||
});
|
||||
}
|
||||
|
||||
function DailyEffortChart({ month, buckets }: { month: string; buckets: StatisticsDailyBucket[] }) {
|
||||
function DailyEffortChart({ month, buckets, showTeamspaceLine = true }: { month: string; buckets: StatisticsDailyBucket[]; showTeamspaceLine?: boolean }) {
|
||||
const series = buildMonthSeries(month, buckets);
|
||||
const width = 900;
|
||||
const height = 220;
|
||||
const padding = { top: 18, right: 20, bottom: 36, left: 64 };
|
||||
const innerWidth = width - padding.left - padding.right;
|
||||
const innerHeight = height - padding.top - padding.bottom;
|
||||
const maxMinutes = niceCeilMinutes(Math.max(...series.map((bucket) => Math.max(bucket.totalMinutes, bucket.crmBilledMinutes)), 1));
|
||||
const maxMinutes = niceCeilMinutes(Math.max(...series.map((bucket) => Math.max(bucket.totalMinutes, showTeamspaceLine ? bucket.crmBilledMinutes : 0)), 1));
|
||||
const ticks = Array.from({ length: 5 }).map((_, index) => maxMinutes - (index * maxMinutes) / 4);
|
||||
const labelStep = Math.max(1, Math.ceil(series.length / 9));
|
||||
const barWidth = Math.max(5, Math.min(16, innerWidth / Math.max(series.length, 1) / 2));
|
||||
@@ -218,11 +293,11 @@ function DailyEffortChart({ month, buckets }: { month: string; buckets: Statisti
|
||||
crmY: padding.top + (1 - bucket.crmBilledMinutes / maxMinutes) * innerHeight
|
||||
};
|
||||
});
|
||||
const crmPath = buildChartPath(points.filter((point) => point.bucket.crmBilledMinutes > 0).map((point) => ({ x: point.x, y: point.crmY })));
|
||||
const crmPath = showTeamspaceLine ? buildChartPath(points.filter((point) => point.bucket.crmBilledMinutes > 0).map((point) => ({ x: point.x, y: point.crmY }))) : "";
|
||||
|
||||
return (
|
||||
<div className="h-56 w-full overflow-hidden rounded-md border bg-background">
|
||||
{series.some((bucket) => bucket.totalMinutes > 0 || bucket.crmBilledMinutes > 0) ? (
|
||||
{series.some((bucket) => bucket.totalMinutes > 0 || (showTeamspaceLine && bucket.crmBilledMinutes > 0)) ? (
|
||||
<svg viewBox={`0 0 ${width} ${height}`} className="h-full w-full">
|
||||
{ticks.map((tick) => {
|
||||
const y = padding.top + (1 - tick / maxMinutes) * innerHeight;
|
||||
@@ -245,11 +320,11 @@ function DailyEffortChart({ month, buckets }: { month: string; buckets: Statisti
|
||||
) : null;
|
||||
})}
|
||||
{crmPath ? <path d={crmPath} fill="none" stroke="currentColor" strokeWidth="2.5" className={teamspaceStrokeClass} /> : null}
|
||||
{points
|
||||
{showTeamspaceLine ? points
|
||||
.filter((point) => point.bucket.crmBilledMinutes > 0)
|
||||
.map((point) => (
|
||||
<circle key={`${point.bucket.day}-crm`} cx={point.x} cy={point.crmY} r="3" className={`fill-background ${teamspaceStrokeClass}`} stroke="currentColor" strokeWidth="2" />
|
||||
))}
|
||||
)) : null}
|
||||
{points.map((point, index) =>
|
||||
index % labelStep === 0 || index === points.length - 1 ? (
|
||||
<text key={`${point.bucket.day}-label`} x={point.x} y={height - 11} textAnchor="middle" className="fill-muted-foreground text-[11px]">
|
||||
@@ -299,7 +374,17 @@ function MetricCard({
|
||||
);
|
||||
}
|
||||
|
||||
function OrganizationRow({ organization, maxMinutes, totalMinutes }: { organization: StatisticsGroup; maxMinutes: number; totalMinutes: number }) {
|
||||
function OrganizationRow({
|
||||
organization,
|
||||
maxMinutes,
|
||||
totalMinutes,
|
||||
showDifference = true
|
||||
}: {
|
||||
organization: StatisticsGroup;
|
||||
maxMinutes: number;
|
||||
totalMinutes: number;
|
||||
showDifference?: boolean;
|
||||
}) {
|
||||
const monthlyShare = percentage(organization.total_minutes, totalMinutes);
|
||||
|
||||
return (
|
||||
@@ -307,16 +392,18 @@ function OrganizationRow({ organization, maxMinutes, totalMinutes }: { organizat
|
||||
<TableCell className="max-w-[320px] whitespace-normal">
|
||||
<div className="font-medium">{organization.organization_name ?? "Keine Organisation"}</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{organization.tickets} Ticket(s) · {organization.billed_sessions} abgerechnet
|
||||
{organization.tickets} Ticket(s) · {organization.billed_sessions} abgerechnet · {organization.billable_sessions} abrechenbar
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>{organization.billed_sessions}</TableCell>
|
||||
<TableCell>
|
||||
<TimeComparison trackedMinutes={organization.total_minutes} crmMinutes={organization.crm_billed_minutes} />
|
||||
</TableCell>
|
||||
{showDifference ? (
|
||||
<TableCell>
|
||||
<DeltaBadge minutes={organization.crm_delta_minutes} />
|
||||
</TableCell>
|
||||
) : null}
|
||||
<TableCell className="min-w-[150px]">
|
||||
<Progress value={percentage(organization.total_minutes, maxMinutes)} />
|
||||
<div className="mt-1 text-xs text-muted-foreground">{monthlyShare}% vom Monat</div>
|
||||
@@ -325,7 +412,17 @@ function OrganizationRow({ organization, maxMinutes, totalMinutes }: { organizat
|
||||
);
|
||||
}
|
||||
|
||||
function TicketHotspot({ ticket, month, onNavigate }: { ticket: StatisticsTicket; month: string; onNavigate: (to: string) => void }) {
|
||||
function TicketHotspot({
|
||||
ticket,
|
||||
month,
|
||||
showDifference = true,
|
||||
onNavigate
|
||||
}: {
|
||||
ticket: StatisticsTicket;
|
||||
month: string;
|
||||
showDifference?: boolean;
|
||||
onNavigate: (to: string) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="grid gap-2 rounded-md border bg-background p-3 sm:grid-cols-[1fr_auto] sm:items-center">
|
||||
<div className="min-w-0">
|
||||
@@ -336,11 +433,17 @@ function TicketHotspot({ ticket, month, onNavigate }: { ticket: StatisticsTicket
|
||||
<p className="truncate text-sm text-muted-foreground">{ticket.organization_name ?? "Keine Organisation"}</p>
|
||||
<div className="mt-1 flex flex-wrap gap-x-3 gap-y-1 text-xs">
|
||||
<span className={trackedTextClass}>Abgerechnet {formatMinutes(ticket.total_minutes)}</span>
|
||||
<span className={teamspaceTextClass}>Teamspace {formatMinutes(ticket.crm_billed_minutes)}</span>
|
||||
<span className={teamspaceTextClass}>TT-Teamspace {formatMinutes(ticket.crm_billed_minutes)}</span>
|
||||
<span className="text-muted-foreground">{ticket.billed_sessions} abgerechnet</span>
|
||||
<span className="text-muted-foreground">{ticket.billable_sessions} abrechenbar</span>
|
||||
<span>{ticket.active_days} Tag(e)</span>
|
||||
<span className={deltaTextClass(ticket.crm_delta_minutes)}>{formatTeamspaceDelta(ticket.crm_delta_minutes)}</span>
|
||||
{showDifference ? <span className={deltaTextClass(ticket.crm_delta_minutes)}>{formatTeamspaceDelta(ticket.crm_delta_minutes)}</span> : null}
|
||||
</div>
|
||||
<TeamspaceMonthActualsLine
|
||||
actuals={ticket.teamspace_month_actuals}
|
||||
tsMinutes={ticket.teamspace_month_billable_minutes}
|
||||
deltaMinutes={ticket.teamspace_month_delta_minutes}
|
||||
/>
|
||||
</div>
|
||||
<Button variant="secondary" size="sm" onClick={() => onNavigate(`/tickets/${ticket.ticket_id}?month=${month}`)}>
|
||||
Öffnen
|
||||
@@ -350,12 +453,75 @@ function TicketHotspot({ ticket, month, onNavigate }: { ticket: StatisticsTicket
|
||||
);
|
||||
}
|
||||
|
||||
export function StatisticsPage({ onNavigate }: StatisticsPageProps) {
|
||||
function daysSince(value: string) {
|
||||
const then = new Date(value).getTime();
|
||||
|
||||
if (!Number.isFinite(then)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return Math.max(0, Math.floor((Date.now() - then) / 86_400_000));
|
||||
}
|
||||
|
||||
function ProjectFollowupRow({ project, onNavigate }: { project: StatisticsProjectFollowup; onNavigate: (to: string) => void }) {
|
||||
const inactiveDays = daysSince(project.latest_tt_update);
|
||||
const budgetExcluded = Boolean(project.teamspace_budget_excluded);
|
||||
const hasBudget = !budgetExcluded && project.hour_budget_minutes !== null;
|
||||
|
||||
return (
|
||||
<div className="grid gap-3 rounded-md border bg-background p-3 lg:grid-cols-[minmax(0,1fr)_auto] lg:items-center">
|
||||
<div className="min-w-0 space-y-2">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="font-semibold">{project.ticket_number}</span>
|
||||
{budgetExcluded ? (
|
||||
<Badge variant="outline">Budgetprüfung ausgenommen</Badge>
|
||||
) : hasBudget ? (
|
||||
<Badge variant={project.remaining_minutes !== null && project.remaining_minutes > 0 ? "warning" : "secondary"}>
|
||||
{project.remaining_minutes !== null && project.remaining_minutes > 0 ? `${formatMinutes(project.remaining_minutes)} offen` : "Budget verbraucht"}
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge variant="info">ohne Budget</Badge>
|
||||
)}
|
||||
{project.open_followup_todo_id ? <Badge variant="secondary">ToDo erstellt</Badge> : null}
|
||||
{project.followup_state === "done" ? <Badge variant="success">erledigt</Badge> : null}
|
||||
{project.followup_state === "dismissed" ? <Badge variant="outline">gelöscht</Badge> : null}
|
||||
{project.overdue_todos > 0 ? <Badge variant="destructive">{project.overdue_todos} überfällig</Badge> : null}
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<p className="truncate text-sm">{project.title ?? project.teamspace_project ?? "Projekt ohne Titel"}</p>
|
||||
<p className="truncate text-xs text-muted-foreground">{project.organization_name ?? "Keine Organisation"}</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-x-3 gap-y-1 text-xs text-muted-foreground">
|
||||
<span>Letztes TT-Update {formatDateTime(project.latest_tt_update)}</span>
|
||||
{inactiveDays !== null ? <span>{inactiveDays} Tag(e) inaktiv</span> : null}
|
||||
<span>Budget {budgetExcluded ? "Prüfung ausgenommen" : hasBudget ? formatMinutes(project.hour_budget_minutes!) : "nicht gesetzt"}</span>
|
||||
<span>Verbraucht {formatMinutes(project.consumption_minutes)}</span>
|
||||
<span>{project.open_todos} offene ToDo(s)</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2 lg:justify-end">
|
||||
<Button variant="secondary" size="sm" onClick={() => onNavigate(`/tickets/${project.ticket_id}?month=${currentMonth()}`)}>
|
||||
Ticket
|
||||
<ExternalLink className="size-4" />
|
||||
</Button>
|
||||
<Button variant="secondary" size="sm" onClick={() => onNavigate("/projects")}>
|
||||
Projekte
|
||||
<ExternalLink className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function StatisticsPage({ currentUser, onNavigate }: StatisticsPageProps) {
|
||||
const [month, setMonth] = useState(currentMonth());
|
||||
const [stats, setStats] = useState<StatisticsOverview | null>(null);
|
||||
const [followups, setFollowups] = useState<StatisticsProjectFollowupOverview | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [loadingFollowups, setLoadingFollowups] = useState(false);
|
||||
const [organizationPage, setOrganizationPage] = useState(1);
|
||||
const loadRequestId = useRef(0);
|
||||
const followupRequestId = useRef(0);
|
||||
|
||||
async function load() {
|
||||
const requestId = loadRequestId.current + 1;
|
||||
@@ -385,6 +551,44 @@ export function StatisticsPage({ onNavigate }: StatisticsPageProps) {
|
||||
void load();
|
||||
}, [month]);
|
||||
|
||||
async function loadFollowups() {
|
||||
const requestId = followupRequestId.current + 1;
|
||||
followupRequestId.current = requestId;
|
||||
setLoadingFollowups(true);
|
||||
|
||||
try {
|
||||
const result = await getProjectFollowups();
|
||||
|
||||
if (requestId === followupRequestId.current) {
|
||||
setFollowups(result);
|
||||
|
||||
if (result.createdTodos > 0 || result.updatedTodos > 0) {
|
||||
window.dispatchEvent(new Event("tickettracker:todos-changed"));
|
||||
}
|
||||
|
||||
if (result.createdTodos > 0) {
|
||||
toast.success("Wiedervorlagen erstellt", {
|
||||
description: `${result.createdTodos} ToDo(s) automatisch angelegt.`
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
if (requestId === followupRequestId.current) {
|
||||
toast.error("Wiedervorlagen konnten nicht geladen werden", {
|
||||
description: error instanceof Error ? error.message : "Unbekannter Fehler"
|
||||
});
|
||||
}
|
||||
} finally {
|
||||
if (requestId === followupRequestId.current) {
|
||||
setLoadingFollowups(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
void loadFollowups();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
function refreshVisible() {
|
||||
if (document.visibilityState === "visible") {
|
||||
@@ -411,10 +615,16 @@ export function StatisticsPage({ onNavigate }: StatisticsPageProps) {
|
||||
}
|
||||
|
||||
const totals = stats?.totals;
|
||||
const teamspaceTimeTrackingEnabled = currentUser.teamspace_time_tracking_enabled ?? true;
|
||||
const billedSessions = totals?.billedSessions ?? 0;
|
||||
const billedMinutes = totals?.billedMinutes ?? 0;
|
||||
const billableSessions = totals?.billableSessions ?? 0;
|
||||
const billableMinutes = totals?.billableMinutes ?? 0;
|
||||
const nonBillableSessions = totals?.nonBillableSessions ?? 0;
|
||||
const nonBillableMinutes = totals?.nonBillableMinutes ?? 0;
|
||||
const openSessions = totals?.openSessions ?? 0;
|
||||
const openMinutes = totals?.openMinutes ?? 0;
|
||||
const evaluatedSessions = billedSessions + nonBillableSessions;
|
||||
const crmCoverage = percentage(totals?.crmBilledMinutes ?? 0, totals?.minutes ?? 0);
|
||||
const maxOrganizationMinutes = Math.max(...(stats?.organizations ?? []).map((organization) => organization.total_minutes), 1);
|
||||
const topOrganization = stats?.organizations[0] ?? null;
|
||||
@@ -441,7 +651,11 @@ export function StatisticsPage({ onNavigate }: StatisticsPageProps) {
|
||||
<div className="flex flex-col gap-3 xl:flex-row xl:items-end xl:justify-between">
|
||||
<div>
|
||||
<h2 className="text-2xl font-semibold tracking-normal">Statistiken</h2>
|
||||
<p className="text-sm text-muted-foreground">Monatswerte, Teamspace-Abgleich, Organisationen und offene Nacharbeit.</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{teamspaceTimeTrackingEnabled
|
||||
? "Monatswerte, TT-Teamspace-Abgleich, Organisationen und offene Nacharbeit."
|
||||
: "Monatswerte, Session-Status, Organisationen und Teamspace-Istwerte."}
|
||||
</p>
|
||||
</div>
|
||||
<div className="grid gap-1 sm:w-[300px]">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
@@ -460,7 +674,16 @@ export function StatisticsPage({ onNavigate }: StatisticsPageProps) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-3">
|
||||
<Tabs defaultValue="overview" className="space-y-5">
|
||||
<TabsList className="grid h-auto w-full grid-cols-2 gap-1 sm:grid-cols-4">
|
||||
<TabsTrigger value="overview">Überblick</TabsTrigger>
|
||||
<TabsTrigger value="groups">Kunden & Typen</TabsTrigger>
|
||||
<TabsTrigger value="tickets">Tickets</TabsTrigger>
|
||||
<TabsTrigger value="followups">Wiedervorlagen</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="overview" className="space-y-5">
|
||||
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
|
||||
<MetricCard
|
||||
label="Abgerechnet"
|
||||
value={formatMinutes(totals?.minutes ?? 0)}
|
||||
@@ -471,9 +694,19 @@ export function StatisticsPage({ onNavigate }: StatisticsPageProps) {
|
||||
helpLabel="Hilfe zur Kennzahl Abgerechnet"
|
||||
/>
|
||||
<MetricCard
|
||||
label="Teamspace"
|
||||
label="Abrechenbar"
|
||||
value={formatMinutes(billableMinutes)}
|
||||
detail={`${billableSessions} Session(s)`}
|
||||
icon={UserCheck}
|
||||
helpAnchor="bewertungen"
|
||||
helpLabel="Hilfe zu Bewertungen"
|
||||
/>
|
||||
<MetricCard
|
||||
label="TT-Teamspace"
|
||||
value={formatMinutes(totals?.crmBilledMinutes ?? 0)}
|
||||
detail={<span className={deltaTextClass(totals?.crmDeltaMinutes ?? 0)}>Differenz {formatTeamspaceDelta(totals?.crmDeltaMinutes ?? 0)}</span>}
|
||||
detail={teamspaceTimeTrackingEnabled
|
||||
? <span className={deltaTextClass(totals?.crmDeltaMinutes ?? 0)}>Differenz {formatTeamspaceDelta(totals?.crmDeltaMinutes ?? 0)}</span>
|
||||
: "Ist-Werte aus Teamspace"}
|
||||
icon={TrendingUp}
|
||||
valueClassName={teamspaceTextClass}
|
||||
helpAnchor="statistik-teamspace"
|
||||
@@ -499,43 +732,59 @@ export function StatisticsPage({ onNavigate }: StatisticsPageProps) {
|
||||
<CardHeader className="gap-3 p-4 sm:flex-row sm:items-start sm:justify-between sm:space-y-0">
|
||||
<div>
|
||||
<CardTitle>Monatsverlauf und Abrechnungsqualität</CardTitle>
|
||||
<CardDescription>Abgerechnete Zeit pro Tag, Teamspace-Linie und Bewertungsstand in einem Überblick.</CardDescription>
|
||||
<CardDescription>
|
||||
{teamspaceTimeTrackingEnabled
|
||||
? "Abgerechnete Zeit pro Tag, TT-Teamspace-Linie und Bewertungsstand in einem Überblick."
|
||||
: "Abgerechnete Zeit pro Tag und Bewertungsstand in einem Überblick."}
|
||||
</CardDescription>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant="outline">{crmCoverage}% Teamspace-Abdeckung</Badge>
|
||||
{teamspaceTimeTrackingEnabled ? <Badge variant="outline">{crmCoverage}% TT-Teamspace-Abdeckung</Badge> : <Badge variant="outline">Teamspace als Ist-Quelle</Badge>}
|
||||
<HelpLink anchor="statistik-verlauf" label="Hilfe zu Monatsverlauf und Abrechnungsqualität" />
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="grid gap-4 px-4 pb-4 xl:grid-cols-[1.55fr_1fr]">
|
||||
<div className="space-y-3">
|
||||
<DailyEffortChart month={month} buckets={stats?.dailySeries ?? []} />
|
||||
<ColorLegend />
|
||||
{strongestDay ? <p className="text-xs text-muted-foreground">Stärkster abrechenbarer Tag: {formatDate(`${strongestDay.day}T00:00:00`)} mit {formatMinutes(strongestDay.total_minutes)}</p> : null}
|
||||
<DailyEffortChart month={month} buckets={stats?.dailySeries ?? []} showTeamspaceLine={teamspaceTimeTrackingEnabled} />
|
||||
<ColorLegend showDifference={teamspaceTimeTrackingEnabled} />
|
||||
{strongestDay ? <p className="text-xs text-muted-foreground">Stärkster abgerechneter Tag: {formatDate(`${strongestDay.day}T00:00:00`)} mit {formatMinutes(strongestDay.total_minutes)}</p> : null}
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2 rounded-md border bg-muted/20 p-3">
|
||||
<div className="flex items-center justify-between gap-3 text-sm">
|
||||
<span className="text-muted-foreground">Bewertungsstand</span>
|
||||
<span className="font-medium">{billedSessions} abrechenbar</span>
|
||||
<span className="font-medium">{evaluatedSessions} bewertet</span>
|
||||
</div>
|
||||
<div className="grid gap-2 text-xs sm:grid-cols-2 xl:grid-cols-1 2xl:grid-cols-2">
|
||||
<div className={trackedTextClass}>Abgerechnet: {billedSessions} Session(s), {formatMinutes(billedMinutes)}</div>
|
||||
<div className="text-muted-foreground">Abrechenbar: {billableSessions} Session(s), {formatMinutes(billableMinutes)}</div>
|
||||
<div className={nonBillableTextClass}>Nicht abrechenbar: {nonBillableSessions} Session(s), {formatMinutes(nonBillableMinutes)}</div>
|
||||
<div className={openTextClass}>Offen: {openSessions} Session(s), {formatMinutes(openMinutes)}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{teamspaceTimeTrackingEnabled ? (
|
||||
<div className="space-y-2 rounded-md border bg-muted/20 p-3">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-sm font-medium">Teamspace-Abgleich</p>
|
||||
<p className="text-sm font-medium">TT-Teamspace-Abgleich</p>
|
||||
<p className="text-xs text-muted-foreground">{deltaDescription(totals?.crmDeltaMinutes ?? 0)}</p>
|
||||
</div>
|
||||
<DeltaBadge minutes={totals?.crmDeltaMinutes ?? 0} />
|
||||
</div>
|
||||
<TimeComparison trackedMinutes={totals?.minutes ?? 0} crmMinutes={totals?.crmBilledMinutes ?? 0} sessions={totals?.billedSessions ?? 0} />
|
||||
<div className="text-xs text-muted-foreground">Teamspace-Abdeckung: {crmCoverage}% der abgerechneten Zeit</div>
|
||||
<div className="text-xs text-muted-foreground">TT-Teamspace-Abdeckung: {crmCoverage}% der Session-Zeit</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2 rounded-md border bg-muted/20 p-3">
|
||||
<p className="text-sm font-medium">Teamspace als Ist-Quelle</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Für diesen Nutzer werden keine Teamspace-Zeiten in TT erfasst. Die Monatsprüfung bewertet nur den Session-Status.
|
||||
</p>
|
||||
<TimeComparison trackedMinutes={totals?.minutes ?? 0} crmMinutes={totals?.crmBilledMinutes ?? 0} sessions={totals?.billedSessions ?? 0} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-2 gap-2 text-sm">
|
||||
<div className="rounded-md border bg-muted/20 p-3">
|
||||
@@ -563,7 +812,7 @@ export function StatisticsPage({ onNavigate }: StatisticsPageProps) {
|
||||
<p className="truncate font-semibold">{topOrganization.organization_name}</p>
|
||||
<p className="flex flex-wrap gap-x-3 gap-y-1 text-xs">
|
||||
<span className={trackedTextClass}>Abgerechnet {formatMinutes(topOrganization.total_minutes)}</span>
|
||||
<span className={teamspaceTextClass}>Teamspace {formatMinutes(topOrganization.crm_billed_minutes)}</span>
|
||||
<span className={teamspaceTextClass}>TT-Teamspace {formatMinutes(topOrganization.crm_billed_minutes)}</span>
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
@@ -571,12 +820,19 @@ export function StatisticsPage({ onNavigate }: StatisticsPageProps) {
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="groups" className="space-y-5">
|
||||
<div className="grid gap-3 xl:grid-cols-[1.45fr_0.8fr]">
|
||||
<Card>
|
||||
<CardHeader className="gap-3 p-4 sm:flex-row sm:items-start sm:justify-between sm:space-y-0">
|
||||
<div>
|
||||
<CardTitle>Statistik pro Organisation</CardTitle>
|
||||
<CardDescription>Kundenaufwand inklusive Teamspace-Differenz und Anteil an der abgerechneten Monatszeit.</CardDescription>
|
||||
<CardDescription>
|
||||
{teamspaceTimeTrackingEnabled
|
||||
? "Kundenaufwand inklusive Teamspace-Differenz und Anteil an der abgerechneten Monatszeit."
|
||||
: "Kundenaufwand und Anteil an der abgerechneten Monatszeit."}
|
||||
</CardDescription>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant="outline">
|
||||
@@ -593,7 +849,7 @@ export function StatisticsPage({ onNavigate }: StatisticsPageProps) {
|
||||
<TableHead>Organisation</TableHead>
|
||||
<TableHead>Abgerechnet</TableHead>
|
||||
<TableHead>Zeiten</TableHead>
|
||||
<TableHead>Differenz</TableHead>
|
||||
{teamspaceTimeTrackingEnabled ? <TableHead>Differenz</TableHead> : null}
|
||||
<TableHead>Anteil abgerechnet</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
@@ -604,6 +860,7 @@ export function StatisticsPage({ onNavigate }: StatisticsPageProps) {
|
||||
organization={organization}
|
||||
maxMinutes={maxOrganizationMinutes}
|
||||
totalMinutes={totals?.minutes ?? 0}
|
||||
showDifference={teamspaceTimeTrackingEnabled}
|
||||
/>
|
||||
))}
|
||||
</TableBody>
|
||||
@@ -615,16 +872,16 @@ export function StatisticsPage({ onNavigate }: StatisticsPageProps) {
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="min-w-0">
|
||||
<p className="truncate font-medium">{organization.organization_name ?? "Keine Organisation"}</p>
|
||||
<p className="text-xs text-muted-foreground">{organization.tickets} Ticket(s) · {organization.billed_sessions} abgerechnet</p>
|
||||
<p className="text-xs text-muted-foreground">{organization.tickets} Ticket(s) · {organization.billed_sessions} abgerechnet · {organization.billable_sessions} abrechenbar</p>
|
||||
</div>
|
||||
<DeltaBadge minutes={organization.crm_delta_minutes} />
|
||||
{teamspaceTimeTrackingEnabled ? <DeltaBadge minutes={organization.crm_delta_minutes} /> : null}
|
||||
</div>
|
||||
<Progress value={percentage(organization.total_minutes, maxOrganizationMinutes)} />
|
||||
<div className="flex flex-wrap gap-x-3 gap-y-1 text-xs">
|
||||
<span className={trackedTextClass}>Abgerechnet {formatMinutes(organization.total_minutes)}</span>
|
||||
<span className={teamspaceTextClass}>Teamspace {formatMinutes(organization.crm_billed_minutes)}</span>
|
||||
<span className={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={deltaTextClass(organization.crm_delta_minutes)}>{formatTeamspaceDelta(organization.crm_delta_minutes)}</span>
|
||||
{teamspaceTimeTrackingEnabled ? <span className={deltaTextClass(organization.crm_delta_minutes)}>{formatTeamspaceDelta(organization.crm_delta_minutes)}</span> : null}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
@@ -684,17 +941,18 @@ export function StatisticsPage({ onNavigate }: StatisticsPageProps) {
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<p className="font-semibold">{workTypeLabel(group.work_type)}</p>
|
||||
<p className="text-xs text-muted-foreground">{group.tickets} Ticket(s), {group.billed_sessions} abgerechnet</p>
|
||||
<p className="text-xs text-muted-foreground">{group.tickets} Ticket(s), {group.billed_sessions} abgerechnet, {group.billable_sessions} abrechenbar</p>
|
||||
</div>
|
||||
<DeltaBadge minutes={group.crm_delta_minutes} />
|
||||
{teamspaceTimeTrackingEnabled ? <DeltaBadge minutes={group.crm_delta_minutes} /> : null}
|
||||
</div>
|
||||
<Progress value={percentage(group.total_minutes, totals?.minutes ?? 0)} />
|
||||
<div className="flex flex-wrap gap-x-3 gap-y-1 text-xs">
|
||||
<span className={trackedTextClass}>Abgerechnet {formatMinutes(group.total_minutes)}</span>
|
||||
<span className={teamspaceTextClass}>Teamspace {formatMinutes(group.crm_billed_minutes)}</span>
|
||||
<span className={teamspaceTextClass}>TT-Teamspace {formatMinutes(group.crm_billed_minutes)}</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-2 text-xs text-muted-foreground">
|
||||
<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>Abrechb. {group.billable_sessions} / {formatMinutes(group.billable_minutes)}</span>
|
||||
<span>Offen {group.open_sessions} / {formatMinutes(group.open_minutes)}</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -704,6 +962,9 @@ export function StatisticsPage({ onNavigate }: StatisticsPageProps) {
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="tickets" className="space-y-5">
|
||||
<Card>
|
||||
<CardHeader className="gap-3 p-4 sm:flex-row sm:items-start sm:justify-between sm:space-y-0">
|
||||
<div>
|
||||
@@ -720,7 +981,7 @@ export function StatisticsPage({ onNavigate }: StatisticsPageProps) {
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2 px-4 pb-4">
|
||||
{(stats?.tickets ?? []).map((ticket) => (
|
||||
<TicketHotspot key={ticket.ticket_id} ticket={ticket} month={month} onNavigate={onNavigate} />
|
||||
<TicketHotspot key={ticket.ticket_id} ticket={ticket} month={month} showDifference={teamspaceTimeTrackingEnabled} onNavigate={onNavigate} />
|
||||
))}
|
||||
{stats && stats.tickets.length === 0 ? <p className="py-8 text-center text-sm text-muted-foreground">Keine Tickets im gewählten Monat.</p> : null}
|
||||
</CardContent>
|
||||
@@ -741,6 +1002,48 @@ export function StatisticsPage({ onNavigate }: StatisticsPageProps) {
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : null}
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="followups" className="space-y-5">
|
||||
<Card>
|
||||
<CardHeader className="gap-3 p-4 sm:flex-row sm:items-start sm:justify-between sm:space-y-0">
|
||||
<div>
|
||||
<CardTitle>Automatische Wiedervorlagen</CardTitle>
|
||||
<CardDescription>
|
||||
{followups?.enabled === false
|
||||
? "Automatische Wiedervorlagen sind im Profil deaktiviert."
|
||||
: followups?.secondEnabled === false
|
||||
? `Erste Wiedervorlage nach ${followups?.weeks ?? "-"} Woche(n).`
|
||||
: `Erste Wiedervorlage nach ${followups?.weeks ?? "-"} Woche(n), zweite nach weiteren ${followups?.secondWeeks ?? "-"} Woche(n) ohne Session.`}
|
||||
</CardDescription>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant="outline">{followups?.projects.length ?? 0} Projekt(e)</Badge>
|
||||
{loadingFollowups ? <Badge variant="secondary">lädt...</Badge> : null}
|
||||
<Button type="button" size="sm" variant="secondary" onClick={() => void loadFollowups()} disabled={loadingFollowups}>
|
||||
<BellPlus className="size-4" />
|
||||
Prüfen
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3 px-4 pb-4">
|
||||
{(followups?.projects ?? []).map((project) => (
|
||||
<ProjectFollowupRow key={project.project_id} project={project} onNavigate={onNavigate} />
|
||||
))}
|
||||
{!loadingFollowups && followups && followups.projects.length === 0 ? (
|
||||
<div className="grid place-items-center gap-2 rounded-md border bg-muted/20 p-8 text-center">
|
||||
<ListChecks className="size-8 text-muted-foreground" />
|
||||
<div>
|
||||
<p className="font-medium">Keine Wiedervorlagen nötig</p>
|
||||
<p className="text-sm text-muted-foreground">Alle offenen Projektbudgets wurden innerhalb des eingestellten Zeitraums aktualisiert.</p>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
{loadingFollowups && !followups ? <p className="py-8 text-center text-sm text-muted-foreground">Wiedervorlagen werden geprüft...</p> : null}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+2071
-238
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -21,6 +21,7 @@ type TodosPageProps = {
|
||||
|
||||
type TodoSortMode = "custom" | "priority" | "title" | "ticket" | "organization" | "date" | "updated";
|
||||
type TodoGroupMode = "ticket" | "priority" | "organization" | "status";
|
||||
type CalendarDayMode = "current";
|
||||
|
||||
type TodoPreferences = {
|
||||
status: TodoStatus | "all";
|
||||
@@ -277,6 +278,23 @@ function sortTodos(todos: TicketTodo[], sortMode: TodoSortMode) {
|
||||
return [...todos].sort((left, right) => compareTodos(left, right, sortMode));
|
||||
}
|
||||
|
||||
function uniqueTodos(todos: TicketTodo[]) {
|
||||
return todos.filter((todo, index, list) => list.findIndex((item) => item.id === todo.id) === index);
|
||||
}
|
||||
|
||||
function sortCurrentTodos(todos: TicketTodo[], today: string) {
|
||||
return [...todos].sort((left, right) => {
|
||||
const leftOverdue = isTodoOverdue(left, today);
|
||||
const rightOverdue = isTodoOverdue(right, today);
|
||||
|
||||
if (leftOverdue !== rightOverdue) {
|
||||
return leftOverdue ? -1 : 1;
|
||||
}
|
||||
|
||||
return priorityOrder[left.priority] - priorityOrder[right.priority] || left.sort_order - right.sort_order || compareText(left.title, right.title);
|
||||
});
|
||||
}
|
||||
|
||||
function matchesTicketSearch(todo: TicketTodo, search: string) {
|
||||
const normalizedSearch = normalizeSearch(search);
|
||||
|
||||
@@ -400,9 +418,9 @@ export function TodosPage({ onNavigate }: TodosPageProps) {
|
||||
const [modalEndsOn, setModalEndsOn] = useState("");
|
||||
const [modalBusy, setModalBusy] = useState(false);
|
||||
const [selectedCalendarDay, setSelectedCalendarDay] = useState<string | null>(null);
|
||||
const [selectedCalendarDayMode, setSelectedCalendarDayMode] = useState<"summarized" | "overdue">("summarized");
|
||||
const [selectedCalendarDayMode, setSelectedCalendarDayMode] = useState<CalendarDayMode>("current");
|
||||
const [returnCalendarDay, setReturnCalendarDay] = useState<string | null>(null);
|
||||
const [returnCalendarDayMode, setReturnCalendarDayMode] = useState<"summarized" | "overdue">("summarized");
|
||||
const [returnCalendarDayMode, setReturnCalendarDayMode] = useState<CalendarDayMode>("current");
|
||||
|
||||
async function load() {
|
||||
setLoading(true);
|
||||
@@ -422,6 +440,13 @@ export function TodosPage({ onNavigate }: TodosPageProps) {
|
||||
void load();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const onTodosChanged = () => void load();
|
||||
|
||||
window.addEventListener("tickettracker:todos-changed", onTodosChanged);
|
||||
return () => window.removeEventListener("tickettracker:todos-changed", onTodosChanged);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
window.localStorage.setItem(todoPreferencesKey, JSON.stringify(preferences));
|
||||
}, [preferences]);
|
||||
@@ -432,7 +457,7 @@ export function TodosPage({ onNavigate }: TodosPageProps) {
|
||||
if (selectedTodoId && !selectedTodo) {
|
||||
setSelectedTodoId(null);
|
||||
setReturnCalendarDay(null);
|
||||
setReturnCalendarDayMode("summarized");
|
||||
setReturnCalendarDayMode("current");
|
||||
}
|
||||
}, [selectedTodo, selectedTodoId]);
|
||||
|
||||
@@ -473,7 +498,19 @@ export function TodosPage({ onNavigate }: TodosPageProps) {
|
||||
|
||||
return entries;
|
||||
}, [calendarDays, todos]);
|
||||
const selectedCalendarDayTodos = selectedCalendarDay ? calendarTodosByDay.get(selectedCalendarDay)?.[selectedCalendarDayMode] ?? [] : [];
|
||||
const selectedCalendarDayTodos = useMemo(() => {
|
||||
if (!selectedCalendarDay) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const dayTodos = calendarTodosByDay.get(selectedCalendarDay);
|
||||
|
||||
if (!dayTodos) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return sortCurrentTodos(uniqueTodos([...dayTodos.overdue, ...dayTodos.single, ...dayTodos.summarized]), currentDay());
|
||||
}, [calendarTodosByDay, selectedCalendarDay]);
|
||||
const counts = useMemo(
|
||||
() => ({
|
||||
open: todos.filter((todo) => todo.status === "open").length,
|
||||
@@ -531,7 +568,7 @@ export function TodosPage({ onNavigate }: TodosPageProps) {
|
||||
});
|
||||
}
|
||||
|
||||
function openTodoModal(todo: TicketTodo, returnToDay: string | null = null, returnToDayMode: "summarized" | "overdue" = "summarized") {
|
||||
function openTodoModal(todo: TicketTodo, returnToDay: string | null = null, returnToDayMode: CalendarDayMode = "current") {
|
||||
setReturnCalendarDay(returnToDay);
|
||||
setReturnCalendarDayMode(returnToDayMode);
|
||||
setSelectedTodoId(todo.id);
|
||||
@@ -545,7 +582,7 @@ export function TodosPage({ onNavigate }: TodosPageProps) {
|
||||
function closeTodoModal() {
|
||||
setSelectedTodoId(null);
|
||||
setReturnCalendarDay(null);
|
||||
setReturnCalendarDayMode("summarized");
|
||||
setReturnCalendarDayMode("current");
|
||||
}
|
||||
|
||||
function backToCalendarDay() {
|
||||
@@ -557,7 +594,7 @@ export function TodosPage({ onNavigate }: TodosPageProps) {
|
||||
const mode = returnCalendarDayMode;
|
||||
setSelectedTodoId(null);
|
||||
setReturnCalendarDay(null);
|
||||
setReturnCalendarDayMode("summarized");
|
||||
setReturnCalendarDayMode("current");
|
||||
setSelectedCalendarDayMode(mode);
|
||||
setSelectedCalendarDay(day);
|
||||
}
|
||||
@@ -784,7 +821,7 @@ export function TodosPage({ onNavigate }: TodosPageProps) {
|
||||
</div>
|
||||
<HelpLink anchor="todos" label="Hilfe zu ToDos" />
|
||||
</div>
|
||||
<div className="grid gap-3 pt-2 text-sm lg:grid-cols-[minmax(180px,1fr)_150px_190px_auto] lg:items-end">
|
||||
<div className="grid gap-3 pt-2 text-sm md:grid-cols-2 xl:grid-cols-[minmax(180px,1fr)_150px_190px_140px] xl:items-end">
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs font-medium text-muted-foreground" htmlFor="todo-ticket-search">
|
||||
Ticket
|
||||
@@ -904,6 +941,9 @@ export function TodosPage({ onNavigate }: TodosPageProps) {
|
||||
const summarizedTodos = dayTodos.summarized;
|
||||
const overdueTodos = dayTodos.overdue;
|
||||
const totalTodos = singleTodos.length + summarizedTodos.length + overdueTodos.length;
|
||||
const currentTodos = sortCurrentTodos(uniqueTodos([...overdueTodos, ...singleTodos, ...summarizedTodos]), currentDay());
|
||||
const visibleCalendarTodos = currentTodos.slice(0, 2);
|
||||
const additionalTodosCount = Math.max(0, currentTodos.length - visibleCalendarTodos.length);
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -918,7 +958,7 @@ export function TodosPage({ onNavigate }: TodosPageProps) {
|
||||
</div>
|
||||
{!day.workingDay ? <div className="text-[10px] leading-tight text-muted-foreground">{day.holidayName ?? "Wochenende"}</div> : null}
|
||||
<div className="space-y-1">
|
||||
{singleTodos.slice(0, 3).map((todo) => (
|
||||
{visibleCalendarTodos.map((todo) => (
|
||||
<button
|
||||
key={todo.id}
|
||||
type="button"
|
||||
@@ -929,31 +969,18 @@ export function TodosPage({ onNavigate }: TodosPageProps) {
|
||||
<span className="block truncate text-muted-foreground">{todo.ticket_number ?? "Ticket"}</span>
|
||||
</button>
|
||||
))}
|
||||
{singleTodos.length > 3 ? <span className="block px-1 text-[11px] text-muted-foreground">+{singleTodos.length - 3} weitere fixe</span> : null}
|
||||
</div>
|
||||
<div className="mt-auto flex flex-wrap gap-1 pt-1">
|
||||
{summarizedTodos.length > 0 ? (
|
||||
<div className="mt-auto pt-1">
|
||||
{additionalTodosCount > 0 ? (
|
||||
<button
|
||||
type="button"
|
||||
className="min-w-0 flex-1 rounded-md border border-muted-foreground/20 bg-background/60 px-2 py-1 text-left text-[11px] leading-tight text-muted-foreground hover:bg-muted"
|
||||
className="block w-full rounded-md border border-muted-foreground/20 bg-background/60 px-2 py-1 text-left text-[11px] leading-tight text-muted-foreground hover:bg-muted"
|
||||
onClick={() => {
|
||||
setSelectedCalendarDayMode("summarized");
|
||||
setSelectedCalendarDayMode("current");
|
||||
setSelectedCalendarDay(day.iso);
|
||||
}}
|
||||
>
|
||||
+{summarizedTodos.length} ToDo{summarizedTodos.length === 1 ? "" : "s"}
|
||||
</button>
|
||||
) : null}
|
||||
{overdueTodos.length > 0 ? (
|
||||
<button
|
||||
type="button"
|
||||
className="shrink-0 rounded-md border border-red-500/50 bg-red-500/10 px-2 py-1 text-left text-[11px] font-semibold leading-tight text-red-200 hover:bg-red-500/20"
|
||||
onClick={() => {
|
||||
setSelectedCalendarDayMode("overdue");
|
||||
setSelectedCalendarDay(day.iso);
|
||||
}}
|
||||
>
|
||||
+{overdueTodos.length} üf
|
||||
+{additionalTodosCount} weitere ToDo{additionalTodosCount === 1 ? "" : "s"}
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
@@ -1073,13 +1100,11 @@ export function TodosPage({ onNavigate }: TodosPageProps) {
|
||||
<Dialog open={Boolean(selectedCalendarDay)} onOpenChange={(open) => !open && setSelectedCalendarDay(null)}>
|
||||
<DialogContent className="max-h-[calc(100dvh-2rem)] overflow-y-auto sm:max-w-xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{selectedCalendarDayMode === "overdue" ? "Überfällige ToDos" : "ToDos am Tag"}</DialogTitle>
|
||||
<DialogTitle>Aktuelle ToDos</DialogTitle>
|
||||
<DialogDescription>
|
||||
{selectedCalendarDay
|
||||
? `${formatTodoDate(selectedCalendarDay)} · ${selectedCalendarDayTodos.length} ${selectedCalendarDayMode === "overdue" ? "überfällige" : "laufende"} ToDo(s)`
|
||||
: selectedCalendarDayMode === "overdue"
|
||||
? "Überfällige ToDos dieses Tages"
|
||||
: "Laufende ToDos dieses Tages"}
|
||||
? `${formatTodoDate(selectedCalendarDay)} · ${selectedCalendarDayTodos.length} aktuelle ToDo(s)`
|
||||
: "Aktuelle ToDos dieses Tages"}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-2">
|
||||
@@ -1142,9 +1167,7 @@ export function TodosPage({ onNavigate }: TodosPageProps) {
|
||||
</div>
|
||||
))}
|
||||
{selectedCalendarDayTodos.length === 0 ? (
|
||||
<p className="rounded-md border bg-muted/20 p-4 text-center text-sm text-muted-foreground">
|
||||
{selectedCalendarDayMode === "overdue" ? "Keine überfälligen ToDos an diesem Tag." : "Keine laufenden ToDos an diesem Tag."}
|
||||
</p>
|
||||
<p className="rounded-md border bg-muted/20 p-4 text-center text-sm text-muted-foreground">Keine aktuellen ToDos an diesem Tag.</p>
|
||||
) : null}
|
||||
</div>
|
||||
</DialogContent>
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,24 @@
|
||||
export const zammadArticleTypeOptions = [
|
||||
{ value: "4", label: "Dienstleistung" },
|
||||
{ value: "1", label: "Koordination, Kommunikation und Dokumentation" },
|
||||
{ value: "6", label: "Nicht kategorisiert" },
|
||||
{ value: "2", label: "Schulungen, Workshops und Meetings" },
|
||||
{ value: "5", label: "Support" },
|
||||
{ value: "3", label: "Vor- und Nacharbeiten" }
|
||||
] as const;
|
||||
|
||||
export type ZammadArticleTypeId = number;
|
||||
|
||||
export function zammadArticleTypeValue(typeId: ZammadArticleTypeId | null | undefined) {
|
||||
return String(typeId ?? 6);
|
||||
}
|
||||
|
||||
export function zammadArticleTypeFromValue(value: string): ZammadArticleTypeId {
|
||||
return Number(value || 6);
|
||||
}
|
||||
|
||||
export function zammadArticleTypeLabel(typeId: ZammadArticleTypeId | null | undefined) {
|
||||
const value = zammadArticleTypeValue(typeId);
|
||||
|
||||
return zammadArticleTypeOptions.find((option) => option.value === value)?.label ?? "Nicht kategorisiert";
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
import { AlertTriangle, ArrowDown, ArrowUp, CheckCircle2, CircleDot, CirclePause, Flag, Minus } from "lucide-react";
|
||||
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export type ZammadStateFilter = "all" | "none" | "2" | "3" | "4" | "7";
|
||||
export type ZammadPriorityFilter = "all" | "none" | "1" | "2" | "3";
|
||||
|
||||
export const zammadStateFilterOptions: Array<{ value: ZammadStateFilter; label: string }> = [
|
||||
{ value: "all", label: "Alle Zammad-Status" },
|
||||
{ value: "2", label: "Offen" },
|
||||
{ value: "3", label: "Wartend" },
|
||||
{ value: "4", label: "Geschlossen" },
|
||||
{ value: "7", label: "Abschluss" },
|
||||
{ value: "none", label: "Ohne Zammad-Status" }
|
||||
];
|
||||
|
||||
export const zammadPriorityFilterOptions: Array<{ value: ZammadPriorityFilter; label: string }> = [
|
||||
{ value: "all", label: "Alle Prioritäten" },
|
||||
{ value: "1", label: "Niedrig" },
|
||||
{ value: "2", label: "Normal" },
|
||||
{ value: "3", label: "Hoch" },
|
||||
{ value: "none", label: "Ohne Priorität" }
|
||||
];
|
||||
|
||||
const zammadStateMeta = {
|
||||
"2": {
|
||||
label: "Offen",
|
||||
order: 10,
|
||||
className: "border-emerald-500/30 bg-emerald-500/10 text-emerald-700 dark:text-emerald-300",
|
||||
Icon: CircleDot
|
||||
},
|
||||
"3": {
|
||||
label: "Wartend",
|
||||
order: 20,
|
||||
className: "border-blue-500/30 bg-blue-500/10 text-blue-700 dark:text-blue-300",
|
||||
Icon: CirclePause
|
||||
},
|
||||
"7": {
|
||||
label: "Abschluss",
|
||||
order: 30,
|
||||
className: "border-amber-500/30 bg-amber-500/10 text-amber-700 dark:text-amber-300",
|
||||
Icon: Flag
|
||||
},
|
||||
"4": {
|
||||
label: "Geschlossen",
|
||||
order: 40,
|
||||
className: "border-slate-500/30 bg-slate-500/10 text-slate-700 dark:text-slate-300",
|
||||
Icon: CheckCircle2
|
||||
}
|
||||
};
|
||||
|
||||
const zammadPriorityMeta = {
|
||||
"1": {
|
||||
label: "Niedrig",
|
||||
order: 10,
|
||||
className: "border-blue-500/30 bg-blue-500/10 text-blue-700 dark:text-blue-300",
|
||||
Icon: ArrowDown
|
||||
},
|
||||
"2": {
|
||||
label: "Normal",
|
||||
order: 20,
|
||||
className: "border-emerald-500/30 bg-emerald-500/10 text-emerald-700 dark:text-emerald-300",
|
||||
Icon: Minus
|
||||
},
|
||||
"3": {
|
||||
label: "Hoch",
|
||||
order: 30,
|
||||
className: "border-red-500/30 bg-red-500/10 text-red-700 dark:text-red-300",
|
||||
Icon: ArrowUp
|
||||
}
|
||||
};
|
||||
|
||||
export function zammadId(value: unknown) {
|
||||
if (value === null || value === undefined || value === "") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const normalized = String(value).trim();
|
||||
return /^\d+$/.test(normalized) ? normalized.replace(/^0+(?=\d)/, "") : normalized || null;
|
||||
}
|
||||
|
||||
export function zammadStateId(value: unknown) {
|
||||
const id = zammadId(value);
|
||||
return id === "1" ? "2" : id;
|
||||
}
|
||||
|
||||
export function zammadStateLabel(value: unknown) {
|
||||
const id = zammadStateId(value);
|
||||
return id ? zammadStateMeta[id as keyof typeof zammadStateMeta]?.label ?? `Status ${id}` : "-";
|
||||
}
|
||||
|
||||
export function zammadPriorityLabel(value: unknown) {
|
||||
const id = zammadId(value);
|
||||
return id ? zammadPriorityMeta[id as keyof typeof zammadPriorityMeta]?.label ?? `Priorität ${id}` : "-";
|
||||
}
|
||||
|
||||
export function zammadStateSortRank(value: unknown) {
|
||||
const id = zammadStateId(value);
|
||||
const fallback = id ? Number(id) : Number.NaN;
|
||||
return id ? zammadStateMeta[id as keyof typeof zammadStateMeta]?.order ?? (Number.isFinite(fallback) ? 900 + fallback : 999) : 999;
|
||||
}
|
||||
|
||||
export function zammadPrioritySortRank(value: unknown) {
|
||||
const id = zammadId(value);
|
||||
const fallback = id ? Number(id) : Number.NaN;
|
||||
return id ? zammadPriorityMeta[id as keyof typeof zammadPriorityMeta]?.order ?? (Number.isFinite(fallback) ? 900 + fallback : 999) : 999;
|
||||
}
|
||||
|
||||
export function matchesZammadStateFilter(value: unknown, filter: ZammadStateFilter) {
|
||||
const id = zammadStateId(value);
|
||||
return filter === "all" || (filter === "none" ? !id : id === filter);
|
||||
}
|
||||
|
||||
export function matchesZammadPriorityFilter(value: unknown, filter: ZammadPriorityFilter) {
|
||||
const id = zammadId(value);
|
||||
return filter === "all" || (filter === "none" ? !id : id === filter);
|
||||
}
|
||||
|
||||
export function ZammadStateBadge({ stateId, className }: { stateId: unknown; className?: string }) {
|
||||
const id = zammadStateId(stateId);
|
||||
|
||||
if (!id) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const meta = zammadStateMeta[id as keyof typeof zammadStateMeta];
|
||||
const Icon = meta?.Icon ?? AlertTriangle;
|
||||
|
||||
return (
|
||||
<Badge variant="outline" className={cn(meta?.className ?? "border-border bg-muted/40 text-muted-foreground", className)}>
|
||||
<Icon className="size-3" />
|
||||
{meta?.label ?? `Status ${id}`}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
export function ZammadPriorityBadge({ priorityId, className }: { priorityId: unknown; className?: string }) {
|
||||
const id = zammadId(priorityId);
|
||||
|
||||
if (!id) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const meta = zammadPriorityMeta[id as keyof typeof zammadPriorityMeta];
|
||||
const Icon = meta?.Icon ?? AlertTriangle;
|
||||
|
||||
return (
|
||||
<Badge variant="outline" className={cn(meta?.className ?? "border-border bg-muted/40 text-muted-foreground", className)}>
|
||||
<Icon className="size-3" />
|
||||
{meta?.label ?? `Priorität ${id}`}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
Executable
+91
@@ -0,0 +1,91 @@
|
||||
#!/usr/bin/env bash
|
||||
# Holt alle Zammad-Articles der letzten X Tage, gefiltert nach type_id und created_by_id.
|
||||
#
|
||||
# Aufruf: ZAMMAD_TOKEN=xxx ./zammad_articles.sh [TAGE] [CREATED_BY_ID] [TYPE_ID]
|
||||
# Beispiel: ZAMMAD_TOKEN=xxx ./zammad_articles.sh 2 1764 1 > mails.json
|
||||
#
|
||||
# Voraussetzungen: curl, jq, API-Token mit Admin-Rechten (für /ticket_articles)
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
BASE_URL="${ZAMMAD_URL:-https://aikava.zammad.com}"
|
||||
TOKEN="${ZAMMAD_TOKEN:?Bitte ZAMMAD_TOKEN setzen}"
|
||||
DAYS="${1:-2}"
|
||||
CREATED_BY="${2:-1764}"
|
||||
TYPE_ID="${3:-1}"
|
||||
PER_PAGE=1000
|
||||
MAX_PAGE=1000000 # Schutz gegen Endlosschleifen
|
||||
|
||||
# Call-Zähler über eine Temp-Datei, da fetch_page oft in $(...) läuft
|
||||
CALLS_FILE=$(mktemp)
|
||||
trap 'rm -f "$CALLS_FILE"' EXIT
|
||||
calls() { wc -l < "$CALLS_FILE" | tr -d ' '; }
|
||||
|
||||
# Stichtag in UTC (GNU date oder BSD/macOS date)
|
||||
# CALENDAR=1: ab lokaler Mitternacht vor (TAGE-1) Tagen, also TAGE=2 -> gestern 00:00 bis jetzt
|
||||
# sonst: rollierend, jetzt minus TAGE*24h
|
||||
if date -u -d "now" >/dev/null 2>&1; then
|
||||
if [ "${CALENDAR:-0}" = "1" ]; then
|
||||
TS=$(date -d "$((DAYS - 1)) days ago 00:00" +%s)
|
||||
else
|
||||
TS=$(date -d "${DAYS} days ago" +%s)
|
||||
fi
|
||||
CUTOFF=$(date -u -d "@${TS}" +%Y-%m-%dT%H:%M:%S)
|
||||
else
|
||||
if [ "${CALENDAR:-0}" = "1" ]; then
|
||||
TS=$(date -v-"$((DAYS - 1))"d -v0H -v0M -v0S +%s)
|
||||
else
|
||||
TS=$(date -v-"${DAYS}"d +%s)
|
||||
fi
|
||||
CUTOFF=$(date -u -r "${TS}" +%Y-%m-%dT%H:%M:%S)
|
||||
fi
|
||||
|
||||
log() { echo "$*" >&2; }
|
||||
|
||||
fetch_page() {
|
||||
echo "$1" >> "$CALLS_FILE"
|
||||
curl -sf -H "Authorization: Token token=${TOKEN}" \
|
||||
"${BASE_URL}/api/v1/ticket_articles?page=$1&per_page=${PER_PAGE}"
|
||||
}
|
||||
|
||||
page_len() { fetch_page "$1" | jq 'length'; }
|
||||
|
||||
log "Stichtag: ${CUTOFF}Z | created_by_id=${CREATED_BY} | type_id=${TYPE_ID}"
|
||||
|
||||
# 1) Letzte nicht-leere Seite per Binärsuche finden
|
||||
if [ "$(page_len 1)" -eq 0 ]; then
|
||||
log "Keine Articles gefunden. (API-Calls: $(calls))"
|
||||
echo "[]"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
lo=1; hi=2
|
||||
while [ "$hi" -le "$MAX_PAGE" ] && [ "$(page_len "$hi")" -gt 0 ]; do
|
||||
lo=$hi; hi=$((hi * 2))
|
||||
done
|
||||
while [ $((hi - lo)) -gt 1 ]; do
|
||||
mid=$(( (lo + hi) / 2 ))
|
||||
if [ "$(page_len "$mid")" -gt 0 ]; then lo=$mid; else hi=$mid; fi
|
||||
done
|
||||
SEARCH_CALLS=$(calls)
|
||||
log "Letzte Seite: ${lo} (Binärsuche: ${SEARCH_CALLS} Calls)"
|
||||
|
||||
# 2) Rückwärts lesen bis zum Stichtag und filtern
|
||||
results="[]"
|
||||
p=$lo
|
||||
while [ "$p" -ge 1 ]; do
|
||||
batch=$(fetch_page "$p")
|
||||
matches=$(jq --arg c "$CUTOFF" --argjson u "$CREATED_BY" --argjson t "$TYPE_ID" \
|
||||
'[.[] | select(.created_at >= $c and .type_id == $t and .created_by_id == $u)]' <<<"$batch")
|
||||
results=$(jq -s 'add' <(echo "$results") <(echo "$matches"))
|
||||
|
||||
oldest=$(jq -r '.[0].created_at // empty' <<<"$batch")
|
||||
log "Seite ${p}: $(jq 'length' <<<"$matches") Treffer (ältester Article: ${oldest})"
|
||||
[[ -z "$oldest" || "$oldest" < "$CUTOFF" ]] && break
|
||||
p=$((p - 1))
|
||||
done
|
||||
|
||||
TOTAL_CALLS=$(calls)
|
||||
log "Gesamt: $(jq 'length' <<<"$results") Articles"
|
||||
log "API-Calls: ${TOTAL_CALLS} (Binärsuche: ${SEARCH_CALLS}, Rückwärts lesen: $((TOTAL_CALLS - SEARCH_CALLS)))"
|
||||
jq 'sort_by(.id)' <<<"$results"
|
||||
+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
|
||||
}
|
||||
]
|
||||
+679
@@ -0,0 +1,679 @@
|
||||
Richtung out
|
||||
URL
|
||||
http://192.168.42.169:3911/api/zammad/email-session-webhook
|
||||
Methode
|
||||
POST
|
||||
Status
|
||||
0
|
||||
Anfrage
|
||||
accept-encoding: gzip;q=1.0,deflate;q=0.6,identity;q=0.3
|
||||
accept: */*
|
||||
user-agent: Zammad User Agent
|
||||
host: 192.168.42.169:3911
|
||||
x-zammad-trigger: Tickettracker Test
|
||||
x-zammad-delivery: ef364583-36b3-40f3-bacb-967a09e7ddae
|
||||
content-type: application/json; charset=utf-8
|
||||
Authorization: Bearer [FILTERED]@ySW3uTFKjGFMrti8BcDkNv8mN2g71...[TRUNCATED]
|
||||
|
||||
{
|
||||
"ticket": {
|
||||
"abgerechnet": "",
|
||||
"ai_agent_running": false,
|
||||
"ai_stored_result_ids": [],
|
||||
"ai_stored_results": [],
|
||||
"article_count": 13,
|
||||
"article_ids": [
|
||||
66138,
|
||||
66151,
|
||||
66152,
|
||||
66139,
|
||||
66141,
|
||||
66145,
|
||||
66144,
|
||||
66146,
|
||||
66130,
|
||||
66149,
|
||||
66131,
|
||||
66150,
|
||||
66133
|
||||
],
|
||||
"atc_version": "-",
|
||||
"bemerkung": "",
|
||||
"bestellnummer": "",
|
||||
"checklist_id": null,
|
||||
"close_at": null,
|
||||
"close_diff_in_min": null,
|
||||
"close_escalation_at": null,
|
||||
"close_in_min": null,
|
||||
"create_article_sender": "Customer",
|
||||
"create_article_sender_id": 2,
|
||||
"create_article_type": "phone",
|
||||
"create_article_type_id": 5,
|
||||
"created_at": "2026-09-22T14:56:23.929Z",
|
||||
"created_by": {
|
||||
"active": true,
|
||||
"address": "",
|
||||
"city": "",
|
||||
"country": "",
|
||||
"created_at": "2025-03-31T08:14:45.514Z",
|
||||
"created_by": "marco.christener@aikava.de",
|
||||
"created_by_id": 3,
|
||||
"department": "Senior Software Consultant",
|
||||
"email": "michael.boehmlaender@aikava.de",
|
||||
"fax": "",
|
||||
"firstname": "Michael",
|
||||
"id": 1764,
|
||||
"image": "facb8844175ddeb0a0fab286b9b68fbd",
|
||||
"image_source": null,
|
||||
"lastname": "Böhmländer",
|
||||
"login": "michael.boehmlaender@aikava.de",
|
||||
"mobile": "",
|
||||
"mobile_2": "",
|
||||
"note": "Geplante Abwesenheiten:",
|
||||
"organization": "AIKAVA GmbH",
|
||||
"organization_id": 2,
|
||||
"organization_ids": [],
|
||||
"organizations": [],
|
||||
"out_of_office": true,
|
||||
"out_of_office_end_at": "2026-08-28",
|
||||
"out_of_office_replacement": "marco.dirscherl@aikava.de",
|
||||
"out_of_office_replacement_id": 77,
|
||||
"out_of_office_start_at": "2026-08-17",
|
||||
"overview_sorting_ids": [
|
||||
301,
|
||||
302,
|
||||
303,
|
||||
304,
|
||||
305,
|
||||
306,
|
||||
307,
|
||||
308,
|
||||
309,
|
||||
310,
|
||||
311,
|
||||
312,
|
||||
313,
|
||||
314,
|
||||
315,
|
||||
316,
|
||||
317,
|
||||
318,
|
||||
319,
|
||||
320
|
||||
],
|
||||
"phone": "",
|
||||
"role_ids": [
|
||||
2,
|
||||
1
|
||||
],
|
||||
"roles": [
|
||||
"Admin",
|
||||
"Agent"
|
||||
],
|
||||
"source": null,
|
||||
"street": "",
|
||||
"two_factor_preference_ids": [],
|
||||
"two_factor_preferences": [],
|
||||
"updated_at": "2026-09-21T05:56:12.741Z",
|
||||
"updated_by": "michael.boehmlaender@aikava.de",
|
||||
"updated_by_id": 1764,
|
||||
"verified": false,
|
||||
"vip": false,
|
||||
"web": "",
|
||||
"zip": ""
|
||||
},
|
||||
"created_by_id": 1764,
|
||||
"customer": {
|
||||
"active": true,
|
||||
"address": "testadresse",
|
||||
"city": "",
|
||||
"country": "",
|
||||
"created_at": "2023-01-10T13:07:40.366Z",
|
||||
"created_by": "thomas.roider@aikava.de",
|
||||
"created_by_id": 4,
|
||||
"department": "",
|
||||
"email": "test@test.de",
|
||||
"fax": "",
|
||||
"firstname": "Testkundenbesitzer",
|
||||
"id": 6,
|
||||
"image": null,
|
||||
"image_source": null,
|
||||
"lastname": "Testnachname",
|
||||
"login": "test@test.de",
|
||||
"mobile": "",
|
||||
"mobile_2": null,
|
||||
"note": "testkunde",
|
||||
"organization": "AIKAVA GmbH",
|
||||
"organization_id": 2,
|
||||
"organization_ids": [],
|
||||
"organizations": [],
|
||||
"out_of_office": false,
|
||||
"out_of_office_end_at": null,
|
||||
"out_of_office_replacement_id": null,
|
||||
"out_of_office_start_at": null,
|
||||
"overview_sorting_ids": [],
|
||||
"overview_sortings": [],
|
||||
"phone": "+49123456789",
|
||||
"role_ids": [],
|
||||
"roles": [],
|
||||
"source": null,
|
||||
"street": "",
|
||||
"two_factor_preference_ids": [],
|
||||
"two_factor_preferences": [],
|
||||
"updated_at": "2026-09-22T14:56:23.989Z",
|
||||
"updated_by": "thomas.roider@aikava.de",
|
||||
"updated_by_id": 4,
|
||||
"verified": false,
|
||||
"vip": false,
|
||||
"web": "",
|
||||
"zip": ""
|
||||
},
|
||||
"customer_id": 6,
|
||||
"daily_event_lock_ids": [],
|
||||
"daily_event_locks": [],
|
||||
"escalation_at": null,
|
||||
"first_response_at": "2026-09-22T15:00:35.799Z",
|
||||
"first_response_diff_in_min": null,
|
||||
"first_response_escalation_at": null,
|
||||
"first_response_in_min": null,
|
||||
"gesamtbudget": "",
|
||||
"group": {
|
||||
"active": true,
|
||||
"assignment_timeout": null,
|
||||
"created_at": "2023-01-10T12:33:22.977Z",
|
||||
"created_by": "thomas.roider@aikava.de",
|
||||
"created_by_id": 4,
|
||||
"email_address": "Zammad Helpdesk Support",
|
||||
"email_address_id": 6,
|
||||
"follow_up_assignment": true,
|
||||
"follow_up_possible": "yes",
|
||||
"id": 3,
|
||||
"name": "Support",
|
||||
"name_last": "Support",
|
||||
"note": "Support AIKAVA",
|
||||
"parent_id": null,
|
||||
"reopen_time_in_days": null,
|
||||
"shared_drafts": true,
|
||||
"signature": "AIKAVA - Abteilung als Berufsbezeichnung (Gruppe Support)",
|
||||
"signature_id": 2,
|
||||
"summary_generation": "global_default",
|
||||
"updated_at": "2026-07-30T09:34:01.249Z",
|
||||
"updated_by": "thomas.roider@aikava.de",
|
||||
"updated_by_id": 4,
|
||||
"user_ids": [
|
||||
6,
|
||||
2308,
|
||||
2309,
|
||||
251,
|
||||
77,
|
||||
4,
|
||||
3
|
||||
],
|
||||
"users": [
|
||||
"test@test.de",
|
||||
"apiuser@aikava.de",
|
||||
"hung.nguyen@aikava.de",
|
||||
"corinna.breu@aikava.de",
|
||||
"marco.dirscherl@aikava.de",
|
||||
"thomas.roider@aikava.de",
|
||||
"marco.christener@aikava.de"
|
||||
]
|
||||
},
|
||||
"group_id": 3,
|
||||
"id": 7035,
|
||||
"last_close_at": null,
|
||||
"last_contact_agent_at": "2026-09-23T06:47:38.553Z",
|
||||
"last_contact_at": "2026-09-23T06:47:38.553Z",
|
||||
"last_contact_customer_at": "2026-09-22T14:56:24.008Z",
|
||||
"last_owner_update_at": "2026-09-23T06:47:38.566Z",
|
||||
"netzwerkpfad": "",
|
||||
"note": null,
|
||||
"number": "947021",
|
||||
"organization": {
|
||||
"active": true,
|
||||
"country": "",
|
||||
"created_at": "2023-01-06T11:06:22.976Z",
|
||||
"created_by": "-",
|
||||
"created_by_id": 1,
|
||||
"customer_owner": null,
|
||||
"dauerticket_bestellnummer": "",
|
||||
"dauerticket_url": "",
|
||||
"domain": "aikava.de",
|
||||
"domain_assignment": false,
|
||||
"hardware": null,
|
||||
"hosting_adress": null,
|
||||
"hostingkunde": false,
|
||||
"hotline_contract": null,
|
||||
"id": 2,
|
||||
"member_ids": [
|
||||
297,
|
||||
295,
|
||||
251,
|
||||
301,
|
||||
6,
|
||||
2309,
|
||||
982,
|
||||
479,
|
||||
78,
|
||||
1764,
|
||||
77,
|
||||
3,
|
||||
4
|
||||
],
|
||||
"members": [
|
||||
"auto-feb98421-3344-4f54-a650-dc54b010ba90",
|
||||
"auto-b4d24e63-db3e-44c2-9fb9-b8f3b6a1a675",
|
||||
"corinna.breu@aikava.de",
|
||||
"auto-27ce025f-f458-4a90-b320-df18812c150b",
|
||||
"test@test.de",
|
||||
"hung.nguyen@aikava.de",
|
||||
"marcel.reisch@aikava.de",
|
||||
"cristian.dimcia@aikava.de",
|
||||
"heidi.schmaderer@aikava.de",
|
||||
"michael.boehmlaender@aikava.de",
|
||||
"marco.dirscherl@aikava.de",
|
||||
"marco.christener@aikava.de",
|
||||
"thomas.roider@aikava.de"
|
||||
],
|
||||
"name": "AIKAVA GmbH",
|
||||
"note": "",
|
||||
"ort": "Cham",
|
||||
"plz": "93413",
|
||||
"rechnungsadresse": "",
|
||||
"secondary_member_ids": [],
|
||||
"shared": true,
|
||||
"stock_customer": false,
|
||||
"strasse": "Galgenfeld 7",
|
||||
"support_project": null,
|
||||
"updated_at": "2026-09-22T14:56:23.997Z",
|
||||
"updated_by": "corinna.breu@aikava.de",
|
||||
"updated_by_id": 251,
|
||||
"vertrieb_atoss": [],
|
||||
"vip": false
|
||||
},
|
||||
"organization_id": 2,
|
||||
"owner": {
|
||||
"active": true,
|
||||
"address": "",
|
||||
"city": "",
|
||||
"country": "",
|
||||
"created_at": "2025-03-31T08:14:45.514Z",
|
||||
"created_by": "marco.christener@aikava.de",
|
||||
"created_by_id": 3,
|
||||
"department": "Senior Software Consultant",
|
||||
"email": "michael.boehmlaender@aikava.de",
|
||||
"fax": "",
|
||||
"firstname": "Michael",
|
||||
"id": 1764,
|
||||
"image": "facb8844175ddeb0a0fab286b9b68fbd",
|
||||
"image_source": null,
|
||||
"lastname": "Böhmländer",
|
||||
"login": "michael.boehmlaender@aikava.de",
|
||||
"mobile": "",
|
||||
"mobile_2": "",
|
||||
"note": "Geplante Abwesenheiten:",
|
||||
"organization": "AIKAVA GmbH",
|
||||
"organization_id": 2,
|
||||
"organization_ids": [],
|
||||
"organizations": [],
|
||||
"out_of_office": true,
|
||||
"out_of_office_end_at": "2026-08-28",
|
||||
"out_of_office_replacement": "marco.dirscherl@aikava.de",
|
||||
"out_of_office_replacement_id": 77,
|
||||
"out_of_office_start_at": "2026-08-17",
|
||||
"overview_sorting_ids": [
|
||||
301,
|
||||
302,
|
||||
303,
|
||||
304,
|
||||
305,
|
||||
306,
|
||||
307,
|
||||
308,
|
||||
309,
|
||||
310,
|
||||
311,
|
||||
312,
|
||||
313,
|
||||
314,
|
||||
315,
|
||||
316,
|
||||
317,
|
||||
318,
|
||||
319,
|
||||
320
|
||||
],
|
||||
"phone": "",
|
||||
"role_ids": [
|
||||
2,
|
||||
1
|
||||
],
|
||||
"roles": [
|
||||
"Admin",
|
||||
"Agent"
|
||||
],
|
||||
"source": null,
|
||||
"street": "",
|
||||
"two_factor_preference_ids": [],
|
||||
"two_factor_preferences": [],
|
||||
"updated_at": "2026-09-21T05:56:12.741Z",
|
||||
"updated_by": "michael.boehmlaender@aikava.de",
|
||||
"updated_by_id": 1764,
|
||||
"verified": false,
|
||||
"vip": false,
|
||||
"web": "",
|
||||
"zip": ""
|
||||
},
|
||||
"owner_id": 1764,
|
||||
"pending_time": null,
|
||||
"preferences": {},
|
||||
"priority": {
|
||||
"active": true,
|
||||
"created_at": "2023-01-06T10:59:49.163Z",
|
||||
"created_by": "-",
|
||||
"created_by_id": 1,
|
||||
"default_create": true,
|
||||
"id": 2,
|
||||
"name": "2 normal",
|
||||
"note": null,
|
||||
"ui_color": null,
|
||||
"ui_icon": null,
|
||||
"updated_at": "2023-01-06T10:59:49.163Z",
|
||||
"updated_by": "-",
|
||||
"updated_by_id": 1
|
||||
},
|
||||
"priority_id": 2,
|
||||
"project": "",
|
||||
"referencing_checklist_ids": [],
|
||||
"referencing_checklists": [],
|
||||
"state": "open",
|
||||
"state_id": 2,
|
||||
"ticket_time_accounting_ids": [
|
||||
16133,
|
||||
16135,
|
||||
16137,
|
||||
16138,
|
||||
16139,
|
||||
16140,
|
||||
16141,
|
||||
16142,
|
||||
16144,
|
||||
16145,
|
||||
16146,
|
||||
16147
|
||||
],
|
||||
"time_unit": "2.75",
|
||||
"title": "Bla",
|
||||
"type": null,
|
||||
"update_diff_in_min": null,
|
||||
"update_escalation_at": null,
|
||||
"update_in_min": null,
|
||||
"updated_at": "2026-09-23T06:47:42.937Z",
|
||||
"updated_by": {
|
||||
"active": true,
|
||||
"address": "",
|
||||
"city": "",
|
||||
"country": "",
|
||||
"created_at": "2025-03-31T08:14:45.514Z",
|
||||
"created_by": "marco.christener@aikava.de",
|
||||
"created_by_id": 3,
|
||||
"department": "Senior Software Consultant",
|
||||
"email": "michael.boehmlaender@aikava.de",
|
||||
"fax": "",
|
||||
"firstname": "Michael",
|
||||
"id": 1764,
|
||||
"image": "facb8844175ddeb0a0fab286b9b68fbd",
|
||||
"image_source": null,
|
||||
"lastname": "Böhmländer",
|
||||
"login": "michael.boehmlaender@aikava.de",
|
||||
"mobile": "",
|
||||
"mobile_2": "",
|
||||
"note": "Geplante Abwesenheiten:",
|
||||
"organization": "AIKAVA GmbH",
|
||||
"organization_id": 2,
|
||||
"organization_ids": [],
|
||||
"organizations": [],
|
||||
"out_of_office": true,
|
||||
"out_of_office_end_at": "2026-08-28",
|
||||
"out_of_office_replacement": "marco.dirscherl@aikava.de",
|
||||
"out_of_office_replacement_id": 77,
|
||||
"out_of_office_start_at": "2026-08-17",
|
||||
"overview_sorting_ids": [
|
||||
301,
|
||||
302,
|
||||
303,
|
||||
304,
|
||||
305,
|
||||
306,
|
||||
307,
|
||||
308,
|
||||
309,
|
||||
310,
|
||||
311,
|
||||
312,
|
||||
313,
|
||||
314,
|
||||
315,
|
||||
316,
|
||||
317,
|
||||
318,
|
||||
319,
|
||||
320
|
||||
],
|
||||
"phone": "",
|
||||
"role_ids": [
|
||||
2,
|
||||
1
|
||||
],
|
||||
"roles": [
|
||||
"Admin",
|
||||
"Agent"
|
||||
],
|
||||
"source": null,
|
||||
"street": "",
|
||||
"two_factor_preference_ids": [],
|
||||
"two_factor_preferences": [],
|
||||
"updated_at": "2026-09-21T05:56:12.741Z",
|
||||
"updated_by": "michael.boehmlaender@aikava.de",
|
||||
"updated_by_id": 1764,
|
||||
"verified": false,
|
||||
"vip": false,
|
||||
"web": "",
|
||||
"zip": ""
|
||||
},
|
||||
"updated_by_id": 1764
|
||||
},
|
||||
"article": {
|
||||
"ai_stored_result_ids": [],
|
||||
"ai_stored_results": [],
|
||||
"attachments": [],
|
||||
"body": "test<br><br><div data-signature=\"true\" data-signature-id=\"2\"><div>
|
||||
<div><b>Michael Böhmländer</b></div>
|
||||
<div>Senior Software Consultant<br>Geplante Abwesenheiten:</div>
|
||||
</div><div><br></div><div><b>AIKAVA GmbH</b></div><div>Galgenfeld 7 | 93413 Cham</div><div><b><br></b></div><div>T: +49 9971 88999 40</div><div>
|
||||
<a href=\"mailto:support.atoss@aikava.de\" title=\"mailto:support.atoss@aikava.de\">support@aikava.de</a><br>
|
||||
</div><div>
|
||||
<a href=\"https://aikava.de/\" rel=\"nofollow noreferrer noopener\" target=\"_blank\">aikava.de</a><br>
|
||||
</div><div><br></div><div><p>Amtsgericht Regensburg HRB 19554<br></p></div><div>Geschäftsführung Marco Christener</div><div><i><br></i></div><div><i>This e-mail may contain confidential and/or privileged information. If you are not the intended recipient or have received this e-mail in error please notify the sender immediately and delete this e-mail.</i></div></div>",
|
||||
"body_rendering_error": false,
|
||||
"cc": "",
|
||||
"content_type": "text/html",
|
||||
"created_at": "2026-09-23T06:47:38.553Z",
|
||||
"created_by": {
|
||||
"active": true,
|
||||
"address": "",
|
||||
"city": "",
|
||||
"country": "",
|
||||
"created_at": "2025-03-31T08:14:45.514Z",
|
||||
"created_by": "marco.christener@aikava.de",
|
||||
"created_by_id": 3,
|
||||
"department": "Senior Software Consultant",
|
||||
"email": "michael.boehmlaender@aikava.de",
|
||||
"fax": "",
|
||||
"firstname": "Michael",
|
||||
"id": 1764,
|
||||
"image": "facb8844175ddeb0a0fab286b9b68fbd",
|
||||
"image_source": null,
|
||||
"lastname": "Böhmländer",
|
||||
"login": "michael.boehmlaender@aikava.de",
|
||||
"mobile": "",
|
||||
"mobile_2": "",
|
||||
"note": "Geplante Abwesenheiten:",
|
||||
"organization": "AIKAVA GmbH",
|
||||
"organization_id": 2,
|
||||
"organization_ids": [],
|
||||
"organizations": [],
|
||||
"out_of_office": true,
|
||||
"out_of_office_end_at": "2026-08-28",
|
||||
"out_of_office_replacement": "marco.dirscherl@aikava.de",
|
||||
"out_of_office_replacement_id": 77,
|
||||
"out_of_office_start_at": "2026-08-17",
|
||||
"overview_sorting_ids": [
|
||||
301,
|
||||
302,
|
||||
303,
|
||||
304,
|
||||
305,
|
||||
306,
|
||||
307,
|
||||
308,
|
||||
309,
|
||||
310,
|
||||
311,
|
||||
312,
|
||||
313,
|
||||
314,
|
||||
315,
|
||||
316,
|
||||
317,
|
||||
318,
|
||||
319,
|
||||
320
|
||||
],
|
||||
"phone": "",
|
||||
"role_ids": [
|
||||
2,
|
||||
1
|
||||
],
|
||||
"roles": [
|
||||
"Admin",
|
||||
"Agent"
|
||||
],
|
||||
"source": null,
|
||||
"street": "",
|
||||
"two_factor_preference_ids": [],
|
||||
"two_factor_preferences": [],
|
||||
"updated_at": "2026-09-21T05:56:12.741Z",
|
||||
"updated_by": "michael.boehmlaender@aikava.de",
|
||||
"updated_by_id": 1764,
|
||||
"verified": false,
|
||||
"vip": false,
|
||||
"web": "",
|
||||
"zip": ""
|
||||
},
|
||||
"created_by_id": 1764,
|
||||
"detected_language": null,
|
||||
"from": "\"Michael Böhmländer via Zammad Helpdesk Support\" <support@aikava.de>",
|
||||
"id": 66152,
|
||||
"in_reply_to": "",
|
||||
"internal": false,
|
||||
"message_id": "<20260923064738.7035.8a97ff10-62fc-4d7a-9d91-2d968ea53002@aikava.zammad.com>",
|
||||
"message_id_md5": "4d6a39f57fc20c1834cc2e7b8c6e94cd",
|
||||
"origin_by_id": null,
|
||||
"preferences": {
|
||||
"email_address_id": 6,
|
||||
"delivery_retry": 1,
|
||||
"delivery_channel_id": 23,
|
||||
"delivery_status_message": null,
|
||||
"delivery_status": "success",
|
||||
"delivery_status_date": "2026-09-23T06:47:42.903Z"
|
||||
},
|
||||
"reply_to": null,
|
||||
"sender": "Agent",
|
||||
"sender_id": 1,
|
||||
"subject": "test",
|
||||
"ticket_id": 7035,
|
||||
"time_unit": "0.0",
|
||||
"to": "michael.boehmlaender@aikava.de",
|
||||
"type": "email",
|
||||
"type_id": 1,
|
||||
"updated_at": "2026-09-23T06:47:42.905Z",
|
||||
"updated_by": {
|
||||
"active": true,
|
||||
"address": "",
|
||||
"city": "",
|
||||
"country": "",
|
||||
"created_at": "2025-03-31T08:14:45.514Z",
|
||||
"created_by": "marco.christener@aikava.de",
|
||||
"created_by_id": 3,
|
||||
"department": "Senior Software Consultant",
|
||||
"email": "michael.boehmlaender@aikava.de",
|
||||
"fax": "",
|
||||
"firstname": "Michael",
|
||||
"id": 1764,
|
||||
"image": "facb8844175ddeb0a0fab286b9b68fbd",
|
||||
"image_source": null,
|
||||
"lastname": "Böhmländer",
|
||||
"login": "michael.boehmlaender@aikava.de",
|
||||
"mobile": "",
|
||||
"mobile_2": "",
|
||||
"note": "Geplante Abwesenheiten:",
|
||||
"organization": "AIKAVA GmbH",
|
||||
"organization_id": 2,
|
||||
"organization_ids": [],
|
||||
"organizations": [],
|
||||
"out_of_office": true,
|
||||
"out_of_office_end_at": "2026-08-28",
|
||||
"out_of_office_replacement": "marco.dirscherl@aikava.de",
|
||||
"out_of_office_replacement_id": 77,
|
||||
"out_of_office_start_at": "2026-08-17",
|
||||
"overview_sorting_ids": [
|
||||
301,
|
||||
302,
|
||||
303,
|
||||
304,
|
||||
305,
|
||||
306,
|
||||
307,
|
||||
308,
|
||||
309,
|
||||
310,
|
||||
311,
|
||||
312,
|
||||
313,
|
||||
314,
|
||||
315,
|
||||
316,
|
||||
317,
|
||||
318,
|
||||
319,
|
||||
320
|
||||
],
|
||||
"phone": "",
|
||||
"role_ids": [
|
||||
2,
|
||||
1
|
||||
],
|
||||
"roles": [
|
||||
"Admin",
|
||||
"Agent"
|
||||
],
|
||||
"source": null,
|
||||
"street": "",
|
||||
"two_factor_preference_ids": [],
|
||||
"two_factor_preferences": [],
|
||||
"updated_at": "2026-09-21T05:56:12.741Z",
|
||||
"updated_by": "michael.boehmlaender@aikava.de",
|
||||
"updated_by_id": 1764,
|
||||
"verified": false,
|
||||
"vip": false,
|
||||
"web": "",
|
||||
"zip": ""
|
||||
},
|
||||
"updated_by_id": 1764,
|
||||
"accounted_time": 0
|
||||
}
|
||||
}
|
||||
Antwort
|
||||
|
||||
Erstellt am 2026-09-23 08:48:13
|
||||
Executable
+24
@@ -0,0 +1,24 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
APP_NAME="${APP_NAME:-tickettracker}"
|
||||
GITEA_REGISTRY="${GITEA_REGISTRY:-git.d-razz.de}"
|
||||
GITEA_OWNER="${GITEA_OWNER:-michael}"
|
||||
IMAGE_TAG="${IMAGE_TAG:-latest}"
|
||||
PLATFORM="${PLATFORM:-linux/amd64}"
|
||||
IMAGE="${IMAGE:-${GITEA_REGISTRY}/${GITEA_OWNER}/${APP_NAME}:${IMAGE_TAG}}"
|
||||
|
||||
echo "Baue ${IMAGE} fuer ${PLATFORM}"
|
||||
docker buildx build \
|
||||
--platform "${PLATFORM}" \
|
||||
--tag "${IMAGE}" \
|
||||
--push \
|
||||
.
|
||||
|
||||
echo
|
||||
echo "Image gepusht: ${IMAGE}"
|
||||
echo "Auf dem Docker-Server:"
|
||||
echo " export TICKETTRACKER_IMAGE='${IMAGE}'"
|
||||
echo " docker compose --env-file .env.docker-external -f compose.yml up -d"
|
||||
echo "oder mit PostgreSQL 17 im Stack:"
|
||||
echo " docker compose --env-file .env.docker-postgres -f compose.postgres.yml up -d"
|
||||
@@ -10,6 +10,7 @@ IMAGE="${IMAGE:-${GITEA_REGISTRY}/${GITEA_OWNER}/${APP_NAME}:${IMAGE_TAG}}"
|
||||
|
||||
echo "Baue ${IMAGE} fuer ${PLATFORM}"
|
||||
docker buildx build \
|
||||
--builder tickettracker-builder \
|
||||
--platform "${PLATFORM}" \
|
||||
--tag "${IMAGE}" \
|
||||
--push \
|
||||
|
||||
@@ -3,10 +3,22 @@ set -euo pipefail
|
||||
|
||||
NODE_MAJOR_REQUIRED=22
|
||||
NODESOURCE_SETUP_URL="https://deb.nodesource.com/setup_${NODE_MAJOR_REQUIRED}.x"
|
||||
MIN_DISK_MB=2048
|
||||
MIN_MEMORY_MB=768
|
||||
|
||||
SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
|
||||
REPO_ROOT="$(cd -- "${SCRIPT_DIR}/.." && pwd)"
|
||||
|
||||
if [[ "${1:-}" != "--run" ]]; then
|
||||
echo "Dieses Setup-Script macht ohne explizites --run nichts."
|
||||
echo
|
||||
echo "So startest du es bewusst:"
|
||||
echo " bash scripts/install-local-dependencies.sh --run"
|
||||
echo
|
||||
echo "Es installiert nur Webapp-Abhaengigkeiten, keine DB und kein Docker."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [[ ! -f "${REPO_ROOT}/package.json" ]]; then
|
||||
echo "Repo-Root nicht gefunden. Erwartet: ${REPO_ROOT}/package.json"
|
||||
exit 1
|
||||
@@ -46,6 +58,33 @@ node_major() {
|
||||
node -p 'Number(process.versions.node.split(".")[0])' 2>/dev/null || echo 0
|
||||
}
|
||||
|
||||
available_memory_mb() {
|
||||
awk '/MemAvailable:/ { print int($2 / 1024) }' /proc/meminfo 2>/dev/null || echo 0
|
||||
}
|
||||
|
||||
available_disk_mb() {
|
||||
df -Pm "${REPO_ROOT}" | awk 'NR == 2 { print $4 }'
|
||||
}
|
||||
|
||||
if [[ "${SKIP_RESOURCE_CHECK:-false}" != "true" ]]; then
|
||||
AVAILABLE_DISK_MB="$(available_disk_mb)"
|
||||
AVAILABLE_MEMORY_MB="$(available_memory_mb)"
|
||||
|
||||
if [[ "${AVAILABLE_DISK_MB}" -lt "${MIN_DISK_MB}" ]]; then
|
||||
echo "Zu wenig freier Speicherplatz fuer npm-Abhaengigkeiten."
|
||||
echo "Frei: ${AVAILABLE_DISK_MB} MB, benoetigt empfohlen: mindestens ${MIN_DISK_MB} MB."
|
||||
echo "Falls du das bewusst trotzdem starten willst: SKIP_RESOURCE_CHECK=true bash scripts/install-local-dependencies.sh --run"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ "${AVAILABLE_MEMORY_MB}" -gt 0 && "${AVAILABLE_MEMORY_MB}" -lt "${MIN_MEMORY_MB}" ]]; then
|
||||
echo "Zu wenig freier Arbeitsspeicher fuer eine stabile npm-Installation."
|
||||
echo "Frei: ${AVAILABLE_MEMORY_MB} MB, benoetigt empfohlen: mindestens ${MIN_MEMORY_MB} MB."
|
||||
echo "Falls Swap vorhanden ist und du das bewusst trotzdem starten willst: SKIP_RESOURCE_CHECK=true bash scripts/install-local-dependencies.sh --run"
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
echo "Installiere Basiswerkzeuge fuer die lokale Webapp..."
|
||||
run_apt update
|
||||
run_apt install -y ca-certificates curl gnupg git bash build-essential
|
||||
|
||||
Executable
+59
@@ -0,0 +1,59 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
if [[ -f ".env_dev" ]]; then
|
||||
set -a
|
||||
source .env_dev
|
||||
set +a
|
||||
fi
|
||||
|
||||
APP_PORT="${APP_PORT:-3911}"
|
||||
BACKEND_PORT="${BACKEND_PORT:-3002}"
|
||||
API_PROXY_TARGET="${API_PROXY_TARGET:-http://127.0.0.1:${BACKEND_PORT}}"
|
||||
export APP_PORT BACKEND_PORT API_PROXY_TARGET
|
||||
export NEXT_TELEMETRY_DISABLED=1
|
||||
|
||||
kill_port_processes() {
|
||||
local port="$1"
|
||||
local pids=""
|
||||
|
||||
if command -v lsof >/dev/null 2>&1; then
|
||||
pids="${pids}"$'\n'"$(lsof -tiTCP:"${port}" -sTCP:LISTEN 2>/dev/null || true)"
|
||||
fi
|
||||
|
||||
if command -v fuser >/dev/null 2>&1; then
|
||||
pids="${pids}"$'\n'"$(fuser -n tcp "${port}" 2>/dev/null | tr ' ' '\n' || true)"
|
||||
fi
|
||||
|
||||
if command -v ss >/dev/null 2>&1; then
|
||||
pids="${pids}"$'\n'"$(ss -ltnp "sport = :${port}" 2>/dev/null | grep -o 'pid=[0-9]*' | cut -d= -f2 || true)"
|
||||
fi
|
||||
|
||||
pids="$(printf '%s\n' "${pids}" | sed '/^$/d' | sort -u)"
|
||||
|
||||
if [[ -z "${pids}" ]]; then
|
||||
echo "Port ${port} ist frei."
|
||||
return
|
||||
fi
|
||||
|
||||
echo "Beende Prozess(e) auf Port ${port}: ${pids//$'\n'/ }"
|
||||
kill ${pids} 2>/dev/null || true
|
||||
sleep 1
|
||||
|
||||
local remaining=""
|
||||
for pid in ${pids}; do
|
||||
if kill -0 "${pid}" 2>/dev/null; then
|
||||
remaining="${remaining} ${pid}"
|
||||
fi
|
||||
done
|
||||
|
||||
if [[ -n "${remaining}" ]]; then
|
||||
echo "Erzwinge Stop für:${remaining}"
|
||||
kill -9 ${remaining} 2>/dev/null || true
|
||||
fi
|
||||
}
|
||||
|
||||
kill_port_processes "${BACKEND_PORT}"
|
||||
if [[ "${APP_PORT}" != "${BACKEND_PORT}" ]]; then
|
||||
kill_port_processes "${APP_PORT}"
|
||||
fi
|
||||
@@ -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();
|
||||
}
|
||||
+107
-6
@@ -1,23 +1,100 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
APP_PORT="${APP_PORT:-3910}"
|
||||
BACKEND_PORT="${BACKEND_PORT:-3001}"
|
||||
export API_PROXY_TARGET="${API_PROXY_TARGET:-http://127.0.0.1:${BACKEND_PORT}}"
|
||||
export NEXT_TELEMETRY_DISABLED=1
|
||||
|
||||
if [[ -f ".env_dev" ]]; then
|
||||
set -a
|
||||
source .env_dev
|
||||
set +a
|
||||
fi
|
||||
|
||||
APP_PORT="${APP_PORT:-3911}"
|
||||
BACKEND_PORT="${BACKEND_PORT:-3002}"
|
||||
API_PROXY_TARGET="${API_PROXY_TARGET:-http://127.0.0.1:${BACKEND_PORT}}"
|
||||
export APP_PORT BACKEND_PORT API_PROXY_TARGET
|
||||
export NEXT_TELEMETRY_DISABLED=1
|
||||
|
||||
kill_port_processes() {
|
||||
local port="$1"
|
||||
local pids=""
|
||||
|
||||
if command -v lsof >/dev/null 2>&1; then
|
||||
pids="${pids}"$'\n'"$(lsof -tiTCP:"${port}" -sTCP:LISTEN 2>/dev/null || true)"
|
||||
fi
|
||||
|
||||
if command -v fuser >/dev/null 2>&1; then
|
||||
pids="${pids}"$'\n'"$(fuser -n tcp "${port}" 2>/dev/null | tr ' ' '\n' || true)"
|
||||
fi
|
||||
|
||||
if command -v ss >/dev/null 2>&1; then
|
||||
pids="${pids}"$'\n'"$(ss -ltnp "sport = :${port}" 2>/dev/null | grep -o 'pid=[0-9]*' | cut -d= -f2 || true)"
|
||||
fi
|
||||
|
||||
pids="$(printf '%s\n' "${pids}" | sed '/^$/d' | sort -u)"
|
||||
|
||||
if [[ -z "${pids}" ]]; then
|
||||
echo "Port ${port} ist frei."
|
||||
return
|
||||
fi
|
||||
|
||||
echo "Beende Prozess(e) auf Port ${port}: ${pids//$'\n'/ }"
|
||||
kill ${pids} 2>/dev/null || true
|
||||
sleep 1
|
||||
|
||||
local remaining=""
|
||||
for pid in ${pids}; do
|
||||
if kill -0 "${pid}" 2>/dev/null; then
|
||||
remaining="${remaining} ${pid}"
|
||||
fi
|
||||
done
|
||||
|
||||
if [[ -n "${remaining}" ]]; then
|
||||
echo "Erzwinge Stop für:${remaining}"
|
||||
kill -9 ${remaining} 2>/dev/null || true
|
||||
fi
|
||||
}
|
||||
|
||||
kill_port_processes "${BACKEND_PORT}"
|
||||
if [[ "${APP_PORT}" != "${BACKEND_PORT}" ]]; then
|
||||
kill_port_processes "${APP_PORT}"
|
||||
fi
|
||||
|
||||
if [[ -z "${DATABASE_URL:-}" ]]; then
|
||||
echo "DATABASE_URL fehlt. Beispiel:"
|
||||
echo " export DATABASE_URL='postgresql://user:pass@localhost:5432/tickettracker'"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ -z "${SECRET_ENCRYPTION_KEY:-}" && -z "${TICKETTRACKER_SECRET_KEY:-}" ]]; then
|
||||
export SECRET_ENCRYPTION_KEY="${DATABASE_URL}"
|
||||
fi
|
||||
|
||||
DATABASE_HOST="$(node -e 'const url = new URL(process.env.DATABASE_URL); process.stdout.write(url.hostname)' 2>/dev/null || true)"
|
||||
DATABASE_PORT="$(node -e 'const url = new URL(process.env.DATABASE_URL); process.stdout.write(url.port || "5432")' 2>/dev/null || echo 5432)"
|
||||
POSTGRES_CONTAINER="${POSTGRES_CONTAINER:-tickettracker-postgres}"
|
||||
|
||||
if command -v docker >/dev/null 2>&1 && docker inspect "${POSTGRES_CONTAINER}" >/dev/null 2>&1; then
|
||||
POSTGRES_CONTAINER_IP="$(docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' "${POSTGRES_CONTAINER}")"
|
||||
|
||||
if [[ -n "${POSTGRES_CONTAINER_IP}" ]]; then
|
||||
SHOULD_USE_CONTAINER_IP=false
|
||||
|
||||
if [[ "${DATABASE_HOST}" == "postgres" || "${DATABASE_HOST}" == "postgres-tickettracker" ]]; then
|
||||
if ! getent hosts "${DATABASE_HOST}" >/dev/null 2>&1; then
|
||||
SHOULD_USE_CONTAINER_IP=true
|
||||
fi
|
||||
elif [[ "${DATABASE_HOST}" =~ ^172\. ]] && [[ "${DATABASE_HOST}" != "${POSTGRES_CONTAINER_IP}" ]]; then
|
||||
SHOULD_USE_CONTAINER_IP=true
|
||||
fi
|
||||
|
||||
if [[ "${SHOULD_USE_CONTAINER_IP}" == "true" ]]; then
|
||||
export DATABASE_URL="$(
|
||||
DATABASE_HOST="${POSTGRES_CONTAINER_IP}" node -e 'const url = new URL(process.env.DATABASE_URL); url.hostname = process.env.DATABASE_HOST; process.stdout.write(url.toString())'
|
||||
)"
|
||||
echo "Nutze Docker-Postgres ${POSTGRES_CONTAINER} unter ${POSTGRES_CONTAINER_IP}:${DATABASE_PORT} für den lokalen Start."
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
NODE_MAJOR="$(node -p 'Number(process.versions.node.split(".")[0])' 2>/dev/null || echo 0)"
|
||||
|
||||
if [[ "${NODE_MAJOR}" -lt 20 ]]; then
|
||||
@@ -37,8 +114,32 @@ cleanup() {
|
||||
trap cleanup EXIT INT TERM
|
||||
|
||||
echo "Starte Backend auf Port ${BACKEND_PORT}"
|
||||
SERVE_FRONTEND=false PORT="${BACKEND_PORT}" npm run start --workspace backend &
|
||||
BACKEND_LOG="${TMPDIR:-/tmp}/tickettracker-backend-local.log"
|
||||
rm -f "${BACKEND_LOG}"
|
||||
SERVE_FRONTEND=false PORT="${BACKEND_PORT}" npm run start --workspace backend >"${BACKEND_LOG}" 2>&1 &
|
||||
BACKEND_PID=$!
|
||||
|
||||
echo "Warte auf Backend ${API_PROXY_TARGET}"
|
||||
for _ in $(seq 1 50); do
|
||||
if ! kill -0 "${BACKEND_PID}" 2>/dev/null; then
|
||||
echo "Backend konnte nicht gestartet werden:"
|
||||
cat "${BACKEND_LOG}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if curl -fsS "${API_PROXY_TARGET}/api/debug/status" >/dev/null 2>&1; then
|
||||
break
|
||||
fi
|
||||
|
||||
sleep 0.2
|
||||
done
|
||||
|
||||
if ! curl -fsS "${API_PROXY_TARGET}/api/debug/status" >/dev/null 2>&1; then
|
||||
echo "Backend antwortet nicht unter ${API_PROXY_TARGET}."
|
||||
echo "Letzte Backend-Ausgabe:"
|
||||
tail -n 80 "${BACKEND_LOG}" 2>/dev/null || true
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Starte Frontend auf http://localhost:${APP_PORT}"
|
||||
PORT="${APP_PORT}" npm run start --workspace frontend
|
||||
Reference in new issue
Block a user