Compare commits
68
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 | ||
|
|
07ef8ef025 | ||
|
|
c2063fe1ed | ||
|
|
d1da86beab | ||
|
|
3d9674bd5a | ||
|
|
239be9f218 | ||
|
|
3da436423f | ||
|
|
2792051a16 | ||
|
|
018c06dc3e | ||
|
|
b67dbad0b1 | ||
|
|
19cc37d42a | ||
|
|
4e77674cdf | ||
|
|
4f0020a61d | ||
|
|
a760f423a4 | ||
|
|
dcc115a82c | ||
|
|
4390f9a7da | ||
|
|
7d47a05bff | ||
|
|
05c842e406 | ||
|
|
72229cf14c | ||
|
|
19e4621aeb | ||
|
|
0bef65ab75 | ||
|
|
e527576ed6 | ||
|
|
ac71eb88c0 | ||
|
|
eff8823371 | ||
|
|
7d4d373863 | ||
|
|
bc184fef56 | ||
|
|
ccb859ad49 | ||
|
|
7f71c23232 | ||
|
|
964bd44a82 | ||
|
|
971d493015 | ||
|
|
8fef5980db | ||
|
|
cf7b33f331 | ||
|
|
2aec561f3c | ||
|
|
76bc438b4e |
No files matched your search
@@ -2,11 +2,9 @@ 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
|
||||
|
||||
ADMIN_USERNAME=admin
|
||||
ADMIN_PASSWORD=change-me-now
|
||||
ADMIN_DISPLAY_NAME=Admin
|
||||
|
||||
COOKIE_SECURE=false
|
||||
SESSION_MAX_AGE_MS=1209600000
|
||||
@@ -4,11 +4,9 @@ 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
|
||||
|
||||
ADMIN_USERNAME=admin
|
||||
ADMIN_PASSWORD=change-me-now
|
||||
ADMIN_DISPLAY_NAME=Admin
|
||||
|
||||
COOKIE_SECURE=false
|
||||
SESSION_MAX_AGE_MS=1209600000
|
||||
+2
-3
@@ -1,7 +1,6 @@
|
||||
DATABASE_URL=postgresql://tickettracker:change-me@10.10.10.23:5432/tickettracker
|
||||
ADMIN_USERNAME=admin
|
||||
ADMIN_PASSWORD=change-me-now
|
||||
ADMIN_DISPLAY_NAME=Admin
|
||||
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
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
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": {
|
||||
|
||||
+67
-2
@@ -15,6 +15,23 @@ export type AuthUser = {
|
||||
display_name: string;
|
||||
role: UserRole;
|
||||
active: boolean;
|
||||
zammad_user_id: string | null;
|
||||
has_zammad_api_token: boolean;
|
||||
teamspace_user_id: string | null;
|
||||
has_teamspace_credentials: boolean;
|
||||
zammad_email_session_webhook_enabled: boolean;
|
||||
session_billing_prompt_enabled: boolean;
|
||||
teamspace_time_tracking_enabled: boolean;
|
||||
zammad_session_note_default_enabled: boolean;
|
||||
zammad_time_unit_rounding_enabled: boolean;
|
||||
zammad_time_unit_rounding_minutes: number;
|
||||
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 {
|
||||
@@ -73,7 +90,24 @@ function publicUser(user: AuthUser) {
|
||||
username: user.username,
|
||||
display_name: user.display_name,
|
||||
role: user.role,
|
||||
active: user.active
|
||||
active: user.active,
|
||||
zammad_user_id: user.zammad_user_id ?? null,
|
||||
has_zammad_api_token: Boolean(user.has_zammad_api_token),
|
||||
teamspace_user_id: user.teamspace_user_id ?? null,
|
||||
has_teamspace_credentials: Boolean(user.has_teamspace_credentials),
|
||||
zammad_email_session_webhook_enabled: Boolean(user.zammad_email_session_webhook_enabled),
|
||||
session_billing_prompt_enabled: Boolean(user.session_billing_prompt_enabled),
|
||||
teamspace_time_tracking_enabled: Boolean(user.teamspace_time_tracking_enabled ?? true),
|
||||
zammad_session_note_default_enabled: Boolean(user.zammad_session_note_default_enabled),
|
||||
zammad_time_unit_rounding_enabled: Boolean(user.zammad_time_unit_rounding_enabled),
|
||||
zammad_time_unit_rounding_minutes: Number(user.zammad_time_unit_rounding_minutes ?? 15),
|
||||
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)
|
||||
};
|
||||
}
|
||||
|
||||
@@ -123,7 +157,29 @@ export async function requireAuth(req: Request, res: Response, next: NextFunctio
|
||||
const tokenHash = hashToken(token);
|
||||
const result = await query<AuthUser>(
|
||||
`
|
||||
SELECT u.id, u.username, u.display_name, u.role, u.active
|
||||
SELECT
|
||||
u.id,
|
||||
u.username,
|
||||
u.display_name,
|
||||
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.teamspace_user_id::text AS teamspace_user_id,
|
||||
(u.teamspace_device_id IS NOT NULL AND u.teamspace_device_password IS NOT NULL) AS has_teamspace_credentials,
|
||||
u.zammad_email_session_webhook_enabled,
|
||||
u.session_billing_prompt_enabled,
|
||||
u.teamspace_time_tracking_enabled,
|
||||
u.zammad_session_note_default_enabled,
|
||||
u.zammad_time_unit_rounding_enabled,
|
||||
u.zammad_time_unit_rounding_minutes,
|
||||
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
|
||||
@@ -155,6 +211,15 @@ export function requireAdmin(req: Request, res: Response, next: NextFunction) {
|
||||
next();
|
||||
}
|
||||
|
||||
export function requireUser(req: Request, res: Response, next: NextFunction) {
|
||||
if (req.user?.role !== "user") {
|
||||
res.status(403).json({ error: "Dieser Bereich ist nur für Benutzerkonten vorgesehen" });
|
||||
return;
|
||||
}
|
||||
|
||||
next();
|
||||
}
|
||||
|
||||
export function currentUser(req: Request) {
|
||||
if (!req.user) {
|
||||
throw Object.assign(new Error("Nicht angemeldet"), { status: 401 });
|
||||
|
||||
@@ -0,0 +1,365 @@
|
||||
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",
|
||||
"organizations",
|
||||
"tickets",
|
||||
"projects",
|
||||
"project_teamspace_entries",
|
||||
"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 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 (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");
|
||||
}
|
||||
+13016
-784
File diff suppressed because it is too large.
Load diff
+986
-40
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,97 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import { nextSequenceIdAfterRestore, restoreSqlText, sequenceSetvalSql, sqlLiteral, type QueryExecutor } from "../src/databaseExport.ts";
|
||||
import { exportTableSql } from "../src/databaseExport.ts";
|
||||
|
||||
const jsonbColumn = { dataType: "jsonb", udtName: "jsonb" };
|
||||
|
||||
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("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 @@
|
||||
{"_url":"https://app1.teamspace.de/api/project;limit=1000;depth=1;sort=projectId?parent=18260325&isActive=true&isTemplate=false","_links":[{"caption":"Self","href":"https://app1.teamspace.de/api/project;limit=1000;depth=1;sort=projectId?parent=18260325&isActive=true&isTemplate=false","rel":"self"},{"caption":"|< firstPage","href":"https://app1.teamspace.de/api/project;offset=0;limit=1000;sort=projectId?parent=18260325&isActive=true&isTemplate=false","rel":"first","title":"First page of projects of this CollectionResource"},{"caption":">| lastPage","href":"https://app1.teamspace.de/api/project;offset=0;limit=1000;sort=projectId?parent=18260325&isActive=true&isTemplate=false","rel":"last","title":"Last page of projects of this CollectionResource"}],"_etag":{"value":"ee49b390d3cca9c74112cb0d0dc361be","weak":false},"size":13,"offset":0,"limit":1000,"items":[{"caption":"Time Control Installation Software","title":"referenced resource","href":"https://app1.teamspace.de/api/project/18260341","lastModifiedDate":"2026-09-30T12:01:22.000+00:00","value":18260341,"idKey":"22","data":{"_url":"https://app1.teamspace.de/api/project/18260341","_id":18260341,"_idKey":"22","_links":[{"caption":"Holzwerk Schilling GmbH & Co. KG ∕ 49951 ∕ Neukunde","title":"parent project","href":"https://app1.teamspace.de/api/project/18260325","value":18260325,"idKey":"22","rel":"parent"}],"_lastModifiedDate":"2026-09-30T12:01:22.000+00:00","name":"Time Control Installation Software","mainprojectId":"EP-2025-Comp-009","mainProject":{"caption":"Holzwerk Schilling GmbH & Co. KG / 49951 / Neukunde","title":"main project","href":"https://app1.teamspace.de/api/project/18260325","value":18260325,"idKey":"22"},"projectId":"0001","path":"Holzwerk Schilling GmbH & Co. KG ∕ 49951 ∕ Neukunde/Time Control Installation Software","description":"","isMainproject":false,"isWorkpackage":true,"isActive":true,"type":null,"projectFolder":null,"projectWorkflow":null,"priority":null,"state":{"caption":"Abgeschlossen","title":"projectState","href":"https://app1.teamspace.de/api/projectstate/13338166","value":13338166,"idKey":"154","rel":"projectState set to this project","optionsUrl":"https://app1.teamspace.de/api/projectstate?workflow=13338231"},"stateRelevant":{"caption":"Abgeschlossen","title":"projectStateRelevant","href":"https://app1.teamspace.de/api/projectstate/13338166","value":13338166,"idKey":"154","rel":"effective projectState of this project"},"progress":100,"progressRelevant":100,"plannedMinutesEnabled":true,"sumMinutes":360,"plannedMinutesBillableEnabled":true,"sumMinutesBillable":180,"plannedMinutesPerformanceEnabled":true,"sumMinutesPerformance":0,"plannedWorkingCostsEnabled":true,"sumWorkingCosts":0.0,"plannedExpensesEnabled":true,"sumExpenses":0.0,"plannedIncomeEnabled":true,"plannedIncome":360.0,"plannedIncomeCalculated":360.0,"sumIncome":360.0,"customer":{"caption":"CompData Computer GmbH","title":"customer","href":"https://app1.teamspace.de/api/organization/13905417","value":13905417,"idKey":"78"},"customerContact":null,"contractor":{"caption":"AIKAVA GmbH","title":"contractor","href":"https://app1.teamspace.de/api/organization/13343077","value":13343077,"idKey":"78"},"contractorContact":null,"currency":{"title":"currency","href":"https://app1.teamspace.de/api/currency/13337775","value":13337775,"idKey":"105"},"colorlabel":null,"colorlabelRelevant":null,"contractPositions":[{"caption":"Contract position","title":"contractPosition","href":"https://app1.teamspace.de/api/contractposition/18260213","value":18260213,"idKey":"104"}],"exportId":null,"exportComment":null,"exportDate":null,"customfields":[],"article":null,"articleRelevant":null,"ticket":null,"endless":false,"parent":{"caption":"Holzwerk Schilling GmbH & Co. KG ∕ 49951 ∕ Neukunde","title":"parent project","href":"https://app1.teamspace.de/api/project/18260325","value":18260325,"idKey":"22","rel":"parent"}},"path":"Holzwerk Schilling GmbH & Co. KG ∕ 49951 ∕ Neukunde/Time Control Installation Software","rel":"link"},{"caption":"Time Control Installation Kommunikationssoftware","title":"referenced resource","href":"https://app1.teamspace.de/api/project/18260349","lastModifiedDate":"2026-09-30T12:01:22.000+00:00","value":18260349,"idKey":"22","data":{"_url":"https://app1.teamspace.de/api/project/18260349","_id":18260349,"_idKey":"22","_links":[{"caption":"Holzwerk Schilling GmbH & Co. KG ∕ 49951 ∕ Neukunde","title":"parent project","href":"https://app1.teamspace.de/api/project/18260325","value":18260325,"idKey":"22","rel":"parent"}],"_lastModifiedDate":"2026-09-30T12:01:22.000+00:00","name":"Time Control Installation Kommunikationssoftware","mainprojectId":"EP-2025-Comp-009","mainProject":{"caption":"Holzwerk Schilling GmbH & Co. KG / 49951 / Neukunde","title":"main project","href":"https://app1.teamspace.de/api/project/18260325","value":18260325,"idKey":"22"},"projectId":"0002","path":"Holzwerk Schilling GmbH & Co. KG ∕ 49951 ∕ Neukunde/Time Control Installation KoLine truncated
|
||||
@@ -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,14 +26,13 @@ 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
|
||||
SERVE_FRONTEND: "false"
|
||||
NEXT_TELEMETRY_DISABLED: "1"
|
||||
ADMIN_USERNAME: ${ADMIN_USERNAME:-admin}
|
||||
ADMIN_PASSWORD: ${ADMIN_PASSWORD:-}
|
||||
ADMIN_DISPLAY_NAME: ${ADMIN_DISPLAY_NAME:-Admin}
|
||||
SESSION_MAX_AGE_MS: ${SESSION_MAX_AGE_MS:-1209600000}
|
||||
COOKIE_SECURE: ${COOKIE_SECURE:-false}
|
||||
DB_POOL_SIZE: ${DB_POOL_SIZE:-10}
|
||||
|
||||
+2
-3
@@ -7,14 +7,13 @@ 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
|
||||
SERVE_FRONTEND: "false"
|
||||
NEXT_TELEMETRY_DISABLED: "1"
|
||||
ADMIN_USERNAME: ${ADMIN_USERNAME:-admin}
|
||||
ADMIN_PASSWORD: ${ADMIN_PASSWORD:-}
|
||||
ADMIN_DISPLAY_NAME: ${ADMIN_DISPLAY_NAME:-Admin}
|
||||
SESSION_MAX_AGE_MS: ${SESSION_MAX_AGE_MS:-1209600000}
|
||||
COOKIE_SECURE: ${COOKIE_SECURE:-false}
|
||||
DB_POOL_SIZE: ${DB_POOL_SIZE:-10}
|
||||
|
||||
+11
-2
@@ -14,7 +14,6 @@ cp .env.docker-external.example .env.docker-external
|
||||
|
||||
```bash
|
||||
DATABASE_URL=postgresql://tickettracker:PASSWORT@10.10.10.23:5432/tickettracker
|
||||
ADMIN_PASSWORD=ein-sicheres-passwort
|
||||
TICKETTRACKER_IMAGE=git.d-razz.de/michael/tickettracker:latest
|
||||
APP_PORT=3910
|
||||
```
|
||||
@@ -37,7 +36,6 @@ cp .env.docker-postgres.example .env.docker-postgres
|
||||
|
||||
```bash
|
||||
POSTGRES_PASSWORD=ein-sicheres-db-passwort
|
||||
ADMIN_PASSWORD=ein-sicheres-admin-passwort
|
||||
TICKETTRACKER_IMAGE=git.d-razz.de/michael/tickettracker:latest
|
||||
APP_PORT=3910
|
||||
```
|
||||
@@ -50,6 +48,17 @@ docker compose --env-file .env.docker-postgres -f compose.postgres.yml up -d
|
||||
|
||||
Die Datenbankdaten liegen im Docker-Volume `tickettracker_postgres_data`.
|
||||
|
||||
## Erstes Setup
|
||||
|
||||
Admin-Zugangsdaten werden nicht per `.env` gesetzt. Nach dem ersten Start oeffnest du TicketTracker im Browser und landest automatisch auf der Setup-Seite.
|
||||
|
||||
Dort gibt es zwei Wege:
|
||||
|
||||
- `Admin anlegen`: festen Admin direkt erstellen und anmelden.
|
||||
- `Backup einspielen`: ein TicketTracker-SQL-Backup hochladen, Struktur pruefen und danach einspielen. Danach meldest du dich mit einem Benutzer aus dem Backup an.
|
||||
|
||||
Ein Restore ersetzt den kompletten Systemstand. Vor dem Einspielen wird geprueft, ob Dump-Struktur und aktuelle Datenbankstruktur zusammenpassen. Danach werden alle TicketTracker-Tabellen geleert; anschliessend gelten ausschliesslich Benutzer, Passwoerter, Sessions, Settings, Organisationen, fixe Abrechnungen und Abschluesse aus dem Backup.
|
||||
|
||||
## Image bauen und in Gitea pushen
|
||||
|
||||
Einmal am Docker/Gitea-Registry anmelden:
|
||||
|
||||
+399
-57
@@ -1,49 +1,105 @@
|
||||
# TicketTracker Benutzeranleitung
|
||||
|
||||
Diese Anleitung beschreibt die Nutzung von TicketTracker im Arbeitsalltag: Zeiten erfassen, Sessions bewerten, CRM-Zeiten eintragen, fixe Abrechnungen verwalten und Monatsauswertungen abschliessen.
|
||||
Diese Anleitung beschreibt die Nutzung von TicketTracker im Arbeitsalltag: Zeiten erfassen, Sessions bewerten, Teamspace-Zeiten eintragen, fixe Abrechnungen verwalten, Statistiken lesen und den Monatsabschluss durchfuehren.
|
||||
|
||||
## Erstes Setup
|
||||
|
||||
Wenn noch kein Admin existiert, zeigt TicketTracker beim Aufruf automatisch die Setup-Seite.
|
||||
|
||||
Dort gibt es zwei Moeglichkeiten:
|
||||
|
||||
- `Admin anlegen`: festen Admin mit Benutzername, vollem Namen und Passwort erstellen. Danach ist der Admin direkt angemeldet.
|
||||
- `Backup einspielen`: ein TicketTracker-SQL-Backup aus einem frueheren Systemstand hochladen, Struktur pruefen und danach einspielen. Danach meldet man sich mit einem Benutzer aus dem Backup an.
|
||||
|
||||
Ein Restore ersetzt den kompletten Systemstand. Vor dem Einspielen wird geprueft, ob die Struktur des Dumps zur aktuellen Datenbank passt. Fehlen Felder oder enthaelt der Dump unbekannte Felder, wird eine Fehlermeldung angezeigt und der Restore kann nicht gestartet werden. Beim eigentlichen Restore werden alle TicketTracker-Tabellen geleert. Danach gilt ausschliesslich der Stand des Backups, inklusive aller Benutzer, Passwoerter, Sessions, Einstellungen, Organisationen, fixen Abrechnungen und Abschluesse.
|
||||
|
||||
## Grundprinzip
|
||||
|
||||
TicketTracker ersetzt nicht das Ticketsystem oder CRM. Die App dient dazu, Arbeitszeiten sauber zu messen, auszuwerten und fuer die spaetere Abrechnung vorzubereiten.
|
||||
TicketTracker ersetzt nicht das Ticketsystem und nicht das CRM. Die App ist die Arbeitszeiterfassung und Auswertung daneben.
|
||||
|
||||
Es gibt drei Zeitebenen:
|
||||
Es gibt drei zentrale Zeitebenen:
|
||||
|
||||
- Session-Zeit: Die gemessene oder manuell erfasste Arbeitszeit einer einzelnen Taetigkeit.
|
||||
- Tages-CRM-Zeit: Die Zeit, die fuer ein Ticket an einem konkreten Tag wirklich im CRM eingetragen wurde.
|
||||
- Monatsabschluss: Ein Ticket wird fuer einen Monat geschlossen, wenn alle Sessions dieses Tickets im Monat bewertet sind.
|
||||
- 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 final bewertet sind.
|
||||
|
||||
## Anmeldung
|
||||
Wichtig: Tickets werden nicht geschlossen. Tickets dienen nur als Gruppierung fuer Sessions. Abgeschlossen wird ausschliesslich der Monat.
|
||||
|
||||
## Hilfe und FAQ
|
||||
|
||||
Die App hat eine eigene Unterseite `FAQ`. Dort steht die ausfuehrliche Bedienungsanleitung mit Abschnitten zu Timer, Tickets, Teamspace, Monatsabschluss, Statistiken, fixen Abrechnungen, Profil und Adminbereich.
|
||||
|
||||
An vielen Stellen in der Oberflaeche gibt es ein Fragezeichen-Icon. Ein Klick darauf oeffnet ein kurzes Hilfe-Modal zur jeweiligen Card oder Funktion. Im Modal gibt es einen Link zum passenden ausfuehrlichen FAQ-Abschnitt.
|
||||
|
||||
## Anmeldung und Navigation
|
||||
|
||||
1. App im Browser oeffnen.
|
||||
2. Mit Benutzername und Passwort anmelden.
|
||||
3. Nach der Anmeldung ist die Seitenleiste sichtbar.
|
||||
|
||||
Normale Benutzer sehen ihre eigenen Zeiten, Auswertungen, fixen Abrechnungen und ihr Profil. Administratoren sehen zusaetzlich den Adminbereich.
|
||||
Normale Benutzer sehen ihre eigenen Zeiten, Auswertungen, fixen Abrechnungen und ihr Profil. Der feste Admin sieht nur Administration, Profil und FAQ.
|
||||
|
||||
Die Navigation ist in Workflows gegliedert:
|
||||
|
||||
- Session starten
|
||||
- Tickets
|
||||
- Monatsabschluss
|
||||
- Statistiken
|
||||
- Fixe Abrechnung
|
||||
- Profil
|
||||
- Administration, nur fuer Admin
|
||||
- FAQ
|
||||
|
||||
Nach `Tickets`, nach `Monatsabschluss` und vor `FAQ` trennt eine Linie die Bereiche optisch.
|
||||
|
||||
## Globaler Timer-Schnellstart
|
||||
|
||||
Oben im Header gibt es auf allen Unterseiten ein Ticket-Eingabefeld fuer den schnellen Timerstart.
|
||||
|
||||
1. Ticketnummer im Format `Ticket#XXXXXX` eingeben.
|
||||
2. `Timer starten` klicken.
|
||||
3. Der neue Timer startet sofort.
|
||||
|
||||
Wenn fuer diese Ticketnummer bereits ein Timer existiert, wird dieser vorhandene Timer aktiviert. Alle anderen Timer werden automatisch pausiert.
|
||||
|
||||
Wenn zu der Ticketnummer bereits Ticketdaten vorhanden sind, zeigt der Timer auch die Organisation an.
|
||||
|
||||
## Timer
|
||||
|
||||
Die Seite `Timer` ist fuer laufende Arbeit an Tickets gedacht.
|
||||
|
||||
1. Ticketnummer im Format `Ticket#XXXXXX` eingeben.
|
||||
2. `Session beginnen` klicken.
|
||||
2. `Timer starten` klicken.
|
||||
3. Der Timer startet.
|
||||
4. Bei Bedarf kann der Timer pausiert, fortgesetzt, beendet oder zurueckgesetzt 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 gestartet oder fortgesetzt, werden alle anderen laufenden Timer automatisch pausiert.
|
||||
Es koennen mehrere Timer vorbereitet sein, aber immer nur ein Timer laeuft aktiv. Wird ein anderer Timer aktiviert, werden alle anderen Timer automatisch pausiert.
|
||||
|
||||
Beim Klick auf `Beenden` stoppt der Timer sofort. Danach wird die Session fertig gespeichert:
|
||||
Alle laufenden und pausierten Timer stehen in der Seitenleiste. Dadurch bleiben sie auf allen Unterseiten sichtbar.
|
||||
|
||||
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.
|
||||
|
||||
## Session Nachtragen
|
||||
|
||||
Sessions koennen auch manuell nachgetragen werden, zum Beispiel wenn ein Timer vergessen wurde.
|
||||
Sessions koennen manuell nachgetragen werden, zum Beispiel wenn ein Timer vergessen wurde.
|
||||
|
||||
Normale Benutzer koennen eigene Sessions nachtragen. Administratoren koennen Sessions fuer andere Benutzer nachtragen.
|
||||
Normale Benutzer koennen eigene Sessions nachtragen. Der feste Admin erfasst keine Sessions.
|
||||
|
||||
Beim Nachtragen werden angegeben:
|
||||
|
||||
@@ -51,11 +107,22 @@ Beim Nachtragen werden angegeben:
|
||||
- Organisation
|
||||
- Art: Support oder Consulting
|
||||
- Datum
|
||||
- Von- und Bis-Uhrzeit
|
||||
- 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.
|
||||
|
||||
## Ticketdaten Bearbeiten
|
||||
|
||||
In der Ticketdetailansicht koennen Ticketdaten nachtraeglich korrigiert werden:
|
||||
@@ -64,36 +131,43 @@ In der Ticketdetailansicht koennen Ticketdaten nachtraeglich korrigiert werden:
|
||||
- Organisation
|
||||
- Art
|
||||
|
||||
Aenderungen an Organisation und Art werden auf vorhandene manuelle Sessions des Tickets uebernommen. Bei wiederkehrend erzeugten Sessions bleibt die Art aus der Regel erhalten und kann nur ueber die fixe Abrechnung geaendert werden.
|
||||
Aenderungen an Organisation und Art werden auf vorhandene manuelle Sessions des Tickets uebernommen.
|
||||
|
||||
Bei wiederkehrend erzeugten Sessions bleibt die Art aus der Regel erhalten und kann nur ueber die fixe Abrechnung geaendert werden.
|
||||
|
||||
Wenn ein Ticket bereits durch einen anderen Benutzer existiert, koennen die Ticketdaten beim Beenden eines Timers uebernommen werden. Die Session selbst bleibt trotzdem dem angemeldeten Benutzer zugeordnet.
|
||||
|
||||
## Organisationen
|
||||
|
||||
Organisationen werden aus Zammad synchronisiert. Danach koennen Organisationen in TicketTracker nur noch aus der Liste ausgewaehlt werden, nicht mehr frei als Text eingetragen werden.
|
||||
Organisationen werden aus Zammad synchronisiert. Danach koennen Organisationen in TicketTracker nur noch aus der lokalen Liste ausgewaehlt werden. Freitext ist nicht mehr moeglich.
|
||||
|
||||
Der Grund: Nur mit eindeutigen Organisationen sind saubere Auswertungen pro Kunde ueber mehrere Monate moeglich.
|
||||
|
||||
Bereits vorhandene alte Freitexte muessen bei Bedarf manuell korrigiert werden, indem das Ticket oder die Session bearbeitet und eine Organisation aus der Liste ausgewaehlt wird.
|
||||
|
||||
## Auswertung
|
||||
## Tickets
|
||||
|
||||
Die Seite `Auswertung` hat zwei Ansichten:
|
||||
Die Seite `Tickets` hat zwei Ansichten:
|
||||
|
||||
- Monat
|
||||
- Tag
|
||||
|
||||
Mit den Pfeilen neben dem Datumsfeld kann ein Monat oder Tag vor- und zurueckgeschaltet werden. Die Daten aktualisieren sich automatisch.
|
||||
Mit den Pfeilen neben dem Datumsfeld kann ein Monat oder Tag vor- und zurueckgeschaltet werden. Die Daten aktualisieren sich automatisch. Ein separater Aktualisieren-Button ist nicht notwendig.
|
||||
|
||||
Die oberen Karten zeigen:
|
||||
|
||||
- Tickets: Anzahl der Tickets im Zeitraum
|
||||
- Sessions: Anzahl der Sessions im Zeitraum
|
||||
- Zeit: Summe der erfassten Session-Zeit, plus CRM-Zeit
|
||||
- Offen: Anzahl unbewerteter Sessions
|
||||
- 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 nicht final bewerteter Sessions, inklusive `Abrechenbar`
|
||||
|
||||
Der Graph `Gesamtaufwand` zeigt den Aufwand im Zeitraum:
|
||||
Der Verlauf der abgerechneten Zeit liegt auf der Seite `Statistiken`. Dort wird der Monatsverlauf gemeinsam mit Teamspace und Bewertungsstand betrachtet.
|
||||
|
||||
- In der Monatsansicht pro Tag
|
||||
- In der Tagesansicht pro Stunde
|
||||
In der Monatsansicht kann ueber `Monat exportieren` eine CSV-Datei fuer den eigenen Benutzer heruntergeladen werden. Der Export enthaelt alle eigenen Sessions des Monats mit Ticket, Organisation, Art, Datum, Von/Bis, Bewertung, Session-Minuten, Teamspace-Minuten und Taetigkeit. Er ist fuer Kontrolle, Ablage oder Weiterverarbeitung gedacht.
|
||||
|
||||
## Tickets Im Zeitraum
|
||||
## Tickets im Zeitraum
|
||||
|
||||
Unter `Tickets im Zeitraum` werden alle Tickets angezeigt, die im gewaehlten Zeitraum Sessions haben.
|
||||
|
||||
@@ -103,53 +177,185 @@ Pro Ticket sind sichtbar:
|
||||
- Organisation
|
||||
- Art
|
||||
- Anzahl Sessions
|
||||
- Zeit
|
||||
- Abgerechnete Zeit
|
||||
- Anzahl abgerechneter Sessions
|
||||
- Anzahl nicht abrechenbarer Sessions
|
||||
- Status
|
||||
|
||||
Neben der Ticketnummer gibt es ein Copy-Icon. Ein Klick kopiert die Ticketnummer in die Zwischenablage.
|
||||
|
||||
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
|
||||
|
||||
Die Ticketliste kann gefiltert, gruppiert und sortiert werden.
|
||||
|
||||
Filter:
|
||||
|
||||
- Suche nach Ticketnummer oder Organisation
|
||||
- Typ
|
||||
- Organisation
|
||||
- Status
|
||||
|
||||
Gruppierung:
|
||||
|
||||
- Keine
|
||||
- Typ
|
||||
- Organisation
|
||||
- Status
|
||||
|
||||
Sortierung:
|
||||
|
||||
- Ticket
|
||||
- Typ
|
||||
- Organisation
|
||||
- Status
|
||||
|
||||
Die Einstellungen bleiben im Browser gespeichert, auch nach Aktualisierung oder Browserneustart. Mit `Zuruecksetzen` werden sie auf Standard gesetzt.
|
||||
|
||||
## Ticketdetailansicht
|
||||
|
||||
Ein Ticket wird aus der Auswertung heraus geoeffnet.
|
||||
Ein Ticket wird aus der Ticketliste heraus geoeffnet.
|
||||
|
||||
In der Ticketdetailansicht sieht man:
|
||||
|
||||
- Ticketnummer mit Copy-Icon
|
||||
- Abschlussstatus
|
||||
- Status des Monats
|
||||
- Ticketdaten
|
||||
- Projektinformationen, falls das Ticket ein Projekt ist
|
||||
- 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
|
||||
|
||||
Projekte erweitern ein normales Ticket um Projektinformationen. Sie werden ueber den Navipunkt `Projekte` oder direkt aus einem Ticket heraus angelegt.
|
||||
|
||||
Ein Projekt enthaelt:
|
||||
|
||||
- Ticketnummer
|
||||
- Optionalen Titel
|
||||
- Organisation
|
||||
- Teamspace-Projekt
|
||||
- Beschreibung/Ziele
|
||||
- Ungefaehren Zeitraum
|
||||
- Optionales Stundenkontingent
|
||||
- Optionalen manuellen Verbrauch in Stunden
|
||||
|
||||
Projekt-Tickets sind immer Consulting. Wird ein Projekt geloescht, bleiben das Ticket, die Sessions und die ToDos erhalten. Geloescht werden nur die Projektinformationen.
|
||||
|
||||
Projekte koennen offen oder geschlossen sein. Auf der Projektseite werden standardmaessig nur offene Projekte angezeigt. Mit `Geschlossene anzeigen` werden geschlossene Projekte zusaetzlich eingeblendet. Offene Projekte stehen immer vor geschlossenen Projekten.
|
||||
|
||||
Der Projektstatus betrifft nur die Projektverwaltung. Die Sessions eines Tickets werden dadurch nicht ausgeblendet oder veraendert.
|
||||
|
||||
Der Verbrauch eines Projekts ist abrechnungstechnisch von den getrackten Sessions getrennt. Fuer den Projektverbrauch zaehlen nur gepflegte Teamspace-Tageswerte dieses Projekt-Tickets und der optionale manuelle Verbrauch am Projekt. Getrackte Sessionzeiten werden im Projekt zwar weiterhin angezeigt und bleiben normal bewertbar, sie erhoehen den Projektverbrauch aber nicht direkt.
|
||||
|
||||
Der manuelle Verbrauch ist fuer Altbestaende oder einmalige Buchungen gedacht, die vor der Nutzung von TicketTracker bereits im Projekt angefallen sind. Dieser Wert wirkt ausschliesslich auf den Projektverbrauch und wird nicht in Statistiken, Monatsabschluss, Auswertung oder Exporte als Sessionzeit eingerechnet.
|
||||
|
||||
Die Projektseite enthaelt Filter und Sortierungen. Projekte koennen nach Freitext, Status, Organisation, Kontingentstatus und ToDos gefiltert werden. Sortiert werden kann unter anderem nach Ticket, Titel, Organisation, Teamspace-Projekt, Verbrauch, Reststunden, Kontingent und offenen ToDos. Die Einstellungen bleiben im Browser gespeichert und koennen ueber `Zuruecksetzen` wieder auf Standard gestellt werden.
|
||||
|
||||
Reststunden werden aus `Stundenkontingent minus Projektverbrauch` berechnet. Wenn kein Stundenkontingent gepflegt ist, kann kein offener Rest berechnet werden. Ist der Verbrauch hoeher als das Kontingent, zeigt die Projektkarte die Ueberziehung an.
|
||||
|
||||
Projektinformationen und ToDos stehen in der Ticketdetailansicht auf breiten Bildschirmen nebeneinander. Auf kleineren Bildschirmen werden sie untereinander angezeigt.
|
||||
|
||||
ToDos werden direkt am Ticket oder Projekt angelegt. Ein ToDo enthaelt:
|
||||
|
||||
- Aufgabe
|
||||
- Prioritaet
|
||||
- Optionalen Umsetzungszeitraum von/bis
|
||||
- Optionale Details
|
||||
|
||||
Wenn `Umsetzung von` und `Umsetzung bis` gleich sind, ist das ToDo ein festes Datum.
|
||||
|
||||
Die zentrale Seite `ToDos` zeigt oben `Heutige ToDos`. Dort erscheinen nur offene ToDos, bei denen `Umsetzung von` und `Umsetzung bis` beide auf den heutigen Tag gesetzt sind. Ein Zeitraum, der heute nur einschliesst, wird nicht als heutiges ToDo gewertet.
|
||||
|
||||
Darunter steht die Aufgabenliste mit Filter fuer:
|
||||
|
||||
- Offen
|
||||
- Alle
|
||||
- Erledigt
|
||||
- Ticketnummer
|
||||
|
||||
Die Aufgabenliste kann zusaetzlich sortiert werden nach:
|
||||
|
||||
- Benutzerdefiniert
|
||||
- Prioritaet
|
||||
- Titel
|
||||
- Ticketnummer
|
||||
- Organisation
|
||||
- Umsetzungsdatum
|
||||
- Zuletzt geaendert
|
||||
|
||||
Die Ticket-Suche filtert nach Ticketnummer. Dabei reicht auch die Nummer ohne `Ticket#`, zum Beispiel `944079`.
|
||||
|
||||
`Benutzerdefiniert` ist die frei gespeicherte Reihenfolge. Diese Reihenfolge wird per Drag and Drop geaendert: ToDo am Griff ziehen, an der neuen Stelle fallen lassen. Sobald frei sortiert wird, stellt das Dropdown automatisch auf `Benutzerdefiniert`. Die Reihenfolge wird serverseitig pro Benutzer gespeichert. Du kannst also zwischendurch nach Titel oder Prioritaet sortieren und spaeter wieder auf deine eigene Reihenfolge zurueckwechseln.
|
||||
|
||||
Mit den drei Gruppierungsfeldern kann die Aufgabenliste mehrstufig gruppiert werden. Die Reihenfolge der Felder ist die Reihenfolge der Gruppen, zum Beispiel `Ticket` -> `Prioritaet` -> `Kunde`. Moegliche Gruppierungen sind `Ticket`, `Prioritaet`, `Kunde` und `Status`. Statusfilter, Ticket-Suche, Sortierung und Gruppierung bleiben nach Aktualisierung und Browserneustart erhalten. Mit `Zuruecksetzen` werden die Standardeinstellungen wiederhergestellt.
|
||||
|
||||
Unterhalb von `FAQ` zeigt die Seitenleiste die Kachel `Termine`. Dort stehen die festen ToDos des heutigen Tages, eine Summe laufender ToDos fuer heute und ueberfaellige ToDos. Ein Klick auf einen Eintrag oeffnet das zugehoerige Ticket.
|
||||
|
||||
Unter der Aufgabenliste steht der `ToDo-Kalender`. Er zeigt offene ToDos pro Monat; erledigte ToDos werden im Kalender ausgeblendet. Wochenenden und bayerische Feiertage bleiben im Kalender ohne ToDo-Einträge. Der Monat kann mit den Pfeilen gewechselt werden.
|
||||
|
||||
Ein-Tages-ToDos werden einzeln angezeigt. Das sind Eintraege, bei denen `Umsetzung von` und `Umsetzung bis` gleich sind. Die Farbe richtet sich nach der Prioritaet.
|
||||
|
||||
ToDos mit Zeitraum oder nur einem Datum werden nicht einzeln ausgeschrieben, sondern pro Tag als Summe angezeigt:
|
||||
|
||||
- `Umsetzung von` und `Umsetzung bis` unterschiedlich: Das ToDo zaehlt an jedem Tag im Zeitraum.
|
||||
- Nur `Umsetzung von`: Das ToDo zaehlt ab diesem Datum weiter, bis es erledigt, geloescht oder mit einem Bis-Datum begrenzt wird.
|
||||
- Nur `Umsetzung bis`: Das ToDo zaehlt ab heute bis zum Bis-Datum.
|
||||
|
||||
Ein offenes ToDo ist ueberfaellig, sobald `Umsetzung bis` vor dem heutigen Tag liegt. Ueberfaellige ToDos werden in Listen, im Kalender und in der Termine-Kachel rot hervorgehoben. Im Kalender bleiben sie ab dem Tag nach der Deadline an jedem Arbeitstag als roter Badge `+X uef` sichtbar, bis sie erledigt oder geloescht werden.
|
||||
|
||||
Ein Klick auf ein einzelnes ToDo oeffnet direkt das Bearbeiten-Modal. Ein Klick auf `+X ToDos` oeffnet ein kompaktes Tagesmodal mit den einzelnen ToDos dieses Tages. Dort koennen ToDos erledigt, geloescht, als einzelnes ToDo geoeffnet oder das zugehoerige Ticket geoeffnet werden.
|
||||
|
||||
Bei den Datumsfeldern gibt es jeweils `Datum entfernen`, um `Umsetzung von` oder `Umsetzung bis` gezielt zu leeren.
|
||||
|
||||
ToDos koennen erledigt, wieder geoeffnet, bearbeitet oder geloescht werden.
|
||||
|
||||
Die Sessions sind pro Tag gruppiert. In jeder Tagesgruppe stehen:
|
||||
|
||||
- Tagesdatum
|
||||
- `Tag gesamt`: Summe der erfassten Session-Zeit dieses Tages
|
||||
- `CRM`: Stunden, die fuer dieses Ticket an diesem Tag wirklich im CRM eingetragen wurden
|
||||
- `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 nicht final bewerteten Session-Zeit dieses Tages, inklusive `Abrechenbar`
|
||||
- `Teamspace`: Stunden, die fuer dieses Ticket an diesem Tag wirklich im CRM eingetragen wurden
|
||||
|
||||
Das CRM-Feld wird in Stunden eingetragen, zum Beispiel:
|
||||
Das Teamspace-Feld wird in Stunden eingetragen, zum Beispiel:
|
||||
|
||||
- `1`
|
||||
- `1,5`
|
||||
- `2.25`
|
||||
- `0,75`
|
||||
|
||||
Ein leeres CRM-Feld entfernt den gespeicherten CRM-Wert fuer diesen Ticket-Tag. Der Wert ist derselbe in Monats- und Tagesansicht.
|
||||
Der Teamspace-Wert wird nicht waehrend der Eingabe gespeichert. Sobald sich der Wert geaendert hat, erscheint ein Speichern-Button. Erst dieser Button speichert den Wert.
|
||||
|
||||
Ein leeres Teamspace-Feld entfernt den gespeicherten Teamspace-Wert fuer diesen Ticket-Tag.
|
||||
|
||||
Der Wert ist derselbe in Monats- und Tagesansicht. Wenn er in einer Ansicht gespeichert wird, ist er in der anderen Ansicht identisch gesetzt.
|
||||
|
||||
Jede einzelne Session ist innerhalb der Tagesgruppe aufklappbar. In der geschlossenen Zeile stehen nur Uhrzeit, berechnete Zeit und die Buttons fuer Bewertung, Bearbeiten und Loeschen. Der Freitext der Taetigkeit wird erst beim Aufklappen angezeigt. Ueber `Alle aufklappen` koennen alle Sessiondetails gleichzeitig eingeblendet werden.
|
||||
|
||||
## Sessions Bewerten
|
||||
|
||||
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.
|
||||
|
||||
`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.
|
||||
|
||||
Wenn der Monat bereits abgeschlossen ist, muss er zuerst wieder geoeffnet werden, bevor Bewertungen geaendert werden.
|
||||
@@ -169,21 +375,40 @@ Bearbeitbar sind:
|
||||
|
||||
Bei wiederkehrenden Sessions ist die Art gesperrt. Sie kommt aus der Regel der fixen Abrechnung.
|
||||
|
||||
Von und Bis beziehen sich nur auf die Uhrzeit. Das Datum ist ein eigenes Feld.
|
||||
|
||||
## Sessions Loeschen
|
||||
|
||||
Eine Session kann ueber das Papierkorb-Icon geloescht werden.
|
||||
|
||||
Wird die letzte Session eines Tickets geloescht, wird das Ticket entfernt. Die Ansicht springt danach zurueck zur Auswertung.
|
||||
Wird die letzte Session eines Tickets geloescht, wird das Ticket entfernt. Die Ansicht springt danach zurueck zur Ticketliste.
|
||||
|
||||
Bei wiederkehrenden Sessions wird das Loeschen als Ausnahme gespeichert. Dadurch wird die Session fuer diesen Tag nicht automatisch wieder neu erzeugt.
|
||||
|
||||
## Zeitraum Abschliessen
|
||||
Wenn eine wiederkehrende Leistung an einem anderen Tag erbracht wurde, kann die erzeugte Session bearbeitet beziehungsweise verschoben werden.
|
||||
|
||||
## Monatsabschluss
|
||||
|
||||
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 CRM-Tageswerte wirken in Monats- und Tagesansicht auf dieselben Daten, abgeschlossen wird aber ausschliesslich der Monat.
|
||||
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
|
||||
- Button zum Monat abschliessen oder wieder oeffnen
|
||||
|
||||
Offene Bewertungen werden nach Ticket gruppiert. Mit `Ticket oeffnen` gelangt man direkt zur Bewertung.
|
||||
|
||||
Tage ohne Teamspace-Wert sind eine Pruefliste. Wenn ein Tag bewusst keinen Teamspace-Wert bekommen soll, kann der Eintrag mit `Zur Kenntnis` abgehakt werden.
|
||||
|
||||
Zur Kenntnis genommene Eintraege koennen ueber die Checkbox `Zur Kenntnis anzeigen` wieder eingeblendet werden. Diese Anzeigeeinstellung bleibt im Browser gespeichert. Ein abgehakter Eintrag kann mit `Zuruecknehmen` wieder offen gemacht werden.
|
||||
|
||||
## Fixe Abrechnungen
|
||||
|
||||
@@ -208,12 +433,97 @@ Eine fixe Abrechnung enthaelt:
|
||||
|
||||
Wenn keine Ticketnummer hinterlegt ist, wird automatisch `Fix#ID` verwendet.
|
||||
|
||||
Muster:
|
||||
|
||||
- `Wochentage`: Es werden einzelne Slots mit Wochentag, Startzeit und Dauer gepflegt.
|
||||
- `Alle X Wochen`: Das Gueltig-von-Datum ist der Startpunkt. Das Intervall laeuft wochenweise.
|
||||
|
||||
Bayerische Feiertage werden bei der automatischen Erzeugung beruecksichtigt. Fuer Feiertage werden keine Sessions erzeugt.
|
||||
|
||||
Fixe Abrechnungen koennen bearbeitet werden. Wenn Gueltigkeiten oder Slots geaendert werden, werden nicht mehr passende erzeugte Sessions entfernt oder angepasst.
|
||||
|
||||
Fix erzeugte einzelne Sessions koennen geloescht werden, zum Beispiel wegen Urlaub oder Krankheit. Diese Loeschung wird als Ausnahme gespeichert, damit die Session nicht beim naechsten Aufruf wieder automatisch erscheint.
|
||||
|
||||
## Statistiken
|
||||
|
||||
Die Seite `Statistiken` betrachtet immer einen Monat. Sie dient als Ueberblick fuer abgerechnete Zeiten, nicht abrechenbare Arbeit, Teamspace-Vergleich, Bewertungsstand und Auffaelligkeiten.
|
||||
|
||||
### Kennzahlen
|
||||
|
||||
`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.
|
||||
|
||||
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:
|
||||
|
||||
```text
|
||||
Teamspace-Zeit minus abgerechnete Session-Zeit
|
||||
```
|
||||
|
||||
Das bedeutet:
|
||||
|
||||
- Positive Differenz: In Teamspace steht mehr Zeit als abgerechnet wurde.
|
||||
- Negative Differenz: In Teamspace steht weniger Zeit als abgerechnet wurde.
|
||||
- Ausgeglichen: Beide Werte sind gleich.
|
||||
|
||||
Farben:
|
||||
|
||||
- Abgerechnet: blau
|
||||
- Abrechenbar: grau
|
||||
- Teamspace: fuchsia
|
||||
- Positive oder ausgeglichene Differenz: gruen
|
||||
- Negative Differenz: rot
|
||||
- Nicht abrechenbar: indigo
|
||||
- Offen: gelb/orange
|
||||
|
||||
`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 finalen Status, also auch `Abrechenbar`. Diese blockieren den Monatsabschluss.
|
||||
|
||||
### Monatsverlauf und Abrechnungsqualitaet
|
||||
|
||||
Diese Card kombiniert den Tagesverlauf und die Bewertungsqualitaet.
|
||||
|
||||
- Balken zeigen abgerechnete Session-Zeit.
|
||||
- Die Linie zeigt Teamspace-Zeit.
|
||||
- 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.
|
||||
|
||||
### Statistik pro Organisation
|
||||
|
||||
Diese Card gruppiert den Aufwand nach Organisation.
|
||||
|
||||
Sichtbar sind:
|
||||
|
||||
- Organisation
|
||||
- Sessions
|
||||
- Zeiten: Abgerechnet, abrechenbar, nicht abrechenbar und Teamspace
|
||||
- Differenz
|
||||
- Anteil an der abgerechneten Monatszeit
|
||||
|
||||
Bei vielen Organisationen wird paginiert.
|
||||
|
||||
### Typen
|
||||
|
||||
Die Typen-Card trennt Support und Consulting.
|
||||
|
||||
Sie zeigt je Typ:
|
||||
|
||||
- Tickets
|
||||
- Sessions
|
||||
- Abgerechnete Session-Zeit
|
||||
- Nicht abrechenbare Session-Zeit
|
||||
- Teamspace-Zeit
|
||||
- Differenz
|
||||
- Anzahl abgerechnet, abrechenbar, nicht abrechenbar und offen
|
||||
|
||||
### Ticket-Hotspots
|
||||
|
||||
Ticket-Hotspots zeigen die groessten Zeitbloecke des Monats nach Ticket.
|
||||
|
||||
Sie helfen, die groessten Aufwandstreiber schnell zu finden. Mit `Oeffnen` gelangt man direkt in die Ticketdetailansicht des Monats.
|
||||
|
||||
## Profil
|
||||
|
||||
Unter `Profil` kann jeder Benutzer seine eigenen Daten pflegen:
|
||||
@@ -221,22 +531,49 @@ Unter `Profil` kann jeder Benutzer seine eigenen Daten pflegen:
|
||||
- Benutzername
|
||||
- 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.
|
||||
Passwortaenderungen erfordern das aktuelle Passwort. Bleiben die Passwortfelder leer, werden nur Benutzername und voller Name gespeichert.
|
||||
|
||||
Normale Benutzer koennen im Profil einen persoenlichen Zammad API-Token hinterlegen. Beim Speichern eines neuen Tokens ruft TicketTracker Zammad ueber `/api/v1/users/me` auf und speichert die User-ID, die Zammad fuer diesen Token zurueckliefert. Die User-ID wird danach read-only im Profil angezeigt.
|
||||
|
||||
Die Pruefung findet nur statt, wenn der Token im Profil neu eingetragen oder entfernt wird. Wenn nur Benutzername, voller Name oder Passwort gespeichert werden, wird Zammad nicht erneut abgefragt.
|
||||
|
||||
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
|
||||
|
||||
Administratoren koennen:
|
||||
Der feste Admin ist nur fuer die Systemverwaltung vorgesehen. Er kann:
|
||||
|
||||
- Benutzer anlegen
|
||||
- Benutzer bearbeiten
|
||||
- Passwoerter fuer Benutzer setzen
|
||||
- Sessions aller Benutzer sehen
|
||||
- Sessions anderen Benutzern zuweisen
|
||||
- Sessions fuer Benutzer nachtragen
|
||||
- Zammad-Organisationen synchronisieren
|
||||
- Restore-faehige SQL-Backups herunterladen
|
||||
|
||||
Bei der Auswertung normaler Benutzer werden nur die Sessions des angemeldeten Benutzers beruecksichtigt. Ticketdaten koennen trotzdem von Sessions anderer Benutzer uebernommen werden, wenn dasselbe Ticket schon existiert.
|
||||
Neue Benutzer erhalten automatisch die Rolle `User`. Eine Rollenauswahl gibt es nicht. Der feste Admin kann nicht deaktiviert oder zur User-Rolle herabgestuft werden.
|
||||
|
||||
Der Admin sieht keine Timer, keine Auswertungen, keine Statistiken, keine fixen Abrechnungen und keinen Monatsabschluss. Diese Bereiche sind nur fuer normale Benutzer vorgesehen.
|
||||
|
||||
Der Datenbankexport im Adminbereich erzeugt eine SQL-Datei fuer einen kompletten Restore. Fuer einen Umzug wird auf dem Zielsystem zuerst TicketTracker gestartet, damit die Migrationen die Tabellen anlegen. Danach wird die SQL-Datei mit `psql` in die Ziel-Datenbank eingespielt.
|
||||
|
||||
Der Export enthaelt alle Tabellen, die fuer den Stand des Systems notwendig sind: Benutzer, Einstellungen, Organisationen, Tickets, Sessions, Teamspace-Werte, fixe Abrechnungen, Ausnahmen und Monatsabschluesse. Er ist kein reiner CSV-Auszug, sondern ein Restore-Backup.
|
||||
|
||||
Im laufenden System kann der Admin ein SQL-Backup ebenfalls im Adminbereich wieder einspielen. Nach dem Hochladen muss zuerst die Strukturpruefung erfolgreich durchlaufen. Erst danach wird der Restore-Button freigegeben. Vor dem Restore erscheint ein Hinweis, weil alle aktuellen Daten ersetzt werden. Nach dem Restore ist eine neue Anmeldung mit den Zugangsdaten aus dem Backup notwendig.
|
||||
|
||||
## Zammad Sync
|
||||
|
||||
@@ -251,21 +588,26 @@ Gespeichert werden:
|
||||
- Name
|
||||
- Zeitstempel
|
||||
|
||||
Neue Organisationen werden hinzugefuegt, geaenderte Namen aktualisiert und nicht mehr vorhandene Organisationen entfernt beziehungsweise von bestehenden Tickets geloest.
|
||||
Neue Organisationen werden hinzugefuegt, geaenderte Namen aktualisiert und nicht mehr vorhandene Organisationen entfernt beziehungsweise von bestehenden Tickets und Sessions geloest.
|
||||
|
||||
## Typischer Monatsablauf
|
||||
|
||||
1. Im Alltag Sessions per Timer oder Nachtragen erfassen.
|
||||
2. Am Monatsende `Auswertung` oeffnen.
|
||||
3. Monat auswaehlen.
|
||||
4. Offene Sessions pruefen.
|
||||
5. Pro Ticket jede Session als `Abgerechnet` oder `Nicht abrechenbar` markieren.
|
||||
6. Pro Tag und Ticket die wirklich ins CRM eingetragene Zeit im Feld `CRM` pflegen.
|
||||
7. Wenn keine Sessions im Monat mehr offen sind, Monat abschliessen.
|
||||
2. Optional fixe Abrechnungen pflegen, damit wiederkehrende Sessions automatisch erzeugt werden.
|
||||
3. Am Monatsende `Tickets` oeffnen.
|
||||
4. Monat auswaehlen.
|
||||
5. Offene Sessions pruefen.
|
||||
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 und keine Session mehr auf `Abrechenbar` steht, Monat abschliessen.
|
||||
|
||||
## Hinweise
|
||||
|
||||
- Ticketnummern koennen ueber das Copy-Icon schnell in die Zwischenablage kopiert werden.
|
||||
- Die App aktualisiert Auswertungen automatisch beim Wechsel von Monat oder Tag.
|
||||
- Eine zurueckgenommene Bewertung macht eine Session wieder offen und kann Abschluesse wieder oeffnen.
|
||||
- CRM-Zeiten sind Tageswerte pro Ticket und Benutzer. Sie sind nicht an einzelne Sessions gebunden.
|
||||
- Die App aktualisiert Ticket- und Statistikdaten automatisch beim Wechsel von Monat oder Tag.
|
||||
- Eine zurueckgenommene Bewertung macht eine Session wieder offen und kann den Monatsabschluss wieder verhindern.
|
||||
- Teamspace-Zeiten sind Tageswerte pro Ticket und Benutzer. Sie sind nicht an einzelne Sessions gebunden.
|
||||
- Fragezeichen-Icons oeffnen kurze Hilfen direkt in der aktuellen Ansicht.
|
||||
+998
-85
File diff suppressed because it is too large.
Load diff
+756
-30
@@ -1,29 +1,60 @@
|
||||
import type {
|
||||
AdminSession,
|
||||
AdminUser,
|
||||
AuthUser,
|
||||
BillingStatus,
|
||||
DashboardDayTicket,
|
||||
DashboardPreference,
|
||||
Organization,
|
||||
PeriodOverview,
|
||||
PeriodType,
|
||||
ProjectTeamspaceSubentry,
|
||||
ProjectSummary,
|
||||
RecurringBilling,
|
||||
StatisticsOverview,
|
||||
StatisticsProjectFollowupOverview,
|
||||
TicketMeta,
|
||||
TicketPeriod,
|
||||
UserRole,
|
||||
WorkType
|
||||
TicketTodo,
|
||||
TicketSearchResult,
|
||||
TodoPriority,
|
||||
TodoStatus,
|
||||
WorkType,
|
||||
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;
|
||||
zammadLookup?: boolean;
|
||||
projectTeamspaceEntryId?: string | null;
|
||||
billingStatus?: BillingStatus;
|
||||
createZammadArticle?: 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;
|
||||
@@ -31,9 +62,140 @@ type DeleteSessionResult = {
|
||||
started_at: string;
|
||||
userTicketEmpty: boolean;
|
||||
ticketDeleted: boolean;
|
||||
projectDeleteAvailable: boolean;
|
||||
project_id: string | null;
|
||||
};
|
||||
};
|
||||
|
||||
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;
|
||||
status: "updated" | "current" | "missing" | "ambiguous";
|
||||
message: string | null;
|
||||
};
|
||||
|
||||
export type TeamspaceProjectIdSyncResult = {
|
||||
checkedEntries: number;
|
||||
matched: number;
|
||||
updated: number;
|
||||
alreadyCurrent: number;
|
||||
missing: number;
|
||||
ambiguous: number;
|
||||
cleared: number;
|
||||
remoteProjectsScanned: number;
|
||||
remoteProjectIds: number;
|
||||
remoteSubprojectsScanned: number;
|
||||
subentriesSynced: number;
|
||||
subentriesRemoved: number;
|
||||
duplicateRemoteProjectIds: string[];
|
||||
items: TeamspaceProjectIdSyncItem[];
|
||||
};
|
||||
|
||||
export type TeamspaceProjectSubentrySyncResult = {
|
||||
projectId: string;
|
||||
checkedEntries: number;
|
||||
remoteProjectsScanned: number;
|
||||
subentriesSynced: number;
|
||||
subentriesRemoved: number;
|
||||
};
|
||||
|
||||
export type TeamspaceAllSubentrySyncResult = {
|
||||
checkedProjects: number;
|
||||
checkedEntries: number;
|
||||
remoteProjectsScanned: number;
|
||||
subentriesSynced: number;
|
||||
subentriesRemoved: number;
|
||||
};
|
||||
|
||||
export type TicketZammadMailSessionItem = {
|
||||
articleId: number;
|
||||
ticketNumber: string;
|
||||
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;
|
||||
issues: string[];
|
||||
};
|
||||
|
||||
export class ApiError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
@@ -44,15 +206,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(() => ({}));
|
||||
@@ -62,6 +259,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",
|
||||
@@ -84,6 +293,22 @@ export function updateCurrentUser(payload: {
|
||||
displayName: string;
|
||||
currentPassword?: string;
|
||||
newPassword?: string;
|
||||
zammadApiToken?: string;
|
||||
teamspaceDeviceId?: string;
|
||||
teamspaceDevicePassword?: string;
|
||||
zammadEmailSessionWebhookEnabled?: boolean;
|
||||
sessionBillingPromptEnabled?: boolean;
|
||||
teamspaceTimeTrackingEnabled?: boolean;
|
||||
zammadSessionNoteDefaultEnabled?: 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",
|
||||
@@ -91,6 +316,63 @@ export function updateCurrentUser(payload: {
|
||||
});
|
||||
}
|
||||
|
||||
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",
|
||||
body: JSON.stringify({ preference })
|
||||
});
|
||||
}
|
||||
|
||||
export function addFavoriteTicket(ticketNumber: string) {
|
||||
return request<{ preference: Partial<DashboardPreference> }>("/api/dashboard/favorites", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ ticketNumber })
|
||||
});
|
||||
}
|
||||
|
||||
export function removeFavoriteTicket(ticketNumber: string) {
|
||||
return request<{ preference: Partial<DashboardPreference> }>(`/api/dashboard/favorites/${encodeURIComponent(ticketNumber)}`, {
|
||||
method: "DELETE"
|
||||
});
|
||||
}
|
||||
|
||||
export function getSetupStatus() {
|
||||
return request<{ required: boolean }>("/api/setup/status");
|
||||
}
|
||||
|
||||
export function createSetupAdmin(payload: { username: string; displayName: string; password: string }) {
|
||||
return request<{ user: AuthUser }>("/api/setup/admin", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
}
|
||||
|
||||
export function restoreSetupBackup(sql: string) {
|
||||
return requestRestoreEndpoint<{ ok: boolean }>("/api/setup/restore", {
|
||||
method: "POST",
|
||||
timeoutMs: 10 * 60 * 1000,
|
||||
headers: { "Content-Type": "text/plain; charset=utf-8" },
|
||||
body: sql
|
||||
});
|
||||
}
|
||||
|
||||
export function validateSetupBackup(sql: string) {
|
||||
return requestRestoreEndpoint<RestoreValidationResult>("/api/setup/restore/validate", {
|
||||
method: "POST",
|
||||
timeoutMs: 60_000,
|
||||
headers: { "Content-Type": "text/plain; charset=utf-8" },
|
||||
body: backupValidationSql(sql)
|
||||
});
|
||||
}
|
||||
|
||||
export function getAdminUsers() {
|
||||
return request<{ users: AdminUser[] }>("/api/admin/users");
|
||||
}
|
||||
@@ -99,7 +381,6 @@ export function createAdminUser(payload: {
|
||||
username: string;
|
||||
displayName: string;
|
||||
password: string;
|
||||
role: UserRole;
|
||||
active: boolean;
|
||||
}) {
|
||||
return request<{ user: AdminUser }>("/api/admin/users", {
|
||||
@@ -114,7 +395,6 @@ export function updateAdminUser(
|
||||
username: string;
|
||||
displayName: string;
|
||||
password?: string;
|
||||
role: UserRole;
|
||||
active: boolean;
|
||||
}
|
||||
) {
|
||||
@@ -124,8 +404,60 @@ export function updateAdminUser(
|
||||
});
|
||||
}
|
||||
|
||||
export function getAdminSessions() {
|
||||
return request<{ sessions: AdminSession[] }>("/api/admin/sessions");
|
||||
export async function downloadDatabaseExport() {
|
||||
const response = await fetch("/api/admin/export/database", {
|
||||
credentials: "same-origin"
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const body = await response.json().catch(() => ({}));
|
||||
throw new ApiError(body.error ?? "Export fehlgeschlagen", response.status);
|
||||
}
|
||||
|
||||
const contentDisposition = response.headers.get("content-disposition") ?? "";
|
||||
const filenameMatch = contentDisposition.match(/filename="([^"]+)"/);
|
||||
|
||||
return {
|
||||
blob: await response.blob(),
|
||||
filename: filenameMatch?.[1] ?? `tickettracker-backup-${new Date().toISOString().replace(/[:.]/g, "-")}.sql`
|
||||
};
|
||||
}
|
||||
|
||||
export function restoreDatabaseBackup(sql: string) {
|
||||
return requestRestoreEndpoint<{ ok: boolean }>("/api/admin/restore/database", {
|
||||
method: "POST",
|
||||
timeoutMs: 10 * 60 * 1000,
|
||||
headers: { "Content-Type": "text/plain; charset=utf-8" },
|
||||
body: sql
|
||||
});
|
||||
}
|
||||
|
||||
export function validateDatabaseBackup(sql: string) {
|
||||
return requestRestoreEndpoint<RestoreValidationResult>("/api/admin/restore/database/validate", {
|
||||
method: "POST",
|
||||
timeoutMs: 60_000,
|
||||
headers: { "Content-Type": "text/plain; charset=utf-8" },
|
||||
body: backupValidationSql(sql)
|
||||
});
|
||||
}
|
||||
|
||||
export async function downloadMonthExport(month: string) {
|
||||
const response = await fetch(`/api/export/months/${month}`, {
|
||||
credentials: "same-origin"
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const body = await response.json().catch(() => ({}));
|
||||
throw new ApiError(body.error ?? "Export fehlgeschlagen", response.status);
|
||||
}
|
||||
|
||||
const contentDisposition = response.headers.get("content-disposition") ?? "";
|
||||
const filenameMatch = contentDisposition.match(/filename="([^"]+)"/);
|
||||
|
||||
return {
|
||||
blob: await response.blob(),
|
||||
filename: filenameMatch?.[1] ?? `tickettracker-monat-${month}.csv`
|
||||
};
|
||||
}
|
||||
|
||||
export function getOrganizations(search = "") {
|
||||
@@ -149,6 +481,17 @@ export function saveZammadSettings(payload: { baseUrl: string; apiKey?: string }
|
||||
});
|
||||
}
|
||||
|
||||
export function getTeamspaceSettings() {
|
||||
return request<{ settings: { baseUrl: string } }>("/api/admin/teamspace/settings");
|
||||
}
|
||||
|
||||
export function saveTeamspaceSettings(payload: { baseUrl: string }) {
|
||||
return request<{ settings: { baseUrl: string } }>("/api/admin/teamspace/settings", {
|
||||
method: "PUT",
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
}
|
||||
|
||||
export function syncZammadOrganizations(payload: { baseUrl?: string; apiKey?: string }) {
|
||||
return request<{ synced: number; skipped: number; removed: number; unlinkedTickets: number; unlinkedSessions: number }>("/api/admin/zammad/organizations/sync", {
|
||||
method: "POST",
|
||||
@@ -156,6 +499,137 @@ export function syncZammadOrganizations(payload: { baseUrl?: string; apiKey?: st
|
||||
});
|
||||
}
|
||||
|
||||
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[] } = {}) {
|
||||
const { result } = await runZammadSyncJob("/api/zammad-sync/jobs/existing", {
|
||||
fields,
|
||||
dryRun: options.dryRun ?? true,
|
||||
ticketNumbers: options.ticketNumbers
|
||||
});
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
export async function importOwnZammadConsultingTickets(options: { dryRun?: boolean; ticketNumbers?: string[] } = {}) {
|
||||
const { result } = await runZammadSyncJob("/api/zammad-sync/jobs/consulting-import", {
|
||||
dryRun: options.dryRun ?? true,
|
||||
ticketNumbers: options.ticketNumbers
|
||||
});
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
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() {
|
||||
return request<{ result: TeamspaceProjectIdSyncResult }>("/api/teamspace/project-ids/sync", {
|
||||
method: "POST",
|
||||
timeoutMs: 5 * 60 * 1000
|
||||
});
|
||||
}
|
||||
|
||||
export function syncProjectTeamspaceSubentries(projectId: string) {
|
||||
return request<{ result: TeamspaceProjectSubentrySyncResult }>(`/api/projects/${projectId}/teamspace-subentries/sync`, {
|
||||
method: "POST",
|
||||
timeoutMs: 5 * 60 * 1000
|
||||
});
|
||||
}
|
||||
|
||||
export function syncAllTeamspaceSubentries() {
|
||||
return request<{ result: TeamspaceAllSubentrySyncResult }>("/api/teamspace/subentries/sync", {
|
||||
method: "POST",
|
||||
timeoutMs: 5 * 60 * 1000
|
||||
});
|
||||
}
|
||||
|
||||
export function updateProjectTeamspaceSubentry(
|
||||
subentryId: string,
|
||||
payload: {
|
||||
alias: string;
|
||||
}
|
||||
) {
|
||||
return request<{ subentry: ProjectTeamspaceSubentry }>(`/api/project-teamspace-subentries/${subentryId}`, {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
}
|
||||
|
||||
export function getRecurringBillings() {
|
||||
return request<{ recurringBillings: RecurringBilling[] }>("/api/recurring-billings");
|
||||
}
|
||||
@@ -214,45 +688,245 @@ export function deleteRecurringBilling(billingId: string) {
|
||||
});
|
||||
}
|
||||
|
||||
export function createAdminSession(payload: {
|
||||
userId: string;
|
||||
export function getProjects(includeClosed = false, hidden = false) {
|
||||
const params = new URLSearchParams();
|
||||
|
||||
if (includeClosed) {
|
||||
params.set("includeClosed", "true");
|
||||
}
|
||||
|
||||
if (hidden) {
|
||||
params.set("hidden", "true");
|
||||
}
|
||||
|
||||
return request<{ projects: ProjectSummary[]; hiddenCount: number; totalCount: number }>(`/api/projects${params.size ? `?${params}` : ""}`);
|
||||
}
|
||||
|
||||
export function getProject(projectId: string) {
|
||||
return request<{ project: ProjectSummary }>(`/api/projects/${projectId}`);
|
||||
}
|
||||
|
||||
export function createProject(payload: {
|
||||
ticketNumber: string;
|
||||
organizationId: string;
|
||||
activity: string;
|
||||
workType: WorkType;
|
||||
startedAt: string;
|
||||
endedAt: string;
|
||||
title?: string | null;
|
||||
organizationId?: string | null;
|
||||
zammadLookup?: boolean;
|
||||
teamspaceProject?: string | null;
|
||||
teamspaceEntries?: Array<{
|
||||
id?: string | null;
|
||||
teamspaceProject?: string | null;
|
||||
teamspaceDescription?: string | null;
|
||||
isDefault: boolean;
|
||||
hourBudgetMinutes?: number | null;
|
||||
budgetSyncDisabled?: boolean;
|
||||
manualConsumptionMinutes?: number | null;
|
||||
}>;
|
||||
description: string;
|
||||
goals: string;
|
||||
startsOn?: string | null;
|
||||
endsOn?: string | null;
|
||||
hourBudgetMinutes?: number | null;
|
||||
manualConsumptionMinutes?: number | null;
|
||||
}) {
|
||||
return request<{ session: AdminSession }>("/api/admin/sessions", {
|
||||
return request<{ project: ProjectSummary }>("/api/projects", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
}
|
||||
|
||||
export function reassignAdminSession(sessionId: string, userId: string) {
|
||||
return request<{ session: AdminSession }>(`/api/admin/sessions/${sessionId}/owner`, {
|
||||
export function updateProject(
|
||||
projectId: string,
|
||||
payload: {
|
||||
ticketNumber: string;
|
||||
title?: string | null;
|
||||
organizationId: string;
|
||||
teamspaceProject?: string | null;
|
||||
teamspaceEntries?: Array<{
|
||||
id?: string | null;
|
||||
teamspaceProject?: string | null;
|
||||
teamspaceDescription?: string | null;
|
||||
isDefault: boolean;
|
||||
hourBudgetMinutes?: number | null;
|
||||
budgetSyncDisabled?: boolean;
|
||||
manualConsumptionMinutes?: number | null;
|
||||
}>;
|
||||
description: string;
|
||||
goals: string;
|
||||
startsOn?: string | null;
|
||||
endsOn?: string | null;
|
||||
hourBudgetMinutes?: number | null;
|
||||
manualConsumptionMinutes?: number | null;
|
||||
}
|
||||
) {
|
||||
return request<{ project: ProjectSummary }>(`/api/projects/${projectId}`, {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify({ userId })
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
}
|
||||
|
||||
export function updateProjectStatus(projectId: string, closed: boolean) {
|
||||
return request<{ project: { id: string; ticket_id: string; closed_at: string | null } }>(`/api/projects/${projectId}/status`, {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify({ closed })
|
||||
});
|
||||
}
|
||||
|
||||
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();
|
||||
|
||||
if (status && status !== "all") {
|
||||
params.set("status", status);
|
||||
}
|
||||
|
||||
return request<{ todos: TicketTodo[] }>(`/api/todos${params.size ? `?${params}` : ""}`);
|
||||
}
|
||||
|
||||
export function createTicketTodo(
|
||||
ticketId: string,
|
||||
payload: {
|
||||
title: string;
|
||||
description: string;
|
||||
priority: TodoPriority;
|
||||
startsOn?: string | null;
|
||||
endsOn?: string | null;
|
||||
}
|
||||
) {
|
||||
return request<{ todo: TicketTodo }>(`/api/tickets/${ticketId}/todos`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
}
|
||||
|
||||
export function updateTodo(
|
||||
todoId: string,
|
||||
payload: {
|
||||
title: string;
|
||||
description: string;
|
||||
priority: TodoPriority;
|
||||
status: TodoStatus;
|
||||
startsOn?: string | null;
|
||||
endsOn?: string | null;
|
||||
}
|
||||
) {
|
||||
return request<{ todo: TicketTodo }>(`/api/todos/${todoId}`, {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
}
|
||||
|
||||
export function updateTodoOrder(todoIds: string[]) {
|
||||
return request<{ ok: boolean }>("/api/todos/order", {
|
||||
method: "PUT",
|
||||
body: JSON.stringify({ todoIds })
|
||||
});
|
||||
}
|
||||
|
||||
export function deleteTodo(todoId: string) {
|
||||
return request<{ deleted: { id: string } }>(`/api/todos/${todoId}`, {
|
||||
method: "DELETE"
|
||||
});
|
||||
}
|
||||
|
||||
export function createSession(payload: CreateSessionPayload) {
|
||||
return request("/api/sessions", {
|
||||
return request<CreateSessionResult>("/api/sessions", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
}
|
||||
|
||||
export function lookupTicket(ticketNumber: string) {
|
||||
return request<{ ticket: TicketMeta | null }>(`/api/tickets/lookup?ticketNumber=${encodeURIComponent(ticketNumber)}`);
|
||||
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 });
|
||||
|
||||
if (source === "zammad") {
|
||||
params.set("source", source);
|
||||
}
|
||||
|
||||
return request<{ ticket: TicketMeta | null }>(`/api/tickets/lookup?${params}`);
|
||||
}
|
||||
|
||||
export function searchTickets(query: string) {
|
||||
return request<{ tickets: TicketSearchResult[] }>(`/api/tickets/search?q=${encodeURIComponent(query)}`);
|
||||
}
|
||||
|
||||
export function updateTicket(
|
||||
ticketId: string,
|
||||
payload: {
|
||||
ticketNumber: string;
|
||||
title?: string | null;
|
||||
organizationId: string;
|
||||
workType: WorkType;
|
||||
removeProjectData?: boolean;
|
||||
}
|
||||
) {
|
||||
return request<{ ticket: TicketMeta }>(`/api/tickets/${ticketId}`, {
|
||||
@@ -266,7 +940,10 @@ export function updateSessionDetails(
|
||||
payload: {
|
||||
organizationId: string;
|
||||
activity: string;
|
||||
zammadArticleBody?: string | null;
|
||||
workType: WorkType;
|
||||
removeProjectData?: boolean;
|
||||
projectTeamspaceEntryId?: string | null;
|
||||
startedAt: string;
|
||||
endedAt: string;
|
||||
}
|
||||
@@ -277,10 +954,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
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
@@ -307,14 +992,55 @@ 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) {
|
||||
return request<TicketPeriod>(`/api/periods/${periodPath(type)}/${period}/tickets/${ticketId}`);
|
||||
}
|
||||
|
||||
export function getTicketDetails(
|
||||
ticketId: string,
|
||||
filters: {
|
||||
month?: string;
|
||||
status?: "all" | "open" | "billable" | "billed" | "non_billable";
|
||||
source?: "all" | "manual" | "recurring";
|
||||
} = {}
|
||||
) {
|
||||
const params = new URLSearchParams();
|
||||
|
||||
if (filters.month && filters.month !== "all") {
|
||||
params.set("month", filters.month);
|
||||
}
|
||||
|
||||
if (filters.status && filters.status !== "all") {
|
||||
params.set("status", filters.status);
|
||||
}
|
||||
|
||||
if (filters.source && filters.source !== "all") {
|
||||
params.set("source", filters.source);
|
||||
}
|
||||
|
||||
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",
|
||||
|
||||
@@ -7,6 +7,8 @@
|
||||
@import "../styles/presets/soft-pop.css";
|
||||
@import "../styles/presets/tangerine.css";
|
||||
|
||||
|
||||
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
|
||||
@theme inline {
|
||||
@@ -50,6 +52,9 @@
|
||||
--color-sidebar-ring: var(--sidebar-ring);
|
||||
--font-heading: var(--font-sans);
|
||||
--font-sans: var(--font-sans);
|
||||
--breakpoint-xxl: 108.125rem;
|
||||
--breakpoint-details: 85rem;
|
||||
|
||||
}
|
||||
|
||||
/* Default theme styles (used when no data-theme-preset is set or when 'default' is selected).
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,399 @@
|
||||
import { CalendarDays, CheckCircle2, Circle, GripVertical, Pencil, Plus, Save, Trash2, X } from "lucide-react";
|
||||
import { FormEvent, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { createTicketTodo, deleteTodo, updateTodo } from "../api";
|
||||
import { currentDay } from "../format";
|
||||
import { formatTodoPeriod, isTodoOverdue } from "../todos";
|
||||
import type { TicketTodo, TodoPriority, TodoStatus } from "../types";
|
||||
|
||||
const priorityLabels: Record<TodoPriority, string> = {
|
||||
low: "Niedrig",
|
||||
medium: "Normal",
|
||||
high: "Hoch",
|
||||
urgent: "Dringend"
|
||||
};
|
||||
|
||||
const priorityClasses: Record<TodoPriority, string> = {
|
||||
low: "border-sky-500/30 bg-sky-500/10 text-sky-300",
|
||||
medium: "border-muted-foreground/25 bg-muted/50 text-muted-foreground",
|
||||
high: "border-amber-500/30 bg-amber-500/10 text-amber-300",
|
||||
urgent: "border-red-500/30 bg-red-500/10 text-red-300"
|
||||
};
|
||||
|
||||
function notifyTodosChanged() {
|
||||
window.dispatchEvent(new Event("tickettracker:todos-changed"));
|
||||
}
|
||||
|
||||
type TodoPanelProps = {
|
||||
ticketId?: string;
|
||||
todos: TicketTodo[];
|
||||
onChanged: () => Promise<void> | void;
|
||||
showCreate?: boolean;
|
||||
showTicketMeta?: boolean;
|
||||
onNavigate?: (to: string) => void;
|
||||
reorderEnabled?: boolean;
|
||||
onReorder?: (draggedTodoId: string, targetTodoId: string) => void;
|
||||
};
|
||||
|
||||
export function TodoPanel({ ticketId, todos, onChanged, showCreate = true, showTicketMeta = false, onNavigate, reorderEnabled = false, onReorder }: TodoPanelProps) {
|
||||
const [title, setTitle] = useState("");
|
||||
const [description, setDescription] = useState("");
|
||||
const [priority, setPriority] = useState<TodoPriority>("medium");
|
||||
const [startsOn, setStartsOn] = useState("");
|
||||
const [endsOn, setEndsOn] = useState("");
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const [editingId, setEditingId] = useState<string | null>(null);
|
||||
const [editTitle, setEditTitle] = useState("");
|
||||
const [editDescription, setEditDescription] = useState("");
|
||||
const [editPriority, setEditPriority] = useState<TodoPriority>("medium");
|
||||
const [editStartsOn, setEditStartsOn] = useState("");
|
||||
const [editEndsOn, setEditEndsOn] = useState("");
|
||||
const [busyId, setBusyId] = useState<string | null>(null);
|
||||
const [draggingId, setDraggingId] = useState<string | null>(null);
|
||||
|
||||
async function submitTodo(event: FormEvent) {
|
||||
event.preventDefault();
|
||||
|
||||
if (!ticketId) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (startsOn && endsOn && endsOn < startsOn) {
|
||||
toast.error("Zeitraum prüfen", {
|
||||
description: "Das Bis-Datum darf nicht vor dem Von-Datum liegen."
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
setSaving(true);
|
||||
try {
|
||||
await createTicketTodo(ticketId, { title, description, priority, startsOn: startsOn || null, endsOn: endsOn || null });
|
||||
setTitle("");
|
||||
setDescription("");
|
||||
setPriority("medium");
|
||||
setStartsOn("");
|
||||
setEndsOn("");
|
||||
setCreateOpen(false);
|
||||
toast.success("ToDo angelegt");
|
||||
await onChanged();
|
||||
notifyTodosChanged();
|
||||
} catch (error) {
|
||||
toast.error("ToDo konnte nicht angelegt werden", {
|
||||
description: error instanceof Error ? error.message : "Unbekannter Fehler"
|
||||
});
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
function startEdit(todo: TicketTodo) {
|
||||
setEditingId(todo.id);
|
||||
setEditTitle(todo.title);
|
||||
setEditDescription(todo.description);
|
||||
setEditPriority(todo.priority);
|
||||
setEditStartsOn(todo.starts_on ?? "");
|
||||
setEditEndsOn(todo.ends_on ?? "");
|
||||
}
|
||||
|
||||
async function saveTodo(todo: TicketTodo, status: TodoStatus = todo.status) {
|
||||
if (editingId === todo.id && editStartsOn && editEndsOn && editEndsOn < editStartsOn) {
|
||||
toast.error("Zeitraum prüfen", {
|
||||
description: "Das Bis-Datum darf nicht vor dem Von-Datum liegen."
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
setBusyId(todo.id);
|
||||
try {
|
||||
await updateTodo(todo.id, {
|
||||
title: editingId === todo.id ? editTitle : todo.title,
|
||||
description: editingId === todo.id ? editDescription : todo.description,
|
||||
priority: editingId === todo.id ? editPriority : todo.priority,
|
||||
status,
|
||||
startsOn: editingId === todo.id ? editStartsOn || null : todo.starts_on,
|
||||
endsOn: editingId === todo.id ? editEndsOn || null : todo.ends_on
|
||||
});
|
||||
setEditingId(null);
|
||||
await onChanged();
|
||||
notifyTodosChanged();
|
||||
} catch (error) {
|
||||
toast.error("ToDo konnte nicht gespeichert werden", {
|
||||
description: error instanceof Error ? error.message : "Unbekannter Fehler"
|
||||
});
|
||||
} finally {
|
||||
setBusyId(null);
|
||||
}
|
||||
}
|
||||
|
||||
async function removeTodo(todo: TicketTodo) {
|
||||
const confirmed = window.confirm(`ToDo "${todo.title}" wirklich löschen?`);
|
||||
|
||||
if (!confirmed) {
|
||||
return;
|
||||
}
|
||||
|
||||
setBusyId(todo.id);
|
||||
try {
|
||||
await deleteTodo(todo.id);
|
||||
toast.success("ToDo gelöscht");
|
||||
await onChanged();
|
||||
notifyTodosChanged();
|
||||
} catch (error) {
|
||||
toast.error("ToDo konnte nicht gelöscht werden", {
|
||||
description: error instanceof Error ? error.message : "Unbekannter Fehler"
|
||||
});
|
||||
} finally {
|
||||
setBusyId(null);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{showCreate && ticketId ? (
|
||||
<div className="flex justify-end">
|
||||
<Dialog open={createOpen} onOpenChange={setCreateOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button type="button" size="sm">
|
||||
<Plus className="size-4" />
|
||||
ToDo anlegen
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>ToDo anlegen</DialogTitle>
|
||||
<DialogDescription>Neue Aufgabe zu diesem Ticket oder Projekt erfassen.</DialogDescription>
|
||||
</DialogHeader>
|
||||
<form className="space-y-4" onSubmit={submitTodo}>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium" htmlFor={`todo-title-${ticketId}`}>
|
||||
Aufgabe
|
||||
</label>
|
||||
<Input id={`todo-title-${ticketId}`} value={title} onChange={(event) => setTitle(event.currentTarget.value)} autoFocus required />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium" htmlFor={`todo-priority-${ticketId}`}>
|
||||
Priorität
|
||||
</label>
|
||||
<select
|
||||
id={`todo-priority-${ticketId}`}
|
||||
className="h-9 w-full rounded-lg border border-input bg-background px-2 text-sm focus-visible:outline-none focus-visible:ring-3 focus-visible:ring-ring/50"
|
||||
value={priority}
|
||||
onChange={(event) => setPriority(event.currentTarget.value as TodoPriority)}
|
||||
>
|
||||
{Object.entries(priorityLabels).map(([value, label]) => (
|
||||
<option key={value} value={value}>
|
||||
{label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium" htmlFor={`todo-starts-on-${ticketId}`}>
|
||||
Umsetzung von
|
||||
</label>
|
||||
<div className="flex gap-2">
|
||||
<Input id={`todo-starts-on-${ticketId}`} type="date" value={startsOn} onChange={(event) => setStartsOn(event.currentTarget.value)} />
|
||||
{startsOn ? (
|
||||
<Button type="button" size="icon" variant="ghost" className="shrink-0" onClick={() => setStartsOn("")} aria-label="Von-Datum entfernen">
|
||||
<X className="size-4" />
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium" htmlFor={`todo-ends-on-${ticketId}`}>
|
||||
Umsetzung bis
|
||||
</label>
|
||||
<div className="flex gap-2">
|
||||
<Input id={`todo-ends-on-${ticketId}`} type="date" value={endsOn} onChange={(event) => setEndsOn(event.currentTarget.value)} />
|
||||
{endsOn ? (
|
||||
<Button type="button" size="icon" variant="ghost" className="shrink-0" onClick={() => setEndsOn("")} aria-label="Bis-Datum entfernen">
|
||||
<X className="size-4" />
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium" htmlFor={`todo-description-${ticketId}`}>
|
||||
Details
|
||||
</label>
|
||||
<Textarea
|
||||
id={`todo-description-${ticketId}`}
|
||||
className="min-h-24"
|
||||
value={description}
|
||||
onChange={(event) => setDescription(event.currentTarget.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex justify-end">
|
||||
<Button type="submit" disabled={saving}>
|
||||
<Plus className="size-4" />
|
||||
{saving ? "Speichert..." : "Anlegen"}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="space-y-2">
|
||||
{todos.map((todo) => {
|
||||
const isEditing = editingId === todo.id;
|
||||
const periodLabel = formatTodoPeriod(todo);
|
||||
const overdue = isTodoOverdue(todo, currentDay());
|
||||
|
||||
return (
|
||||
<div
|
||||
key={todo.id}
|
||||
className={cn(
|
||||
"rounded-md border bg-card/50 p-3 transition-colors",
|
||||
overdue && "border-red-500/50 bg-red-500/10",
|
||||
todo.status === "done" && "opacity-70",
|
||||
reorderEnabled && draggingId === todo.id && "border-primary/50 bg-primary/5"
|
||||
)}
|
||||
onDragOver={
|
||||
reorderEnabled
|
||||
? (event) => {
|
||||
event.preventDefault();
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
onDrop={
|
||||
reorderEnabled
|
||||
? (event) => {
|
||||
event.preventDefault();
|
||||
const draggedTodoId = event.dataTransfer.getData("text/todo-id") || draggingId;
|
||||
|
||||
if (draggedTodoId && draggedTodoId !== todo.id) {
|
||||
onReorder?.(draggedTodoId, todo.id);
|
||||
}
|
||||
|
||||
setDraggingId(null);
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{showTicketMeta ? (
|
||||
<div className="mb-2 flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
|
||||
<button type="button" className="font-medium text-foreground hover:underline" onClick={() => onNavigate?.(`/tickets/${todo.ticket_id}`)}>
|
||||
{todo.ticket_number}
|
||||
</button>
|
||||
{todo.project_id ? <Badge variant="outline">Projekt</Badge> : null}
|
||||
{todo.organization_name ? <span>{todo.organization_name}</span> : null}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{isEditing ? (
|
||||
<div className="space-y-2">
|
||||
<Input value={editTitle} onChange={(event) => setEditTitle(event.currentTarget.value)} />
|
||||
<Textarea className="min-h-16" value={editDescription} onChange={(event) => setEditDescription(event.currentTarget.value)} />
|
||||
<div className="grid gap-2 sm:grid-cols-2">
|
||||
<div className="flex gap-2">
|
||||
<Input type="date" value={editStartsOn} onChange={(event) => setEditStartsOn(event.currentTarget.value)} />
|
||||
{editStartsOn ? (
|
||||
<Button type="button" size="icon" variant="ghost" className="shrink-0" onClick={() => setEditStartsOn("")} aria-label="Von-Datum entfernen">
|
||||
<X className="size-4" />
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Input type="date" value={editEndsOn} onChange={(event) => setEditEndsOn(event.currentTarget.value)} />
|
||||
{editEndsOn ? (
|
||||
<Button type="button" size="icon" variant="ghost" className="shrink-0" onClick={() => setEditEndsOn("")} aria-label="Bis-Datum entfernen">
|
||||
<X className="size-4" />
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<select
|
||||
className="h-8 rounded-lg border border-input bg-background px-2 text-sm focus-visible:outline-none focus-visible:ring-3 focus-visible:ring-ring/50"
|
||||
value={editPriority}
|
||||
onChange={(event) => setEditPriority(event.currentTarget.value as TodoPriority)}
|
||||
>
|
||||
{Object.entries(priorityLabels).map(([value, label]) => (
|
||||
<option key={value} value={value}>
|
||||
{label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<div className="flex gap-1">
|
||||
<Button type="button" size="sm" variant="ghost" onClick={() => setEditingId(null)}>
|
||||
Abbrechen
|
||||
</Button>
|
||||
<Button type="button" size="sm" disabled={busyId === todo.id} onClick={() => void saveTodo(todo)}>
|
||||
<Save className="size-4" />
|
||||
Speichern
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div className="flex min-w-0 flex-1 items-start gap-2">
|
||||
{reorderEnabled ? (
|
||||
<button
|
||||
type="button"
|
||||
className="mt-0.5 flex size-6 shrink-0 cursor-grab items-center justify-center rounded-md text-muted-foreground hover:bg-muted hover:text-foreground active:cursor-grabbing"
|
||||
aria-label="ToDo verschieben"
|
||||
draggable
|
||||
onDragStart={(event) => {
|
||||
event.dataTransfer.effectAllowed = "move";
|
||||
event.dataTransfer.setData("text/todo-id", todo.id);
|
||||
setDraggingId(todo.id);
|
||||
}}
|
||||
onDragEnd={() => setDraggingId(null)}
|
||||
>
|
||||
<GripVertical className="size-4" />
|
||||
</button>
|
||||
) : null}
|
||||
<button
|
||||
type="button"
|
||||
className="flex min-w-0 flex-1 items-start gap-2 text-left"
|
||||
disabled={busyId === todo.id}
|
||||
onClick={() => void saveTodo(todo, todo.status === "done" ? "open" : "done")}
|
||||
>
|
||||
{todo.status === "done" ? <CheckCircle2 className="mt-0.5 size-4 shrink-0 text-emerald-400" /> : <Circle className="mt-0.5 size-4 shrink-0 text-muted-foreground" />}
|
||||
<span className="min-w-0">
|
||||
<span className={cn("block break-words text-sm font-medium", todo.status === "done" && "line-through")}>{todo.title}</span>
|
||||
{periodLabel ? (
|
||||
<span className={cn("mt-1 flex items-center gap-1 text-xs", overdue ? "text-red-300" : "text-muted-foreground")}>
|
||||
<CalendarDays className="size-3.5" />
|
||||
{periodLabel}
|
||||
</span>
|
||||
) : null}
|
||||
{todo.description ? <span className="mt-1 block whitespace-pre-wrap break-words text-xs text-muted-foreground">{todo.description}</span> : null}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
{overdue ? <Badge variant="destructive">überfällig</Badge> : null}
|
||||
<span className={cn("rounded-full border px-2 py-0.5 text-xs font-medium", priorityClasses[todo.priority])}>{priorityLabels[todo.priority]}</span>
|
||||
<Button type="button" size="icon" variant="ghost" className="size-8" onClick={() => startEdit(todo)}>
|
||||
<Pencil className="size-4" />
|
||||
</Button>
|
||||
<Button type="button" size="icon" variant="ghost" className="size-8 text-destructive hover:text-destructive" onClick={() => void removeTodo(todo)}>
|
||||
<Trash2 className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{todos.length === 0 ? <p className="rounded-md border bg-muted/20 p-4 text-center text-sm text-muted-foreground">Keine ToDos vorhanden.</p> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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")}`;
|
||||
|
||||
+253
-88
@@ -4,6 +4,8 @@ export type HelpSection = {
|
||||
description: string;
|
||||
quickItems: string[];
|
||||
details: string[];
|
||||
workflow?: string[];
|
||||
diagram?: string[];
|
||||
};
|
||||
|
||||
export const helpSections: HelpSection[] = [
|
||||
@@ -14,17 +16,95 @@ 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: [
|
||||
"TicketTracker ersetzt nicht das Ticketsystem. Ticketnummern dienen als Bezug zur Arbeit, die eigentliche Dokumentation bleibt im vorhandenen Ticketsystem.",
|
||||
"TicketTracker ersetzt auch nicht das CRM. Die CRM- beziehungsweise Teamspace-Zeit wird als Kontrollwert gepflegt, damit getrackte Zeiten und eingetragene Abrechnung gegenübergestellt werden können.",
|
||||
"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."
|
||||
]
|
||||
},
|
||||
{
|
||||
id: "monatsworkflow",
|
||||
title: "Monatsworkflow",
|
||||
description: "Der empfohlene Ablauf vom Start einer Arbeitssession bis zum sauberen Monatsabschluss.",
|
||||
quickItems: [
|
||||
"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.",
|
||||
"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 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 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.",
|
||||
"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.",
|
||||
"Monatsabschluss öffnen, offene Bewertungen und Teamspace-Prüfpunkte abarbeiten.",
|
||||
"Teamspace-Summe mit dem CRM vergleichen.",
|
||||
"Monat abschließen."
|
||||
],
|
||||
diagram: [
|
||||
"Ticketnummer eingeben",
|
||||
"Timer starten",
|
||||
"Arbeit am Ticket erledigen",
|
||||
"Tätigkeit im Timer eintragen",
|
||||
"Timer beenden",
|
||||
"Session wird dem Ticket und Benutzer gespeichert",
|
||||
"Optional: vergessene Session nachtragen",
|
||||
"Tickets öffnen und Ticket prüfen",
|
||||
"Sessions bewerten",
|
||||
"Teamspace pro Ticket-Tag eintragen",
|
||||
"Monatsabschluss öffnen",
|
||||
"Offene Bewertungen und Teamspace-Prüfpunkte klären",
|
||||
"Teamspace-Summe mit CRM vergleichen",
|
||||
"Monat abschließen"
|
||||
]
|
||||
},
|
||||
{
|
||||
id: "erstes-setup",
|
||||
title: "Erstes Setup",
|
||||
description: "Wenn noch kein Admin existiert, startet TicketTracker mit einer Setup-Seite.",
|
||||
quickItems: [
|
||||
"Admin direkt im Browser anlegen.",
|
||||
"Alternativ ein TicketTracker-SQL-Backup prüfen und einspielen.",
|
||||
"Nach Restore gelten ausschließlich die Daten aus dem Backup."
|
||||
],
|
||||
details: [
|
||||
"Admin-Zugangsdaten werden nicht mehr über .env gesetzt.",
|
||||
"Beim ersten Start ohne Admin zeigt TicketTracker automatisch die Setup-Seite.",
|
||||
"Über Admin anlegen erstellst du den festen Admin und wirst direkt angemeldet.",
|
||||
"Über Backup einspielen lädst du ein restore-fähiges SQL-Backup aus TicketTracker hoch.",
|
||||
"Nach dem Hochladen prüft TicketTracker die Dump-Struktur gegen die aktuelle Datenbankstruktur.",
|
||||
"Der Restore-Button wird erst aktiv, wenn keine Felder fehlen und keine unbekannten Felder enthalten sind.",
|
||||
"Beim Restore werden zuerst alle TicketTracker-Tabellen geleert. Danach gelten Benutzer, Passwörter, Sessions, Teamspace-Werte, Settings und Abschlüsse aus dem Backup.",
|
||||
"Nach einem Restore meldest du dich mit einem Benutzer aus dem Backup an."
|
||||
]
|
||||
},
|
||||
{
|
||||
id: "timer",
|
||||
title: "Timer",
|
||||
@@ -32,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."
|
||||
]
|
||||
@@ -50,8 +136,8 @@ 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.",
|
||||
"Benutzer tragen eigene Sessions nach; Admins können Benutzer auswählen.",
|
||||
"Datum, Von, Bis, Bewertung und Tätigkeit eintragen.",
|
||||
"Benutzer tragen eigene Sessions nach.",
|
||||
"Tätigkeiten sind mehrzeilig."
|
||||
],
|
||||
details: [
|
||||
@@ -59,9 +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.",
|
||||
"Admins können im Adminbereich zusätzlich bestimmen, welchem Benutzer die nachgetragene Session gehört.",
|
||||
"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."
|
||||
]
|
||||
},
|
||||
{
|
||||
@@ -101,19 +188,22 @@ export const helpSections: HelpSection[] = [
|
||||
},
|
||||
{
|
||||
id: "auswertung",
|
||||
title: "Auswertung",
|
||||
description: "Die Auswertung ist die Arbeitsliste für Monat und Tag: Tickets öffnen, Sessions prüfen, sortieren und bewerten.",
|
||||
title: "Tickets",
|
||||
description: "Tickets ist die Arbeitsliste für Monat und Tag: Tickets öffnen, Sessions prüfen, sortieren und bewerten.",
|
||||
quickItems: [
|
||||
"Monat oder Tag wählen; die Ansicht aktualisiert automatisch.",
|
||||
"Tickets können gefiltert, gruppiert und sortiert werden.",
|
||||
"Die Einstellungen bleiben nach Refresh und Browserneustart erhalten."
|
||||
"Die Einstellungen bleiben nach Refresh und Browserneustart erhalten.",
|
||||
"Monate können als CSV exportiert werden."
|
||||
],
|
||||
details: [
|
||||
"Die Monatsansicht zeigt alle Tickets, die im gewählten Monat mindestens eine Session haben.",
|
||||
"Die Tagesansicht zeigt dieselben Datenlogiken auf einen einzelnen Tag gefiltert. Es gibt keinen Tagesabschluss.",
|
||||
"Die Pfeile neben der Zeitraum-Eingabe springen jeweils einen Monat oder einen Tag vor beziehungsweise zurück.",
|
||||
"Der Gesamtaufwand-Graph zeigt den getrackten Aufwand im Zeitraum. In der Monatsansicht pro Tag, in der Tagesansicht pro Stunde.",
|
||||
"Die Ticketliste kann nach Typ, Organisation oder Status gruppiert werden. Gruppenüberschriften zeigen Anzahl, Zeit und offene Bewertungen.",
|
||||
"Über Monat exportieren lädst du eine CSV-Datei deiner eigenen Sessions dieses Monats herunter.",
|
||||
"Der CSV-Export enthält Ticket, Organisation, Art, Datum, Von/Bis, Bewertung, Session-Minuten, Teamspace-Minuten und Tätigkeit.",
|
||||
"Der Graph zeigt abgerechnete Zeit im Zeitraum. In der Monatsansicht pro Tag, in der Tagesansicht pro Stunde.",
|
||||
"Die Ticketliste kann nach Typ, Organisation oder Status gruppiert werden. Gruppenüberschriften zeigen Anzahl, abgerechnete Zeit und offene Bewertungen.",
|
||||
"Filter und Sortierungen bleiben im Browser gespeichert und können über Zurücksetzen wieder auf Standard gestellt werden.",
|
||||
"Das Copy-Icon neben der Ticketnummer kopiert die Nummer direkt in die Zwischenablage."
|
||||
]
|
||||
@@ -129,32 +219,103 @@ export const helpSections: HelpSection[] = [
|
||||
],
|
||||
details: [
|
||||
"Oben steht die Ticketnummer mit Copy-Icon, damit sie schnell in andere Systeme übernommen werden kann.",
|
||||
"Die Session-Einträge sind nach Kalendertagen gruppiert. Pro Tagesgruppe steht die Summe der getrackten Zeit.",
|
||||
"Ü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.",
|
||||
"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.",
|
||||
"Sessions können bewertet, bearbeitet oder gelöscht werden. Gelöschte letzte Sessions können dazu führen, dass ein leeres Ticket entfernt wird."
|
||||
]
|
||||
},
|
||||
{
|
||||
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."
|
||||
]
|
||||
},
|
||||
{
|
||||
id: "projekte",
|
||||
title: "Projekte",
|
||||
description: "Projekte erweitern ein normales Ticket um Consulting-Projektinformationen und bleiben trotzdem über dieselbe Ticketdetailansicht bearbeitbar.",
|
||||
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: [
|
||||
"Der Navipunkt Projekte ist für Consulting-Arbeiten gedacht, die länger laufen oder ein Stundenkontingent haben.",
|
||||
"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 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 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.",
|
||||
"Aus einem Projekt heraus kann direkt eine Session gestartet werden. Der Timer verhält sich wie jeder andere Timer und pausiert andere Timer automatisch."
|
||||
]
|
||||
},
|
||||
{
|
||||
id: "todos",
|
||||
title: "ToDos",
|
||||
description: "ToDos sind kleine Aufgabenlisten pro Ticket oder Projekt, damit offene Arbeit sichtbar bleibt.",
|
||||
quickItems: [
|
||||
"ToDos werden direkt im Ticket angelegt.",
|
||||
"Die zentrale ToDo-Seite sammelt alle offenen Aufgaben.",
|
||||
"Prioritäten und optionale Umsetzungszeiträume helfen beim Sortieren der nächsten Schritte."
|
||||
],
|
||||
details: [
|
||||
"Jedes Ticket kann ToDos enthalten, egal ob es ein Projekt oder ein normales Einzelticket ist.",
|
||||
"In der Ticketdetailansicht legst du neue Aufgaben mit Titel, optionaler Beschreibung, Priorität und optionalem Umsetzungzeitraum an.",
|
||||
"Wenn Umsetzung von und Umsetzung bis gleich sind, ist das ToDo ein fester Termin an diesem Tag.",
|
||||
"Die zentrale Seite ToDos zeigt zusätzlich Heutige ToDos. Dort erscheinen nur offene Aufgaben, bei denen Umsetzung von und Umsetzung bis beide auf heute stehen.",
|
||||
"Die Aufgabenliste kann nach Status und Ticketnummer gefiltert, nach Kriterien sortiert oder per Drag and Drop frei sortiert werden.",
|
||||
"Wenn du eine Aufgabe ziehst, wird die Sortierung automatisch auf Benutzerdefiniert gestellt und dauerhaft gespeichert.",
|
||||
"Optional kannst du bis zu drei Gruppierungen in Reihenfolge setzen, zum Beispiel erst Ticket, dann Priorität, dann Kunde. Filter, Sortierung und Gruppierung bleiben im Browser gespeichert.",
|
||||
"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.",
|
||||
"Die Prioritäten Niedrig, Normal, Hoch und Dringend helfen dabei, wichtige Punkte nicht zwischen Sessions zu verlieren.",
|
||||
"ToDos lassen sich als erledigt markieren, wieder öffnen, bearbeiten oder löschen.",
|
||||
"Die zentrale Seite ToDos zeigt Aufgaben aus allen deinen Tickets. Von dort öffnest du direkt das zugehörige Ticket.",
|
||||
"ToDos sind benutzerbezogen. Andere Benutzer sehen ihre eigenen Aufgaben, auch wenn mehrere Benutzer am gleichen Ticket arbeiten."
|
||||
]
|
||||
},
|
||||
{
|
||||
id: "sessions-bearbeiten",
|
||||
title: "Sessions Bearbeiten und Löschen",
|
||||
@@ -169,7 +330,7 @@ export const helpSections: HelpSection[] = [
|
||||
"Die Art ist bei manuellen Sessions änderbar. Bei wiederkehrenden Sessions ist sie gesperrt und kommt aus der Regel.",
|
||||
"Die Dauer wird aus Von und Bis neu berechnet und wieder auf Minuten gerundet.",
|
||||
"Beim Löschen wird geprüft, ob dadurch ein Ticket für dich oder komplett leer wird. Leere Tickets werden entfernt.",
|
||||
"Wenn die letzte Session eines Tickets gelöscht wird, springt die Ansicht zurück zur Auswertung.",
|
||||
"Wenn die letzte Session eines Tickets gelöscht wird, springt die Ansicht zurück zur Ticketliste.",
|
||||
"Wiederkehrend erzeugte Sessions werden beim Löschen als Ausnahme gespeichert. Dadurch erscheinen sie beim nächsten Öffnen des Monats nicht wieder automatisch.",
|
||||
"Wenn eine wiederkehrende Leistung nur an einem anderen Tag erledigt wurde, wird die erzeugte Session bearbeitet beziehungsweise verschoben."
|
||||
]
|
||||
@@ -184,7 +345,7 @@ export const helpSections: HelpSection[] = [
|
||||
"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.",
|
||||
@@ -217,38 +378,39 @@ export const helpSections: HelpSection[] = [
|
||||
{
|
||||
id: "statistiken",
|
||||
title: "Statistiken",
|
||||
description: "Statistiken vergleichen getrackte Arbeit, bewertete Sessions und Teamspace-Werte.",
|
||||
description: "Statistiken vergleichen abgerechnete Session-Zeit, offene Bewertungen und Teamspace-Werte.",
|
||||
quickItems: [
|
||||
"Sessions und Teamspace haben getrennte Farben.",
|
||||
"Abgerechnet und Teamspace haben getrennte Farben.",
|
||||
"Positive oder gleiche Differenz ist grün.",
|
||||
"Negative Differenz ist rot."
|
||||
],
|
||||
details: [
|
||||
"Die Statistikseite betrachtet immer einen Monat.",
|
||||
"Getrackt zeigt die Summe aller Session-Zeiten und die Anzahl der Sessions und Tickets.",
|
||||
"Teamspace zeigt, wie viel Zeit für dieselben Ticket-Tage im CRM eingetragen wurde.",
|
||||
"Eine positive Differenz bedeutet: In Teamspace steht mehr Zeit als in den Sessions getrackt wurde.",
|
||||
"Eine negative Differenz bedeutet: In Teamspace steht weniger Zeit als getrackt wurde.",
|
||||
"Bewertet umfasst sowohl Abgerechnet als auch Nicht abrechenbar. Offen sind nur Sessions ohne Bewertung.",
|
||||
"Der Monatsverlauf zeigt getrackte Session-Zeit als Balken und Teamspace als Linie.",
|
||||
"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.",
|
||||
"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."
|
||||
]
|
||||
},
|
||||
{
|
||||
id: "statistik-getrackt",
|
||||
title: "Statistik: Getrackt",
|
||||
description: "Diese Kennzahl zeigt die gesamte erfasste Session-Zeit im gewählten Monat.",
|
||||
title: "Statistik: Abgerechnet",
|
||||
description: "Diese Kennzahl zeigt die abgerechnete Session-Zeit im gewählten Monat.",
|
||||
quickItems: [
|
||||
"Summe aller getrackten und nachgetragenen Sessions.",
|
||||
"Zeigt zusätzlich Session- und Ticketanzahl.",
|
||||
"Summe aller Sessions mit Bewertung Abgerechnet.",
|
||||
"Zeigt zusätzlich die abgerechnete Sessionanzahl.",
|
||||
"Basiswert für den Vergleich mit Teamspace."
|
||||
],
|
||||
details: [
|
||||
"Getrackt umfasst alle Sessions des angemeldeten Benutzers im gewählten Monat.",
|
||||
"Dabei ist egal, ob die Session bereits bewertet wurde oder noch offen ist.",
|
||||
"Auch automatisch erzeugte Sessions aus fixen Abrechnungen zählen hier mit.",
|
||||
"Die Ticketanzahl zeigt, auf wie viele unterschiedliche Tickets sich die Sessions verteilen.",
|
||||
"Dieser Wert ist die Ausgangsbasis für Teamspace-Differenzen: Teamspace minus getrackte Session-Zeit ergibt die Differenz."
|
||||
"Abgerechnet umfasst nur Sessions des angemeldeten Benutzers, die aktiv als Abgerechnet markiert wurden.",
|
||||
"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."
|
||||
]
|
||||
},
|
||||
{
|
||||
@@ -263,41 +425,25 @@ export const helpSections: HelpSection[] = [
|
||||
details: [
|
||||
"Teamspace-Werte werden pro Ticket und Tag gepflegt, nicht pro einzelner Session.",
|
||||
"Die Statistik summiert alle Teamspace-Werte des Monats für den angemeldeten Benutzer.",
|
||||
"Eine positive Differenz bedeutet, dass in Teamspace mehr Zeit steht als in den Sessions getrackt wurde.",
|
||||
"Eine negative Differenz bedeutet, dass in Teamspace weniger Zeit steht als getrackt wurde.",
|
||||
"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.",
|
||||
"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."
|
||||
]
|
||||
},
|
||||
{
|
||||
id: "statistik-bewertet",
|
||||
title: "Statistik: Bewertet",
|
||||
description: "Diese Kennzahl zeigt, wie viel der erfassten Arbeit bereits fachlich eingeordnet wurde.",
|
||||
quickItems: [
|
||||
"Abgerechnet und Nicht abrechenbar zählen beide als bewertet.",
|
||||
"Der Prozentwert bezieht sich auf die Sessionanzahl.",
|
||||
"Bewertete Sessions blockieren den Monatsabschluss nicht."
|
||||
],
|
||||
details: [
|
||||
"Bewertet ist die Summe aus Abgerechnet und Nicht abrechenbar.",
|
||||
"Nicht abrechenbare Sessions fallen also nicht aus der Qualität heraus, sondern gelten als bewusst entschieden.",
|
||||
"Die Zeit zeigt die Summe der bewerteten Session-Minuten.",
|
||||
"Die Prozentwerte werden zusammen mit den offenen Sessions gerundet, sodass bewertet und offen zusammen 100 Prozent ergeben.",
|
||||
"Wenn eine Bewertung zurückgenommen wird, wandert die Session wieder in Offen."
|
||||
]
|
||||
},
|
||||
{
|
||||
id: "statistik-offen",
|
||||
title: "Statistik: Offen",
|
||||
description: "Diese Kennzahl zeigt Sessions, die noch keine Bewertung haben.",
|
||||
quickItems: [
|
||||
"Offen bedeutet: weder Abgerechnet noch Nicht abrechenbar.",
|
||||
"Offen bedeutet: noch keine Bewertung.",
|
||||
"Offene Sessions blockieren den Monatsabschluss.",
|
||||
"Die Klärung erfolgt in der Ticketdetailansicht."
|
||||
],
|
||||
details: [
|
||||
"Offen umfasst alle Sessions des Monats ohne Bewertung.",
|
||||
"Diese Sessions müssen vor dem Monatsabschluss entweder als Abgerechnet oder Nicht abrechenbar markiert 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."
|
||||
]
|
||||
@@ -305,19 +451,19 @@ export const helpSections: HelpSection[] = [
|
||||
{
|
||||
id: "statistik-verlauf",
|
||||
title: "Statistik: Monatsverlauf und Qualität",
|
||||
description: "Diese Card verbindet Tagesverlauf, Teamspace-Linie, Bewertungsstand und Qualitätswerte.",
|
||||
description: "Diese Card verbindet Tagesverlauf, Teamspace-Linie und reine Statuswerte.",
|
||||
quickItems: [
|
||||
"Balken zeigen getrackte Sessions.",
|
||||
"Balken zeigen abgerechnete Sessions.",
|
||||
"Die Linie zeigt Teamspace-Werte.",
|
||||
"Der rechte Bereich zeigt Bewertung und Teamspace-Abgleich."
|
||||
],
|
||||
details: [
|
||||
"Der Monatsverlauf zeigt jeden Tag des Monats. Tage ohne Sessions bleiben leer, damit die Monatsstruktur sichtbar bleibt.",
|
||||
"Die blauen Balken stehen für getrackte Session-Zeit.",
|
||||
"Die grüne Linie steht für Teamspace-Zeit, sofern für den Tag Werte gepflegt wurden.",
|
||||
"Der Bewertungsstand vergleicht bewertete und offene Sessions.",
|
||||
"Der Teamspace-Abgleich zeigt die Gesamtdifferenz zwischen CRM-Werten und getrackter Session-Zeit.",
|
||||
"Manuell und Fix trennen selbst erfasste Sessions von automatisch erzeugten Sessions aus fixen Abrechnungen."
|
||||
"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, 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."
|
||||
]
|
||||
},
|
||||
{
|
||||
@@ -332,9 +478,9 @@ export const helpSections: HelpSection[] = [
|
||||
details: [
|
||||
"Die Organisationsstatistik fasst alle Tickets und Sessions einer Organisation im gewählten Monat zusammen.",
|
||||
"Sessions zeigt die Anzahl der einzelnen Arbeitseinträge.",
|
||||
"Zeiten vergleicht getrackte Session-Zeit mit Teamspace-Zeit.",
|
||||
"Die Differenz wird als Teamspace minus Sessions berechnet.",
|
||||
"Der Anteil am Monat zeigt, wie groß der getrackte Aufwand dieser Organisation im Verhältnis zur größten Organisation im Monat ist.",
|
||||
"Zeiten vergleicht abgerechnete Session-Zeit mit Teamspace-Zeit.",
|
||||
"Die Differenz wird als Teamspace minus abgerechnete Zeit berechnet.",
|
||||
"Der Anteil am Monat zeigt, wie groß die abgerechnete Zeit dieser Organisation im Verhältnis zur größten Organisation im Monat ist.",
|
||||
"Bei vielen Organisationen wird die Liste paginiert, damit die Seite kompakt bleibt."
|
||||
]
|
||||
},
|
||||
@@ -350,9 +496,9 @@ export const helpSections: HelpSection[] = [
|
||||
details: [
|
||||
"Die Typenstatistik gruppiert Sessions nach Art.",
|
||||
"Support und Consulting werden getrennt summiert.",
|
||||
"Die Zeitwerte zeigen getrackte Sessions und Teamspace für den jeweiligen Typ.",
|
||||
"Die Differenz folgt derselben Logik wie überall: Teamspace minus getrackte Sessions.",
|
||||
"Die Zeile Abr., Nicht und Offen zeigt, wie viele Sessions dieses Typs bereits bewertet oder noch offen sind."
|
||||
"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., Abrechenbar, Nicht abrechenbar und Offen zeigt, wie viele Sessions dieses Typs bereits abgerechnet, als abrechenbar markiert, bewusst nicht abrechenbar oder noch offen sind."
|
||||
]
|
||||
},
|
||||
{
|
||||
@@ -366,8 +512,8 @@ export const helpSections: HelpSection[] = [
|
||||
],
|
||||
details: [
|
||||
"Ticket-Hotspots helfen, die größten Aufwandstreiber im Monat schnell zu erkennen.",
|
||||
"Die Liste zeigt die wichtigsten Tickets nach getrackter Zeit.",
|
||||
"Pro Ticket siehst du Organisation, Sessionanzahl, aktive Tage, getrackte Zeit, Teamspace-Zeit und Differenz.",
|
||||
"Die Liste zeigt die wichtigsten Tickets nach abgerechneter Zeit.",
|
||||
"Pro Ticket siehst du Organisation, Sessionanzahl, aktive abrechenbare Tage, abgerechnete Zeit, Teamspace-Zeit und Differenz.",
|
||||
"Offene Tickets werden mit einem Warnstatus angezeigt, damit Bewertungen nachgezogen werden können.",
|
||||
"Über Öffnen gelangst du direkt in die Ticketdetailansicht des Monats."
|
||||
]
|
||||
@@ -379,6 +525,7 @@ export const helpSections: HelpSection[] = [
|
||||
quickItems: [
|
||||
"Benutzername und voller Name sind änderbar.",
|
||||
"Passwortänderung benötigt das aktuelle Passwort.",
|
||||
"Normale Benutzer können ihren persönlichen Zammad API-Token hinterlegen.",
|
||||
"Der volle Name wird in der Oberfläche angezeigt."
|
||||
],
|
||||
details: [
|
||||
@@ -386,26 +533,44 @@ export const helpSections: HelpSection[] = [
|
||||
"Der volle Name erscheint in der App, zum Beispiel im Kopfbereich oder im Adminbereich.",
|
||||
"Wenn du dein Passwort ändern willst, musst du das aktuelle Passwort sowie das neue Passwort zweimal eingeben.",
|
||||
"Bleiben die Passwortfelder leer, werden nur Benutzername und voller Name gespeichert.",
|
||||
"Normale Benutzer können im Profil einen persönlichen Zammad API-Token speichern.",
|
||||
"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. 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."
|
||||
]
|
||||
},
|
||||
{
|
||||
id: "admin",
|
||||
title: "Adminbereich",
|
||||
description: "Der Adminbereich verwaltet Benutzer, Session-Zuordnung, manuelle Admin-Nachträge und Zammad-Sync.",
|
||||
description: "Der feste Adminbereich verwaltet Benutzer, Zammad-Sync und restore-fähige Datenbankexporte.",
|
||||
quickItems: [
|
||||
"Admins legen Benutzer an und bearbeiten Rollen.",
|
||||
"Sessions können anderen Benutzern zugewiesen werden.",
|
||||
"Zammad-Organisationen werden über Data-Sync aktualisiert."
|
||||
"Neue Benutzer erhalten automatisch die Rolle User.",
|
||||
"Der Admin erfasst keine Sessions und sieht keine Auswertungen.",
|
||||
"SQL-Backups sind für einen kompletten Restore gedacht."
|
||||
],
|
||||
details: [
|
||||
"Neue Benutzer bekommen Benutzername, vollen Namen, Passwort, Rolle und Aktivstatus.",
|
||||
"Neue Benutzer bekommen Benutzername, vollen Namen, Passwort und Aktivstatus. Eine Rollenauswahl gibt es nicht.",
|
||||
"Admins können bestehende Benutzer bearbeiten, Passwörter setzen und Benutzer deaktivieren.",
|
||||
"Der eigene Admin-Zugang kann nicht versehentlich deaktiviert oder zur normalen User-Rolle herabgestuft werden.",
|
||||
"In der Session-Zuordnung sehen Admins die letzten Sessions inklusive Besitzer und können einzelne Sessions umverteilen.",
|
||||
"Beim Umverteilen werden die betroffenen Monatsabschlüsse für alten und neuen Besitzer wieder geöffnet, damit Bewertungen und Auswertungen konsistent bleiben.",
|
||||
"Admins können Sessions für andere Benutzer nachtragen, wenn Zeiten vergessen wurden.",
|
||||
"Der Data-Sync speichert Zammad-Zugangsdaten und synchronisiert Organisationen in die lokale Datenbank."
|
||||
"Der feste Admin-Zugang kann nicht deaktiviert oder zur normalen User-Rolle herabgestuft werden.",
|
||||
"Der Data-Sync speichert Zammad-Zugangsdaten und synchronisiert Organisationen in die lokale Datenbank.",
|
||||
"Der 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.",
|
||||
"Beim Einspielen leert TicketTracker zuerst alle Tabellen und spielt danach den Backup-Stand ein.",
|
||||
"Nach einem Restore ist eine neue Anmeldung mit den Zugangsdaten aus dem Backup notwendig.",
|
||||
"Für einen Restore lässt du auf dem Zielsystem zuerst die TicketTracker-Migrationen laufen und spielst danach die SQL-Datei mit psql ein."
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import type { ProjectSummary, RecurringBilling, TicketMeta, TicketPeriod } from "./types";
|
||||
|
||||
type RecurringAwareTicket = (TicketMeta | TicketPeriod["ticket"]) & {
|
||||
recurring_session_count?: number;
|
||||
};
|
||||
|
||||
export function isGeneratedRecurringTicketNumber(ticketNumber: string | null | undefined) {
|
||||
return Boolean(ticketNumber?.startsWith("Fix#"));
|
||||
}
|
||||
|
||||
export function recurringBillingTicketNumbers(recurringBillings: RecurringBilling[]) {
|
||||
const numbers = new Set<string>();
|
||||
|
||||
for (const billing of recurringBillings) {
|
||||
numbers.add(billing.ticket_number);
|
||||
|
||||
if (billing.configured_ticket_number) {
|
||||
numbers.add(billing.configured_ticket_number);
|
||||
}
|
||||
}
|
||||
|
||||
return numbers;
|
||||
}
|
||||
|
||||
export function isRecurringBillingTicketNumber(ticketNumber: string | null | undefined, recurringBillings: RecurringBilling[] = []) {
|
||||
if (!ticketNumber) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return isGeneratedRecurringTicketNumber(ticketNumber) || recurringBillingTicketNumbers(recurringBillings).has(ticketNumber);
|
||||
}
|
||||
|
||||
export function ticketHasRecurringBilling(ticket: RecurringAwareTicket | null | undefined, recurringBillings: RecurringBilling[] = []) {
|
||||
return Boolean(ticket?.has_recurring_billing || Number(ticket?.recurring_session_count ?? 0) > 0 || isRecurringBillingTicketNumber(ticket?.ticket_number, recurringBillings));
|
||||
}
|
||||
|
||||
export function projectHasRecurringBilling(project: ProjectSummary) {
|
||||
return Boolean(project.has_recurring_billing || Number(project.recurring_session_count ?? 0) > 0 || isGeneratedRecurringTicketNumber(project.ticket_number));
|
||||
}
|
||||
+102
-6
@@ -1,3 +1,5 @@
|
||||
import type { BillingStatus } from "./types";
|
||||
|
||||
export type TimerPhase = "running" | "paused";
|
||||
|
||||
export type TimerEntry = {
|
||||
@@ -5,7 +7,12 @@ export type TimerEntry = {
|
||||
ticketNumber: string;
|
||||
organizationName?: string | null;
|
||||
workType?: "support" | "consulting" | null;
|
||||
isProject?: boolean;
|
||||
projectName?: string | null;
|
||||
ticketLookupDone?: boolean;
|
||||
activity: string;
|
||||
zammadArticleBody?: string;
|
||||
billingStatus: BillingStatus;
|
||||
startedAt: number;
|
||||
pausedTotalMs: number;
|
||||
pausedAt: number | null;
|
||||
@@ -14,9 +21,18 @@ export type TimerEntry = {
|
||||
|
||||
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;
|
||||
@@ -49,23 +65,30 @@ 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,
|
||||
};
|
||||
|
||||
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);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -73,6 +96,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 [];
|
||||
@@ -96,13 +190,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 {
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
import type { TicketTodo } from "./types";
|
||||
|
||||
function parseDateOnly(value: string) {
|
||||
const [year, month, day] = value.split("-").map(Number);
|
||||
return new Date(year, month - 1, day);
|
||||
}
|
||||
|
||||
export function formatDateOnly(date: Date) {
|
||||
const year = date.getFullYear();
|
||||
const month = String(date.getMonth() + 1).padStart(2, "0");
|
||||
const day = String(date.getDate()).padStart(2, "0");
|
||||
|
||||
return `${year}-${month}-${day}`;
|
||||
}
|
||||
|
||||
export function formatTodoDate(value: string) {
|
||||
return new Intl.DateTimeFormat("de-DE", {
|
||||
day: "2-digit",
|
||||
month: "2-digit",
|
||||
year: "numeric"
|
||||
}).format(parseDateOnly(value));
|
||||
}
|
||||
|
||||
export function formatTodoPeriod(todo: Pick<TicketTodo, "starts_on" | "ends_on">) {
|
||||
if (!todo.starts_on && !todo.ends_on) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (todo.starts_on && todo.ends_on && todo.starts_on === todo.ends_on) {
|
||||
return `am ${formatTodoDate(todo.starts_on)}`;
|
||||
}
|
||||
|
||||
if (todo.starts_on && todo.ends_on) {
|
||||
return `${formatTodoDate(todo.starts_on)} - ${formatTodoDate(todo.ends_on)}`;
|
||||
}
|
||||
|
||||
if (todo.starts_on) {
|
||||
return `ab ${formatTodoDate(todo.starts_on)}`;
|
||||
}
|
||||
|
||||
return `bis ${formatTodoDate(todo.ends_on!)}`;
|
||||
}
|
||||
|
||||
export function isTodoActiveOnDay(todo: Pick<TicketTodo, "starts_on" | "ends_on" | "status">, day: string) {
|
||||
if (todo.status !== "open") {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!todo.starts_on && !todo.ends_on) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return (!todo.starts_on || todo.starts_on <= day) && (!todo.ends_on || todo.ends_on >= day);
|
||||
}
|
||||
|
||||
export function isTodoFixedOnDay(todo: Pick<TicketTodo, "starts_on" | "ends_on" | "status">, day: string) {
|
||||
return todo.status === "open" && todo.starts_on === day && todo.ends_on === day;
|
||||
}
|
||||
|
||||
export function isTodoOverdue(todo: Pick<TicketTodo, "ends_on" | "status">, today: string) {
|
||||
return todo.status === "open" && Boolean(todo.ends_on) && todo.ends_on! < today;
|
||||
}
|
||||
+317
-19
@@ -1,6 +1,7 @@
|
||||
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";
|
||||
|
||||
export type AuthUser = {
|
||||
@@ -9,6 +10,66 @@ export type AuthUser = {
|
||||
display_name: string;
|
||||
role: UserRole;
|
||||
active: boolean;
|
||||
zammad_user_id: string | null;
|
||||
has_zammad_api_token: boolean;
|
||||
teamspace_user_id: string | null;
|
||||
has_teamspace_credentials: boolean;
|
||||
zammad_email_session_webhook_enabled: boolean;
|
||||
session_billing_prompt_enabled: boolean;
|
||||
teamspace_time_tracking_enabled: boolean;
|
||||
zammad_session_note_default_enabled: boolean;
|
||||
zammad_time_unit_rounding_enabled: boolean;
|
||||
zammad_time_unit_rounding_minutes: number;
|
||||
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 = "dayControl" | "today" | "month" | "openRatings" | "projectBudgets" | "todos" | "teamspace" | "favorites" | "divider" | "statistic" | "notes";
|
||||
export type DashboardSortDirection = "asc" | "desc";
|
||||
|
||||
export type DashboardFavorite = {
|
||||
ticketNumber: string;
|
||||
addedAt: string;
|
||||
sortOrder: number;
|
||||
};
|
||||
|
||||
export type DashboardWidget = {
|
||||
id: string;
|
||||
type: DashboardWidgetType;
|
||||
title?: string;
|
||||
x: number;
|
||||
y: number;
|
||||
w: number;
|
||||
h: number;
|
||||
config?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
export type DashboardPreference = {
|
||||
version: number;
|
||||
widgets: DashboardWidget[];
|
||||
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 & {
|
||||
@@ -16,27 +77,21 @@ export type AdminUser = AuthUser & {
|
||||
updated_at: string;
|
||||
};
|
||||
|
||||
export type AdminSession = {
|
||||
export type ZammadSessionArticleQueueItem = {
|
||||
id: string;
|
||||
session_id: string;
|
||||
ticket_id: string;
|
||||
user_id: string;
|
||||
owner_username: string;
|
||||
owner_display_name: string;
|
||||
ticket_number: string;
|
||||
organization_id: string | null;
|
||||
organization_name: string | null;
|
||||
customer_name: string;
|
||||
zammad_ticket_id: string | null;
|
||||
activity: string;
|
||||
work_type: WorkType;
|
||||
started_at: string;
|
||||
ended_at: string;
|
||||
duration_seconds: number;
|
||||
rounded_minutes: number;
|
||||
billing_status: BillingStatus;
|
||||
time_unit: number;
|
||||
zammad_article_type_id: number;
|
||||
attempt_count: number;
|
||||
last_error: string | null;
|
||||
last_attempt_at: string | null;
|
||||
created_at: string;
|
||||
recurring_billing_id: string | null;
|
||||
recurring_billing_slot_id: string | null;
|
||||
recurring_occurrence_date: string | null;
|
||||
started_at: string;
|
||||
rounded_minutes: number;
|
||||
};
|
||||
|
||||
export type Organization = {
|
||||
@@ -78,23 +133,177 @@ export type RecurringBilling = {
|
||||
export type TicketMeta = {
|
||||
id: string;
|
||||
ticket_number: string;
|
||||
title: string | null;
|
||||
organization_id: string | null;
|
||||
organization_name: string | null;
|
||||
customer_name: string | null;
|
||||
work_type: WorkType | 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;
|
||||
project_hour_budget_minutes?: number | null;
|
||||
project_teamspace_entry_count?: number;
|
||||
project_teamspace_entries?: ProjectTeamspaceEntry[];
|
||||
project_owner_user_id?: string | null;
|
||||
project_owner_display_name?: string | null;
|
||||
can_edit_project?: boolean;
|
||||
has_recurring_billing?: boolean;
|
||||
};
|
||||
|
||||
export type TicketSearchResult = {
|
||||
ticket_id: string;
|
||||
ticket_number: string;
|
||||
title: string | null;
|
||||
organization_id: string | null;
|
||||
organization_name: string | null;
|
||||
customer_name: string | null;
|
||||
work_type: WorkType | null;
|
||||
project_id: string | null;
|
||||
is_project: boolean;
|
||||
project_name: string | null;
|
||||
latest_started_at: string | null;
|
||||
latest_period: string;
|
||||
session_count: number;
|
||||
total_minutes: number;
|
||||
};
|
||||
|
||||
export type ProjectSummary = {
|
||||
id: string;
|
||||
user_id?: string;
|
||||
owner_username?: string | null;
|
||||
owner_display_name?: string | null;
|
||||
can_edit?: boolean;
|
||||
ticket_id: string;
|
||||
ticket_number: string;
|
||||
title: string | null;
|
||||
organization_id: string | null;
|
||||
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;
|
||||
manual_consumption_minutes: number;
|
||||
teamspace_consumption_minutes?: number;
|
||||
consumption_minutes?: number;
|
||||
closed_at: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
session_count?: number;
|
||||
recurring_session_count?: number;
|
||||
manual_session_count?: number;
|
||||
has_recurring_billing?: boolean;
|
||||
billed_minutes?: number;
|
||||
open_todos?: number;
|
||||
overdue_todos?: number;
|
||||
done_todos?: number;
|
||||
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;
|
||||
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";
|
||||
|
||||
export type TicketTodo = {
|
||||
id: string;
|
||||
ticket_id: string;
|
||||
ticket_number?: string;
|
||||
organization_name?: string | null;
|
||||
project_id?: string | null;
|
||||
teamspace_project?: string | null;
|
||||
title: string;
|
||||
description: string;
|
||||
priority: TodoPriority;
|
||||
status: TodoStatus;
|
||||
sort_order: number;
|
||||
starts_on: string | null;
|
||||
ends_on: string | null;
|
||||
completed_at: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
};
|
||||
|
||||
export type TicketSummary = {
|
||||
id: string;
|
||||
ticket_number: string;
|
||||
title: string | null;
|
||||
organization_id: string | null;
|
||||
organization_name: string | null;
|
||||
customer_name: string | null;
|
||||
work_type: WorkType | null;
|
||||
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;
|
||||
@@ -113,8 +322,11 @@ export type OpenSession = {
|
||||
customer_name: string;
|
||||
activity: string;
|
||||
work_type: WorkType;
|
||||
project_teamspace_entry_id?: string | null;
|
||||
project_teamspace_project?: string | null;
|
||||
started_at: string;
|
||||
rounded_minutes: number;
|
||||
recurring_billing_id?: string | null;
|
||||
};
|
||||
|
||||
export type ActivityBucket = {
|
||||
@@ -134,6 +346,9 @@ export type PeriodOverview = {
|
||||
sessions: number;
|
||||
minutes: number;
|
||||
crmBilledMinutes: number;
|
||||
billableMinutes: number;
|
||||
nonBillableMinutes: number;
|
||||
openMinutes: number;
|
||||
openSessions: number;
|
||||
};
|
||||
tickets: TicketSummary[];
|
||||
@@ -143,16 +358,25 @@ export type PeriodOverview = {
|
||||
|
||||
export type TicketDayBilling = {
|
||||
day: string;
|
||||
project_teamspace_entry_id?: string | null;
|
||||
billed_minutes: number;
|
||||
};
|
||||
|
||||
export type TicketAvailableMonth = {
|
||||
month: string;
|
||||
session_count: number;
|
||||
};
|
||||
|
||||
export type SessionEntry = {
|
||||
id: string;
|
||||
organization_id: string | null;
|
||||
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;
|
||||
started_at: string;
|
||||
ended_at: string;
|
||||
duration_seconds: number;
|
||||
@@ -163,27 +387,51 @@ export type SessionEntry = {
|
||||
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 = {
|
||||
periodType: PeriodType;
|
||||
periodType: TicketDetailPeriodType;
|
||||
period: string;
|
||||
closed: boolean;
|
||||
closedAt: string | null;
|
||||
filters?: {
|
||||
month: string;
|
||||
status: "all" | "open" | "billable" | "billed" | "non_billable";
|
||||
source: "all" | "manual" | "recurring";
|
||||
};
|
||||
availableMonths?: TicketAvailableMonth[];
|
||||
ticket: {
|
||||
id: string;
|
||||
ticket_number: string;
|
||||
title: string | null;
|
||||
organization_id: string | null;
|
||||
organization_name: string | null;
|
||||
customer_name: string | null;
|
||||
work_type: WorkType | 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;
|
||||
manual_session_count: number;
|
||||
has_recurring_billing?: boolean;
|
||||
};
|
||||
sessions: SessionEntry[];
|
||||
dayBillings: TicketDayBilling[];
|
||||
project?: ProjectSummary | null;
|
||||
todos?: TicketTodo[];
|
||||
canClose: boolean;
|
||||
openCount: number;
|
||||
ticketOpenCount: number;
|
||||
@@ -197,9 +445,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;
|
||||
@@ -213,9 +463,11 @@ 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;
|
||||
@@ -231,9 +483,11 @@ 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;
|
||||
@@ -262,8 +516,49 @@ export type StatisticsCrmDay = {
|
||||
acknowledged_at?: string | null;
|
||||
};
|
||||
|
||||
export type StatisticsTeamspaceDay = {
|
||||
ticket_id: string;
|
||||
ticket_number: string;
|
||||
organization_name: string;
|
||||
day: string;
|
||||
tracked_minutes: number;
|
||||
crm_billed_minutes: number;
|
||||
delta_minutes: number;
|
||||
session_count: number;
|
||||
project_teamspace_project: string | null;
|
||||
};
|
||||
|
||||
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;
|
||||
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;
|
||||
@@ -272,9 +567,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;
|
||||
@@ -296,4 +593,5 @@ export type StatisticsOverview = {
|
||||
acknowledgedMissingCrmDays: StatisticsCrmDay[];
|
||||
crmMismatches: StatisticsCrmDay[];
|
||||
};
|
||||
teamspaceDays: StatisticsTeamspaceDay[];
|
||||
};
|
||||
@@ -1,115 +1,89 @@
|
||||
import { FormEvent, useEffect, useState } from "react";
|
||||
import { CalendarPlus, DatabaseZap, Save, Shield, Shuffle, UserPlus } from "lucide-react";
|
||||
import { ChangeEvent, FormEvent, useEffect, useState } from "react";
|
||||
import { CheckCircle2, DatabaseBackup, DatabaseZap, Save, Shield, Unplug, Upload, UserPlus } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
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 { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { HelpLink } from "@/components/HelpLink";
|
||||
import { OrganizationSelect } from "@/components/OrganizationSelect";
|
||||
import {
|
||||
createAdminSession,
|
||||
createAdminUser,
|
||||
getAdminSessions,
|
||||
downloadDatabaseExport,
|
||||
getAdminUsers,
|
||||
getTeamspaceSettings,
|
||||
getZammadSettings,
|
||||
reassignAdminSession,
|
||||
restoreDatabaseBackup,
|
||||
saveZammadSettings,
|
||||
saveTeamspaceSettings,
|
||||
syncZammadOrganizations,
|
||||
updateAdminUser
|
||||
updateAdminUser,
|
||||
validateDatabaseBackup,
|
||||
type RestoreValidationResult
|
||||
} from "../api";
|
||||
import { currentDay, formatDateTime, formatMinutes } from "../format";
|
||||
import type { AdminSession, AdminUser, AuthUser, UserRole, WorkType } from "../types";
|
||||
import type { AdminUser, AuthUser } from "../types";
|
||||
|
||||
type UserFormState = {
|
||||
username: string;
|
||||
displayName: string;
|
||||
password: string;
|
||||
role: UserRole;
|
||||
active: boolean;
|
||||
};
|
||||
|
||||
type ManualSessionFormState = {
|
||||
userId: string;
|
||||
ticketNumber: string;
|
||||
organizationId: string;
|
||||
organizationName: string | null;
|
||||
activity: string;
|
||||
workType: WorkType;
|
||||
day: string;
|
||||
startTime: string;
|
||||
endTime: string;
|
||||
};
|
||||
|
||||
const emptyForm: UserFormState = {
|
||||
username: "",
|
||||
displayName: "",
|
||||
password: "",
|
||||
role: "user",
|
||||
active: true
|
||||
};
|
||||
|
||||
function emptyManualSession(): ManualSessionFormState {
|
||||
return {
|
||||
userId: "",
|
||||
ticketNumber: "",
|
||||
organizationId: "",
|
||||
organizationName: null,
|
||||
activity: "",
|
||||
workType: "support",
|
||||
day: currentDay(),
|
||||
startTime: "09:00",
|
||||
endTime: "09:30"
|
||||
};
|
||||
}
|
||||
|
||||
function formFromUser(user: AdminUser): UserFormState {
|
||||
return {
|
||||
username: user.username,
|
||||
displayName: user.display_name,
|
||||
password: "",
|
||||
role: user.role,
|
||||
active: user.active
|
||||
};
|
||||
}
|
||||
|
||||
export function AdminUsersPage({ currentUser }: { currentUser: AuthUser }) {
|
||||
const [users, setUsers] = useState<AdminUser[]>([]);
|
||||
const [sessions, setSessions] = useState<AdminSession[]>([]);
|
||||
const [forms, setForms] = useState<Record<string, UserFormState>>({});
|
||||
const [sessionOwners, setSessionOwners] = useState<Record<string, string>>({});
|
||||
const [newUser, setNewUser] = useState<UserFormState>(emptyForm);
|
||||
const [manualSession, setManualSession] = useState<ManualSessionFormState>(() => emptyManualSession());
|
||||
const [zammadBaseUrl, setZammadBaseUrl] = useState("");
|
||||
const [zammadApiKey, setZammadApiKey] = useState("");
|
||||
const [hasZammadApiKey, setHasZammadApiKey] = useState(false);
|
||||
const [teamspaceBaseUrl, setTeamspaceBaseUrl] = useState("");
|
||||
const [syncingOrganizations, setSyncingOrganizations] = useState(false);
|
||||
const [savingZammadSettings, setSavingZammadSettings] = useState(false);
|
||||
const [savingTeamspaceSettings, setSavingTeamspaceSettings] = useState(false);
|
||||
const [exportingDatabase, setExportingDatabase] = useState(false);
|
||||
const [restoreSql, setRestoreSql] = useState("");
|
||||
const [restoreValidation, setRestoreValidation] = useState<RestoreValidationResult | null>(null);
|
||||
const [validatingRestore, setValidatingRestore] = useState(false);
|
||||
const [restoringDatabase, setRestoringDatabase] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [savingId, setSavingId] = useState<string | null>(null);
|
||||
const [movingId, setMovingId] = useState<string | null>(null);
|
||||
const [creating, setCreating] = useState(false);
|
||||
const [creatingSession, setCreatingSession] = useState(false);
|
||||
|
||||
async function load(options: { preserveEdits?: boolean } = {}) {
|
||||
setLoading(true);
|
||||
try {
|
||||
const [usersResult, sessionsResult, zammadSettingsResult] = await Promise.all([getAdminUsers(), getAdminSessions(), getZammadSettings()]);
|
||||
const [usersResult, zammadSettingsResult, teamspaceSettingsResult] = await Promise.all([getAdminUsers(), getZammadSettings(), getTeamspaceSettings()]);
|
||||
setUsers(usersResult.users);
|
||||
setSessions(sessionsResult.sessions);
|
||||
|
||||
if (!options.preserveEdits) {
|
||||
setZammadBaseUrl(zammadSettingsResult.settings.baseUrl);
|
||||
setHasZammadApiKey(zammadSettingsResult.settings.hasApiKey);
|
||||
setTeamspaceBaseUrl(teamspaceSettingsResult.settings.baseUrl);
|
||||
}
|
||||
|
||||
setForms((current) =>
|
||||
Object.fromEntries(usersResult.users.map((user) => [user.id, options.preserveEdits ? current[user.id] ?? formFromUser(user) : formFromUser(user)]))
|
||||
);
|
||||
setSessionOwners((current) =>
|
||||
Object.fromEntries(sessionsResult.sessions.map((session) => [session.id, options.preserveEdits ? current[session.id] ?? session.user_id : session.user_id]))
|
||||
);
|
||||
} catch (error) {
|
||||
toast.error("Adminbereich konnte nicht geladen werden", {
|
||||
description: error instanceof Error ? error.message : "Unbekannter Fehler"
|
||||
@@ -157,7 +131,9 @@ export function AdminUsersPage({ currentUser }: { currentUser: AuthUser }) {
|
||||
|
||||
try {
|
||||
await createAdminUser(newUser);
|
||||
toast.success("Benutzer angelegt");
|
||||
toast.success("Benutzer angelegt", {
|
||||
description: "Neue Accounts erhalten automatisch die Rolle User."
|
||||
});
|
||||
setNewUser(emptyForm);
|
||||
await load();
|
||||
} catch (error) {
|
||||
@@ -181,7 +157,6 @@ export function AdminUsersPage({ currentUser }: { currentUser: AuthUser }) {
|
||||
await updateAdminUser(user.id, {
|
||||
username: form.username,
|
||||
displayName: form.displayName,
|
||||
role: form.role,
|
||||
active: form.active,
|
||||
password: form.password || undefined
|
||||
});
|
||||
@@ -196,91 +171,6 @@ export function AdminUsersPage({ currentUser }: { currentUser: AuthUser }) {
|
||||
}
|
||||
}
|
||||
|
||||
async function moveSession(session: AdminSession) {
|
||||
const nextUserId = sessionOwners[session.id] ?? session.user_id;
|
||||
|
||||
if (nextUserId === session.user_id) {
|
||||
toast.info("Keine Änderung", {
|
||||
description: "Diese Session gehört bereits diesem Benutzer."
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
setMovingId(session.id);
|
||||
try {
|
||||
await reassignAdminSession(session.id, nextUserId);
|
||||
toast.success("Session umverteilt", {
|
||||
description: "Betroffene Tages- und Monatsabschlüsse wurden wieder geöffnet."
|
||||
});
|
||||
await load();
|
||||
} catch (error) {
|
||||
toast.error("Session konnte nicht umverteilt werden", {
|
||||
description: error instanceof Error ? error.message : "Unbekannter Fehler"
|
||||
});
|
||||
} finally {
|
||||
setMovingId(null);
|
||||
}
|
||||
}
|
||||
|
||||
const activeUsers = users.filter((user) => user.active);
|
||||
const manualSessionUserId = manualSession.userId || activeUsers[0]?.id || "";
|
||||
|
||||
async function createManualSession(event: FormEvent) {
|
||||
event.preventDefault();
|
||||
|
||||
if (!manualSessionUserId) {
|
||||
toast.error("Kein aktiver Benutzer vorhanden");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!manualSession.organizationId) {
|
||||
toast.error("Organisation wählen");
|
||||
return;
|
||||
}
|
||||
|
||||
const startedAt = new Date(`${manualSession.day}T${manualSession.startTime}:00`);
|
||||
const endedAt = new Date(`${manualSession.day}T${manualSession.endTime}:00`);
|
||||
|
||||
if (Number.isNaN(startedAt.getTime()) || Number.isNaN(endedAt.getTime())) {
|
||||
toast.error("Datum oder Uhrzeit prüfen");
|
||||
return;
|
||||
}
|
||||
|
||||
if (endedAt <= startedAt) {
|
||||
toast.error("Ende muss nach Beginn liegen");
|
||||
return;
|
||||
}
|
||||
|
||||
setCreatingSession(true);
|
||||
|
||||
try {
|
||||
await createAdminSession({
|
||||
userId: manualSessionUserId,
|
||||
ticketNumber: manualSession.ticketNumber,
|
||||
organizationId: manualSession.organizationId,
|
||||
activity: manualSession.activity,
|
||||
workType: manualSession.workType,
|
||||
startedAt: startedAt.toISOString(),
|
||||
endedAt: endedAt.toISOString()
|
||||
});
|
||||
toast.success("Session nachgetragen", {
|
||||
description: "Der Eintrag ist in der Auswertung des gewählten Benutzers offen."
|
||||
});
|
||||
setManualSession({
|
||||
...emptyManualSession(),
|
||||
userId: manualSessionUserId,
|
||||
day: manualSession.day
|
||||
});
|
||||
await load();
|
||||
} catch (error) {
|
||||
toast.error("Session konnte nicht nachgetragen werden", {
|
||||
description: error instanceof Error ? error.message : "Unbekannter Fehler"
|
||||
});
|
||||
} finally {
|
||||
setCreatingSession(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function syncOrganizations(event: FormEvent) {
|
||||
event.preventDefault();
|
||||
setSyncingOrganizations(true);
|
||||
@@ -327,14 +217,141 @@ export function AdminUsersPage({ currentUser }: { currentUser: AuthUser }) {
|
||||
}
|
||||
}
|
||||
|
||||
async function saveTeamspaceAccess() {
|
||||
setSavingTeamspaceSettings(true);
|
||||
|
||||
try {
|
||||
const result = await saveTeamspaceSettings({
|
||||
baseUrl: teamspaceBaseUrl
|
||||
});
|
||||
setTeamspaceBaseUrl(result.settings.baseUrl);
|
||||
toast.success("Teamspace-Zugang gespeichert", {
|
||||
description: "URL ist hinterlegt."
|
||||
});
|
||||
} catch (error) {
|
||||
toast.error("Teamspace-Zugang konnte nicht gespeichert werden", {
|
||||
description: error instanceof Error ? error.message : "Unbekannter Fehler"
|
||||
});
|
||||
} finally {
|
||||
setSavingTeamspaceSettings(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function exportDatabase() {
|
||||
setExportingDatabase(true);
|
||||
|
||||
try {
|
||||
const result = await downloadDatabaseExport();
|
||||
const url = window.URL.createObjectURL(result.blob);
|
||||
const link = document.createElement("a");
|
||||
link.href = url;
|
||||
link.download = result.filename;
|
||||
document.body.append(link);
|
||||
link.click();
|
||||
link.remove();
|
||||
window.URL.revokeObjectURL(url);
|
||||
toast.success("Datenbankexport erstellt", {
|
||||
description: result.filename
|
||||
});
|
||||
} catch (error) {
|
||||
toast.error("Datenbankexport fehlgeschlagen", {
|
||||
description: error instanceof Error ? error.message : "Unbekannter Fehler"
|
||||
});
|
||||
} finally {
|
||||
setExportingDatabase(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function readRestoreFile(event: ChangeEvent<HTMLInputElement>) {
|
||||
const file = event.currentTarget.files?.[0];
|
||||
|
||||
if (!file) {
|
||||
return;
|
||||
}
|
||||
|
||||
const sql = await file.text();
|
||||
setRestoreSql(sql);
|
||||
setRestoreValidation(null);
|
||||
await validateRestore(sql);
|
||||
}
|
||||
|
||||
async function validateRestore(sql = restoreSql) {
|
||||
if (!sql.trim()) {
|
||||
setRestoreValidation(null);
|
||||
return;
|
||||
}
|
||||
|
||||
setValidatingRestore(true);
|
||||
|
||||
try {
|
||||
const result = await validateDatabaseBackup(sql);
|
||||
setRestoreValidation(result);
|
||||
|
||||
if (result.valid) {
|
||||
toast.success("Backup-Struktur passt", {
|
||||
description: `${result.checkedTables} Tabelle(n) geprüft.`
|
||||
});
|
||||
} else {
|
||||
toast.error("Backup-Struktur passt nicht", {
|
||||
description: result.issues[0] ?? "Unbekannter Strukturfehler"
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
setRestoreValidation({
|
||||
valid: false,
|
||||
checkedTables: 0,
|
||||
issues: [error instanceof Error ? error.message : "Unbekannter Fehler"]
|
||||
});
|
||||
toast.error("Backup-Prüfung fehlgeschlagen", {
|
||||
description: error instanceof Error ? error.message : "Unbekannter Fehler"
|
||||
});
|
||||
} finally {
|
||||
setValidatingRestore(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function restoreDatabase(event: FormEvent) {
|
||||
event.preventDefault();
|
||||
|
||||
if (!restoreValidation?.valid) {
|
||||
toast.error("Backup zuerst prüfen", {
|
||||
description: "Der Restore kann erst nach erfolgreicher Strukturprüfung ausgeführt werden."
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (!window.confirm("Restore wirklich einspielen? Alle aktuellen TicketTracker-Daten werden durch den Stand des Backups ersetzt.")) {
|
||||
return;
|
||||
}
|
||||
|
||||
setRestoringDatabase(true);
|
||||
|
||||
try {
|
||||
await restoreDatabaseBackup(restoreSql);
|
||||
toast.success("Restore eingespielt", {
|
||||
description: "Melde dich jetzt mit einem Benutzer aus dem Backup neu an."
|
||||
});
|
||||
window.location.href = "/";
|
||||
} catch (error) {
|
||||
toast.error("Restore fehlgeschlagen", {
|
||||
description: error instanceof Error ? error.message : "Unbekannter Fehler"
|
||||
});
|
||||
} finally {
|
||||
setRestoringDatabase(false);
|
||||
}
|
||||
}
|
||||
|
||||
const regularUserCount = users.filter((user) => user.role === "user").length;
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold tracking-normal sm:text-2xl">Benutzer</h2>
|
||||
<p className="text-sm text-muted-foreground">Accounts verwalten, Besitzer von Sessions prüfen und Einträge umverteilen.</p>
|
||||
<h2 className="text-xl font-semibold tracking-normal sm:text-2xl">Administration</h2>
|
||||
<p className="text-sm text-muted-foreground">Systemverwaltung für Benutzer, Zammad-Organisationen und restore-fähige Backups.</p>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<div className="grid min-w-0 gap-4 2xl:grid-cols-[minmax(0,1.2fr)_minmax(340px,0.8fr)]">
|
||||
<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">
|
||||
@@ -346,7 +363,7 @@ export function AdminUsersPage({ currentUser }: { currentUser: AuthUser }) {
|
||||
<CardDescription>Zammad-Zugang speichern und Organisationen in die lokale TicketTracker-Datenbank übernehmen.</CardDescription>
|
||||
</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}>
|
||||
<form className="grid gap-3 xl:grid-cols-2" onSubmit={syncOrganizations}>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium" htmlFor="zammad-base-url">Zammad URL</label>
|
||||
<Input
|
||||
@@ -362,12 +379,12 @@ export function AdminUsersPage({ currentUser }: { currentUser: AuthUser }) {
|
||||
<Input
|
||||
id="zammad-api-key"
|
||||
type="password"
|
||||
placeholder={hasZammadApiKey ? "Gespeicherter API-Key wird verwendet" : ""}
|
||||
placeholder={hasZammadApiKey ? "••••••••••••••••" : ""}
|
||||
value={zammadApiKey}
|
||||
onChange={(event) => setZammadApiKey(event.currentTarget.value)}
|
||||
/>
|
||||
{hasZammadApiKey ? <p className="text-xs text-muted-foreground">API-Key ist gespeichert. Leer lassen, um ihn weiter zu verwenden.</p> : null}
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2 xl:col-span-2">
|
||||
<Button type="button" variant="secondary" disabled={savingZammadSettings} onClick={() => void saveZammadAccess()}>
|
||||
<Save className="size-4" />
|
||||
{savingZammadSettings ? "Speichert..." : "Zugang speichern"}
|
||||
@@ -376,9 +393,85 @@ export function AdminUsersPage({ currentUser }: { currentUser: AuthUser }) {
|
||||
<DatabaseZap className="size-4" />
|
||||
{syncingOrganizations ? "Synchronisiert..." : "Organisationen synchronisieren"}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
<div className="mt-4 border-t pt-4">
|
||||
<div className="mb-3 flex items-center gap-2">
|
||||
<Unplug className="size-4 text-muted-foreground" />
|
||||
<p className="text-sm font-medium">Teamspace API</p>
|
||||
</div>
|
||||
<div className="grid gap-3 xl:grid-cols-[minmax(220px,1fr)_auto] xl:items-end">
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium" htmlFor="teamspace-base-url">Teamspace URL</label>
|
||||
<Input
|
||||
id="teamspace-base-url"
|
||||
placeholder="https://app1.teamspace.de"
|
||||
value={teamspaceBaseUrl}
|
||||
onChange={(event) => setTeamspaceBaseUrl(event.currentTarget.value)}
|
||||
/>
|
||||
</div>
|
||||
<Button type="button" variant="secondary" disabled={savingTeamspaceSettings} onClick={() => void saveTeamspaceAccess()}>
|
||||
<Save className="size-4" />
|
||||
{savingTeamspaceSettings ? "Speichert..." : "URL speichern"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<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">
|
||||
<DatabaseBackup className="size-5 text-muted-foreground" />
|
||||
<CardTitle>Backup und Restore</CardTitle>
|
||||
</div>
|
||||
<HelpLink anchor="admin" label="Hilfe zu Backup und Restore" />
|
||||
</div>
|
||||
<CardDescription>SQL-Backup herunterladen oder einen kompletten Systemstand wiederherstellen.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3 px-4 pb-4">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Enthält Benutzer, Einstellungen, Organisationen, Tickets, Sessions, Teamspace-Werte, fixe Abrechnungen und Abschlüsse.
|
||||
</p>
|
||||
<Button className="w-full" disabled={exportingDatabase} onClick={() => void exportDatabase()}>
|
||||
<DatabaseBackup className="size-4" />
|
||||
{exportingDatabase ? "Exportiert..." : "SQL-Backup herunterladen"}
|
||||
</Button>
|
||||
<form className="space-y-3 rounded-md border bg-muted/20 p-3" onSubmit={restoreDatabase}>
|
||||
<Alert variant="warning">
|
||||
<Upload className="size-4" />
|
||||
<AlertTitle>Restore ersetzt alles</AlertTitle>
|
||||
<AlertDescription>
|
||||
Vor dem Einspielen werden alle Tabellen geleert. Danach gilt nur noch der Stand des Backups, inklusive aller User, Passwörter, Sessions und Settings.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
<Input type="file" accept=".sql,text/sql,text/plain" onChange={(event) => void readRestoreFile(event)} />
|
||||
{restoreValidation ? (
|
||||
<Alert variant={restoreValidation.valid ? "success" : "destructive"}>
|
||||
{restoreValidation.valid ? <CheckCircle2 className="size-4" /> : <Upload className="size-4" />}
|
||||
<AlertTitle>{restoreValidation.valid ? "Strukturprüfung erfolgreich" : "Strukturprüfung fehlgeschlagen"}</AlertTitle>
|
||||
<AlertDescription>
|
||||
{restoreValidation.valid
|
||||
? `${restoreValidation.checkedTables} Tabelle(n) passen zum aktuellen Datenbankschema.`
|
||||
: restoreValidation.issues.slice(0, 4).join(" ")}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
) : null}
|
||||
<div className="grid gap-2 sm:grid-cols-2">
|
||||
<Button type="button" variant="secondary" disabled={validatingRestore || !restoreSql.trim()} onClick={() => void validateRestore()}>
|
||||
<CheckCircle2 className="size-4" />
|
||||
{validatingRestore ? "Prüft..." : "Struktur prüfen"}
|
||||
</Button>
|
||||
<Button type="submit" variant="destructive" disabled={restoringDatabase || !restoreValidation?.valid}>
|
||||
<Upload className="size-4" />
|
||||
{restoringDatabase ? "Spielt ein..." : "SQL-Backup einspielen"}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="p-4">
|
||||
@@ -391,7 +484,7 @@ export function AdminUsersPage({ currentUser }: { currentUser: AuthUser }) {
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="px-4 pb-4">
|
||||
<form className="grid gap-3 lg:grid-cols-[160px_minmax(180px,1fr)_160px_130px_auto_auto] lg:items-end" onSubmit={createUser}>
|
||||
<form className="grid gap-3 lg:grid-cols-[160px_minmax(180px,1fr)_160px_110px_auto] lg:items-end" onSubmit={createUser}>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium" htmlFor="new-username">Benutzername</label>
|
||||
<Input id="new-username" value={newUser.username} onChange={(event) => setNewUser({ ...newUser, username: event.currentTarget.value })} required />
|
||||
@@ -405,16 +498,8 @@ export function AdminUsersPage({ currentUser }: { currentUser: AuthUser }) {
|
||||
<Input id="new-password" type="password" value={newUser.password} onChange={(event) => setNewUser({ ...newUser, password: event.currentTarget.value })} required />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium" htmlFor="new-role">Rolle</label>
|
||||
<select
|
||||
id="new-role"
|
||||
className="h-8 w-full rounded-lg border border-input bg-background px-2 text-sm focus-visible:outline-none focus-visible:ring-3 focus-visible:ring-ring/50"
|
||||
value={newUser.role}
|
||||
onChange={(event) => setNewUser({ ...newUser, role: event.currentTarget.value as UserRole })}
|
||||
>
|
||||
<option value="user">User</option>
|
||||
<option value="admin">Admin</option>
|
||||
</select>
|
||||
<span className="text-sm font-medium">Rolle</span>
|
||||
<Badge variant="secondary" className="flex h-8 w-full items-center justify-center">User</Badge>
|
||||
</div>
|
||||
<label className="flex h-8 items-center gap-2 text-sm">
|
||||
<Switch checked={newUser.active} onCheckedChange={(active) => setNewUser({ ...newUser, active })} />
|
||||
@@ -428,130 +513,11 @@ export function AdminUsersPage({ currentUser }: { currentUser: AuthUser }) {
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="p-4">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<CalendarPlus className="size-5 text-muted-foreground" />
|
||||
<CardTitle>Session nachtragen</CardTitle>
|
||||
</div>
|
||||
<HelpLink anchor="session-nachtragen" label="Hilfe zum Nachtragen für Benutzer" />
|
||||
</div>
|
||||
<CardDescription>Vergessene Zeiten manuell erfassen und direkt einem Benutzer zuweisen.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="px-4 pb-4">
|
||||
<form className="space-y-4" onSubmit={createManualSession}>
|
||||
<div className="grid gap-3 lg:grid-cols-[160px_minmax(180px,1fr)_150px_150px]">
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium" htmlFor="manual-ticket-number">Ticket</label>
|
||||
<Input
|
||||
id="manual-ticket-number"
|
||||
placeholder="Ticket#123456"
|
||||
value={manualSession.ticketNumber}
|
||||
onChange={(event) => setManualSession({ ...manualSession, ticketNumber: event.currentTarget.value })}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium" htmlFor="manual-organization">Organisation</label>
|
||||
<OrganizationSelect
|
||||
value={manualSession.organizationId}
|
||||
selectedName={manualSession.organizationName}
|
||||
onChange={(organization) =>
|
||||
setManualSession({
|
||||
...manualSession,
|
||||
organizationId: organization.id,
|
||||
organizationName: organization.name
|
||||
})
|
||||
}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium" htmlFor="manual-work-type">Art</label>
|
||||
<select
|
||||
id="manual-work-type"
|
||||
className="h-8 w-full rounded-lg border border-input bg-background px-2 text-sm focus-visible:outline-none focus-visible:ring-3 focus-visible:ring-ring/50"
|
||||
value={manualSession.workType}
|
||||
onChange={(event) => setManualSession({ ...manualSession, workType: event.currentTarget.value as WorkType })}
|
||||
>
|
||||
<option value="support">Support</option>
|
||||
<option value="consulting">Consulting</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium" htmlFor="manual-user">Benutzer</label>
|
||||
<select
|
||||
id="manual-user"
|
||||
className="h-8 w-full rounded-lg border border-input bg-background px-2 text-sm focus-visible:outline-none focus-visible:ring-3 focus-visible:ring-ring/50"
|
||||
value={manualSessionUserId}
|
||||
onChange={(event) => setManualSession({ ...manualSession, userId: event.currentTarget.value })}
|
||||
required
|
||||
>
|
||||
{activeUsers.map((user) => (
|
||||
<option key={user.id} value={user.id}>
|
||||
{user.display_name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3 lg:grid-cols-[160px_120px_120px_minmax(220px,1fr)_auto] lg:items-end">
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium" htmlFor="manual-day">Datum</label>
|
||||
<Input
|
||||
id="manual-day"
|
||||
type="date"
|
||||
value={manualSession.day}
|
||||
onChange={(event) => setManualSession({ ...manualSession, day: event.currentTarget.value })}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium" htmlFor="manual-start">Von</label>
|
||||
<Input
|
||||
id="manual-start"
|
||||
type="time"
|
||||
value={manualSession.startTime}
|
||||
onChange={(event) => setManualSession({ ...manualSession, startTime: event.currentTarget.value })}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium" htmlFor="manual-end">Bis</label>
|
||||
<Input
|
||||
id="manual-end"
|
||||
type="time"
|
||||
value={manualSession.endTime}
|
||||
onChange={(event) => setManualSession({ ...manualSession, endTime: event.currentTarget.value })}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium" htmlFor="manual-activity">Tätigkeit</label>
|
||||
<Textarea
|
||||
id="manual-activity"
|
||||
className="min-h-20"
|
||||
value={manualSession.activity}
|
||||
onChange={(event) => setManualSession({ ...manualSession, activity: event.currentTarget.value })}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<Button type="submit" disabled={creatingSession || activeUsers.length === 0}>
|
||||
<CalendarPlus className="size-4" />
|
||||
{creatingSession ? "Speichert..." : "Nachtragen"}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="gap-3 p-4 sm:flex-row sm:items-center sm:justify-between sm:space-y-0">
|
||||
<div>
|
||||
<CardTitle>Vorhandene Benutzer</CardTitle>
|
||||
<CardDescription>{loading ? "Lädt..." : `${users.length} Account(s)`}</CardDescription>
|
||||
<CardDescription>{loading ? "Lädt..." : `${regularUserCount} User, 1 fester Admin`}</CardDescription>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant="outline">{currentUser.display_name}</Badge>
|
||||
@@ -574,6 +540,7 @@ export function AdminUsersPage({ currentUser }: { currentUser: AuthUser }) {
|
||||
<TableBody>
|
||||
{users.map((user) => {
|
||||
const form = forms[user.id] ?? formFromUser(user);
|
||||
const isFixedAdmin = user.role === "admin";
|
||||
|
||||
return (
|
||||
<TableRow key={user.id}>
|
||||
@@ -584,18 +551,11 @@ export function AdminUsersPage({ currentUser }: { currentUser: AuthUser }) {
|
||||
<Input className="h-8" value={form.displayName} onChange={(event) => updateForm(user.id, { displayName: event.currentTarget.value })} />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<select
|
||||
className="h-8 w-full rounded-lg border border-input bg-background px-2 text-sm focus-visible:outline-none focus-visible:ring-3 focus-visible:ring-ring/50"
|
||||
value={form.role}
|
||||
onChange={(event) => updateForm(user.id, { role: event.currentTarget.value as UserRole })}
|
||||
>
|
||||
<option value="user">User</option>
|
||||
<option value="admin">Admin</option>
|
||||
</select>
|
||||
<Badge variant={isFixedAdmin ? "default" : "secondary"}>{isFixedAdmin ? "Fester Admin" : "User"}</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<Switch checked={form.active} onCheckedChange={(active) => updateForm(user.id, { active })} />
|
||||
<Switch checked={form.active} disabled={isFixedAdmin} onCheckedChange={(active) => updateForm(user.id, { active })} />
|
||||
{form.active ? "aktiv" : "inaktiv"}
|
||||
</label>
|
||||
</TableCell>
|
||||
@@ -624,6 +584,7 @@ export function AdminUsersPage({ currentUser }: { currentUser: AuthUser }) {
|
||||
<div className="space-y-2 xl:hidden">
|
||||
{users.map((user) => {
|
||||
const form = forms[user.id] ?? formFromUser(user);
|
||||
const isFixedAdmin = user.role === "admin";
|
||||
|
||||
return (
|
||||
<div key={user.id} className="space-y-3 rounded-md border bg-background p-3">
|
||||
@@ -632,18 +593,10 @@ export function AdminUsersPage({ currentUser }: { currentUser: AuthUser }) {
|
||||
<Shield className="size-4 text-muted-foreground" />
|
||||
<span className="font-medium">{user.username}</span>
|
||||
</div>
|
||||
<Badge variant={form.role === "admin" ? "default" : "secondary"}>{form.role === "admin" ? "Admin" : "User"}</Badge>
|
||||
<Badge variant={isFixedAdmin ? "default" : "secondary"}>{isFixedAdmin ? "Fester Admin" : "User"}</Badge>
|
||||
</div>
|
||||
<Input value={form.username} onChange={(event) => updateForm(user.id, { username: event.currentTarget.value })} />
|
||||
<Input value={form.displayName} onChange={(event) => updateForm(user.id, { displayName: event.currentTarget.value })} />
|
||||
<select
|
||||
className="h-9 w-full rounded-lg border border-input bg-background px-2 text-sm focus-visible:outline-none focus-visible:ring-3 focus-visible:ring-ring/50"
|
||||
value={form.role}
|
||||
onChange={(event) => updateForm(user.id, { role: event.currentTarget.value as UserRole })}
|
||||
>
|
||||
<option value="user">User</option>
|
||||
<option value="admin">Admin</option>
|
||||
</select>
|
||||
<Input
|
||||
type="password"
|
||||
placeholder="Passwort unverändert lassen"
|
||||
@@ -652,7 +605,7 @@ export function AdminUsersPage({ currentUser }: { currentUser: AuthUser }) {
|
||||
/>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<Switch checked={form.active} onCheckedChange={(active) => updateForm(user.id, { active })} />
|
||||
<Switch checked={form.active} disabled={isFixedAdmin} onCheckedChange={(active) => updateForm(user.id, { active })} />
|
||||
{form.active ? "aktiv" : "inaktiv"}
|
||||
</label>
|
||||
<Button size="sm" onClick={() => void saveUser(user)} disabled={savingId === user.id}>
|
||||
@@ -666,118 +619,6 @@ export function AdminUsersPage({ currentUser }: { currentUser: AuthUser }) {
|
||||
</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>Session-Zuordnung</CardTitle>
|
||||
<CardDescription>{loading ? "Lädt..." : `${sessions.length} letzte Session(s), inklusive Besitzer.`}</CardDescription>
|
||||
</div>
|
||||
<HelpLink anchor="admin" label="Hilfe zur Session-Zuordnung" />
|
||||
</CardHeader>
|
||||
<CardContent className="px-4 pb-4">
|
||||
<div className="hidden xl:block">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Besitzer</TableHead>
|
||||
<TableHead>Ticket</TableHead>
|
||||
<TableHead>Organisation</TableHead>
|
||||
<TableHead>Tätigkeit</TableHead>
|
||||
<TableHead>Beginn</TableHead>
|
||||
<TableHead>Zeit</TableHead>
|
||||
<TableHead>Zuordnen zu</TableHead>
|
||||
<TableHead />
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{sessions.map((session) => (
|
||||
<TableRow key={session.id}>
|
||||
<TableCell>
|
||||
<div className="font-medium">{session.owner_display_name}</div>
|
||||
<div className="text-xs text-muted-foreground">{session.owner_username}</div>
|
||||
</TableCell>
|
||||
<TableCell className="font-semibold">{session.ticket_number}</TableCell>
|
||||
<TableCell>{session.customer_name}</TableCell>
|
||||
<TableCell className="max-w-xs whitespace-normal">{session.activity}</TableCell>
|
||||
<TableCell className="whitespace-nowrap">{formatDateTime(session.started_at)}</TableCell>
|
||||
<TableCell>{formatMinutes(session.rounded_minutes)}</TableCell>
|
||||
<TableCell>
|
||||
<select
|
||||
className="h-8 w-full min-w-40 rounded-lg border border-input bg-background px-2 text-sm focus-visible:outline-none focus-visible:ring-3 focus-visible:ring-ring/50"
|
||||
value={sessionOwners[session.id] ?? session.user_id}
|
||||
onChange={(event) =>
|
||||
setSessionOwners((current) => ({
|
||||
...current,
|
||||
[session.id]: event.currentTarget.value
|
||||
}))
|
||||
}
|
||||
>
|
||||
{activeUsers.map((user) => (
|
||||
<option key={user.id} value={user.id}>
|
||||
{user.display_name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<Button size="sm" onClick={() => void moveSession(session)} disabled={movingId === session.id}>
|
||||
<Shuffle className="size-4" />
|
||||
{movingId === session.id ? "Speichert..." : "Umverteilen"}
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2 xl:hidden">
|
||||
{sessions.map((session) => (
|
||||
<div key={session.id} className="space-y-3 rounded-md border bg-background p-3">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div>
|
||||
<p className="font-semibold">{session.ticket_number}</p>
|
||||
<p className="text-sm text-muted-foreground">{session.customer_name}</p>
|
||||
</div>
|
||||
<Badge variant="outline">{formatMinutes(session.rounded_minutes)}</Badge>
|
||||
</div>
|
||||
<div className="rounded-md bg-muted/40 p-2 text-sm">
|
||||
<p className="font-medium">{session.owner_display_name}</p>
|
||||
<p className="text-muted-foreground">{session.owner_username} · {formatDateTime(session.started_at)}</p>
|
||||
</div>
|
||||
<p className="text-sm">{session.activity}</p>
|
||||
<div className="grid gap-2 sm:grid-cols-[1fr_auto]">
|
||||
<select
|
||||
className="h-9 w-full rounded-lg border border-input bg-background px-2 text-sm focus-visible:outline-none focus-visible:ring-3 focus-visible:ring-ring/50"
|
||||
value={sessionOwners[session.id] ?? session.user_id}
|
||||
onChange={(event) =>
|
||||
setSessionOwners((current) => ({
|
||||
...current,
|
||||
[session.id]: event.currentTarget.value
|
||||
}))
|
||||
}
|
||||
>
|
||||
{activeUsers.map((user) => (
|
||||
<option key={user.id} value={user.id}>
|
||||
{user.display_name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<Button size="sm" onClick={() => void moveSession(session)} disabled={movingId === session.id}>
|
||||
<Shuffle className="size-4" />
|
||||
Umverteilen
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{sessions.length === 0 ? (
|
||||
<p className="py-8 text-center text-sm text-muted-foreground">Noch keine Sessions vorhanden.</p>
|
||||
) : null}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+297
-344
@@ -1,7 +1,8 @@
|
||||
import { ChevronLeft, ChevronRight, Clock3, ExternalLink, RotateCcw, SlidersHorizontal, Ticket, UserCheck, Users } from "lucide-react";
|
||||
import { 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";
|
||||
import { Alert } from "@/components/ui/alert";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
@@ -10,35 +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 { getPeriodOverview } from "../api";
|
||||
import { currentDay, currentMonth, formatMinutes } from "../format";
|
||||
import type { PeriodOverview, PeriodType, TicketSummary } from "../types";
|
||||
import { MultiFilterDropdown, type MultiFilterOption } from "@/components/MultiFilterDropdown";
|
||||
import { downloadMonthExport, getPeriodOverview } from "../api";
|
||||
import { currentDay, currentMonth, formatDateTime, formatMinutes } from "../format";
|
||||
import type { AuthUser, PeriodOverview, PeriodType, TicketSummary } from "../types";
|
||||
|
||||
type AnalysisPageProps = {
|
||||
currentUser: AuthUser;
|
||||
onNavigate: (to: string) => void;
|
||||
};
|
||||
|
||||
type TimelineBucket = {
|
||||
key: string;
|
||||
label: string;
|
||||
totalMinutes: number;
|
||||
sessionCount: number;
|
||||
};
|
||||
|
||||
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 = {
|
||||
@@ -57,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" },
|
||||
@@ -76,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;
|
||||
@@ -89,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;
|
||||
@@ -142,6 +196,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" });
|
||||
}
|
||||
@@ -223,87 +328,12 @@ function groupTicketSections(tickets: TicketSummary[], groupBy: TicketGroupBy):
|
||||
});
|
||||
}
|
||||
|
||||
function buildChartPath(points: Array<{ x: number; y: number }>) {
|
||||
if (points.length === 0) {
|
||||
return "";
|
||||
}
|
||||
|
||||
if (points.length === 1) {
|
||||
return `M ${points[0].x - 18} ${points[0].y} L ${points[0].x + 18} ${points[0].y}`;
|
||||
}
|
||||
|
||||
return points.map((point, index) => `${index === 0 ? "M" : "L"} ${point.x} ${point.y}`).join(" ");
|
||||
}
|
||||
|
||||
function niceCeilMinutes(value: number) {
|
||||
if (value <= 30) {
|
||||
return Math.max(5, Math.ceil(value / 5) * 5);
|
||||
}
|
||||
|
||||
if (value <= 120) {
|
||||
return Math.ceil(value / 15) * 15;
|
||||
}
|
||||
|
||||
if (value <= 480) {
|
||||
return Math.ceil(value / 30) * 30;
|
||||
}
|
||||
|
||||
return Math.ceil(value / 60) * 60;
|
||||
}
|
||||
|
||||
function formatAxisMinutes(value: number) {
|
||||
const minutes = Math.round(value);
|
||||
|
||||
if (minutes === 0) {
|
||||
return "0";
|
||||
}
|
||||
|
||||
if (minutes >= 60) {
|
||||
return `${new Intl.NumberFormat("de-DE", { maximumFractionDigits: minutes < 600 ? 1 : 0 }).format(minutes / 60)} h`;
|
||||
}
|
||||
|
||||
return `${minutes} min`;
|
||||
}
|
||||
|
||||
function buildTimeline(periodType: PeriodType, period: string, overview: PeriodOverview | null): TimelineBucket[] {
|
||||
const series = new Map((overview?.activitySeries ?? []).map((bucket) => [bucket.bucket_key, bucket]));
|
||||
|
||||
if (periodType === "day") {
|
||||
return Array.from({ length: 24 }).map((_, hour) => {
|
||||
const key = String(hour).padStart(2, "0");
|
||||
const bucket = series.get(key);
|
||||
|
||||
return {
|
||||
key,
|
||||
label: `${key}:00`,
|
||||
totalMinutes: bucket?.total_minutes ?? 0,
|
||||
sessionCount: bucket?.session_count ?? 0
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
const [year, month] = period.split("-").map(Number);
|
||||
const daysInMonth = Number.isFinite(year) && Number.isFinite(month) ? new Date(year, month, 0).getDate() : 31;
|
||||
|
||||
return Array.from({ length: daysInMonth }).map((_, index) => {
|
||||
const day = String(index + 1).padStart(2, "0");
|
||||
const key = `${period}-${day}`;
|
||||
const bucket = series.get(key);
|
||||
|
||||
return {
|
||||
key,
|
||||
label: day,
|
||||
totalMinutes: bucket?.total_minutes ?? 0,
|
||||
sessionCount: bucket?.session_count ?? 0
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
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);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [exportingMonth, setExportingMonth] = useState(false);
|
||||
const [ticketViewSettings, setTicketViewSettings] = useState<AnalysisTicketViewSettings>(() => readStoredTicketViewSettings());
|
||||
const loadRequestId = useRef(0);
|
||||
|
||||
@@ -323,7 +353,7 @@ export function AnalysisPage({ onNavigate }: AnalysisPageProps) {
|
||||
}
|
||||
} catch (error) {
|
||||
if (requestId === loadRequestId.current) {
|
||||
toast.error("Auswertung konnte nicht geladen werden", {
|
||||
toast.error("Tickets konnten nicht geladen werden", {
|
||||
description: error instanceof Error ? error.message : "Unbekannter Fehler"
|
||||
});
|
||||
}
|
||||
@@ -379,13 +409,49 @@ export function AnalysisPage({ onNavigate }: AnalysisPageProps) {
|
||||
|
||||
}
|
||||
|
||||
async function exportMonth() {
|
||||
if (periodType !== "month") {
|
||||
return;
|
||||
}
|
||||
|
||||
setExportingMonth(true);
|
||||
|
||||
try {
|
||||
const { blob, filename } = await downloadMonthExport(period);
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const link = document.createElement("a");
|
||||
link.href = url;
|
||||
link.download = filename;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
link.remove();
|
||||
window.URL.revokeObjectURL(url);
|
||||
toast.success("Monatsexport erstellt", {
|
||||
description: filename
|
||||
});
|
||||
} catch (error) {
|
||||
toast.error("Monatsexport fehlgeschlagen", {
|
||||
description: error instanceof Error ? error.message : "Unbekannter Fehler"
|
||||
});
|
||||
} finally {
|
||||
setExportingMonth(false);
|
||||
}
|
||||
}
|
||||
|
||||
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: "Zeit", value: formatMinutes(totals?.minutes ?? 0), detail: `Teamspace ${formatMinutes(totals?.crmBilledMinutes ?? 0)}`, icon: Clock3 },
|
||||
{ label: "Offen", value: totals?.openSessions ?? 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 ?? [];
|
||||
const organizationOptions = useMemo(() => {
|
||||
@@ -405,57 +471,28 @@ export function AnalysisPage({ onNavigate }: AnalysisPageProps) {
|
||||
const matchesQuery =
|
||||
!normalizedQuery ||
|
||||
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;
|
||||
const timeline = buildTimeline(periodType, period, overview);
|
||||
const chartWidth = 900;
|
||||
const chartHeight = 180;
|
||||
const chartPadding = { top: 18, right: 18, bottom: 34, left: 64 };
|
||||
const chartInnerWidth = chartWidth - chartPadding.left - chartPadding.right;
|
||||
const chartInnerHeight = chartHeight - chartPadding.top - chartPadding.bottom;
|
||||
const maxMinutes = Math.max(...timeline.map((bucket) => bucket.totalMinutes), 1);
|
||||
const chartMaxMinutes = niceCeilMinutes(maxMinutes);
|
||||
const yAxisTicks = Array.from({ length: 5 }).map((_, index) => chartMaxMinutes - (index * chartMaxMinutes) / 4);
|
||||
const barWidth = Math.max(4, Math.min(18, chartInnerWidth / Math.max(timeline.length, 1) / 1.8));
|
||||
const chartPoints = timeline.map((bucket, index) => {
|
||||
const x =
|
||||
timeline.length === 1
|
||||
? chartPadding.left + chartInnerWidth / 2
|
||||
: chartPadding.left + (index * chartInnerWidth) / (timeline.length - 1);
|
||||
|
||||
return {
|
||||
bucket,
|
||||
x,
|
||||
barHeight: bucket.totalMinutes > 0 ? Math.max(2, (bucket.totalMinutes / chartMaxMinutes) * chartInnerHeight) : 0,
|
||||
y: chartPadding.top + (1 - bucket.totalMinutes / chartMaxMinutes) * chartInnerHeight
|
||||
};
|
||||
});
|
||||
const effortPath = buildChartPath(chartPoints.filter((point) => point.bucket.totalMinutes > 0).map((point) => ({ x: point.x, y: point.y })));
|
||||
const labelStep = periodType === "month" ? Math.max(1, Math.ceil(timeline.length / 8)) : 3;
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<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">Auswertung</h2>
|
||||
<h2 className="text-2xl font-semibold tracking-normal">Tickets</h2>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{periodType === "month" ? "Tickets prüfen, Sessions bewerten und Zeiten kontrollieren." : "Sessions des Tages prüfen und bewerten."}
|
||||
</p>
|
||||
@@ -492,10 +529,22 @@ export function AnalysisPage({ onNavigate }: AnalysisPageProps) {
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{periodType === "month" ? (
|
||||
<Button type="button" variant="secondary" className="sm:col-span-2" disabled={exportingMonth} onClick={() => void exportMonth()}>
|
||||
<Download className="size-4" />
|
||||
{exportingMonth ? "Exportiert..." : "Monat exportieren"}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
|
||||
{periodType === "month" && overview?.closed ? (
|
||||
<Alert variant="success" className="py-3 text-sm">
|
||||
Dieser Monat wurde am {formatDateTime(overview.closedAt!)} abgeschlossen. Bewertungen und Änderungen sind geschützt, bis der Monat im Monatsabschluss wieder geöffnet wird.
|
||||
</Alert>
|
||||
) : null}
|
||||
|
||||
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-5">
|
||||
{metricCards.map(({ label, value, detail, icon: Icon }) => (
|
||||
<Card key={label}>
|
||||
<CardContent className="flex items-center gap-3 p-3">
|
||||
@@ -513,188 +562,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>Gesamtaufwand</CardTitle>
|
||||
<CardDescription>
|
||||
{periodType === "month" ? "Aufwand pro Tag im ausgewählten Monat." : "Aufwand pro Stunde am ausgewählten Tag."}
|
||||
</CardDescription>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant="outline">{formatMinutes(totals?.minutes ?? 0)}</Badge>
|
||||
<HelpLink anchor="auswertung" label="Hilfe zur Auswertung" />
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="px-4 pb-4">
|
||||
<div className="h-44 w-full overflow-hidden rounded-md border bg-background">
|
||||
{(totals?.minutes ?? 0) > 0 ? (
|
||||
<svg viewBox={`0 0 ${chartWidth} ${chartHeight}`} className="h-full w-full">
|
||||
{yAxisTicks.map((tick) => {
|
||||
const y = chartPadding.top + (1 - tick / chartMaxMinutes) * chartInnerHeight;
|
||||
|
||||
return (
|
||||
<g key={tick}>
|
||||
<line x1={chartPadding.left} x2={chartWidth - chartPadding.right} y1={y} y2={y} stroke="currentColor" className="text-muted/70" />
|
||||
<line x1={chartPadding.left - 5} x2={chartPadding.left} y1={y} y2={y} stroke="currentColor" className="text-muted-foreground/70" />
|
||||
<text x={chartPadding.left - 9} y={y + 4} textAnchor="end" className="fill-muted-foreground text-[11px]">
|
||||
{formatAxisMinutes(tick)}
|
||||
</text>
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
<line
|
||||
x1={chartPadding.left}
|
||||
x2={chartPadding.left}
|
||||
y1={chartPadding.top}
|
||||
y2={chartPadding.top + chartInnerHeight}
|
||||
stroke="currentColor"
|
||||
className="text-muted-foreground/70"
|
||||
/>
|
||||
|
||||
{chartPoints.map((point) => {
|
||||
const barY = chartPadding.top + chartInnerHeight - point.barHeight;
|
||||
|
||||
return (
|
||||
<g key={point.bucket.key}>
|
||||
{point.bucket.totalMinutes > 0 ? (
|
||||
<rect
|
||||
x={point.x - barWidth / 2}
|
||||
y={barY}
|
||||
width={barWidth}
|
||||
height={point.barHeight}
|
||||
rx="4"
|
||||
className="fill-neutral-300 dark:fill-neutral-700"
|
||||
/>
|
||||
) : null}
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
|
||||
{effortPath ? <path d={effortPath} fill="none" stroke="currentColor" strokeWidth="2.5" className="text-foreground" /> : null}
|
||||
|
||||
{chartPoints.filter((point) => point.bucket.totalMinutes > 0).map((point) => (
|
||||
<circle key={`${point.bucket.key}-effort`} cx={point.x} cy={point.y} r="3" className="fill-background stroke-foreground" strokeWidth="2" />
|
||||
))}
|
||||
|
||||
{chartPoints.map((point, index) =>
|
||||
index % labelStep === 0 || index === chartPoints.length - 1 ? (
|
||||
<text key={`${point.bucket.key}-label`} x={point.x} y={chartHeight - 10} textAnchor="middle" className="fill-muted-foreground text-[11px]">
|
||||
{point.bucket.label}
|
||||
</text>
|
||||
) : null
|
||||
)}
|
||||
</svg>
|
||||
) : (
|
||||
<div className="grid h-full place-items-center text-sm text-muted-foreground">Keine Daten für den gewählten Zeitraum.</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-3 flex flex-wrap gap-3 text-xs text-muted-foreground">
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<span className="size-2 rounded-sm bg-neutral-300 dark:bg-neutral-700" />
|
||||
Minuten
|
||||
</span>
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<span className="h-0.5 w-4 bg-foreground" />
|
||||
Verlauf
|
||||
</span>
|
||||
</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>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"
|
||||
onChange={(event) => setTicketViewSettings((current) => ({ ...current, query: event.currentTarget.value }))}
|
||||
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">
|
||||
@@ -709,8 +631,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">
|
||||
@@ -725,22 +646,45 @@ 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>
|
||||
@@ -749,9 +693,7 @@ export function AnalysisPage({ onNavigate }: AnalysisPageProps) {
|
||||
<TableHead>Organisation</TableHead>
|
||||
<TableHead>Art</TableHead>
|
||||
<TableHead>Sessions</TableHead>
|
||||
<TableHead>Zeit</TableHead>
|
||||
<TableHead>Abgerechnet</TableHead>
|
||||
<TableHead>Nicht abrechenbar</TableHead>
|
||||
<TableHead>Zeit abgerechnet</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead />
|
||||
</TableRow>
|
||||
@@ -761,14 +703,14 @@ export function AnalysisPage({ onNavigate }: AnalysisPageProps) {
|
||||
<Fragment key={section.key}>
|
||||
{ticketViewSettings.groupBy !== "none" ? (
|
||||
<TableRow className={`${sectionIndex > 0 ? "border-t-[12px] border-t-background" : ""} border-b-2 border-border bg-muted/70 hover:bg-muted/70`}>
|
||||
<TableCell colSpan={9} className="py-0">
|
||||
<TableCell colSpan={8} className="py-0">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3 rounded-md border border-border/80 bg-card px-3 py-2 shadow-sm">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<span className="rounded-sm bg-primary px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-wide text-primary-foreground">Gruppe</span>
|
||||
<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>
|
||||
@@ -779,22 +721,28 @@ export function AnalysisPage({ onNavigate }: AnalysisPageProps) {
|
||||
{section.tickets.map((ticket) => (
|
||||
<TableRow key={ticket.id}>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="font-semibold">{ticket.ticket_number}</span>
|
||||
<div className="flex min-w-0 items-center justify-between gap-2">
|
||||
<div className="flex min-w-0 items-center gap-1">
|
||||
<span className="truncate font-semibold">{ticket.ticket_number}</span>
|
||||
<CopyTicketButton ticketNumber={ticket.ticket_number} />
|
||||
</div>
|
||||
{ticket.project_id ? <Badge variant="outline" className="shrink-0">Projekt</Badge> : null}
|
||||
</div>
|
||||
{ticket.title ? <p className="max-w-[220px] truncate text-xs text-muted-foreground">{ticket.title}</p> : null}
|
||||
</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>{ticket.non_billable_count}</TableCell>
|
||||
<TableCell>
|
||||
<TicketStatus ticket={ticket} />
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<Button variant="ghost" size="sm" onClick={() => onNavigate(`/analysis/${periodType}/${period}/tickets/${ticket.id}`)}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => onNavigate(`/tickets/${ticket.id}?month=${periodType === "day" ? period.slice(0, 7) : period}`)}
|
||||
>
|
||||
Öffnen
|
||||
<ExternalLink className="size-4" />
|
||||
</Button>
|
||||
@@ -817,7 +765,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>
|
||||
@@ -827,29 +775,34 @@ export function AnalysisPage({ onNavigate }: AnalysisPageProps) {
|
||||
<div key={ticket.id} className="space-y-2 rounded-md border bg-background p-3">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-1">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="flex min-w-0 items-center gap-1">
|
||||
<p className="truncate font-semibold">{ticket.ticket_number}</p>
|
||||
<CopyTicketButton ticketNumber={ticket.ticket_number} />
|
||||
</div>
|
||||
{ticket.project_id ? <Badge variant="outline" className="shrink-0">Projekt</Badge> : null}
|
||||
</div>
|
||||
{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>
|
||||
<TicketStatus ticket={ticket} />
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-2 text-sm">
|
||||
<div className="grid grid-cols-2 gap-2 text-sm">
|
||||
<div className="rounded-md bg-muted/50 p-2">
|
||||
<p className="text-muted-foreground">Zeit</p>
|
||||
<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">Abr.</p>
|
||||
<p className="font-medium">{ticket.billed_count}</p>
|
||||
</div>
|
||||
<div className="rounded-md bg-muted/50 p-2">
|
||||
<p className="text-muted-foreground">Nicht</p>
|
||||
<p className="font-medium">{ticket.non_billable_count}</p>
|
||||
<p className="text-muted-foreground">Abrechenbar</p>
|
||||
<p className="font-medium">{ticket.billable_count}</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button className="w-full" size="sm" variant="secondary" onClick={() => onNavigate(`/analysis/${periodType}/${period}/tickets/${ticket.id}`)}>
|
||||
<Button
|
||||
className="w-full"
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
onClick={() => onNavigate(`/tickets/${ticket.id}?month=${periodType === "day" ? period.slice(0, 7) : period}`)}
|
||||
>
|
||||
Öffnen
|
||||
<ExternalLink className="size-4" />
|
||||
</Button>
|
||||
@@ -862,7 +815,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
@@ -4,6 +4,41 @@ import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { helpSections } from "../help-content";
|
||||
import { cn } from "../lib/utils";
|
||||
|
||||
function WorkflowList({ items }: { items: string[] }) {
|
||||
return (
|
||||
<div>
|
||||
<p className="mb-2 text-sm font-medium">Empfohlener Ablauf</p>
|
||||
<ol className="space-y-2 text-sm text-muted-foreground">
|
||||
{items.map((item, index) => (
|
||||
<li key={item} className="flex gap-2">
|
||||
<span className="grid size-5 shrink-0 place-items-center rounded-full bg-primary/10 text-xs font-semibold text-primary">{index + 1}</span>
|
||||
<span>{item}</span>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function FlowDiagram({ items }: { items: string[] }) {
|
||||
return (
|
||||
<div>
|
||||
<p className="mb-2 text-sm font-medium">Ablaufdiagramm</p>
|
||||
<div className="overflow-x-auto rounded-md border bg-muted/20 p-3">
|
||||
<div className="flex min-w-[520px] flex-col items-center gap-2 text-center text-xs text-muted-foreground">
|
||||
{items.map((item, index) => (
|
||||
<div key={item} className="contents">
|
||||
<div className="w-full max-w-[360px] rounded-md border bg-background px-3 py-2 font-medium text-foreground">{item}</div>
|
||||
{index < items.length - 1 ? <div className="text-muted-foreground">↓</div> : null}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function FaqPage() {
|
||||
return (
|
||||
@@ -11,7 +46,7 @@ export function FaqPage() {
|
||||
<div className="flex flex-col gap-3 lg:flex-row lg:items-end lg:justify-between">
|
||||
<div>
|
||||
<h2 className="text-2xl font-semibold tracking-normal">FAQ und Anleitung</h2>
|
||||
<p className="text-sm text-muted-foreground">Ausführliche Anleitung für Timer, Auswertung, Teamspace, fixe Abrechnungen, Monatsabschluss und Adminfunktionen.</p>
|
||||
<p className="text-sm text-muted-foreground">Ausführliche Anleitung für Timer, Tickets, Teamspace, fixe Abrechnungen, Monatsabschluss und Adminfunktionen.</p>
|
||||
</div>
|
||||
<Badge variant="outline">{helpSections.length} Abschnitt(e)</Badge>
|
||||
</div>
|
||||
@@ -34,13 +69,18 @@ export function FaqPage() {
|
||||
</Card>
|
||||
|
||||
<div className="grid gap-3 xl:grid-cols-2">
|
||||
{helpSections.map((section) => (
|
||||
<Card key={section.id} id={section.id} className="scroll-mt-16">
|
||||
{helpSections.map((section) => {
|
||||
const isWide = section.id === "grundprinzip" || section.id === "monatsworkflow";
|
||||
const isWorkflow = section.id === "monatsworkflow";
|
||||
|
||||
return (
|
||||
<Card key={section.id} id={section.id} className={cn("scroll-mt-16", isWide ? "xl:col-span-2" : "")}>
|
||||
<CardHeader className="p-4">
|
||||
<CardTitle>{section.title}</CardTitle>
|
||||
<CardDescription>{section.description}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4 px-4 pb-4">
|
||||
<CardContent className={cn("px-4 pb-4", isWorkflow ? "grid gap-5 lg:grid-cols-[minmax(0,1fr)_minmax(320px,0.9fr)]" : "space-y-4")}>
|
||||
<div className="space-y-4">
|
||||
<div className="rounded-md border bg-muted/20 p-3">
|
||||
<p className="mb-2 text-sm font-medium">Kurzfassung</p>
|
||||
<ul className="space-y-2 text-sm text-muted-foreground">
|
||||
@@ -63,7 +103,10 @@ export function FaqPage() {
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
<div className="flex justify-end">
|
||||
{section.workflow ? <WorkflowList items={section.workflow} /> : null}
|
||||
</div>
|
||||
{section.diagram ? <FlowDiagram items={section.diagram} /> : null}
|
||||
<div className={cn("flex justify-end", isWorkflow ? "lg:col-span-2" : "")}>
|
||||
<Button asChild variant="ghost" size="sm">
|
||||
<a href="#top">
|
||||
<ArrowUp className="size-4" />
|
||||
@@ -73,7 +116,8 @@ export function FaqPage() {
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</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", {
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion";
|
||||
import { Alert } from "@/components/ui/alert";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -21,8 +22,8 @@ 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 { currentMonth, currentWeek, formatDate, formatDateTime, formatMinutes } from "../format";
|
||||
import type { StatisticsCrmDay, StatisticsOpenSession, StatisticsOverview, StatisticsTeamspaceDay } from "../types";
|
||||
|
||||
type MonthlyClosePageProps = {
|
||||
onNavigate: (to: string) => void;
|
||||
@@ -31,6 +32,17 @@ type MonthlyClosePageProps = {
|
||||
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";
|
||||
|
||||
type MonthlyCloseViewMode = "month" | "week";
|
||||
|
||||
function readMonthlyCloseViewMode(): MonthlyCloseViewMode {
|
||||
if (typeof window === "undefined") {
|
||||
return "month";
|
||||
}
|
||||
|
||||
return window.localStorage.getItem(monthlyCloseViewModeStorageKey) === "week" ? "week" : "month";
|
||||
}
|
||||
|
||||
function formatSignedMinutes(minutes: number) {
|
||||
if (minutes === 0) {
|
||||
@@ -62,6 +74,7 @@ type OpenTicketGroup = {
|
||||
ticketId: string;
|
||||
ticketNumber: string;
|
||||
organizationName: string;
|
||||
month: string;
|
||||
openCount: number;
|
||||
totalMinutes: number;
|
||||
};
|
||||
@@ -82,6 +95,7 @@ function groupOpenSessions(sessions: StatisticsOpenSession[]) {
|
||||
ticketId: session.ticket_id,
|
||||
ticketNumber: session.ticket_number,
|
||||
organizationName: session.organization_name,
|
||||
month: session.started_at.slice(0, 7),
|
||||
openCount: 1,
|
||||
totalMinutes: session.rounded_minutes
|
||||
});
|
||||
@@ -90,20 +104,20 @@ function groupOpenSessions(sessions: StatisticsOpenSession[]) {
|
||||
return Array.from(groups.values()).sort((left, right) => left.ticketNumber.localeCompare(right.ticketNumber, "de", { numeric: true, sensitivity: "base" }));
|
||||
}
|
||||
|
||||
function OpenTicketItem({ group, month, onNavigate }: { group: OpenTicketGroup; month: string; onNavigate: (to: string) => void }) {
|
||||
function OpenTicketItem({ group, onNavigate }: { group: OpenTicketGroup; onNavigate: (to: string) => void }) {
|
||||
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="rounded-md border bg-background px-3 py-2">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0 space-y-1">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="font-medium">{group.ticketNumber}</span>
|
||||
<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>
|
||||
<p className={`text-xs font-medium ${trackedTextClass}`}>{formatMinutes(group.totalMinutes)} offen zu bewerten</p>
|
||||
</div>
|
||||
<div className="flex justify-end">
|
||||
<Button variant="secondary" size="sm" onClick={() => onNavigate(`/analysis/month/${month}/tickets/${group.ticketId}`)}>
|
||||
<Button className="h-8 shrink-0" variant="secondary" size="sm" onClick={() => onNavigate(`/tickets/${group.ticketId}?month=${group.month}`)}>
|
||||
Ticket öffnen
|
||||
<ExternalLink className="size-4" />
|
||||
</Button>
|
||||
@@ -126,8 +140,9 @@ function CrmDayItem({
|
||||
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={`rounded-md border bg-background px-3 py-2 ${isAcknowledged ? "opacity-75" : ""}`}>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0 space-y-1">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="font-medium">{item.ticket_number}</span>
|
||||
<CopyTicketButton ticketNumber={item.ticket_number} />
|
||||
@@ -136,26 +151,56 @@ function CrmDayItem({
|
||||
</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}>Sessions {formatMinutes(item.tracked_minutes)}</span>
|
||||
<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)}>
|
||||
<div className="flex shrink-0 flex-wrap justify-end gap-2">
|
||||
<Button className="h-8" variant={isAcknowledged ? "secondary" : "ghost"} size="sm" disabled={busy} onClick={() => onSetAcknowledgement(item, !isAcknowledged)}>
|
||||
{isAcknowledged ? "Zurücknehmen" : "Zur Kenntnis"}
|
||||
</Button>
|
||||
<Button variant="secondary" size="sm" onClick={() => onNavigate(`/analysis/day/${item.day}/tickets/${item.ticket_id}`)}>
|
||||
<Button className="h-8" variant="secondary" size="sm" onClick={() => onNavigate(`/tickets/${item.ticket_id}?month=${item.day.slice(0, 7)}`)}>
|
||||
Tag öffnen
|
||||
<ExternalLink className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TeamspaceDayItem({ item, onNavigate }: { item: StatisticsTeamspaceDay; onNavigate: (to: string) => void }) {
|
||||
return (
|
||||
<div className="grid gap-3 rounded-md border bg-background px-3 py-2 lg:grid-cols-[minmax(8rem,0.75fr)_minmax(0,1.65fr)_minmax(8rem,0.6fr)] lg:items-center">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Badge variant="outline">{formatDate(`${item.day}T00:00:00`)}</Badge>
|
||||
</div>
|
||||
<div className="min-w-0 space-y-1">
|
||||
<div className="flex min-w-0 flex-wrap items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
className="font-medium underline-offset-4 hover:underline"
|
||||
onClick={() => onNavigate(`/tickets/${item.ticket_id}?month=${item.day.slice(0, 7)}`)}
|
||||
>
|
||||
{item.ticket_number}
|
||||
</button>
|
||||
<CopyTicketButton ticketNumber={item.ticket_number} />
|
||||
{item.project_teamspace_project ? <Badge variant="secondary">{item.project_teamspace_project}</Badge> : null}
|
||||
</div>
|
||||
<p className="truncate text-sm text-muted-foreground">{item.organization_name}</p>
|
||||
</div>
|
||||
<div className="text-sm">
|
||||
<Badge variant="success">{formatMinutes(item.crm_billed_minutes)}</Badge>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function MonthlyClosePage({ 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);
|
||||
@@ -164,6 +209,11 @@ export function MonthlyClosePage({ onNavigate }: MonthlyClosePageProps) {
|
||||
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";
|
||||
|
||||
async function load() {
|
||||
const requestId = loadRequestId.current + 1;
|
||||
@@ -171,14 +221,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 +241,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 +265,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 {
|
||||
@@ -272,18 +342,35 @@ export function MonthlyClosePage({ onNavigate }: MonthlyClosePageProps) {
|
||||
const openSessions = stats?.attention.openSessions ?? [];
|
||||
const missingCrmDays = stats?.attention.missingCrmDays ?? [];
|
||||
const acknowledgedMissingCrmDays = stats?.attention.acknowledgedMissingCrmDays ?? [];
|
||||
const teamspaceDays = stats?.teamspaceDays ?? [];
|
||||
const openTicketGroups = useMemo(() => groupOpenSessions(openSessions), [openSessions]);
|
||||
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 canClose = Boolean(isMonthView && stats && !stats.closed && stats.totals.sessions > 0 && openSessions.length === 0);
|
||||
const checklist = useMemo(
|
||||
() => [
|
||||
{ label: "Offene Bewertungen", count: openSessions.length, detail: `${openTicketGroups.length} Ticket(s)`, blocker: true },
|
||||
{ label: "Tage ohne Teamspace-Wert", count: missingCrmDays.length, detail: `${acknowledgedMissingCrmDays.length} zur Kenntnis genommen`, blocker: false }
|
||||
{
|
||||
label: "Offene Bewertungen",
|
||||
ok: openSessions.length === 0,
|
||||
value: openSessions.length,
|
||||
detail: openSessions.length === 0 ? "Keine offenen Bewertungen." : `${openTicketGroups.length} Ticket(s) bearbeiten`
|
||||
},
|
||||
{
|
||||
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`
|
||||
},
|
||||
{
|
||||
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, openSessions.length, openTicketGroups.length, missingCrmDays.length]
|
||||
[acknowledgedMissingCrmDays.length, canClose, isMonthView, openSessions.length, openTicketGroups.length, missingCrmDays.length, stats?.closed]
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -291,25 +378,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 Teamspace-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"}
|
||||
@@ -323,7 +431,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>
|
||||
@@ -331,11 +439,16 @@ 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)} getrackt`} />
|
||||
<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="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>
|
||||
@@ -348,14 +461,14 @@ export function MonthlyClosePage({ onNavigate }: MonthlyClosePageProps) {
|
||||
<HelpLink anchor="monatsabschluss" label="Hilfe zum Monatsabschluss" />
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="grid gap-2 px-4 pb-4 md:grid-cols-2">
|
||||
<CardContent className="grid gap-2 px-4 pb-4 md:grid-cols-3">
|
||||
{checklist.map((item) => (
|
||||
<div key={item.label} className="rounded-md border bg-background p-3">
|
||||
<div className="mb-2 flex items-center justify-between gap-2">
|
||||
<span className="text-sm font-medium">{item.label}</span>
|
||||
<Badge variant={item.count > 0 ? "warning" : "success"}>{item.count}</Badge>
|
||||
<Badge variant={item.ok ? "success" : "warning"}>{item.value}</Badge>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">{item.blocker ? "Muss erledigt sein." : item.detail}</p>
|
||||
<p className="text-xs text-muted-foreground">{item.detail}</p>
|
||||
</div>
|
||||
))}
|
||||
</CardContent>
|
||||
@@ -366,7 +479,7 @@ export function MonthlyClosePage({ onNavigate }: MonthlyClosePageProps) {
|
||||
<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>
|
||||
<CardDescription>Alles, was für {periodAttentionLabel} noch Aufmerksamkeit braucht.</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">
|
||||
@@ -388,7 +501,7 @@ export function MonthlyClosePage({ onNavigate }: MonthlyClosePageProps) {
|
||||
Offene Bewertungen
|
||||
</div>
|
||||
{openTicketGroups.map((group) => (
|
||||
<OpenTicketItem key={group.ticketId} group={group} month={month} onNavigate={onNavigate} />
|
||||
<OpenTicketItem key={group.ticketId} group={group} 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>
|
||||
@@ -409,20 +522,42 @@ export function MonthlyClosePage({ onNavigate }: MonthlyClosePageProps) {
|
||||
))}
|
||||
{visibleMissingCrmDays.length === 0 ? (
|
||||
<p className="rounded-md border bg-muted/20 p-3 text-sm text-muted-foreground">
|
||||
{acknowledgedMissingCrmDays.length > 0 ? "Alle offenen Punkte sind zur Kenntnis genommen." : "Alle getrackten Tage haben einen Teamspace-Wert."}
|
||||
{acknowledgedMissingCrmDays.length > 0 ? "Alle offenen Punkte sind zur Kenntnis genommen." : "Alle abrechenbaren Tage haben einen Teamspace-Wert."}
|
||||
</p>
|
||||
) : null}
|
||||
</section>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Accordion type="single" collapsible className="rounded-md border bg-card px-4">
|
||||
<AccordionItem value="teamspace-entries" className="border-b-0">
|
||||
<AccordionTrigger className="py-3 hover:no-underline">
|
||||
<span className="flex min-w-0 flex-wrap items-center gap-2">
|
||||
<span>Teamspace-Einträge</span>
|
||||
<Badge variant="outline">{teamspaceDays.length} Eintrag(e)</Badge>
|
||||
</span>
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="space-y-2 pb-4">
|
||||
{teamspaceDays.length > 0 ? (
|
||||
<div className="max-h-[28rem] space-y-2 overflow-y-auto pr-1">
|
||||
{teamspaceDays.map((item) => (
|
||||
<TeamspaceDayItem key={`${item.ticket_id}-${item.day}`} item={item} onNavigate={onNavigate} />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="rounded-md border bg-muted/20 p-3 text-sm text-muted-foreground">Für {periodAttentionLabel} sind noch keine Teamspace-Werte zu abgerechneten Ticket-Tagen vorhanden.</p>
|
||||
)}
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
|
||||
{stats && stats.totals.sessions === 0 ? (
|
||||
<Card>
|
||||
<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,15 @@
|
||||
import { FormEvent, useEffect, useState } from "react";
|
||||
import { KeyRound, Save, UserCircle } 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 { 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;
|
||||
@@ -19,13 +22,101 @@ export function ProfilePage({ currentUser, onUserUpdated }: ProfilePageProps) {
|
||||
const [currentPassword, setCurrentPassword] = useState("");
|
||||
const [newPassword, setNewPassword] = useState("");
|
||||
const [confirmPassword, setConfirmPassword] = useState("");
|
||||
const [zammadApiToken, setZammadApiToken] = useState("");
|
||||
const [zammadTokenDirty, setZammadTokenDirty] = useState(false);
|
||||
const [teamspaceDeviceId, setTeamspaceDeviceId] = useState("");
|
||||
const [teamspaceDevicePassword, setTeamspaceDevicePassword] = useState("");
|
||||
const [teamspaceCredentialsDirty, setTeamspaceCredentialsDirty] = useState(false);
|
||||
const [zammadEmailSessionWebhookEnabled, setZammadEmailSessionWebhookEnabled] = useState(currentUser.zammad_email_session_webhook_enabled);
|
||||
const [zammadAtossEmailSyncEnabled, setZammadAtossEmailSyncEnabled] = useState(currentUser.zammad_atoss_email_sync_enabled);
|
||||
const [sessionBillingPromptEnabled, setSessionBillingPromptEnabled] = useState(currentUser.session_billing_prompt_enabled);
|
||||
const [teamspaceTimeTrackingEnabled, setTeamspaceTimeTrackingEnabled] = useState(currentUser.teamspace_time_tracking_enabled ?? true);
|
||||
const [zammadSessionNoteDefaultEnabled, setZammadSessionNoteDefaultEnabled] = useState(currentUser.zammad_session_note_default_enabled);
|
||||
const [zammadTimeUnitRoundingEnabled, setZammadTimeUnitRoundingEnabled] = useState(currentUser.zammad_time_unit_rounding_enabled);
|
||||
const [zammadTimeUnitRoundingMinutes, setZammadTimeUnitRoundingMinutes] = useState(currentUser.zammad_time_unit_rounding_minutes);
|
||||
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(() => {
|
||||
setUsername(currentUser.username);
|
||||
setDisplayName(currentUser.display_name);
|
||||
setZammadApiToken("");
|
||||
setZammadTokenDirty(false);
|
||||
setTeamspaceDeviceId("");
|
||||
setTeamspaceDevicePassword("");
|
||||
setTeamspaceCredentialsDirty(false);
|
||||
setZammadEmailSessionWebhookEnabled(currentUser.zammad_email_session_webhook_enabled);
|
||||
setZammadAtossEmailSyncEnabled(currentUser.zammad_atoss_email_sync_enabled);
|
||||
setSessionBillingPromptEnabled(currentUser.session_billing_prompt_enabled);
|
||||
setTeamspaceTimeTrackingEnabled(currentUser.teamspace_time_tracking_enabled ?? true);
|
||||
setZammadSessionNoteDefaultEnabled(currentUser.zammad_session_note_default_enabled);
|
||||
setZammadTimeUnitRoundingEnabled(currentUser.zammad_time_unit_rounding_enabled);
|
||||
setZammadTimeUnitRoundingMinutes(currentUser.zammad_time_unit_rounding_minutes);
|
||||
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();
|
||||
|
||||
@@ -45,17 +136,43 @@ export function ProfilePage({ currentUser, onUserUpdated }: ProfilePageProps) {
|
||||
|
||||
setSaving(true);
|
||||
|
||||
try {
|
||||
const result = await updateCurrentUser({
|
||||
const payload = {
|
||||
username,
|
||||
displayName,
|
||||
currentPassword: wantsPasswordChange ? currentPassword : undefined,
|
||||
newPassword: wantsPasswordChange ? newPassword : undefined
|
||||
});
|
||||
newPassword: wantsPasswordChange ? newPassword : undefined,
|
||||
...(currentUser.role === "user" && zammadTokenDirty ? { zammadApiToken } : {}),
|
||||
...(currentUser.role === "user" && teamspaceCredentialsDirty ? { teamspaceDeviceId, teamspaceDevicePassword } : {}),
|
||||
...(currentUser.role === "user"
|
||||
? {
|
||||
zammadEmailSessionWebhookEnabled,
|
||||
zammadAtossEmailSyncEnabled,
|
||||
sessionBillingPromptEnabled,
|
||||
teamspaceTimeTrackingEnabled,
|
||||
zammadSessionNoteDefaultEnabled,
|
||||
zammadTimeUnitRoundingEnabled,
|
||||
zammadTimeUnitRoundingMinutes,
|
||||
zammadArticleTypeId,
|
||||
projectFollowupEnabled,
|
||||
projectFollowupWeeks,
|
||||
projectFollowupSecondEnabled,
|
||||
projectFollowupSecondWeeks,
|
||||
projectFollowupCompleteOnSession
|
||||
}
|
||||
: {})
|
||||
};
|
||||
|
||||
try {
|
||||
const result = await updateCurrentUser(payload);
|
||||
onUserUpdated(result.user);
|
||||
setCurrentPassword("");
|
||||
setNewPassword("");
|
||||
setConfirmPassword("");
|
||||
setZammadApiToken("");
|
||||
setZammadTokenDirty(false);
|
||||
setTeamspaceDeviceId("");
|
||||
setTeamspaceDevicePassword("");
|
||||
setTeamspaceCredentialsDirty(false);
|
||||
toast.success("Profil gespeichert");
|
||||
} catch (error) {
|
||||
toast.error("Profil konnte nicht gespeichert werden", {
|
||||
@@ -146,6 +263,391 @@ export function ProfilePage({ currentUser, onUserUpdated }: ProfilePageProps) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{currentUser.role === "user" ? (
|
||||
<div className="rounded-md border bg-muted/30 p-3">
|
||||
<div className="mb-3 flex items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Unplug className="size-4 text-muted-foreground" />
|
||||
<p className="text-sm font-medium">Zammad API</p>
|
||||
</div>
|
||||
<HelpLink anchor="profil" label="Hilfe zu Zammad im Profil" />
|
||||
</div>
|
||||
<div className="grid gap-3 md:grid-cols-[minmax(220px,1fr)_180px]">
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium" htmlFor="profile-zammad-token">
|
||||
Persönlicher API-Token
|
||||
</label>
|
||||
<Input
|
||||
id="profile-zammad-token"
|
||||
type="password"
|
||||
value={zammadApiToken}
|
||||
placeholder={currentUser.has_zammad_api_token ? "••••••••••••••••" : "Token aus Zammad einfügen"}
|
||||
autoComplete="off"
|
||||
onChange={(event) => {
|
||||
setZammadApiToken(event.currentTarget.value);
|
||||
setZammadTokenDirty(true);
|
||||
}}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Die Zammad-ID wird nur geprüft, wenn du hier einen neuen Token speicherst.
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium" htmlFor="profile-zammad-user-id">
|
||||
Zammad User-ID
|
||||
</label>
|
||||
<Input id="profile-zammad-user-id" value={currentUser.zammad_user_id ?? "nicht verknüpft"} readOnly />
|
||||
{currentUser.has_zammad_api_token ? (
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
className="w-full"
|
||||
onClick={() => {
|
||||
setZammadApiToken("");
|
||||
setZammadTokenDirty(true);
|
||||
}}
|
||||
>
|
||||
<X className="size-4" />
|
||||
Token entfernen
|
||||
</Button>
|
||||
) : 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 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>
|
||||
{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}
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button type="submit" disabled={saving}>
|
||||
<Save className="size-4" />
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -204,7 +204,7 @@ export function RecurringBillingsPage() {
|
||||
setRecurringForm(emptyRecurringForm());
|
||||
setEditingBillingId(null);
|
||||
toast.success(editingBillingId ? "Fixe Abrechnung gespeichert" : "Fixe Abrechnung angelegt", {
|
||||
description: "Die Sessions erscheinen automatisch in der Auswertung, sobald du den Zeitraum öffnest. Nicht mehr gültige erzeugte Sessions werden entfernt."
|
||||
description: "Die Sessions erscheinen automatisch in Tickets, sobald du den Zeitraum öffnest. Nicht mehr gültige erzeugte Sessions werden entfernt."
|
||||
});
|
||||
} catch (error) {
|
||||
toast.error("Fixe Abrechnung konnte nicht angelegt werden", {
|
||||
@@ -454,7 +454,7 @@ export function RecurringBillingsPage() {
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<CardTitle>Regeln</CardTitle>
|
||||
<CardDescription>Erzeugte Einträge können in der Auswertung verschoben oder gelöscht werden.</CardDescription>
|
||||
<CardDescription>Erzeugte Einträge können in Tickets verschoben oder gelöscht werden.</CardDescription>
|
||||
</div>
|
||||
<HelpLink anchor="fixe-abrechnungen" label="Hilfe zu erzeugten fixen Sessions" />
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,244 @@
|
||||
import { ChangeEvent, FormEvent, useState } from "react";
|
||||
import { CheckCircle2, DatabaseBackup, KeyRound, Upload } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { createSetupAdmin, restoreSetupBackup, validateSetupBackup, type RestoreValidationResult } from "../api";
|
||||
import type { AuthUser } from "../types";
|
||||
|
||||
type SetupPageProps = {
|
||||
onAdminCreated: (user: AuthUser) => void;
|
||||
onRestoreComplete: () => void;
|
||||
};
|
||||
|
||||
export function SetupPage({ onAdminCreated, onRestoreComplete }: SetupPageProps) {
|
||||
const [username, setUsername] = useState("admin");
|
||||
const [displayName, setDisplayName] = useState("Admin");
|
||||
const [password, setPassword] = useState("");
|
||||
const [passwordRepeat, setPasswordRepeat] = useState("");
|
||||
const [restoreSql, setRestoreSql] = useState("");
|
||||
const [restoreValidation, setRestoreValidation] = useState<RestoreValidationResult | null>(null);
|
||||
const [creatingAdmin, setCreatingAdmin] = useState(false);
|
||||
const [validatingRestore, setValidatingRestore] = useState(false);
|
||||
const [restoring, setRestoring] = useState(false);
|
||||
|
||||
async function createAdmin(event: FormEvent) {
|
||||
event.preventDefault();
|
||||
|
||||
if (password !== passwordRepeat) {
|
||||
toast.error("Passwörter stimmen nicht überein");
|
||||
return;
|
||||
}
|
||||
|
||||
setCreatingAdmin(true);
|
||||
|
||||
try {
|
||||
const result = await createSetupAdmin({ username, displayName, password });
|
||||
toast.success("Admin angelegt");
|
||||
onAdminCreated(result.user);
|
||||
} catch (error) {
|
||||
toast.error("Admin konnte nicht angelegt werden", {
|
||||
description: error instanceof Error ? error.message : "Unbekannter Fehler"
|
||||
});
|
||||
} finally {
|
||||
setCreatingAdmin(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function readBackupFile(event: ChangeEvent<HTMLInputElement>) {
|
||||
const file = event.currentTarget.files?.[0];
|
||||
|
||||
if (!file) {
|
||||
return;
|
||||
}
|
||||
|
||||
const sql = await file.text();
|
||||
setRestoreSql(sql);
|
||||
setRestoreValidation(null);
|
||||
await validateRestore(sql);
|
||||
}
|
||||
|
||||
async function validateRestore(sql = restoreSql) {
|
||||
if (!sql.trim()) {
|
||||
setRestoreValidation(null);
|
||||
return;
|
||||
}
|
||||
|
||||
setValidatingRestore(true);
|
||||
|
||||
try {
|
||||
const result = await validateSetupBackup(sql);
|
||||
setRestoreValidation(result);
|
||||
|
||||
if (result.valid) {
|
||||
toast.success("Backup-Struktur passt", {
|
||||
description: `${result.checkedTables} Tabelle(n) geprüft.`
|
||||
});
|
||||
} else {
|
||||
toast.error("Backup-Struktur passt nicht", {
|
||||
description: result.issues[0] ?? "Unbekannter Strukturfehler"
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
setRestoreValidation({
|
||||
valid: false,
|
||||
checkedTables: 0,
|
||||
issues: [error instanceof Error ? error.message : "Unbekannter Fehler"]
|
||||
});
|
||||
toast.error("Backup-Prüfung fehlgeschlagen", {
|
||||
description: error instanceof Error ? error.message : "Unbekannter Fehler"
|
||||
});
|
||||
} finally {
|
||||
setValidatingRestore(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function restoreBackup(event: FormEvent) {
|
||||
event.preventDefault();
|
||||
|
||||
if (!restoreValidation?.valid) {
|
||||
toast.error("Backup zuerst prüfen", {
|
||||
description: "Der Restore kann erst nach erfolgreicher Strukturprüfung ausgeführt werden."
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
setRestoring(true);
|
||||
|
||||
try {
|
||||
await restoreSetupBackup(restoreSql);
|
||||
toast.success("Backup eingespielt", {
|
||||
description: "Melde dich jetzt mit einem Benutzer aus dem Backup an."
|
||||
});
|
||||
onRestoreComplete();
|
||||
} catch (error) {
|
||||
toast.error("Restore fehlgeschlagen", {
|
||||
description: error instanceof Error ? error.message : "Unbekannter Fehler"
|
||||
});
|
||||
} finally {
|
||||
setRestoring(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="min-h-screen bg-background p-4">
|
||||
<div className="mx-auto flex min-h-[calc(100vh-2rem)] w-full max-w-4xl flex-col justify-center gap-4">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold tracking-normal">TicketTracker Setup</h1>
|
||||
<p className="text-sm text-muted-foreground">Es wurde noch kein Admin gefunden. Lege einen Admin an oder spiele ein bestehendes Backup ein.</p>
|
||||
</div>
|
||||
|
||||
<Tabs defaultValue="admin" className="w-full">
|
||||
<TabsList className="grid w-full grid-cols-2">
|
||||
<TabsTrigger value="admin">Admin anlegen</TabsTrigger>
|
||||
<TabsTrigger value="restore">Backup einspielen</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="admin">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center gap-2">
|
||||
<KeyRound className="size-5 text-muted-foreground" />
|
||||
<CardTitle>Festen Admin erstellen</CardTitle>
|
||||
</div>
|
||||
<CardDescription>Dieser Account ist nur für Systemverwaltung, Benutzer, Zammad-Sync und Backups vorgesehen.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form className="grid gap-4 sm:grid-cols-2" onSubmit={createAdmin}>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium" htmlFor="setup-username">Benutzername</label>
|
||||
<Input id="setup-username" value={username} onChange={(event) => setUsername(event.currentTarget.value)} autoComplete="username" autoFocus required />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium" htmlFor="setup-display-name">Voller Name</label>
|
||||
<Input id="setup-display-name" value={displayName} onChange={(event) => setDisplayName(event.currentTarget.value)} required />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium" htmlFor="setup-password">Passwort</label>
|
||||
<Input id="setup-password" type="password" value={password} onChange={(event) => setPassword(event.currentTarget.value)} autoComplete="new-password" minLength={6} required />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium" htmlFor="setup-password-repeat">Passwort wiederholen</label>
|
||||
<Input id="setup-password-repeat" type="password" value={passwordRepeat} onChange={(event) => setPasswordRepeat(event.currentTarget.value)} autoComplete="new-password" minLength={6} required />
|
||||
</div>
|
||||
<Button className="sm:col-span-2" type="submit" disabled={creatingAdmin}>
|
||||
<KeyRound className="size-4" />
|
||||
{creatingAdmin ? "Erstellt..." : "Admin erstellen und anmelden"}
|
||||
</Button>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="restore">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center gap-2">
|
||||
<DatabaseBackup className="size-5 text-muted-foreground" />
|
||||
<CardTitle>Backup einspielen</CardTitle>
|
||||
</div>
|
||||
<CardDescription>Nutze ein SQL-Backup aus dem TicketTracker-Adminbereich.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form className="space-y-4" onSubmit={restoreBackup}>
|
||||
<Alert variant="warning">
|
||||
<Upload className="size-4" />
|
||||
<AlertTitle>Restore ersetzt den kompletten Systemstand</AlertTitle>
|
||||
<AlertDescription>
|
||||
Vor dem Einspielen werden alle TicketTracker-Tabellen geleert. Danach gilt ausschließlich der Stand des Backups, inklusive aller Benutzer,
|
||||
Passwörter, Sessions, Teamspace-Werte, Einstellungen und Abschlüsse.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium" htmlFor="setup-restore-file">SQL-Datei</label>
|
||||
<Input id="setup-restore-file" type="file" accept=".sql,text/sql,text/plain" onChange={(event) => void readBackupFile(event)} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium" htmlFor="setup-restore-sql">Backup-Inhalt</label>
|
||||
<Textarea
|
||||
id="setup-restore-sql"
|
||||
className="min-h-44 font-mono text-xs"
|
||||
value={restoreSql}
|
||||
onChange={(event) => {
|
||||
setRestoreSql(event.currentTarget.value);
|
||||
setRestoreValidation(null);
|
||||
}}
|
||||
placeholder="SQL-Backup hier einfügen oder Datei auswählen"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
{restoreValidation ? (
|
||||
<Alert variant={restoreValidation.valid ? "success" : "destructive"}>
|
||||
{restoreValidation.valid ? <CheckCircle2 className="size-4" /> : <Upload className="size-4" />}
|
||||
<AlertTitle>{restoreValidation.valid ? "Strukturprüfung erfolgreich" : "Strukturprüfung fehlgeschlagen"}</AlertTitle>
|
||||
<AlertDescription>
|
||||
{restoreValidation.valid
|
||||
? `${restoreValidation.checkedTables} Tabelle(n) passen zum aktuellen Datenbankschema.`
|
||||
: restoreValidation.issues.slice(0, 4).join(" ")}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
) : null}
|
||||
<div className="grid gap-2 sm:grid-cols-2">
|
||||
<Button type="button" variant="secondary" disabled={validatingRestore || !restoreSql.trim()} onClick={() => void validateRestore()}>
|
||||
<CheckCircle2 className="size-4" />
|
||||
{validatingRestore ? "Prüft..." : "Struktur prüfen"}
|
||||
</Button>
|
||||
<Button type="submit" variant="destructive" disabled={restoring || !restoreValidation?.valid}>
|
||||
<DatabaseBackup className="size-4" />
|
||||
{restoring ? "Spielt ein..." : "Backup einspielen"}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import {
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
BellPlus,
|
||||
CircleAlert,
|
||||
Clock3,
|
||||
ExternalLink,
|
||||
@@ -8,6 +9,7 @@ import {
|
||||
RotateCcw,
|
||||
Ticket,
|
||||
TrendingUp,
|
||||
UserCheck,
|
||||
} from "lucide-react";
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
@@ -20,24 +22,26 @@ 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, WorkType } from "../types";
|
||||
|
||||
type StatisticsPageProps = {
|
||||
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 teamspaceTextClass = "text-fuchsia-700 dark:text-fuchsia-300";
|
||||
const trackedFillClass = "fill-sky-500/70 dark:fill-sky-400/65";
|
||||
const teamspaceStrokeClass = "text-emerald-600 dark:text-emerald-300";
|
||||
const teamspaceStrokeClass = "text-fuchsia-600 dark:text-fuchsia-300";
|
||||
const trackedDotClass = "bg-sky-500 dark:bg-sky-400";
|
||||
const teamspaceDotClass = "bg-emerald-500 dark:bg-emerald-400";
|
||||
const evaluatedTextClass = "text-violet-700 dark:text-violet-300";
|
||||
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) {
|
||||
@@ -60,32 +64,6 @@ function percentage(value: number, total: number) {
|
||||
return Math.max(0, Math.min(100, Math.round((value / total) * 100)));
|
||||
}
|
||||
|
||||
function roundedPercentages(values: number[]) {
|
||||
const total = values.reduce((sum, value) => sum + value, 0);
|
||||
|
||||
if (total <= 0) {
|
||||
return values.map(() => 0);
|
||||
}
|
||||
|
||||
const raw = values.map((value) => (value / total) * 100);
|
||||
const floors = raw.map(Math.floor);
|
||||
let remainder = 100 - floors.reduce((sum, value) => sum + value, 0);
|
||||
const order = raw
|
||||
.map((value, index) => ({ index, fraction: value - Math.floor(value) }))
|
||||
.sort((left, right) => right.fraction - left.fraction);
|
||||
|
||||
for (const item of order) {
|
||||
if (remainder <= 0) {
|
||||
break;
|
||||
}
|
||||
|
||||
floors[item.index] += 1;
|
||||
remainder -= 1;
|
||||
}
|
||||
|
||||
return floors;
|
||||
}
|
||||
|
||||
function formatSignedMinutes(minutes: number) {
|
||||
if (minutes === 0) {
|
||||
return "ausgeglichen";
|
||||
@@ -112,37 +90,47 @@ function DeltaBadge({ minutes }: { minutes: number }) {
|
||||
|
||||
function deltaDescription(minutes: number) {
|
||||
if (minutes > 0) {
|
||||
return "Teamspace liegt über den Sessions";
|
||||
return "Teamspace liegt über der abgerechneten Zeit";
|
||||
}
|
||||
|
||||
if (minutes < 0) {
|
||||
return "Teamspace liegt unter den Sessions";
|
||||
return "Teamspace liegt unter der abgerechneten Zeit";
|
||||
}
|
||||
|
||||
return "Teamspace und Sessions sind ausgeglichen";
|
||||
return "Teamspace und abgerechnete 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}`}>Sessions {formatMinutes(trackedMinutes)}</div>
|
||||
<div className={`font-semibold ${trackedTextClass}`}>Abgerechnet {formatMinutes(trackedMinutes)}</div>
|
||||
<div className={`font-semibold ${teamspaceTextClass}`}>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">
|
||||
<span className={`size-2 rounded-sm ${trackedDotClass}`} />
|
||||
<span className={trackedTextClass}>Sessions</span>
|
||||
<span className={trackedTextClass}>Abgerechnet</span>
|
||||
</span>
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<span className={`h-0.5 w-4 ${teamspaceDotClass}`} />
|
||||
<span className={teamspaceTextClass}>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>
|
||||
@@ -151,10 +139,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>
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<span className="size-2 rounded-full bg-violet-500 dark:bg-violet-400" />
|
||||
<span className={evaluatedTextClass}>bewertet</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>
|
||||
@@ -221,24 +207,22 @@ function buildMonthSeries(month: string, buckets: StatisticsDailyBucket[]) {
|
||||
sessions: bucket?.sessions ?? 0,
|
||||
totalMinutes: bucket?.total_minutes ?? 0,
|
||||
billedMinutes: bucket?.billed_minutes ?? 0,
|
||||
nonBillableMinutes: bucket?.non_billable_minutes ?? 0,
|
||||
openMinutes: bucket?.open_minutes ?? 0,
|
||||
billedSessions: bucket?.billed_sessions ?? 0,
|
||||
nonBillableSessions: bucket?.non_billable_sessions ?? 0,
|
||||
openSessions: bucket?.open_sessions ?? 0,
|
||||
crmBilledMinutes: bucket?.crm_billed_minutes ?? 0
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
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));
|
||||
@@ -252,11 +236,11 @@ function DailyEffortChart({ month, buckets }: { month: string; buckets: Statisti
|
||||
crmY: padding.top + (1 - bucket.crmBilledMinutes / maxMinutes) * innerHeight
|
||||
};
|
||||
});
|
||||
const crmPath = buildChartPath(points.filter((point) => point.bucket.crmBilledMinutes > 0).map((point) => ({ x: point.x, y: point.crmY })));
|
||||
const crmPath = showTeamspaceLine ? buildChartPath(points.filter((point) => point.bucket.crmBilledMinutes > 0).map((point) => ({ x: point.x, y: point.crmY }))) : "";
|
||||
|
||||
return (
|
||||
<div className="h-56 w-full overflow-hidden rounded-md border bg-background">
|
||||
{series.some((bucket) => bucket.totalMinutes > 0 || bucket.crmBilledMinutes > 0) ? (
|
||||
{series.some((bucket) => bucket.totalMinutes > 0 || (showTeamspaceLine && bucket.crmBilledMinutes > 0)) ? (
|
||||
<svg viewBox={`0 0 ${width} ${height}`} className="h-full w-full">
|
||||
{ticks.map((tick) => {
|
||||
const y = padding.top + (1 - tick / maxMinutes) * innerHeight;
|
||||
@@ -279,11 +263,11 @@ function DailyEffortChart({ month, buckets }: { month: string; buckets: Statisti
|
||||
) : null;
|
||||
})}
|
||||
{crmPath ? <path d={crmPath} fill="none" stroke="currentColor" strokeWidth="2.5" className={teamspaceStrokeClass} /> : null}
|
||||
{points
|
||||
{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]">
|
||||
@@ -333,7 +317,17 @@ function MetricCard({
|
||||
);
|
||||
}
|
||||
|
||||
function OrganizationRow({ organization, maxMinutes, totalMinutes }: { organization: StatisticsGroup; maxMinutes: number; totalMinutes: number }) {
|
||||
function OrganizationRow({
|
||||
organization,
|
||||
maxMinutes,
|
||||
totalMinutes,
|
||||
showDifference = true
|
||||
}: {
|
||||
organization: StatisticsGroup;
|
||||
maxMinutes: number;
|
||||
totalMinutes: number;
|
||||
showDifference?: boolean;
|
||||
}) {
|
||||
const monthlyShare = percentage(organization.total_minutes, totalMinutes);
|
||||
|
||||
return (
|
||||
@@ -341,16 +335,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.sessions} Session(s)
|
||||
{organization.tickets} Ticket(s) · {organization.billed_sessions} abgerechnet · {organization.billable_sessions} abrechenbar
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>{organization.sessions}</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>
|
||||
@@ -359,7 +355,17 @@ function OrganizationRow({ organization, maxMinutes, totalMinutes }: { organizat
|
||||
);
|
||||
}
|
||||
|
||||
function TicketHotspot({ ticket, month, onNavigate }: { ticket: StatisticsTicket; month: string; onNavigate: (to: string) => void }) {
|
||||
function TicketHotspot({
|
||||
ticket,
|
||||
month,
|
||||
showDifference = true,
|
||||
onNavigate
|
||||
}: {
|
||||
ticket: StatisticsTicket;
|
||||
month: string;
|
||||
showDifference?: boolean;
|
||||
onNavigate: (to: string) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="grid gap-2 rounded-md border bg-background p-3 sm:grid-cols-[1fr_auto] sm:items-center">
|
||||
<div className="min-w-0">
|
||||
@@ -369,14 +375,15 @@ function TicketHotspot({ ticket, month, onNavigate }: { ticket: StatisticsTicket
|
||||
</div>
|
||||
<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}>Sessions {formatMinutes(ticket.total_minutes)}</span>
|
||||
<span className={trackedTextClass}>Abgerechnet {formatMinutes(ticket.total_minutes)}</span>
|
||||
<span className={teamspaceTextClass}>Teamspace {formatMinutes(ticket.crm_billed_minutes)}</span>
|
||||
<span className="text-muted-foreground">{ticket.sessions} Session(s)</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>
|
||||
</div>
|
||||
<Button variant="secondary" size="sm" onClick={() => onNavigate(`/analysis/month/${month}/tickets/${ticket.ticket_id}`)}>
|
||||
<Button variant="secondary" size="sm" onClick={() => onNavigate(`/tickets/${ticket.ticket_id}?month=${month}`)}>
|
||||
Öffnen
|
||||
<ExternalLink className="size-4" />
|
||||
</Button>
|
||||
@@ -384,12 +391,72 @@ 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 hasBudget = 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>
|
||||
{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 {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;
|
||||
@@ -419,6 +486,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") {
|
||||
@@ -445,10 +550,16 @@ export function StatisticsPage({ onNavigate }: StatisticsPageProps) {
|
||||
}
|
||||
|
||||
const totals = stats?.totals;
|
||||
const evaluatedSessions = (totals?.billedSessions ?? 0) + (totals?.nonBillableSessions ?? 0);
|
||||
const evaluatedMinutes = (totals?.billedMinutes ?? 0) + (totals?.nonBillableMinutes ?? 0);
|
||||
const [evaluatedSessionShare, openSessionShare] = roundedPercentages([evaluatedSessions, totals?.openSessions ?? 0]);
|
||||
const [evaluatedTimeShare, openTimeShare] = roundedPercentages([evaluatedMinutes, totals?.openMinutes ?? 0]);
|
||||
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;
|
||||
@@ -475,7 +586,11 @@ export function StatisticsPage({ onNavigate }: StatisticsPageProps) {
|
||||
<div className="flex flex-col gap-3 xl:flex-row xl:items-end xl:justify-between">
|
||||
<div>
|
||||
<h2 className="text-2xl font-semibold tracking-normal">Statistiken</h2>
|
||||
<p className="text-sm text-muted-foreground">Monatswerte, Teamspace-Abgleich, Organisationen und offene Nacharbeit.</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{teamspaceTimeTrackingEnabled
|
||||
? "Monatswerte, Teamspace-Abgleich, Organisationen und offene Nacharbeit."
|
||||
: "Monatswerte, Session-Status, Organisationen und Teamspace-Istwerte."}
|
||||
</p>
|
||||
</div>
|
||||
<div className="grid gap-1 sm:w-[300px]">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
@@ -494,46 +609,51 @@ export function StatisticsPage({ onNavigate }: StatisticsPageProps) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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="Getrackt"
|
||||
label="Abgerechnet"
|
||||
value={formatMinutes(totals?.minutes ?? 0)}
|
||||
detail={`${totals?.sessions ?? 0} Session(s), ${totals?.tickets ?? 0} Ticket(s)`}
|
||||
detail={`${billedSessions} Session(s), ${totals?.tickets ?? 0} Ticket(s)`}
|
||||
icon={Clock3}
|
||||
valueClassName={trackedTextClass}
|
||||
helpAnchor="statistik-getrackt"
|
||||
helpLabel="Hilfe zur Kennzahl Getrackt"
|
||||
helpLabel="Hilfe zur Kennzahl Abgerechnet"
|
||||
/>
|
||||
<MetricCard
|
||||
label="Abrechenbar"
|
||||
value={formatMinutes(billableMinutes)}
|
||||
detail={`${billableSessions} Session(s)`}
|
||||
icon={UserCheck}
|
||||
helpAnchor="bewertungen"
|
||||
helpLabel="Hilfe zu Bewertungen"
|
||||
/>
|
||||
<MetricCard
|
||||
label="Teamspace"
|
||||
value={formatMinutes(totals?.crmBilledMinutes ?? 0)}
|
||||
detail={<span className={deltaTextClass(totals?.crmDeltaMinutes ?? 0)}>Differenz {formatTeamspaceDelta(totals?.crmDeltaMinutes ?? 0)}</span>}
|
||||
detail={teamspaceTimeTrackingEnabled
|
||||
? <span className={deltaTextClass(totals?.crmDeltaMinutes ?? 0)}>Differenz {formatTeamspaceDelta(totals?.crmDeltaMinutes ?? 0)}</span>
|
||||
: "Ist-Werte aus Teamspace"}
|
||||
icon={TrendingUp}
|
||||
valueClassName={teamspaceTextClass}
|
||||
helpAnchor="statistik-teamspace"
|
||||
helpLabel="Hilfe zur Kennzahl Teamspace"
|
||||
/>
|
||||
<MetricCard
|
||||
label="Bewertet"
|
||||
value={formatMinutes(evaluatedMinutes)}
|
||||
detail={
|
||||
<div className="space-y-0.5">
|
||||
<div>{evaluatedSessions} von {totals?.sessions ?? 0} Session(s), {evaluatedSessionShare}%</div>
|
||||
<div>{evaluatedTimeShare}% der Zeit · Abr. {totals?.billedSessions ?? 0} · Nicht {totals?.nonBillableSessions ?? 0}</div>
|
||||
</div>
|
||||
}
|
||||
icon={ListChecks}
|
||||
valueClassName={evaluatedTextClass}
|
||||
helpAnchor="statistik-bewertet"
|
||||
helpLabel="Hilfe zur Kennzahl Bewertet"
|
||||
/>
|
||||
<MetricCard
|
||||
label="Offen"
|
||||
value={formatMinutes(totals?.openMinutes ?? 0)}
|
||||
value={formatMinutes(openMinutes)}
|
||||
detail={
|
||||
<div className="space-y-0.5">
|
||||
<div>{totals?.openSessions ?? 0} von {totals?.sessions ?? 0} Session(s), {openSessionShare}%</div>
|
||||
<div>{openTimeShare}% der Zeit</div>
|
||||
<div>{openSessions} Session(s)</div>
|
||||
<div>wird nicht kalkuliert</div>
|
||||
</div>
|
||||
}
|
||||
icon={CircleAlert}
|
||||
@@ -547,36 +667,39 @@ 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>Getrackter Aufwand pro Tag, Teamspace-Linie und Bewertungsstand in einem Überblick.</CardDescription>
|
||||
<CardDescription>
|
||||
{teamspaceTimeTrackingEnabled
|
||||
? "Abgerechnete Zeit pro Tag, Teamspace-Linie und Bewertungsstand in einem Überblick."
|
||||
: "Abgerechnete Zeit pro Tag und Bewertungsstand in einem Überblick."}
|
||||
</CardDescription>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant="outline">{crmCoverage}% Teamspace-Abdeckung</Badge>
|
||||
{teamspaceTimeTrackingEnabled ? <Badge variant="outline">{crmCoverage}% Teamspace-Abdeckung</Badge> : <Badge variant="outline">Teamspace als Ist-Quelle</Badge>}
|
||||
<HelpLink anchor="statistik-verlauf" label="Hilfe zu Monatsverlauf und Abrechnungsqualität" />
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="grid gap-4 px-4 pb-4 xl:grid-cols-[1.55fr_1fr]">
|
||||
<div className="space-y-3">
|
||||
<DailyEffortChart month={month} buckets={stats?.dailySeries ?? []} />
|
||||
<ColorLegend />
|
||||
{strongestDay ? <p className="text-xs text-muted-foreground">Stärkster 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">{evaluatedSessionShare}% / {openSessionShare}%</span>
|
||||
</div>
|
||||
<div className="flex h-2 overflow-hidden rounded-full bg-muted">
|
||||
<div className="bg-violet-500 dark:bg-violet-400" style={{ width: `${evaluatedSessionShare}%` }} />
|
||||
<div className="bg-amber-500 dark:bg-amber-400" style={{ width: `${openSessionShare}%` }} />
|
||||
<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={evaluatedTextClass}>Bewertet: {evaluatedSessions} Session(s), {formatMinutes(evaluatedMinutes)}</div>
|
||||
<div className={openTextClass}>Offen: {totals?.openSessions ?? 0} Session(s), {formatMinutes(totals?.openMinutes ?? 0)}</div>
|
||||
<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>
|
||||
@@ -585,9 +708,18 @@ export function StatisticsPage({ onNavigate }: StatisticsPageProps) {
|
||||
</div>
|
||||
<DeltaBadge minutes={totals?.crmDeltaMinutes ?? 0} />
|
||||
</div>
|
||||
<TimeComparison trackedMinutes={totals?.minutes ?? 0} crmMinutes={totals?.crmBilledMinutes ?? 0} sessions={totals?.sessions ?? 0} />
|
||||
<div className="text-xs text-muted-foreground">Teamspace-Abdeckung: {crmCoverage}% der getrackten Session-Zeit</div>
|
||||
<TimeComparison trackedMinutes={totals?.minutes ?? 0} crmMinutes={totals?.crmBilledMinutes ?? 0} sessions={totals?.billedSessions ?? 0} />
|
||||
<div className="text-xs text-muted-foreground">Teamspace-Abdeckung: {crmCoverage}% der abgerechneten Zeit</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2 rounded-md border bg-muted/20 p-3">
|
||||
<p className="text-sm font-medium">Teamspace als Ist-Quelle</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Für diesen Nutzer werden keine Teamspace-Zeiten in TT erfasst. Die Monatsprüfung bewertet nur den Session-Status.
|
||||
</p>
|
||||
<TimeComparison trackedMinutes={totals?.minutes ?? 0} crmMinutes={totals?.crmBilledMinutes ?? 0} sessions={totals?.billedSessions ?? 0} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-2 gap-2 text-sm">
|
||||
<div className="rounded-md border bg-muted/20 p-3">
|
||||
@@ -614,7 +746,7 @@ export function StatisticsPage({ onNavigate }: StatisticsPageProps) {
|
||||
<p className="text-muted-foreground">Top Organisation</p>
|
||||
<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}>Sessions {formatMinutes(topOrganization.total_minutes)}</span>
|
||||
<span className={trackedTextClass}>Abgerechnet {formatMinutes(topOrganization.total_minutes)}</span>
|
||||
<span className={teamspaceTextClass}>Teamspace {formatMinutes(topOrganization.crm_billed_minutes)}</span>
|
||||
</p>
|
||||
</div>
|
||||
@@ -623,12 +755,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 am getrackten Monatsaufwand.</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">
|
||||
@@ -643,10 +782,10 @@ export function StatisticsPage({ onNavigate }: StatisticsPageProps) {
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Organisation</TableHead>
|
||||
<TableHead>Sessions</TableHead>
|
||||
<TableHead>Abgerechnet</TableHead>
|
||||
<TableHead>Zeiten</TableHead>
|
||||
<TableHead>Differenz</TableHead>
|
||||
<TableHead>Anteil am Monat</TableHead>
|
||||
{teamspaceTimeTrackingEnabled ? <TableHead>Differenz</TableHead> : null}
|
||||
<TableHead>Anteil abgerechnet</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
@@ -656,6 +795,7 @@ export function StatisticsPage({ onNavigate }: StatisticsPageProps) {
|
||||
organization={organization}
|
||||
maxMinutes={maxOrganizationMinutes}
|
||||
totalMinutes={totals?.minutes ?? 0}
|
||||
showDifference={teamspaceTimeTrackingEnabled}
|
||||
/>
|
||||
))}
|
||||
</TableBody>
|
||||
@@ -667,16 +807,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.sessions} Session(s)</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}>Sessions {formatMinutes(organization.total_minutes)}</span>
|
||||
<span className={trackedTextClass}>Abgerechnet {formatMinutes(organization.total_minutes)}</span>
|
||||
<span className={teamspaceTextClass}>Teamspace {formatMinutes(organization.crm_billed_minutes)}</span>
|
||||
<span className="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>
|
||||
))}
|
||||
@@ -736,18 +876,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.sessions} Session(s)</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}>Sessions {formatMinutes(group.total_minutes)}</span>
|
||||
<span className={trackedTextClass}>Abgerechnet {formatMinutes(group.total_minutes)}</span>
|
||||
<span className={teamspaceTextClass}>Teamspace {formatMinutes(group.crm_billed_minutes)}</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-3 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>Nicht {group.non_billable_sessions} / {formatMinutes(group.non_billable_minutes)}</span>
|
||||
<span>Abrechb. {group.billable_sessions} / {formatMinutes(group.billable_minutes)}</span>
|
||||
<span>Offen {group.open_sessions} / {formatMinutes(group.open_minutes)}</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -757,6 +897,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>
|
||||
@@ -765,7 +908,7 @@ export function StatisticsPage({ onNavigate }: StatisticsPageProps) {
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="secondary" size="sm" onClick={() => onNavigate("/analysis")}>
|
||||
Auswertung öffnen
|
||||
Tickets öffnen
|
||||
<ExternalLink className="size-4" />
|
||||
</Button>
|
||||
<HelpLink anchor="statistik-hotspots" label="Hilfe zu Ticket-Hotspots" />
|
||||
@@ -773,19 +916,19 @@ 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>
|
||||
</Card>
|
||||
|
||||
{stats && stats.totals.sessions === 0 ? (
|
||||
{stats && stats.totals.billedSessions === 0 ? (
|
||||
<Card>
|
||||
<CardContent className="grid place-items-center gap-2 p-8 text-center">
|
||||
<Ticket className="size-8 text-muted-foreground" />
|
||||
<div>
|
||||
<p className="font-medium">Keine Sessions in diesem Monat</p>
|
||||
<p className="text-sm text-muted-foreground">Sobald Sessions vorhanden sind, füllt sich die Statistik automatisch.</p>
|
||||
<p className="font-medium">Keine abgerechneten Sessions in diesem Monat</p>
|
||||
<p className="text-sm text-muted-foreground">Offene Sessions werden separat gezeigt und fließen nicht in Statistiken oder Vergleiche ein.</p>
|
||||
</div>
|
||||
<Button variant="secondary" size="sm" onClick={() => setMonth(currentMonth())}>
|
||||
<RotateCcw className="size-4" />
|
||||
@@ -794,6 +937,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>
|
||||
);
|
||||
}
|
||||
+2712
-116
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
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"
|
||||
@@ -0,0 +1,37 @@
|
||||
[
|
||||
{
|
||||
"id": 66368,
|
||||
"ticket_id": 3122,
|
||||
"type_id": 1,
|
||||
"sender_id": 1,
|
||||
"from": "XX",
|
||||
"to": "XX",
|
||||
"cc": "XX",
|
||||
"subject": "XX",
|
||||
"reply_to": null,
|
||||
"message_id": "XX",
|
||||
"message_id_md5": "XX",
|
||||
"in_reply_to": "XX",
|
||||
"content_type": "text/html",
|
||||
"body": "",
|
||||
"internal": false,
|
||||
"preferences": {
|
||||
"subtype": "reply",
|
||||
"email_address_id": 7,
|
||||
"delivery_retry": 1,
|
||||
"delivery_channel_id": 24,
|
||||
"delivery_status_message": null,
|
||||
"delivery_status": "success",
|
||||
"delivery_status_date": "2026-09-25T10:55:51.214Z"
|
||||
},
|
||||
"updated_by_id": 77,
|
||||
"created_by_id": 77,
|
||||
"origin_by_id": null,
|
||||
"created_at": "2026-09-25T10:55:48.201Z",
|
||||
"updated_at": "2026-09-25T10:55:51.215Z",
|
||||
"detected_language": null,
|
||||
"ai_stored_result_ids": [],
|
||||
"attachments": [],
|
||||
"body_rendering_error": false
|
||||
}
|
||||
]
|
||||
+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 \
|
||||
|
||||
Executable
+127
@@ -0,0 +1,127 @@
|
||||
#!/usr/bin/env bash
|
||||
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
|
||||
fi
|
||||
|
||||
if ! command -v apt-get >/dev/null 2>&1; then
|
||||
echo "Dieses Setup-Script unterstuetzt aktuell Debian/Ubuntu mit apt-get."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
SUDO=()
|
||||
if [[ "${EUID}" -ne 0 ]]; then
|
||||
if ! command -v sudo >/dev/null 2>&1; then
|
||||
echo "Bitte als root ausfuehren oder sudo installieren."
|
||||
exit 1
|
||||
fi
|
||||
SUDO=(sudo)
|
||||
fi
|
||||
|
||||
run_apt() {
|
||||
if [[ "${#SUDO[@]}" -gt 0 ]]; then
|
||||
"${SUDO[@]}" env DEBIAN_FRONTEND=noninteractive apt-get "$@"
|
||||
else
|
||||
DEBIAN_FRONTEND=noninteractive apt-get "$@"
|
||||
fi
|
||||
}
|
||||
|
||||
run_as_root_preserve_env() {
|
||||
if [[ "${#SUDO[@]}" -gt 0 ]]; then
|
||||
"${SUDO[@]}" -E "$@"
|
||||
else
|
||||
"$@"
|
||||
fi
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
CURRENT_NODE_MAJOR="$(node_major)"
|
||||
if [[ "${CURRENT_NODE_MAJOR}" -lt "${NODE_MAJOR_REQUIRED}" ]]; then
|
||||
echo "Installiere Node.js ${NODE_MAJOR_REQUIRED}.x ueber NodeSource..."
|
||||
TMP_SETUP="$(mktemp)"
|
||||
curl -fsSL "${NODESOURCE_SETUP_URL}" -o "${TMP_SETUP}"
|
||||
run_as_root_preserve_env bash "${TMP_SETUP}"
|
||||
rm -f "${TMP_SETUP}"
|
||||
run_apt install -y nodejs
|
||||
else
|
||||
echo "Node.js $(node --version) ist bereits kompatibel."
|
||||
fi
|
||||
|
||||
CURRENT_NODE_MAJOR="$(node_major)"
|
||||
if [[ "${CURRENT_NODE_MAJOR}" -lt "${NODE_MAJOR_REQUIRED}" ]]; then
|
||||
echo "Node.js >= ${NODE_MAJOR_REQUIRED} wird benoetigt, aktiv ist aber: $(node --version 2>/dev/null || echo nicht installiert)"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! command -v npm >/dev/null 2>&1; then
|
||||
echo "npm wurde nicht gefunden. Bitte Node.js/npm Installation pruefen."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Installiere npm-Abhaengigkeiten im Repo..."
|
||||
cd "${REPO_ROOT}"
|
||||
if [[ -f package-lock.json ]]; then
|
||||
npm ci --include=dev
|
||||
else
|
||||
npm install --include=dev
|
||||
fi
|
||||
|
||||
chmod +x "${REPO_ROOT}/scripts/start-local.sh"
|
||||
|
||||
echo
|
||||
echo "Fertig. Zum Starten noch DATABASE_URL setzen und dann ausfuehren:"
|
||||
echo " npm run serve:local"
|
||||
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
|
||||
Executable
+127
@@ -0,0 +1,127 @@
|
||||
#!/usr/bin/env node
|
||||
import "dotenv/config";
|
||||
import pg from "pg";
|
||||
|
||||
const apply = process.argv.includes("--apply");
|
||||
|
||||
if (!process.env.DATABASE_URL) {
|
||||
console.error("DATABASE_URL fehlt. Bitte .env setzen oder DATABASE_URL vor dem Aufruf exportieren.");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const updates = [
|
||||
[
|
||||
"tickets",
|
||||
`
|
||||
UPDATE tickets t
|
||||
SET organization_id = old_org.zammad_id
|
||||
FROM organizations old_org
|
||||
WHERE t.organization_id = old_org.id
|
||||
AND t.organization_id <> old_org.zammad_id
|
||||
AND COALESCE(t.customer_name, '') = old_org.name
|
||||
`
|
||||
],
|
||||
[
|
||||
"sessions",
|
||||
`
|
||||
UPDATE sessions s
|
||||
SET organization_id = old_org.zammad_id
|
||||
FROM organizations old_org
|
||||
WHERE s.organization_id = old_org.id
|
||||
AND s.organization_id <> old_org.zammad_id
|
||||
AND COALESCE(s.customer_name, '') = old_org.name
|
||||
`
|
||||
],
|
||||
[
|
||||
"recurring_billings",
|
||||
`
|
||||
UPDATE recurring_billings rb
|
||||
SET organization_id = old_org.zammad_id
|
||||
FROM organizations old_org
|
||||
WHERE rb.organization_id = old_org.id
|
||||
AND rb.organization_id <> old_org.zammad_id
|
||||
AND (
|
||||
EXISTS (
|
||||
SELECT 1
|
||||
FROM sessions s
|
||||
WHERE s.recurring_billing_id = rb.id
|
||||
AND COALESCE(s.customer_name, '') = old_org.name
|
||||
)
|
||||
OR EXISTS (
|
||||
SELECT 1
|
||||
FROM tickets t
|
||||
WHERE t.ticket_number = COALESCE(rb.ticket_number, 'Fix#' || rb.id)
|
||||
AND COALESCE(t.customer_name, '') = old_org.name
|
||||
)
|
||||
)
|
||||
`
|
||||
]
|
||||
];
|
||||
|
||||
const remainingSql = `
|
||||
SELECT 'tickets' AS table_name, COUNT(*)::int AS repairable
|
||||
FROM tickets t
|
||||
JOIN organizations old_org ON old_org.id = t.organization_id
|
||||
WHERE t.organization_id IS NOT NULL
|
||||
AND old_org.zammad_id <> t.organization_id
|
||||
AND COALESCE(t.customer_name, '') = old_org.name
|
||||
UNION ALL
|
||||
SELECT 'sessions' AS table_name, COUNT(*)::int AS repairable
|
||||
FROM sessions s
|
||||
JOIN organizations old_org ON old_org.id = s.organization_id
|
||||
WHERE s.organization_id IS NOT NULL
|
||||
AND old_org.zammad_id <> s.organization_id
|
||||
AND COALESCE(s.customer_name, '') = old_org.name
|
||||
UNION ALL
|
||||
SELECT 'recurring_billings' AS table_name, COUNT(*)::int AS repairable
|
||||
FROM recurring_billings rb
|
||||
JOIN organizations old_org ON old_org.id = rb.organization_id
|
||||
WHERE old_org.zammad_id <> rb.organization_id
|
||||
AND (
|
||||
EXISTS (
|
||||
SELECT 1
|
||||
FROM sessions s
|
||||
WHERE s.recurring_billing_id = rb.id
|
||||
AND COALESCE(s.customer_name, '') = old_org.name
|
||||
)
|
||||
OR EXISTS (
|
||||
SELECT 1
|
||||
FROM tickets t
|
||||
WHERE t.ticket_number = COALESCE(rb.ticket_number, 'Fix#' || rb.id)
|
||||
AND COALESCE(t.customer_name, '') = old_org.name
|
||||
)
|
||||
);
|
||||
`;
|
||||
|
||||
const pool = new pg.Pool({ connectionString: process.env.DATABASE_URL });
|
||||
const client = await pool.connect();
|
||||
|
||||
try {
|
||||
await client.query("BEGIN");
|
||||
|
||||
const resultRows = [];
|
||||
for (const [table, sql] of updates) {
|
||||
const result = await client.query(sql);
|
||||
resultRows.push({ table, [apply ? "updated" : "would_update"]: result.rowCount ?? 0 });
|
||||
}
|
||||
|
||||
if (apply) {
|
||||
await client.query("COMMIT");
|
||||
console.log("Organisationszuordnungen wurden korrigiert.");
|
||||
} else {
|
||||
await client.query("ROLLBACK");
|
||||
console.log("Dry-Run: Es wurde nichts gespeichert. Mit --apply wirklich ausfuehren.");
|
||||
}
|
||||
|
||||
console.table(resultRows);
|
||||
|
||||
const remaining = await pool.query(remainingSql);
|
||||
console.log("Noch eindeutig reparierbare Alt-Zuordnungen:");
|
||||
console.table(remaining.rows);
|
||||
} catch (error) {
|
||||
await client.query("ROLLBACK").catch(() => undefined);
|
||||
throw error;
|
||||
} finally {
|
||||
client.release();
|
||||
await pool.end();
|
||||
}
|
||||
+109
-8
@@ -1,15 +1,61 @@
|
||||
#!/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}}"
|
||||
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
|
||||
|
||||
if [[ -f ".env" ]]; then
|
||||
set -a
|
||||
source .env
|
||||
set +a
|
||||
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
|
||||
@@ -18,6 +64,37 @@ if [[ -z "${DATABASE_URL:-}" ]]; then
|
||||
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
|
||||
+48
@@ -0,0 +1,48 @@
|
||||
{
|
||||
"_url": "https://app1.teamspace.de/api/project;limit=1000;depth=2;sort=projectId?parent=32700722&isActive=true&isTemplate=false",
|
||||
"_links": [
|
||||
{
|
||||
"caption": "Self",
|
||||
"href": "https://app1.teamspace.de/api/project;limit=1000;depth=2;sort=projectId?parent=32700722&isActive=true&isTemplate=false",
|
||||
"rel": "self"
|
||||
},
|
||||
{
|
||||
"caption": "|< firstPage",
|
||||
"href": "https://app1.teamspace.de/api/project;offset=0;limit=1000;sort=projectId?parent=32700722&isActive=true&isTemplate=false",
|
||||
"rel": "first",
|
||||
"title": "First page of projects of this CollectionResource"
|
||||
},
|
||||
{
|
||||
"caption": ">| lastPage",
|
||||
"href": "https://app1.teamspace.de/api/project;offset=0;limit=1000;sort=projectId?parent=32700722&isActive=true&isTemplate=false",
|
||||
"rel": "last",
|
||||
"title": "Last page of projects of this CollectionResource"
|
||||
}
|
||||
],
|
||||
"_etag": {
|
||||
"value": "2cdf79c35612fb82487eed3923af2e43",
|
||||
"weak": false
|
||||
},
|
||||
"size": 5,
|
||||
"offset": 0,
|
||||
"limit": 100,
|
||||
"items": [
|
||||
{
|
||||
"data": {
|
||||
|
||||
"contractPositions": [
|
||||
{
|
||||
"billingType": {
|
||||
"caption": "Fix price",
|
||||
"href": "https://app1.teamspace.de/api/enum/financepositionbillingtype/INVOICEABLE_FIX",
|
||||
"value": "INVOICEABLE_FIX",
|
||||
"optionsUrl": "https://app1.teamspace.de/api/enum/financepositionbillingtype"
|
||||
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user