Compare commits
3
Commits
fc0dfd0556
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fa9c122250 | ||
|
|
45ff46b7eb | ||
|
|
649dd6f028 |
No files matched your search
@@ -17,9 +17,14 @@ export type AuthUser = {
|
|||||||
active: boolean;
|
active: boolean;
|
||||||
zammad_user_id: string | null;
|
zammad_user_id: string | null;
|
||||||
has_zammad_api_token: boolean;
|
has_zammad_api_token: boolean;
|
||||||
|
teamspace_user_id: string | null;
|
||||||
|
has_teamspace_credentials: boolean;
|
||||||
zammad_email_session_webhook_enabled: boolean;
|
zammad_email_session_webhook_enabled: boolean;
|
||||||
session_billing_prompt_enabled: boolean;
|
session_billing_prompt_enabled: boolean;
|
||||||
|
session_work_segments_enabled: boolean;
|
||||||
|
teamspace_time_tracking_enabled: boolean;
|
||||||
zammad_session_note_default_enabled: boolean;
|
zammad_session_note_default_enabled: boolean;
|
||||||
|
zammad_session_note_date_header_enabled: boolean;
|
||||||
zammad_time_unit_rounding_enabled: boolean;
|
zammad_time_unit_rounding_enabled: boolean;
|
||||||
zammad_time_unit_rounding_minutes: number;
|
zammad_time_unit_rounding_minutes: number;
|
||||||
zammad_atoss_email_sync_enabled: boolean;
|
zammad_atoss_email_sync_enabled: boolean;
|
||||||
@@ -90,9 +95,14 @@ function publicUser(user: AuthUser) {
|
|||||||
active: user.active,
|
active: user.active,
|
||||||
zammad_user_id: user.zammad_user_id ?? null,
|
zammad_user_id: user.zammad_user_id ?? null,
|
||||||
has_zammad_api_token: Boolean(user.has_zammad_api_token),
|
has_zammad_api_token: Boolean(user.has_zammad_api_token),
|
||||||
|
teamspace_user_id: user.teamspace_user_id ?? null,
|
||||||
|
has_teamspace_credentials: Boolean(user.has_teamspace_credentials),
|
||||||
zammad_email_session_webhook_enabled: Boolean(user.zammad_email_session_webhook_enabled),
|
zammad_email_session_webhook_enabled: Boolean(user.zammad_email_session_webhook_enabled),
|
||||||
session_billing_prompt_enabled: Boolean(user.session_billing_prompt_enabled),
|
session_billing_prompt_enabled: Boolean(user.session_billing_prompt_enabled),
|
||||||
|
session_work_segments_enabled: Boolean(user.session_work_segments_enabled),
|
||||||
|
teamspace_time_tracking_enabled: Boolean(user.teamspace_time_tracking_enabled ?? true),
|
||||||
zammad_session_note_default_enabled: Boolean(user.zammad_session_note_default_enabled),
|
zammad_session_note_default_enabled: Boolean(user.zammad_session_note_default_enabled),
|
||||||
|
zammad_session_note_date_header_enabled: Boolean(user.zammad_session_note_date_header_enabled),
|
||||||
zammad_time_unit_rounding_enabled: Boolean(user.zammad_time_unit_rounding_enabled),
|
zammad_time_unit_rounding_enabled: Boolean(user.zammad_time_unit_rounding_enabled),
|
||||||
zammad_time_unit_rounding_minutes: Number(user.zammad_time_unit_rounding_minutes ?? 15),
|
zammad_time_unit_rounding_minutes: Number(user.zammad_time_unit_rounding_minutes ?? 15),
|
||||||
zammad_atoss_email_sync_enabled: Boolean(user.zammad_atoss_email_sync_enabled ?? true),
|
zammad_atoss_email_sync_enabled: Boolean(user.zammad_atoss_email_sync_enabled ?? true),
|
||||||
@@ -159,9 +169,14 @@ export async function requireAuth(req: Request, res: Response, next: NextFunctio
|
|||||||
u.active,
|
u.active,
|
||||||
u.zammad_user_id::text AS zammad_user_id,
|
u.zammad_user_id::text AS zammad_user_id,
|
||||||
(u.zammad_api_token IS NOT NULL) AS has_zammad_api_token,
|
(u.zammad_api_token IS NOT NULL) AS has_zammad_api_token,
|
||||||
|
u.teamspace_user_id::text AS teamspace_user_id,
|
||||||
|
(u.teamspace_device_id IS NOT NULL AND u.teamspace_device_password IS NOT NULL) AS has_teamspace_credentials,
|
||||||
u.zammad_email_session_webhook_enabled,
|
u.zammad_email_session_webhook_enabled,
|
||||||
u.session_billing_prompt_enabled,
|
u.session_billing_prompt_enabled,
|
||||||
|
u.session_work_segments_enabled,
|
||||||
|
u.teamspace_time_tracking_enabled,
|
||||||
u.zammad_session_note_default_enabled,
|
u.zammad_session_note_default_enabled,
|
||||||
|
u.zammad_session_note_date_header_enabled,
|
||||||
u.zammad_time_unit_rounding_enabled,
|
u.zammad_time_unit_rounding_enabled,
|
||||||
u.zammad_time_unit_rounding_minutes,
|
u.zammad_time_unit_rounding_minutes,
|
||||||
u.zammad_atoss_email_sync_enabled,
|
u.zammad_atoss_email_sync_enabled,
|
||||||
|
|||||||
@@ -12,10 +12,17 @@ export const databaseExportTables = [
|
|||||||
"auth_sessions",
|
"auth_sessions",
|
||||||
"app_settings",
|
"app_settings",
|
||||||
"user_preferences",
|
"user_preferences",
|
||||||
|
"organization_attribute_options",
|
||||||
|
"teamspace_project_exclusions",
|
||||||
|
"teamspace_assignment_rules",
|
||||||
"organizations",
|
"organizations",
|
||||||
"tickets",
|
"tickets",
|
||||||
"projects",
|
"projects",
|
||||||
|
"teamspace_project_resources",
|
||||||
"project_teamspace_entries",
|
"project_teamspace_entries",
|
||||||
|
"teamspace_monthly_user_actuals",
|
||||||
|
"project_teamspace_monthly_actuals",
|
||||||
|
"project_teamspace_subentries",
|
||||||
"hidden_user_projects",
|
"hidden_user_projects",
|
||||||
"ticket_todos",
|
"ticket_todos",
|
||||||
"recurring_billings",
|
"recurring_billings",
|
||||||
@@ -40,6 +47,17 @@ type ColumnInfo = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const jsonDataTypes = new Set(["json", "jsonb"]);
|
const jsonDataTypes = new Set(["json", "jsonb"]);
|
||||||
|
const arrayElementTypes = new Map<string, string>([
|
||||||
|
["_bool", "boolean"],
|
||||||
|
["_date", "date"],
|
||||||
|
["_int2", "smallint"],
|
||||||
|
["_int4", "integer"],
|
||||||
|
["_int8", "bigint"],
|
||||||
|
["_numeric", "numeric"],
|
||||||
|
["_text", "text"],
|
||||||
|
["_timestamptz", "timestamptz"],
|
||||||
|
["_uuid", "uuid"]
|
||||||
|
]);
|
||||||
const insertBatchSize = 500;
|
const insertBatchSize = 500;
|
||||||
|
|
||||||
export function quoteIdentifier(value: string) {
|
export function quoteIdentifier(value: string) {
|
||||||
@@ -76,6 +94,20 @@ export function sqlLiteral(value: unknown, column?: Pick<ColumnInfo, "dataType"
|
|||||||
return quoteSqlString(jsonValueForSql(value));
|
return quoteSqlString(jsonValueForSql(value));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (column?.dataType === "ARRAY") {
|
||||||
|
if (!Array.isArray(value)) {
|
||||||
|
throw new Error(`Expected PostgreSQL array value for ${column.udtName}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const elementType = arrayElementTypes.get(column.udtName);
|
||||||
|
|
||||||
|
if (!elementType) {
|
||||||
|
throw new Error(`Unsupported PostgreSQL array type: ${column.udtName}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return `ARRAY[${value.map((entry) => sqlLiteral(entry)).join(", ")}]::${elementType}[]`;
|
||||||
|
}
|
||||||
|
|
||||||
if (value instanceof Date) {
|
if (value instanceof Date) {
|
||||||
return quoteSqlString(value.toISOString());
|
return quoteSqlString(value.toISOString());
|
||||||
}
|
}
|
||||||
|
|||||||
+7579
-527
File diff suppressed because it is too large.
Load diff
+1098
-42
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,71 @@
|
|||||||
|
export const organizationAttributeNames = ["customer_owner", "hotline_contract"] as const;
|
||||||
|
|
||||||
|
export type OrganizationAttributeName = (typeof organizationAttributeNames)[number];
|
||||||
|
|
||||||
|
export type ZammadOrganizationAttributeOption = {
|
||||||
|
zammadAttributeId: number;
|
||||||
|
attributeName: OrganizationAttributeName;
|
||||||
|
optionKey: string;
|
||||||
|
displayName: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||||
|
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isOrganizationAttributeName(value: unknown): value is OrganizationAttributeName {
|
||||||
|
return typeof value === "string" && organizationAttributeNames.includes(value as OrganizationAttributeName);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseZammadOrganizationAttributeOptions(payload: unknown): ZammadOrganizationAttributeOption[] {
|
||||||
|
if (!Array.isArray(payload)) {
|
||||||
|
throw new Error("Zammad response must be an object-manager attribute array");
|
||||||
|
}
|
||||||
|
|
||||||
|
const options: ZammadOrganizationAttributeOption[] = [];
|
||||||
|
const foundAttributes = new Set<OrganizationAttributeName>();
|
||||||
|
|
||||||
|
for (const item of payload) {
|
||||||
|
if (!isRecord(item) || !isOrganizationAttributeName(item.name)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const zammadAttributeId = Number(item.id);
|
||||||
|
const dataOption = isRecord(item.data_option)
|
||||||
|
? item.data_option
|
||||||
|
: isRecord(item.data_options)
|
||||||
|
? item.data_options
|
||||||
|
: null;
|
||||||
|
const rawOptions = dataOption && isRecord(dataOption.options) ? dataOption.options : null;
|
||||||
|
|
||||||
|
if (!Number.isInteger(zammadAttributeId) || zammadAttributeId <= 0 || !rawOptions) {
|
||||||
|
throw new Error(`Zammad object-manager attribute ${item.name} is invalid`);
|
||||||
|
}
|
||||||
|
|
||||||
|
foundAttributes.add(item.name);
|
||||||
|
|
||||||
|
for (const [rawKey, rawDisplayName] of Object.entries(rawOptions)) {
|
||||||
|
const optionKey = rawKey.trim();
|
||||||
|
const displayName = typeof rawDisplayName === "string" ? rawDisplayName.trim() : "";
|
||||||
|
|
||||||
|
if (!optionKey || !displayName) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
options.push({
|
||||||
|
zammadAttributeId,
|
||||||
|
attributeName: item.name,
|
||||||
|
optionKey,
|
||||||
|
displayName
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const missingAttributes = organizationAttributeNames.filter((name) => !foundAttributes.has(name));
|
||||||
|
|
||||||
|
if (missingAttributes.length > 0) {
|
||||||
|
throw new Error(`Zammad object-manager attributes missing: ${missingAttributes.join(", ")}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return options;
|
||||||
|
}
|
||||||
@@ -1,9 +1,17 @@
|
|||||||
import assert from "node:assert/strict";
|
import assert from "node:assert/strict";
|
||||||
import test from "node:test";
|
import test from "node:test";
|
||||||
import { nextSequenceIdAfterRestore, restoreSqlText, sequenceSetvalSql, sqlLiteral, type QueryExecutor } from "../src/databaseExport.ts";
|
import {
|
||||||
import { exportTableSql } from "../src/databaseExport.ts";
|
databaseExportTables,
|
||||||
|
exportTableSql,
|
||||||
|
nextSequenceIdAfterRestore,
|
||||||
|
restoreSqlText,
|
||||||
|
sequenceSetvalSql,
|
||||||
|
sqlLiteral,
|
||||||
|
type QueryExecutor
|
||||||
|
} from "../src/databaseExport.ts";
|
||||||
|
|
||||||
const jsonbColumn = { dataType: "jsonb", udtName: "jsonb" };
|
const jsonbColumn = { dataType: "jsonb", udtName: "jsonb" };
|
||||||
|
const bigintArrayColumn = { dataType: "ARRAY", udtName: "_int8" };
|
||||||
|
|
||||||
test("sqlLiteral serializes primitive values", () => {
|
test("sqlLiteral serializes primitive values", () => {
|
||||||
assert.equal(sqlLiteral(null), "NULL");
|
assert.equal(sqlLiteral(null), "NULL");
|
||||||
@@ -36,6 +44,20 @@ test("sqlLiteral serializes JSON columns as valid JSON values", () => {
|
|||||||
assert.equal(sqlLiteral(true, jsonbColumn), "'true'");
|
assert.equal(sqlLiteral(true, jsonbColumn), "'true'");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("sqlLiteral serializes PostgreSQL arrays with an explicit element type", () => {
|
||||||
|
assert.equal(sqlLiteral([], bigintArrayColumn), "ARRAY[]::bigint[]");
|
||||||
|
assert.equal(sqlLiteral(["32349965", "32349982"], bigintArrayColumn), "ARRAY['32349965', '32349982']::bigint[]");
|
||||||
|
assert.throws(
|
||||||
|
() => sqlLiteral(["value"], { dataType: "ARRAY", udtName: "_unsupported" }),
|
||||||
|
/Unsupported PostgreSQL array type/
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("database export inserts assignment rules before tickets that reference them", () => {
|
||||||
|
assert.ok(databaseExportTables.indexOf("organization_attribute_options") < databaseExportTables.indexOf("teamspace_assignment_rules"));
|
||||||
|
assert.ok(databaseExportTables.indexOf("teamspace_assignment_rules") < databaseExportTables.indexOf("tickets"));
|
||||||
|
});
|
||||||
|
|
||||||
test("generated table export never contains implicit object stringification", async () => {
|
test("generated table export never contains implicit object stringification", async () => {
|
||||||
const client: QueryExecutor = {
|
const client: QueryExecutor = {
|
||||||
async query<T extends Record<string, unknown>>(text: string): Promise<{ rows: T[]; rowCount: number | null }> {
|
async query<T extends Record<string, unknown>>(text: string): Promise<{ rows: T[]; rowCount: number | null }> {
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import test from "node:test";
|
||||||
|
import { parseZammadOrganizationAttributeOptions } from "../src/zammadOrganizationAttributes.ts";
|
||||||
|
|
||||||
|
test("parses the two organization select attributes and ignores unrelated attributes", () => {
|
||||||
|
const options = parseZammadOrganizationAttributeOptions([
|
||||||
|
{
|
||||||
|
id: 85,
|
||||||
|
name: "customer_owner",
|
||||||
|
data_option: { options: { ATOSS: "ATOSS", sdw: "SD Worx" } }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 12,
|
||||||
|
name: "unrelated",
|
||||||
|
data_option: { options: { no: "No" } }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 86,
|
||||||
|
name: "hotline_contract",
|
||||||
|
data_option: { options: { none: "(kein Hotlinevertrag)" } }
|
||||||
|
}
|
||||||
|
]);
|
||||||
|
|
||||||
|
assert.deepEqual(options, [
|
||||||
|
{ zammadAttributeId: 85, attributeName: "customer_owner", optionKey: "ATOSS", displayName: "ATOSS" },
|
||||||
|
{ zammadAttributeId: 85, attributeName: "customer_owner", optionKey: "sdw", displayName: "SD Worx" },
|
||||||
|
{ zammadAttributeId: 86, attributeName: "hotline_contract", optionKey: "none", displayName: "(kein Hotlinevertrag)" }
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("rejects responses without both required attributes", () => {
|
||||||
|
assert.throws(
|
||||||
|
() => parseZammadOrganizationAttributeOptions([
|
||||||
|
{ id: 85, name: "customer_owner", data_option: { options: { ATOSS: "ATOSS" } } }
|
||||||
|
]),
|
||||||
|
/hotline_contract/
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
# TicketTracker
|
||||||
|
|
||||||
|
Stand: Oktober 2026
|
||||||
|
|
||||||
|
TicketTracker ist ein Arbeits-, Projekt- und Abrechnungscockpit für ticketbasierte Arbeit. Das System ergänzt Zammad und Teamspace: Zammad bleibt das Ticketsystem, Teamspace bleibt die Quelle für Projektbudgets und gebuchte Projektwerte, TicketTracker führt Erfassung, Bewertung, ToDos, Projektüberwachung und Monatsabschluss zusammen.
|
||||||
|
|
||||||
|
Der Kernnutzen: Jeder Benutzer sieht, was er gearbeitet hat, was noch bewertet werden muss, welche Projektbudgets kritisch sind, welche Teamspace-Daten fehlen oder abweichen und wann ein Monat sauber abgeschlossen werden kann.
|
||||||
|
|
||||||
|
## Zielgruppe und Einsatz
|
||||||
|
|
||||||
|
TicketTracker ist für Teams oder Einzelpersonen gedacht, die viel mit Tickets arbeiten und ihre Zeiten zuverlässig nachvollziehen müssen.
|
||||||
|
|
||||||
|
Typische Einsatzfälle:
|
||||||
|
|
||||||
|
- Support- und Consulting-Zeiten per Timer erfassen
|
||||||
|
- Sessions nachträglich ergänzen
|
||||||
|
- Arbeit am Ticket dokumentieren
|
||||||
|
- Sessions als offen, abrechenbar, abgerechnet oder nicht abrechenbar bewerten
|
||||||
|
- Teamspace-Tageswerte für abrechnungsrelevante Projektzeiten pflegen oder Teamspace als Ist-Quelle nutzen
|
||||||
|
- Monatsabschlüsse kontrolliert vorbereiten
|
||||||
|
- Consulting-Projekte mit Teamspace-Budgets, Reststunden und Unterpositionen überwachen
|
||||||
|
- ToDos und Wiedervorlagen direkt am Ticket oder Projekt organisieren
|
||||||
|
- wiederkehrende Leistungen automatisch erzeugen lassen
|
||||||
|
- interne Zammad-Notizen aus TicketTracker heraus erzeugen
|
||||||
|
|
||||||
|
## Grundprinzip
|
||||||
|
|
||||||
|
TicketTracker arbeitet auf drei Ebenen:
|
||||||
|
|
||||||
|
1. **Session-Zeit**
|
||||||
|
Die tatsächlich gemessene oder manuell erfasste Arbeitszeit.
|
||||||
|
2. **Teamspace-/Projekt-Ist**
|
||||||
|
Je nach Profilmodus entweder in TicketTracker gepflegte Teamspace-Tageswerte oder direkt aus Teamspace synchronisierte Ist-Werte.
|
||||||
|
3. **Monatsabschluss**
|
||||||
|
Der kontrollierte Abschluss eines Monats, sobald alle Sessions final bewertet und die benötigten Prüfpunkte erledigt sind.
|
||||||
|
|
||||||
|
Wichtig: Tickets werden nicht abgeschlossen. Tickets dienen als Arbeits- und Auswertungsrahmen. Abgeschlossen wird der Monat.
|
||||||
|
|
||||||
|
## Die wichtigsten Bereiche
|
||||||
|
|
||||||
|
- **Timer und Sessions:** Zeiten erfassen, nachtragen, bewerten und dokumentieren.
|
||||||
|
- **Tickets:** Sessions pro Ticket, Tag und Monat prüfen.
|
||||||
|
- **Projekte:** Consulting-Projekte, Teamspace-Projekte, Budgets, Reststunden und Unterpositionen überwachen.
|
||||||
|
- **Teamspace:** Projekt-IDs, Budgets, Unterpositionen, Monatszeiten und Istwerte synchronisieren.
|
||||||
|
- **Zammad:** Organisationen, Ticketstammdaten, Status, Priorität und interne Notizen anbinden.
|
||||||
|
- **ToDos:** Aufgaben, Termine, Wiedervorlagen und Projektfollow-ups verwalten.
|
||||||
|
- **Dashboard:** Tagessteuerung für offene Bewertungen, ToDos, Teamspace-Nacharbeit und Projektwarnungen.
|
||||||
|
- **Statistiken:** Monatswerte, Organisationen, Tickets, Projektbudgets und Teamspace-Abgleich auswerten.
|
||||||
|
- **Monatsabschluss:** Einen Monat kontrolliert schließen oder wieder öffnen.
|
||||||
|
|
||||||
|
## Unterseiten für Confluence
|
||||||
|
|
||||||
|
Diese Dokumentation ist für den Import als Confluence-Seitenbaum vorbereitet:
|
||||||
|
|
||||||
|
1. `01-zeiterfassung-sessions-monatsabschluss.md`
|
||||||
|
2. `02-projekte-budgets-teamspace.md`
|
||||||
|
3. `03-zammad-sync-und-integration.md`
|
||||||
|
4. `04-dashboard-statistiken-und-widgets.md`
|
||||||
|
5. `05-todos-wiedervorlagen-fixe-abrechnungen.md`
|
||||||
|
6. `06-profil-admin-und-betrieb.md`
|
||||||
|
|
||||||
|
## Typischer Tagesablauf
|
||||||
|
|
||||||
|
1. Ticketnummer eingeben und Timer starten.
|
||||||
|
2. Tätigkeit dokumentieren.
|
||||||
|
3. Timer beenden und Session speichern.
|
||||||
|
4. Bei Bedarf Session nachtragen.
|
||||||
|
5. Sessions im Ticket bewerten.
|
||||||
|
6. Wenn Teamspace-Erfassung in TT aktiv ist: Teamspace-Tageswerte pro Projekt/Tag pflegen.
|
||||||
|
7. ToDos und Wiedervorlagen bearbeiten.
|
||||||
|
8. Dashboard prüfen, ob offene Bewertungen oder Teamspace-Nacharbeiten vorhanden sind.
|
||||||
|
|
||||||
|
## Typischer Monatsablauf
|
||||||
|
|
||||||
|
1. Monat in Tickets, Statistiken oder Monatsabschluss auswählen.
|
||||||
|
2. Offene Sessions prüfen.
|
||||||
|
3. Sessions final als **Abgerechnet** oder **Nicht abrechenbar** bewerten.
|
||||||
|
4. **Abrechenbar**-Zwischenstände klären, weil sie den Abschluss blockieren.
|
||||||
|
5. Bei aktivierter Teamspace-Erfassung: Teamspace-Tageswerte und Differenzen prüfen.
|
||||||
|
6. Im Zammad-Sync bei Bedarf den Teamspace-Monatsabgleich ausführen, um echte TS-Monatszeiten pro Projekt gegen TT zu prüfen.
|
||||||
|
7. Bei deaktivierter Teamspace-Erfassung: Teamspace-Differenzen entfallen; nur Session-Status ist relevant.
|
||||||
|
8. ToDos, Wiedervorlagen und Projektwarnungen prüfen.
|
||||||
|
9. Monat abschließen.
|
||||||
|
|
||||||
|
TicketTracker ist damit das Kontrollzentrum zwischen täglicher Ticketarbeit, Zammad-Dokumentation, Teamspace-Projekten und sauberer Abrechnung.
|
||||||
|
|
||||||
|
Die zentrale Frage bleibt:
|
||||||
|
|
||||||
|
**Ist alles vollständig, bewertet, dokumentiert und abrechnungsbereit?**
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
# Zeiterfassung, Sessions und Monatsabschluss
|
||||||
|
|
||||||
|
Diese Seite beschreibt die tägliche Erfassung, Bewertung und Monatsprüfung in TicketTracker.
|
||||||
|
|
||||||
|
## Timer
|
||||||
|
|
||||||
|
Benutzer starten einen Timer über eine Ticketnummer. Es können mehrere Timer vorbereitet sein, aber immer nur einer läuft aktiv. Wird ein anderer Timer gestartet, pausiert TicketTracker die übrigen Timer automatisch.
|
||||||
|
|
||||||
|
Beim Beenden werden Tätigkeit, Zeit, Ticket, Organisation, Art und optional Bewertung gespeichert. Wenn die Zammad-Notiz im Profil aktiv ist, kann TicketTracker beim Speichern einen internen Zammad-Article erzeugen.
|
||||||
|
|
||||||
|
## Sessions nachtragen
|
||||||
|
|
||||||
|
Vergessene Arbeit kann manuell als Session nachgetragen werden. Dabei werden erfasst:
|
||||||
|
|
||||||
|
- Datum
|
||||||
|
- Start und Ende
|
||||||
|
- Ticket
|
||||||
|
- Organisation
|
||||||
|
- Art, zum Beispiel Support oder Consulting
|
||||||
|
- Tätigkeit
|
||||||
|
- Bewertung
|
||||||
|
- optional interne Zammad-Notiz
|
||||||
|
|
||||||
|
Nachgetragene Sessions laufen genauso in Statistiken, Ticketansichten und Monatsabschluss ein wie per Timer erzeugte Sessions.
|
||||||
|
|
||||||
|
## Session-Bewertungen
|
||||||
|
|
||||||
|
Jede Session kann einen dieser Zustände haben:
|
||||||
|
|
||||||
|
- **Offen**
|
||||||
|
- **Abrechenbar**
|
||||||
|
- **Abgerechnet**
|
||||||
|
- **Nicht abrechenbar**
|
||||||
|
|
||||||
|
Nur **Abgerechnet** und **Nicht abrechenbar** gelten als final. **Abrechenbar** ist ein Zwischenstatus und blockiert weiterhin den Monatsabschluss.
|
||||||
|
|
||||||
|
Ein erneuter Klick auf den bereits gesetzten Status nimmt die Bewertung wieder zurück und macht die Session offen.
|
||||||
|
|
||||||
|
## Ticketdetailansicht
|
||||||
|
|
||||||
|
In der Ticketdetailansicht werden Sessions nach Tagen gruppiert. Dort werden Sessions bewertet, bearbeitet, gelöscht und aufgeklappt.
|
||||||
|
|
||||||
|
Pro Tag sieht man unter anderem:
|
||||||
|
|
||||||
|
- Summe abgerechneter Session-Zeit
|
||||||
|
- Summe abrechenbarer Session-Zeit
|
||||||
|
- Summe nicht abrechenbarer Session-Zeit
|
||||||
|
- offene Session-Zeit
|
||||||
|
- Teamspace-Tageswert, falls die Teamspace-Erfassung in TT aktiv ist
|
||||||
|
|
||||||
|
Die Tageswerte sind pro Benutzer und Projekt/Teamspace-Eintrag gespeichert. Sie sind nicht an einzelne Sessions gebunden.
|
||||||
|
|
||||||
|
## Teamspace-Tageswerte
|
||||||
|
|
||||||
|
Wenn im Profil **Teamspace-Zeiten in TT erfassen** aktiv ist, kann der Benutzer in der Ticketdetailansicht Teamspace-Tageswerte pflegen.
|
||||||
|
|
||||||
|
Die Eingabe erfolgt in Stunden, zum Beispiel:
|
||||||
|
|
||||||
|
- `1`
|
||||||
|
- `1,5`
|
||||||
|
- `2.25`
|
||||||
|
- `0,75`
|
||||||
|
|
||||||
|
Der Wert wird nicht während der Eingabe gespeichert. Sobald sich der Wert ändert, erscheint ein Speichern-Button. Ein leeres Feld entfernt den gespeicherten Teamspace-Wert für diesen Tag.
|
||||||
|
|
||||||
|
Wenn mehrere Teamspace-Projekte an einem Ticket hängen, wird der Tageswert pro Teamspace-Projekt-Eintrag geführt.
|
||||||
|
|
||||||
|
Wenn die Teamspace-Erfassung im Profil deaktiviert ist, sind diese Eingaben ausgeblendet und auch serverseitig gesperrt.
|
||||||
|
|
||||||
|
## Monatsabschluss
|
||||||
|
|
||||||
|
Der Monatsabschluss ist die zentrale Kontrollseite für einen Benutzer und Monat.
|
||||||
|
|
||||||
|
Sie zeigt unter anderem:
|
||||||
|
|
||||||
|
- eine nach Ticket zusammengefuehrte Pruefliste
|
||||||
|
- offene Bewertungen und Sessions mit Zwischenstatus **Abrechenbar**
|
||||||
|
- fehlende oder abweichende Teamspace-Werte als farbige, spaltenweise ausgerichtete Badges; die Tagesdetails stehen am jeweiligen Tag im Ticketdetail
|
||||||
|
- Prüfpunkte vor dem Abschluss
|
||||||
|
- Möglichkeit, den Monat zu schließen oder wieder zu öffnen
|
||||||
|
|
||||||
|
Jedes Ticket erscheint in der Pruefliste nur einmal. Monatsabweichungen, offene Bewertungen und betroffene Tage werden innerhalb dieses Eintrags gebuendelt; Hinweise zum gleichen Tag stehen in einer gemeinsamen Tageszeile.
|
||||||
|
|
||||||
|
Ein TT-Teamspace-Tageswert ist nur konsistent, wenn fuer dasselbe Ticket und denselben Tag mindestens eine Session den Status **Abgerechnet** hat. Bei ausschliesslich abrechenbaren, nicht abrechenbaren oder unbewerteten Sessions wird der hinterlegte Wert als Fehler ausgewiesen; ein zusaetzlicher Unterschied zur TS-API bleibt ebenfalls sichtbar.
|
||||||
|
|
||||||
|
Ein abgeschlossener Monat ist gegen versehentliche Änderungen geschützt. Wird eine Session in einem abgeschlossenen Zeitraum geändert, muss der Zeitraum vorher wieder geöffnet werden.
|
||||||
|
|
||||||
|
## Regeln bei deaktivierter Teamspace-Erfassung
|
||||||
|
|
||||||
|
Der Profil-Schalter **Teamspace-Zeiten in TT erfassen** ist userbezogen.
|
||||||
|
|
||||||
|
Wenn er ausgeschaltet ist:
|
||||||
|
|
||||||
|
- Teamspace-Tageswerte können nicht in TicketTracker gepflegt werden.
|
||||||
|
- Manuelle Projektstunden sind nicht editierbar.
|
||||||
|
- Monatsprüfung und Widgets prüfen nur Session-Status.
|
||||||
|
- Teamspace-Differenzen werden nicht angezeigt.
|
||||||
|
- Projekt-Istwerte kommen aus Teamspace.
|
||||||
|
|
||||||
|
Das ist für Benutzer gedacht, die keine Teamspace-Zeiten in TicketTracker nachpflegen, sondern Teamspace als führende Ist-Quelle verwenden.
|
||||||
@@ -0,0 +1,150 @@
|
|||||||
|
# Projekte, Budgets und Teamspace
|
||||||
|
|
||||||
|
Diese Seite beschreibt die aktuelle Projekt- und Teamspace-Logik in TicketTracker.
|
||||||
|
|
||||||
|
## Projekte
|
||||||
|
|
||||||
|
Projekte erweitern normale Tickets um Consulting-Projektinformationen.
|
||||||
|
|
||||||
|
Ein Projekt enthält unter anderem:
|
||||||
|
|
||||||
|
- Ticketnummer
|
||||||
|
- Titel und Organisation
|
||||||
|
- Teamspace-Projekt-Einträge
|
||||||
|
- Beschreibung und Ziele
|
||||||
|
- Zeitraum
|
||||||
|
- Projektstatus offen oder geschlossen
|
||||||
|
- ToDos
|
||||||
|
- Teamspace-Budgets und Verbrauchswerte
|
||||||
|
- Teamspace-Unterpositionen
|
||||||
|
|
||||||
|
Projekt-Tickets sind Consulting-Tickets. Wird ein Projekt gelöscht, bleiben Ticket, Sessions und ToDos erhalten. Gelöscht werden nur die Projektinformationen.
|
||||||
|
|
||||||
|
## Teamspace als Budgetquelle
|
||||||
|
|
||||||
|
Teamspace ist die fachliche Quelle für Projektbudgets.
|
||||||
|
|
||||||
|
Aktuelle Budgetregeln:
|
||||||
|
|
||||||
|
- Teamspace ist die Source of Truth für Projektbudgets.
|
||||||
|
- Zammad-Budgets werden nicht mehr als Budgetquelle verwendet.
|
||||||
|
- Das Gesamtbudget eines Projekts kommt aus Teamspace.
|
||||||
|
- Wenn mehrere Teamspace-Projekte an einem Ticket hängen, wird das Gesamtbudget aus diesen Teamspace-Projekten zusammengesetzt.
|
||||||
|
- Unterpositionen zeigen ihre eigenen Teamspace-Budgets und Teamspace-Istwerte.
|
||||||
|
- Stückpositionen werden nicht als Stundenbudget in die Stundenbetrachtung eingerechnet.
|
||||||
|
|
||||||
|
## Verbrauchslogik
|
||||||
|
|
||||||
|
Der Projektverbrauch hängt vom Profil-Schalter **Teamspace-Zeiten in TT erfassen** ab.
|
||||||
|
|
||||||
|
Wenn der Schalter aktiv ist:
|
||||||
|
|
||||||
|
- Projekt-Ist kommt aus Teamspace-Tageswerten, die in TicketTracker gepflegt werden.
|
||||||
|
- Zusätzlich können manuelle Projektstunden erfasst werden.
|
||||||
|
- Progress Bars, Reststunden und Budgetwarnungen verwenden diese Werte.
|
||||||
|
|
||||||
|
Wenn der Schalter deaktiviert ist:
|
||||||
|
|
||||||
|
- Projekt-Ist kommt aus Teamspace.
|
||||||
|
- Manuelle Projektstunden sind ausgeblendet und serverseitig gesperrt.
|
||||||
|
- Teamspace-Tageswerte können nicht in TicketTracker erfasst werden.
|
||||||
|
- Statistiken und Widgets zeigen keine TT/Teamspace-Differenzprüfung für diesen Benutzer.
|
||||||
|
|
||||||
|
## Mehrere Teamspace-Projekte pro Ticket
|
||||||
|
|
||||||
|
Ein Ticket kann mehrere Teamspace-Projekt-IDs haben. TicketTracker speichert dafür mehrere Teamspace-Einträge am Projekt.
|
||||||
|
|
||||||
|
Beispiel:
|
||||||
|
|
||||||
|
- `EP-2025-Comp-009`
|
||||||
|
- `EP-2025-Comp-025`
|
||||||
|
|
||||||
|
Einer der Einträge ist der Standard. Sessions können einem Teamspace-Projekt-Eintrag zugeordnet werden. Diese Zuordnung erfolgt auf Ebene des Teamspace-Projekts, nicht auf Ebene einzelner Unterpositionen.
|
||||||
|
|
||||||
|
## Gemeinsames Teamspace-Budget über mehrere Tickets
|
||||||
|
|
||||||
|
Mehrere unterschiedliche Ticketnummern können dieselbe Teamspace-Projekt-ID verwenden. TicketTracker behandelt diese Teamspace-Nummer dann als gemeinsame Budgetquelle.
|
||||||
|
|
||||||
|
Dabei gilt:
|
||||||
|
|
||||||
|
- Es gibt keine automatische Aufteilung auf die Ticketnummern.
|
||||||
|
- Teamspace-API-ID, Teamspace-API-URL, Budget, Unterpositionen und Teamspace-Monatswerte werden für dieselbe Teamspace-Nummer konsolidiert.
|
||||||
|
- Der Teamspace-Sync zieht gleiche Teamspace-Nummern nach jedem Lauf wieder auf denselben Datenstand.
|
||||||
|
- Die Migration konsolidiert bereits vorhandene doppelte Teamspace-Nummern beim Start.
|
||||||
|
- Ticket-spezifische Felder wie Standard-Eintrag, Reihenfolge, Session-Zuordnung und manuelle Projektstunden bleiben am jeweiligen Ticket.
|
||||||
|
|
||||||
|
## Teamspace-Projekt-ID-Sync
|
||||||
|
|
||||||
|
Der Sync auf der Zammad-Sync-Seite löst offene Consulting-Projekte gegen die Teamspace-API auf.
|
||||||
|
|
||||||
|
Gespeichert werden unter anderem:
|
||||||
|
|
||||||
|
- Teamspace-API-ID des Projekts
|
||||||
|
- Teamspace-API-URL
|
||||||
|
- Teamspace-Gesamtbudget
|
||||||
|
- Teamspace-Unterpositionen
|
||||||
|
- Teamspace-Budgets und Istwerte der Unterpositionen
|
||||||
|
- Stückpositionen und deren Erfüllungsstand
|
||||||
|
- echte Teamspace-Monatszeiten aus `/api/time` für den gewählten Monat
|
||||||
|
|
||||||
|
Die fachliche Teamspace-ID aus Zammad oder Projektinformationen, zum Beispiel `EP-2026-A-035`, ist nicht identisch mit der API-ID für Einzelabfragen. TicketTracker speichert deshalb die interne Teamspace-API-ID, sobald sie gefunden wurde.
|
||||||
|
|
||||||
|
## Teamspace-Monatsabgleich
|
||||||
|
|
||||||
|
Der Teamspace-Projekt-ID-Sync auf der Zammad-Sync-Seite enthält zusätzlich einen Monatsabgleich. Der Benutzer wählt einen Monat aus, TicketTracker ruft für diesen Zeitraum die Teamspace-Zeitbuchungen des angemeldeten Teamspace-Benutzers ab und speichert einen Snapshot pro Teamspace-Projekt-Eintrag.
|
||||||
|
|
||||||
|
Technische Grundlage:
|
||||||
|
|
||||||
|
- API-Endpoint: `/api/time`
|
||||||
|
- Filter: `worker={Teamspace User-ID}` und `date=YYYY-MM-01..YYYY-MM-letzterTag`
|
||||||
|
- gezählt werden Einträge mit `draftStatus.value = NORMAL`
|
||||||
|
- relevante Minuten sind `amountBillable`
|
||||||
|
- `amount` wird als Gesamtzeit mitgeführt, ist aber nicht die Vergleichsgröße
|
||||||
|
|
||||||
|
Die Monatswerte werden pro Benutzer, Teamspace-Projekt-Eintrag und Monat gespeichert. Dadurch bleiben drei Dinge getrennt:
|
||||||
|
|
||||||
|
- TT-Tageswerte, die in TicketTracker gepflegt werden
|
||||||
|
- echte Teamspace-Monatszeiten aus Teamspace
|
||||||
|
- Teamspace-Budget- und Unterpositionswerte aus dem Projektsync
|
||||||
|
|
||||||
|
Im Ergebnis des Syncs sieht man pro Teamspace-Projekt:
|
||||||
|
|
||||||
|
- TT-Monatswert
|
||||||
|
- TS-Monatswert
|
||||||
|
- Differenz
|
||||||
|
- Anzahl der gefundenen TS-Zeitbuchungen
|
||||||
|
- betroffene Haupt- oder Unterpositionen mit TS-Minuten
|
||||||
|
|
||||||
|
Wenn Teamspace-Zeiten auf Unterpositionen gebucht wurden, werden diese ebenfalls dem passenden Teamspace-Projekt-Eintrag zugeordnet. TicketTracker mappt dafür nicht nur die Hauptprojekt-API-ID, sondern auch die synchronisierten Unterpositions-API-IDs. Die Positionsaufschlüsselung wird im Monats-Snapshot gespeichert und kann dadurch später in der Monatsübersicht angezeigt werden.
|
||||||
|
|
||||||
|
## Unterpositionen
|
||||||
|
|
||||||
|
Unterpositionen werden aus Teamspace synchronisiert und im Projektdetail angezeigt. Sie dienen als Teamspace-Anzeige, um Budget, Verbrauch und freie Ressourcen pro Teamspace-Position sichtbar zu machen.
|
||||||
|
|
||||||
|
Wichtig:
|
||||||
|
|
||||||
|
- Zeiten werden nicht auf Unterpositionen in TicketTracker erfasst.
|
||||||
|
- Unterpositionen bekommen ihre Budgets und Istwerte aus Teamspace.
|
||||||
|
- Teamspace-Descriptions werden dekodiert und formatiert ausgegeben.
|
||||||
|
- Unterpositionen können einen Alias in TicketTracker bekommen.
|
||||||
|
- Akkordions der Unterpositionsgruppen merken sich den letzten offenen oder geschlossenen Zustand pro Benutzer und Projekt.
|
||||||
|
- Bei Teamspace-Strukturen mit Überpositionen werden nur echte bebuchbare Unterpositionen angezeigt, nicht reine Summenpositionen.
|
||||||
|
- Positionsnummern werden lesbar dargestellt, zum Beispiel `1` statt `0001` oder `1.1` statt `0001.0001`.
|
||||||
|
|
||||||
|
## Stückpositionen
|
||||||
|
|
||||||
|
Teamspace-Positionen mit `billingType = INVOICEABLE_FIX` sind Stück- oder Fixpositionen.
|
||||||
|
|
||||||
|
Für diese Positionen gelten andere Regeln:
|
||||||
|
|
||||||
|
- Stundenwerte werden nicht als Stundenbudget interpretiert.
|
||||||
|
- TicketTracker zeigt sie als Stückpositionen an.
|
||||||
|
- Der Erfüllungsstand wird über `plannedIncome` gegen `sumIncome` geprüft.
|
||||||
|
- Ist das Soll erreicht, wird die Position als abgerechnet beziehungsweise erfüllt markiert.
|
||||||
|
- Überschreitungen werden mit einem Hinweis-Icon angezeigt.
|
||||||
|
|
||||||
|
## Projektansichten
|
||||||
|
|
||||||
|
Die Projektliste zeigt Gesamtbudget, Verbrauch, Reststunden und Projektwarnungen. Bei mehreren Teamspace-Projekten können Detailbalken pro Teamspace-Projekt angezeigt oder ausgeblendet werden. Der Hauptbalken zeigt weiterhin das Gesamtbudget aller Teamspace-Projekte.
|
||||||
|
|
||||||
|
Im Projektdetail stehen Projektinformationen und ToDos getrennt von den Teamspace-Unterpositionen. Die Unterpositionen liegen in einer eigenen breiten Card unterhalb der Projekt-/ToDo-Informationen.
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
# Zammad Sync und Integration
|
||||||
|
|
||||||
|
TicketTracker nutzt Zammad, ohne Zammad zu ersetzen. Zammad bleibt das führende Ticketsystem für Ticketstammdaten, Organisationen, Status und Prioritäten.
|
||||||
|
|
||||||
|
## Persönlicher Zammad-Token
|
||||||
|
|
||||||
|
Jeder Benutzer kann im Profil einen persönlichen Zammad API-Token speichern. Beim Speichern ruft TicketTracker Zammad ab, prüft den Token und speichert die Zammad User-ID.
|
||||||
|
|
||||||
|
Der Token wird verschlüsselt in der Datenbank gespeichert.
|
||||||
|
|
||||||
|
Der persönliche Token wird unter anderem für benutzerbezogene Zammad-Funktionen verwendet, zum Beispiel interne Notizen oder personenbezogene Synchronisierungen.
|
||||||
|
|
||||||
|
## Admin-Zammad-Zugang
|
||||||
|
|
||||||
|
Im Adminbereich wird der Zammad-Zugang für systemweite Synchronisierungen gepflegt.
|
||||||
|
|
||||||
|
Der Admin kann Organisationen aus Zammad synchronisieren. Dabei werden lokale Organisationen angelegt, aktualisiert oder entfernt beziehungsweise von alten Verknüpfungen gelöst.
|
||||||
|
|
||||||
|
## Ticketdaten aus Zammad
|
||||||
|
|
||||||
|
TicketTracker kann Ticketdaten aus Zammad übernehmen:
|
||||||
|
|
||||||
|
- Ticketnummer
|
||||||
|
- Titel
|
||||||
|
- Organisation
|
||||||
|
- Work-Type, zum Beispiel Support oder Consulting
|
||||||
|
- Status
|
||||||
|
- Priorität
|
||||||
|
- Owner
|
||||||
|
- Teamspace-Projektinformationen
|
||||||
|
|
||||||
|
Zammad-verwaltete Ticketstammdaten bleiben geschützt. Ticketnummer, Titel, Art und Organisation können dann in TicketTracker nicht beliebig überschrieben werden.
|
||||||
|
|
||||||
|
## Consulting-Projekte aus Zammad
|
||||||
|
|
||||||
|
Beim Zammad-Sync oder beim Anlegen eines Projekts können Consulting-Projektinformationen übernommen werden.
|
||||||
|
|
||||||
|
Dazu gehören insbesondere:
|
||||||
|
|
||||||
|
- Teamspace-Projekt-ID oder mehrere Teamspace-Projekt-IDs
|
||||||
|
- Zammad-Owner
|
||||||
|
- Projektstatus-Hinweise, zum Beispiel Review bei Abschlussstatus
|
||||||
|
|
||||||
|
Die Teamspace-Projekt-ID aus Zammad ist die fachliche ID, zum Beispiel `EP-2026-A-035`. Für Teamspace-API-Abfragen speichert TicketTracker zusätzlich die interne Teamspace-API-ID.
|
||||||
|
|
||||||
|
## Teamspace-Projekt-ID-Sync auf der Zammad-Sync-Seite
|
||||||
|
|
||||||
|
Auf der Zammad-Sync-Seite gibt es den Sync für Teamspace-Projekt-IDs, Budgets und Positionen.
|
||||||
|
|
||||||
|
Voraussetzungen:
|
||||||
|
|
||||||
|
- Admin hat die Teamspace-Base-URL gespeichert.
|
||||||
|
- Benutzer hat im Profil Teamspace-Geraete-ID und Geraetepasswort gespeichert.
|
||||||
|
- Die Teamspace User-ID ist bekannt.
|
||||||
|
- Das Projekt hat eine Teamspace-Projekt-ID.
|
||||||
|
|
||||||
|
Der Sync speichert:
|
||||||
|
|
||||||
|
- Teamspace-API-ID
|
||||||
|
- Teamspace-API-URL
|
||||||
|
- Gesamtbudget
|
||||||
|
- Unterpositionen
|
||||||
|
- Budgets und Istwerte der Unterpositionen
|
||||||
|
- Stückpositionen
|
||||||
|
|
||||||
|
## Interne Zammad-Notizen
|
||||||
|
|
||||||
|
Beim Timerabschluss und beim manuellen Nachtragen kann TicketTracker einen internen Zammad-Article erzeugen.
|
||||||
|
|
||||||
|
Der Article enthält die Tätigkeit und optional den `time_unit`-Wert. Im Profil kann eingestellt werden:
|
||||||
|
|
||||||
|
- ob die interne Notiz standardmäßig aktiv ist
|
||||||
|
- welcher Zammad-Zeitbuchungstyp vorausgewählt wird
|
||||||
|
- ob `time_unit` auf 5, 10, 15 oder 30 Minuten aufgerundet wird
|
||||||
|
|
||||||
|
Wenn Zammad nicht erreichbar ist oder die Notiz fehlschlägt, speichert TicketTracker die Session trotzdem. Die fehlgeschlagene Notiz wird geparkt und kann später im Profil erneut gesendet werden.
|
||||||
|
|
||||||
|
## eMail-/Article-Import
|
||||||
|
|
||||||
|
TicketTracker kann Zammad-Articles prüfen und daraus Sessions vorbereiten. Das ist besonders hilfreich, wenn Arbeit per eMail-Import ins Ticket kam.
|
||||||
|
|
||||||
|
Solche Sessions können nachträglich geprüft, zugeordnet und bewertet werden. Wenn ein importierter Eintrag keinem Teamspace-Projekt zugeordnet ist, kann das im Projektdetail sichtbar werden.
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
# Dashboard, Statistiken und Widgets
|
||||||
|
|
||||||
|
Diese Seite beschreibt die Kontroll- und Auswertungsflächen in TicketTracker.
|
||||||
|
|
||||||
|
## Dashboard
|
||||||
|
|
||||||
|
Das Dashboard ist das Tagescockpit. Es zeigt, ob heute oder in der aktuellen Arbeitslage noch etwas offen ist.
|
||||||
|
|
||||||
|
Typische Hinweise:
|
||||||
|
|
||||||
|
- offene Session-Bewertungen
|
||||||
|
- Sessions im Zwischenstatus **Abrechenbar**
|
||||||
|
- heutige oder überfällige ToDos
|
||||||
|
- fällige Wiedervorlagen
|
||||||
|
- fehlende Teamspace-Tageswerte, wenn Teamspace-Erfassung in TT aktiv ist
|
||||||
|
- Projektbudget-Hinweise
|
||||||
|
- Nacharbeit für heutige Tickets
|
||||||
|
|
||||||
|
Widgets können je nach Konfiguration angeordnet und genutzt werden. Favoriten und persönliche Notizen unterstützen die tägliche Arbeit.
|
||||||
|
|
||||||
|
## Teamspace-Verhalten im Dashboard
|
||||||
|
|
||||||
|
Der Profil-Schalter **Teamspace-Zeiten in TT erfassen** beeinflusst die Dashboard-Logik.
|
||||||
|
|
||||||
|
Wenn aktiv:
|
||||||
|
|
||||||
|
- Dashboard kann fehlende Teamspace-Tageswerte anzeigen.
|
||||||
|
- Nacharbeitslisten können Teamspace-Differenzen berücksichtigen.
|
||||||
|
- Projektverbrauch basiert auf TT-Teamspace-Tageswerten plus manuellen Projektstunden.
|
||||||
|
|
||||||
|
Wenn deaktiviert:
|
||||||
|
|
||||||
|
- Teamspace-Erfassung wird als ausgeschaltet angezeigt.
|
||||||
|
- Monatsprüfung läuft statusbasiert.
|
||||||
|
- Teamspace-Differenzhinweise werden ausgeblendet.
|
||||||
|
- Projekt-Istwerte kommen aus Teamspace.
|
||||||
|
|
||||||
|
## Statistiken
|
||||||
|
|
||||||
|
Die Statistikseite betrachtet einen Monat.
|
||||||
|
|
||||||
|
Sie zeigt unter anderem:
|
||||||
|
|
||||||
|
- abgerechnete Zeit
|
||||||
|
- abrechenbare Zwischenstände
|
||||||
|
- nicht abrechenbare Zeit
|
||||||
|
- offene Sessions
|
||||||
|
- Organisationen mit Aufwand
|
||||||
|
- Tickets mit Aufwand
|
||||||
|
- Projektbudget-Hinweise
|
||||||
|
- manuelle und fixe Sessions
|
||||||
|
- Teamspace-Abgleich, wenn Teamspace-Erfassung in TT aktiv ist
|
||||||
|
- Teamspace als Ist-Quelle, wenn die Erfassung in TT deaktiviert ist
|
||||||
|
|
||||||
|
## Abgerechnet, abrechenbar und offen
|
||||||
|
|
||||||
|
Für statistische Auswertungen gilt:
|
||||||
|
|
||||||
|
- **Abgerechnet** zählt in die abgerechnete Session-Zeit.
|
||||||
|
- **Abrechenbar** ist ein Zwischenstatus und bleibt offen für den Monatsabschluss.
|
||||||
|
- **Nicht abrechenbar** ist final, zählt aber nicht als abgerechnete Zeit.
|
||||||
|
- **Offen** blockiert den Monatsabschluss.
|
||||||
|
|
||||||
|
## Teamspace-Abgleich in Statistiken
|
||||||
|
|
||||||
|
Wenn Teamspace-Erfassung in TT aktiv ist, vergleicht TicketTracker:
|
||||||
|
|
||||||
|
`Teamspace-Zeit minus abgerechnete Session-Zeit`
|
||||||
|
|
||||||
|
Bedeutung:
|
||||||
|
|
||||||
|
- Positive Differenz: In Teamspace steht mehr Zeit als abgerechnet wurde.
|
||||||
|
- Negative Differenz: In Teamspace steht weniger Zeit als abgerechnet wurde.
|
||||||
|
- Null: Teamspace und abgerechnete Session-Zeit sind ausgeglichen.
|
||||||
|
|
||||||
|
Wenn Teamspace-Erfassung deaktiviert ist, werden Differenzspalten, Differenzbadges und Teamspace-Linien ausgeblendet oder angepasst. Die Statistik zeigt dann Teamspace als Ist-Quelle und prüft primär die Session-Status.
|
||||||
|
|
||||||
|
## Teamspace-Monatsabgleich aus dem Sync
|
||||||
|
|
||||||
|
Zusätzlich zum Tageswert-Abgleich kann der Zammad-Sync echte Teamspace-Monatszeiten abrufen. Diese Daten kommen aus Teamspace `/api/time` und verwenden `amountBillable` der Projektzeitbuchungen.
|
||||||
|
|
||||||
|
Der Sync speichert die Werte als Monats-Snapshot pro Benutzer und Teamspace-Projekt-Eintrag. Dadurch wird sichtbar, ob der in TicketTracker gepflegte Monatswert zu den echten Teamspace-Buchungen passt und bei welchem Projekt die Abweichung liegt.
|
||||||
|
|
||||||
|
Der Monatsabgleich ist besonders hilfreich, wenn mehrere Teamspace-Projekte an einem Ticket hängen oder wenn Teamspace-Zeiten direkt auf Unterpositionen gebucht wurden. Diese Unterpositionszeiten werden dem passenden Teamspace-Projekt-Eintrag zugerechnet.
|
||||||
|
|
||||||
|
In der Monatsübersicht wird der gespeicherte Snapshot direkt am Ticket ausgegeben: TS-Monatswert, Differenz und die betroffenen Haupt- oder Unterpositionen. So ist nicht nur sichtbar, dass eine Abweichung existiert, sondern auch, auf welcher Teamspace-Position die Zeit gebucht wurde.
|
||||||
|
|
||||||
|
## Projektbudget-Hinweise
|
||||||
|
|
||||||
|
Projektbudget-Hinweise basieren auf den aktuellen Projektregeln:
|
||||||
|
|
||||||
|
- Budget kommt aus Teamspace.
|
||||||
|
- Ist kommt entweder aus TT-Teamspace-Tageswerten plus manuellen Projektstunden oder aus Teamspace, abhängig vom Profil-Schalter.
|
||||||
|
- Überschreitungen werden als Warnung angezeigt.
|
||||||
|
- Stückpositionen werden gesondert bewertet und nicht als Stundenbudget behandelt.
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
# ToDos, Wiedervorlagen und fixe Abrechnungen
|
||||||
|
|
||||||
|
Diese Seite beschreibt die Aufgabenlogik und automatisch erzeugte Sessions.
|
||||||
|
|
||||||
|
## ToDos
|
||||||
|
|
||||||
|
ToDos können direkt an Tickets oder Projekte gehängt werden.
|
||||||
|
|
||||||
|
Ein ToDo enthält:
|
||||||
|
|
||||||
|
- Aufgabe
|
||||||
|
- Priorität
|
||||||
|
- Status offen oder erledigt
|
||||||
|
- optional Umsetzung von
|
||||||
|
- optional Umsetzung bis
|
||||||
|
- optionale Beschreibung
|
||||||
|
|
||||||
|
ToDos können bearbeitet, erledigt, wieder geöffnet oder gelöscht werden.
|
||||||
|
|
||||||
|
## ToDo-Seite
|
||||||
|
|
||||||
|
Die zentrale ToDo-Seite bietet:
|
||||||
|
|
||||||
|
- heutige ToDos
|
||||||
|
- offene, erledigte oder alle ToDos
|
||||||
|
- Suche nach Ticketnummer
|
||||||
|
- Sortierung
|
||||||
|
- Gruppierung
|
||||||
|
- freie Reihenfolge per Drag and Drop
|
||||||
|
- Kalenderansicht
|
||||||
|
|
||||||
|
Überfällige ToDos werden hervorgehoben. ToDos mit Zeitraum zählen an jedem Arbeitstag im Zeitraum. Ein-Tages-ToDos erscheinen einzeln am jeweiligen Tag.
|
||||||
|
|
||||||
|
## Wiedervorlagen
|
||||||
|
|
||||||
|
Für Consulting-Projekte können automatische Wiedervorlagen entstehen, wenn länger keine Aktivität im TicketTracker passiert ist.
|
||||||
|
|
||||||
|
Mögliche Regeln:
|
||||||
|
|
||||||
|
- erste Wiedervorlage nach einer definierten Anzahl Wochen ohne Aktivität
|
||||||
|
- optionale zweite Wiedervorlage nach weiterem Stillstand
|
||||||
|
- automatische Erledigung, wenn später wieder eine Session auf dem Projekt erfasst wird
|
||||||
|
|
||||||
|
Die Einstellungen dafür liegen im Profil.
|
||||||
|
|
||||||
|
## Zammad-Termine
|
||||||
|
|
||||||
|
Aus Zammad-Tickets im Status **Wartend** oder **Abschluss** mit Datum kann TicketTracker Termin-ToDos erzeugen.
|
||||||
|
|
||||||
|
So werden Zammad-Termine direkt im TicketTracker-Dashboard, in der ToDo-Seite und im Kalender sichtbar.
|
||||||
|
|
||||||
|
## Fixe Abrechnungen
|
||||||
|
|
||||||
|
Regelmäßige Leistungen können als fixe Abrechnung angelegt werden.
|
||||||
|
|
||||||
|
Beispiele:
|
||||||
|
|
||||||
|
- jeden Donnerstag 4 Stunden
|
||||||
|
- bestimmte Wochentage mit festen Zeiten
|
||||||
|
- alle 2 Wochen eine Leistung
|
||||||
|
|
||||||
|
TicketTracker erzeugt daraus automatisch Sessions.
|
||||||
|
|
||||||
|
## Feiertage und Ausnahmen
|
||||||
|
|
||||||
|
Bayerische Feiertage werden berücksichtigt. Für Feiertage werden keine fixen Sessions erzeugt.
|
||||||
|
|
||||||
|
Einzelne automatisch erzeugte Sessions können gelöscht werden, zum Beispiel wegen Urlaub, Krankheit oder Verschiebung. Diese Löschung wird als Ausnahme gespeichert, damit die Session beim nächsten Aufruf nicht automatisch wieder erscheint.
|
||||||
|
|
||||||
|
Wenn eine wiederkehrende Leistung an einem anderen Tag erbracht wurde, kann die erzeugte Session bearbeitet oder verschoben werden.
|
||||||
|
|
||||||
|
## Bewertung fixer Sessions
|
||||||
|
|
||||||
|
Fix erzeugte Sessions verhalten sich im Abschluss wie normale Sessions:
|
||||||
|
|
||||||
|
- Sie müssen bewertet werden.
|
||||||
|
- **Abrechenbar** bleibt ein Zwischenstatus.
|
||||||
|
- **Abgerechnet** und **Nicht abrechenbar** sind final.
|
||||||
|
- Offene oder abrechenbare Sessions blockieren den Monatsabschluss.
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
# Profil, Adminbereich und Betrieb
|
||||||
|
|
||||||
|
Diese Seite beschreibt persönliche Einstellungen, Adminfunktionen und betriebliche Hinweise.
|
||||||
|
|
||||||
|
## Profil
|
||||||
|
|
||||||
|
Jeder Benutzer pflegt im Profil seine persönlichen Einstellungen:
|
||||||
|
|
||||||
|
- Benutzername und Anzeigename
|
||||||
|
- Passwort
|
||||||
|
- persönlicher Zammad API-Token
|
||||||
|
- Teamspace-Geraete-ID und Geraetepasswort
|
||||||
|
- angezeigte Zammad User-ID
|
||||||
|
- angezeigte Teamspace User-ID
|
||||||
|
- Bewertungsabfrage beim Timerabschluss
|
||||||
|
- Teamspace-Zeiten in TT erfassen
|
||||||
|
- interne Zammad-Notiz beim Timerabschluss
|
||||||
|
- Standard-Typ für interne Zammad-Notizen
|
||||||
|
- optionale Zammad-Zeitaufrundung für `time_unit`
|
||||||
|
- ATOSS/eMail-Sync-Optionen, sofern aktiviert
|
||||||
|
- Projekt-Wiedervorlagen
|
||||||
|
|
||||||
|
Passwortänderungen erfordern das aktuelle Passwort. Bleiben die Passwortfelder leer, werden nur die übrigen Profildaten gespeichert.
|
||||||
|
|
||||||
|
## Zammad-Zugang im Profil
|
||||||
|
|
||||||
|
Normale Benutzer können im Profil einen persönlichen Zammad API-Token hinterlegen. Beim Speichern eines neuen Tokens ruft TicketTracker Zammad ab und speichert die User-ID, die Zammad für diesen Token zurückliefert.
|
||||||
|
|
||||||
|
Der Token wird verschlüsselt gespeichert. Die User-ID wird danach read-only im Profil angezeigt.
|
||||||
|
|
||||||
|
## Teamspace-Zugang im Profil
|
||||||
|
|
||||||
|
Jeder Benutzer kann seine Teamspace-Geraete-ID und sein Geraetepasswort speichern.
|
||||||
|
|
||||||
|
Beim Speichern werden die Zugangsdaten gegen Teamspace geprüft. Wenn die Prüfung erfolgreich ist, speichert TicketTracker die Teamspace User-ID und zeigt sie im Profil an.
|
||||||
|
|
||||||
|
Geraete-ID und Passwort werden verschlüsselt in der Datenbank gespeichert.
|
||||||
|
|
||||||
|
## Teamspace-Zeiten in TT erfassen
|
||||||
|
|
||||||
|
Der Schalter **Teamspace-Zeiten in TT erfassen** ist userbezogen und steuert, ob ein Benutzer Teamspace-Tageswerte in TicketTracker pflegt.
|
||||||
|
|
||||||
|
**An:**
|
||||||
|
|
||||||
|
- Teamspace-Tageswerte können in TicketTracker erfasst werden.
|
||||||
|
- Manuelle Projektstunden sind editierbar.
|
||||||
|
- Projekt-Ist kommt aus TT-Teamspace-Tageswerten plus manuellen Projektstunden.
|
||||||
|
- Statistiken und Monatsprüfung vergleichen TT gegen Teamspace-Werte.
|
||||||
|
|
||||||
|
**Aus:**
|
||||||
|
|
||||||
|
- Teamspace-Tageswerte können nicht erfasst werden.
|
||||||
|
- Manuelle Projektstunden sind ausgeblendet und serverseitig gesperrt.
|
||||||
|
- Projekt-Ist kommt aus Teamspace.
|
||||||
|
- Monatsprüfung und Widgets prüfen nur Session-Status.
|
||||||
|
- Teamspace-Differenzen werden ausgeblendet.
|
||||||
|
|
||||||
|
Der Schalter ist bewusst userbezogen. Zwei Benutzer können am gleichen Projekt arbeiten und trotzdem unterschiedliche Erfassungs- und Prüflogiken nutzen.
|
||||||
|
|
||||||
|
## Adminbereich
|
||||||
|
|
||||||
|
Der feste Admin ist für Systemverwaltung vorgesehen. Er kann:
|
||||||
|
|
||||||
|
- Benutzer anlegen und bearbeiten
|
||||||
|
- Passwörter für Benutzer setzen
|
||||||
|
- Zammad-Adminzugang für Organisationssync pflegen
|
||||||
|
- Teamspace-Base-URL speichern
|
||||||
|
- Organisationen aus Zammad synchronisieren
|
||||||
|
- SQL-Backups herunterladen
|
||||||
|
- Restore-fähige SQL-Backups wieder einspielen
|
||||||
|
|
||||||
|
Der Admin sieht keine Timer, keine persönlichen Auswertungen, keine Statistiken, keine fixen Abrechnungen und keinen Monatsabschluss. Diese Bereiche sind für normale Benutzer vorgesehen.
|
||||||
|
|
||||||
|
## Teamspace-Base-URL
|
||||||
|
|
||||||
|
Die Teamspace-Base-URL wird im Adminbereich gespeichert. Ohne diese URL können Benutzer ihre Teamspace-Zugangsdaten nicht sinnvoll für API-Syncs nutzen.
|
||||||
|
|
||||||
|
Die persönlichen Teamspace-Zugangsdaten liegen dagegen im Benutzerprofil.
|
||||||
|
|
||||||
|
## Backup und Restore
|
||||||
|
|
||||||
|
Der Adminbereich kann SQL-Backups erzeugen und wieder einspielen.
|
||||||
|
|
||||||
|
Der Export enthält alle Tabellen, die für den Systemstand notwendig sind, unter anderem:
|
||||||
|
|
||||||
|
- Benutzer
|
||||||
|
- Einstellungen
|
||||||
|
- Organisationen
|
||||||
|
- Tickets
|
||||||
|
- Sessions
|
||||||
|
- Teamspace-Werte
|
||||||
|
- Projektinformationen
|
||||||
|
- Unterpositionen
|
||||||
|
- fixe Abrechnungen
|
||||||
|
- Ausnahmen
|
||||||
|
- Monatsabschlüsse
|
||||||
|
|
||||||
|
Ein Restore ersetzt den aktuellen Datenbestand durch den Stand des Backups. Vor dem Restore wird die Struktur geprüft. Nach dem Restore ist eine neue Anmeldung mit den Zugangsdaten aus dem Backup notwendig.
|
||||||
|
|
||||||
|
## Technischer Stack
|
||||||
|
|
||||||
|
- Frontend: Next.js, React, shadcn/ui, Tailwind CSS
|
||||||
|
- Backend: Node.js, Express, TypeScript
|
||||||
|
- Datenbank: PostgreSQL
|
||||||
|
- Deployment: Docker-fähig
|
||||||
|
|
||||||
|
Secrets wie Zammad-Token und Teamspace-Zugangsdaten werden verschlüsselt gespeichert.
|
||||||
+5
-4
@@ -400,13 +400,14 @@ Einen separaten Tagesabschluss gibt es nicht. Die Tagesansicht ist nur eine gefi
|
|||||||
Die Seite `Monatsabschluss` enthaelt:
|
Die Seite `Monatsabschluss` enthaelt:
|
||||||
|
|
||||||
- Checkliste
|
- Checkliste
|
||||||
- Offene Bewertungen
|
- eine gemeinsame Pruefliste nach Ticket
|
||||||
- Tage ohne Teamspace-Wert
|
|
||||||
- Button zum Monat abschliessen oder wieder oeffnen
|
- Button zum Monat abschliessen oder wieder oeffnen
|
||||||
|
|
||||||
Offene Bewertungen werden nach Ticket gruppiert. Mit `Ticket oeffnen` gelangt man direkt zur Bewertung.
|
Die Pruefliste fuehrt offene Bewertungen, fehlende Teamspace-Tageswerte sowie Monats- und ticketgenaue Tagesabweichungen zusammen. Jedes Ticket erscheint nur einmal. Farbige Badges stehen in festen Spalten fuer die jeweilige Pruefart. Die zugehoerigen Tagesdetails mit TT-Teamspace-Wert, TS-API-Wert und Differenz werden im Ticketdetail am betroffenen Tag angezeigt. Mit `Ticket oeffnen` gelangt man direkt dorthin.
|
||||||
|
|
||||||
Tage ohne Teamspace-Wert sind eine Pruefliste. Wenn ein Tag bewusst keinen Teamspace-Wert bekommen soll, kann der Eintrag mit `Zur Kenntnis` abgehakt werden.
|
Wenn ein Tag bewusst keinen Teamspace-Wert bekommen soll, kann er innerhalb des Ticket-Eintrags mit `Zur Kenntnis` abgehakt werden. Trifft fuer denselben Tag zugleich eine TT/TS-Abweichung zu, werden beide Hinweise in einer gemeinsamen Tageszeile dargestellt.
|
||||||
|
|
||||||
|
Ein hinterlegter TT-Teamspace-Wert setzt mindestens eine als `Abgerechnet` bewertete Session desselben Tickets und Tages voraus. Gibt es nur `Abrechenbar`, `Nicht abrechenbar` oder unbewertete Sessions, erscheint der Tageswert als Fehler. Der TT-Teamspace-Wert und eine eventuelle Abweichung zur TS-API werden trotzdem angezeigt.
|
||||||
|
|
||||||
Zur Kenntnis genommene Eintraege koennen ueber die Checkbox `Zur Kenntnis anzeigen` wieder eingeblendet werden. Diese Anzeigeeinstellung bleibt im Browser gespeichert. Ein abgehakter Eintrag kann mit `Zuruecknehmen` wieder offen gemacht werden.
|
Zur Kenntnis genommene Eintraege koennen ueber die Checkbox `Zur Kenntnis anzeigen` wieder eingeblendet werden. Diese Anzeigeeinstellung bleibt im Browser gespeichert. Ein abgehakter Eintrag kann mit `Zuruecknehmen` wieder offen gemacht werden.
|
||||||
|
|
||||||
|
|||||||
+88
-19
@@ -1,5 +1,6 @@
|
|||||||
import {
|
import {
|
||||||
CalendarDays,
|
CalendarDays,
|
||||||
|
Building2,
|
||||||
CheckCircle2,
|
CheckCircle2,
|
||||||
ClipboardCheck,
|
ClipboardCheck,
|
||||||
Command,
|
Command,
|
||||||
@@ -87,8 +88,10 @@ import { isRecurringBillingTicketNumber, ticketHasRecurringBilling } from "./rec
|
|||||||
import { activeElapsedMs, mergeTimerActivityDrafts, readStoredTimers, storageKeyForUser, ticketPattern, type TimerEntry } from "./timers";
|
import { activeElapsedMs, mergeTimerActivityDrafts, readStoredTimers, storageKeyForUser, ticketPattern, type TimerEntry } from "./timers";
|
||||||
import { formatTodoPeriod, isTodoActiveOnDay, isTodoFixedOnDay, isTodoOverdue } from "./todos";
|
import { formatTodoPeriod, isTodoActiveOnDay, isTodoFixedOnDay, isTodoOverdue } from "./todos";
|
||||||
import { AdminUsersPage } from "./views/AdminUsersPage";
|
import { AdminUsersPage } from "./views/AdminUsersPage";
|
||||||
|
import { AdminOrganizationsPage } from "./views/AdminOrganizationsPage";
|
||||||
import { AnalysisPage } from "./views/AnalysisPage";
|
import { AnalysisPage } from "./views/AnalysisPage";
|
||||||
import { DashboardPage } from "./views/DashboardPage";
|
import { DashboardPage } from "./views/DashboardPage";
|
||||||
|
import { DashboardTimersPage } from "./views/DashboardTimersPage";
|
||||||
import { FaqPage } from "./views/FaqPage";
|
import { FaqPage } from "./views/FaqPage";
|
||||||
import { LoginPage } from "./views/LoginPage";
|
import { LoginPage } from "./views/LoginPage";
|
||||||
import { MonthlyClosePage } from "./views/MonthlyClosePage";
|
import { MonthlyClosePage } from "./views/MonthlyClosePage";
|
||||||
@@ -136,6 +139,10 @@ function pathWithoutSearch(value: string) {
|
|||||||
return value.split("?")[0];
|
return value.split("?")[0];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function openTicketKey(ticketId: string) {
|
||||||
|
return `ticket:${ticketId}`;
|
||||||
|
}
|
||||||
|
|
||||||
function readOpenTickets(userId: string): OpenTicketNavItem[] {
|
function readOpenTickets(userId: string): OpenTicketNavItem[] {
|
||||||
try {
|
try {
|
||||||
const raw = localStorage.getItem(openTicketsStorageKey(userId));
|
const raw = localStorage.getItem(openTicketsStorageKey(userId));
|
||||||
@@ -145,11 +152,15 @@ function readOpenTickets(userId: string): OpenTicketNavItem[] {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const parsed = JSON.parse(raw) as Partial<OpenTicketNavItem>[];
|
const parsed = JSON.parse(raw) as Partial<OpenTicketNavItem>[];
|
||||||
|
const tickets: OpenTicketNavItem[] = [];
|
||||||
|
|
||||||
return parsed
|
for (const item of parsed) {
|
||||||
.filter((item) => item.key && item.ticketId && item.ticketNumber && item.path)
|
if (!item.ticketId || !item.ticketNumber || !item.path) {
|
||||||
.map((item) => ({
|
continue;
|
||||||
key: String(item.key),
|
}
|
||||||
|
|
||||||
|
const ticket: OpenTicketNavItem = {
|
||||||
|
key: openTicketKey(String(item.ticketId)),
|
||||||
ticketId: String(item.ticketId),
|
ticketId: String(item.ticketId),
|
||||||
ticketNumber: String(item.ticketNumber),
|
ticketNumber: String(item.ticketNumber),
|
||||||
periodType: item.periodType === "day" ? "day" : item.periodType === "month" ? "month" : "all",
|
periodType: item.periodType === "day" ? "day" : item.periodType === "month" ? "month" : "all",
|
||||||
@@ -157,7 +168,17 @@ function readOpenTickets(userId: string): OpenTicketNavItem[] {
|
|||||||
path: String(item.path),
|
path: String(item.path),
|
||||||
returnPath: typeof item.returnPath === "string" ? item.returnPath : undefined,
|
returnPath: typeof item.returnPath === "string" ? item.returnPath : undefined,
|
||||||
dirty: false
|
dirty: false
|
||||||
}));
|
};
|
||||||
|
const existingIndex = tickets.findIndex((existing) => existing.ticketId === ticket.ticketId);
|
||||||
|
|
||||||
|
if (existingIndex >= 0) {
|
||||||
|
tickets[existingIndex] = ticket;
|
||||||
|
} else {
|
||||||
|
tickets.push(ticket);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return tickets.slice(-12);
|
||||||
} catch {
|
} catch {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
@@ -207,6 +228,10 @@ function routeFromPath(pathWithSearch: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (pathname.startsWith("/dashboard")) {
|
if (pathname.startsWith("/dashboard")) {
|
||||||
|
if (pathname.startsWith("/dashboard/timers")) {
|
||||||
|
return { page: "dashboard-timers" as const };
|
||||||
|
}
|
||||||
|
|
||||||
return { page: "dashboard" as const };
|
return { page: "dashboard" as const };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -242,6 +267,10 @@ function routeFromPath(pathWithSearch: string) {
|
|||||||
return { page: "admin-users" as const };
|
return { page: "admin-users" as const };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (pathname.startsWith("/admin/organizations")) {
|
||||||
|
return { page: "admin-organizations" as const };
|
||||||
|
}
|
||||||
|
|
||||||
if (pathname.startsWith("/profile")) {
|
if (pathname.startsWith("/profile")) {
|
||||||
return { page: "profile" as const };
|
return { page: "profile" as const };
|
||||||
}
|
}
|
||||||
@@ -809,6 +838,7 @@ export function App() {
|
|||||||
const [timersLoaded, setTimersLoaded] = useState(false);
|
const [timersLoaded, setTimersLoaded] = useState(false);
|
||||||
const [timerOwnerId, setTimerOwnerId] = useState<string | null>(null);
|
const [timerOwnerId, setTimerOwnerId] = useState<string | null>(null);
|
||||||
const [selectedTimerId, setSelectedTimerId] = useState<string | null>(null);
|
const [selectedTimerId, setSelectedTimerId] = useState<string | null>(null);
|
||||||
|
const [finishRequestTimerId, setFinishRequestTimerId] = useState<string | null>(null);
|
||||||
const [tick, setTick] = useState(Date.now());
|
const [tick, setTick] = useState(Date.now());
|
||||||
const [openTickets, setOpenTickets] = useState<OpenTicketNavItem[]>([]);
|
const [openTickets, setOpenTickets] = useState<OpenTicketNavItem[]>([]);
|
||||||
const [ticketReturnPaths, setTicketReturnPaths] = useState<Record<string, string>>({});
|
const [ticketReturnPaths, setTicketReturnPaths] = useState<Record<string, string>>({});
|
||||||
@@ -1142,7 +1172,7 @@ export function App() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (route.page === "admin-users" || route.page === "profile" || route.page === "faq") {
|
if (route.page === "admin-users" || route.page === "admin-organizations" || route.page === "profile" || route.page === "faq") {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1245,13 +1275,31 @@ export function App() {
|
|||||||
|
|
||||||
const updateOpenTicket = useCallback((ticket: OpenTicketNavItem) => {
|
const updateOpenTicket = useCallback((ticket: OpenTicketNavItem) => {
|
||||||
setOpenTickets((current) => {
|
setOpenTickets((current) => {
|
||||||
const existingIndex = current.findIndex((item) => item.key === ticket.key);
|
const normalizedTicket = {
|
||||||
|
...ticket,
|
||||||
|
key: openTicketKey(ticket.ticketId)
|
||||||
|
};
|
||||||
|
const existingIndex = current.findIndex((item) => item.ticketId === normalizedTicket.ticketId || item.key === normalizedTicket.key);
|
||||||
|
|
||||||
if (existingIndex >= 0) {
|
if (existingIndex >= 0) {
|
||||||
return current.map((item, index) => (index === existingIndex ? ticket : item));
|
return current
|
||||||
|
.map((item, index) => {
|
||||||
|
if (item.ticketId === normalizedTicket.ticketId || item.key === normalizedTicket.key) {
|
||||||
|
return index === existingIndex
|
||||||
|
? {
|
||||||
|
...item,
|
||||||
|
...normalizedTicket,
|
||||||
|
returnPath: normalizedTicket.returnPath ?? item.returnPath
|
||||||
|
}
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return item;
|
||||||
|
})
|
||||||
|
.filter((item): item is OpenTicketNavItem => item !== null);
|
||||||
}
|
}
|
||||||
|
|
||||||
return [...current, ticket].slice(-12);
|
return [...current, normalizedTicket].slice(-12);
|
||||||
});
|
});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@@ -1313,7 +1361,7 @@ export function App() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setOpenTickets((items) => items.filter((item) => item.key !== ticket.key));
|
setOpenTickets((items) => items.filter((item) => item.ticketId !== ticket.ticketId && item.key !== ticket.key));
|
||||||
|
|
||||||
if (route.page === "ticket" && route.ticketId === ticket.ticketId) {
|
if (route.page === "ticket" && route.ticketId === ticket.ticketId) {
|
||||||
const returnPath = current.returnPath && pathWithoutSearch(current.returnPath) !== pathWithoutSearch(ticket.path) ? current.returnPath : "/analysis";
|
const returnPath = current.returnPath && pathWithoutSearch(current.returnPath) !== pathWithoutSearch(ticket.path) ? current.returnPath : "/analysis";
|
||||||
@@ -1354,13 +1402,15 @@ export function App() {
|
|||||||
...(currentUser.role === "admin"
|
...(currentUser.role === "admin"
|
||||||
? [
|
? [
|
||||||
{ type: "item" as const, href: "/admin/users", label: "Administration", icon: Shield, active: path.startsWith("/admin/users") },
|
{ type: "item" as const, href: "/admin/users", label: "Administration", icon: Shield, active: path.startsWith("/admin/users") },
|
||||||
|
{ type: "item" as const, href: "/admin/organizations", label: "Organisationen", icon: Building2, active: path.startsWith("/admin/organizations") },
|
||||||
{ type: "separator" as const, key: "before-profile" },
|
{ type: "separator" as const, key: "before-profile" },
|
||||||
{ type: "item" as const, href: "/profile", label: "Profil", icon: UserCog, active: path.startsWith("/profile") },
|
{ type: "item" as const, href: "/profile", label: "Profil", icon: UserCog, active: path.startsWith("/profile") },
|
||||||
{ type: "separator" as const, key: "before-faq" },
|
{ type: "separator" as const, key: "before-faq" },
|
||||||
{ type: "item" as const, href: "/faq", label: "FAQ", icon: CircleHelp, active: path.startsWith("/faq") }
|
{ type: "item" as const, href: "/faq", label: "FAQ", icon: CircleHelp, active: path.startsWith("/faq") }
|
||||||
]
|
]
|
||||||
: [
|
: [
|
||||||
{ type: "item" as const, href: "/dashboard", label: "Dashboard", icon: LayoutDashboard, active: path.startsWith("/dashboard") },
|
{ type: "item" as const, href: "/dashboard", label: "Dashboard", icon: LayoutDashboard, active: path === "/dashboard" },
|
||||||
|
{ type: "item" as const, href: "/dashboard/timers", label: "Timer-Übersicht", icon: Timer, active: path.startsWith("/dashboard/timers") },
|
||||||
{ type: "separator" as const, key: "after-dashboard" },
|
{ type: "separator" as const, key: "after-dashboard" },
|
||||||
{ type: "item" as const, href: "/timer", label: "Session starten", icon: Timer, active: path.startsWith("/timer") || path === "/" },
|
{ type: "item" as const, href: "/timer", label: "Session starten", icon: Timer, active: path.startsWith("/timer") || path === "/" },
|
||||||
{ type: "item" as const, href: "/analysis", label: "Ticket-Sessions", icon: Ticket, active: path.startsWith("/analysis") },
|
{ type: "item" as const, href: "/analysis", label: "Ticket-Sessions", icon: Ticket, active: path.startsWith("/analysis") },
|
||||||
@@ -1472,7 +1522,7 @@ export function App() {
|
|||||||
<div ref={openTicketsListRef} className="max-h-[clamp(8rem,calc(100svh-31rem),24rem)] overflow-y-auto overscroll-contain pr-1 [scrollbar-gutter:stable] [scrollbar-width:thin]">
|
<div ref={openTicketsListRef} className="max-h-[clamp(8rem,calc(100svh-31rem),24rem)] overflow-y-auto overscroll-contain pr-1 [scrollbar-gutter:stable] [scrollbar-width:thin]">
|
||||||
<SidebarMenu>
|
<SidebarMenu>
|
||||||
{openTickets.map((ticket) => {
|
{openTickets.map((ticket) => {
|
||||||
const isActive = pathWithoutSearch(path) === pathWithoutSearch(ticket.path);
|
const isActive = route.page === "ticket" && route.ticketId === ticket.ticketId;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SidebarMenuItem key={ticket.key}>
|
<SidebarMenuItem key={ticket.key}>
|
||||||
@@ -1531,7 +1581,7 @@ export function App() {
|
|||||||
<SidebarMenuItem>
|
<SidebarMenuItem>
|
||||||
<SidebarMenuButton tooltip="Worklog">
|
<SidebarMenuButton tooltip="Worklog">
|
||||||
<FolderGit />
|
<FolderGit />
|
||||||
<span className="text-xs text-muted-foreground">v1.0.11-rc1</span>
|
<span className="text-xs text-muted-foreground">v1.2.0-rc1</span>
|
||||||
</SidebarMenuButton>
|
</SidebarMenuButton>
|
||||||
</SidebarMenuItem>
|
</SidebarMenuItem>
|
||||||
</SidebarMenu>
|
</SidebarMenu>
|
||||||
@@ -1626,19 +1676,38 @@ export function App() {
|
|||||||
setSelectedTimerId={setSelectedTimerId}
|
setSelectedTimerId={setSelectedTimerId}
|
||||||
tick={tick}
|
tick={tick}
|
||||||
currentUser={currentUser}
|
currentUser={currentUser}
|
||||||
|
finishRequestId={finishRequestTimerId}
|
||||||
|
onFinishRequestHandled={() => setFinishRequestTimerId(null)}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
{!isAdmin && route.page === "dashboard" ? <DashboardPage onNavigate={navigate} /> : null}
|
{!isAdmin && route.page === "dashboard" ? <DashboardPage currentUser={currentUser} onNavigate={navigate} /> : null}
|
||||||
{!isAdmin && route.page === "analysis" ? <AnalysisPage onNavigate={navigate} /> : null}
|
{!isAdmin && route.page === "dashboard-timers" ? (
|
||||||
{!isAdmin && route.page === "monthly-close" ? <MonthlyClosePage onNavigate={navigate} /> : null}
|
<DashboardTimersPage
|
||||||
{!isAdmin && route.page === "statistics" ? <StatisticsPage onNavigate={navigate} /> : null}
|
timers={timers}
|
||||||
{!isAdmin && route.page === "projects" ? <ProjectsPage onNavigate={navigate} onStartTimer={startQuickTimer} /> : null}
|
setTimers={setTimers}
|
||||||
|
selectedTimerId={selectedTimerId}
|
||||||
|
setSelectedTimerId={setSelectedTimerId}
|
||||||
|
tick={tick}
|
||||||
|
currentUser={currentUser}
|
||||||
|
onFinishTimer={(timerId) => {
|
||||||
|
setSelectedTimerId(timerId);
|
||||||
|
setFinishRequestTimerId(timerId);
|
||||||
|
navigate("/timer");
|
||||||
|
}}
|
||||||
|
onOpenActiveTimer={() => navigate("/timer")}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
{!isAdmin && route.page === "analysis" ? <AnalysisPage currentUser={currentUser} onNavigate={navigate} /> : null}
|
||||||
|
{!isAdmin && route.page === "monthly-close" ? <MonthlyClosePage currentUser={currentUser} onNavigate={navigate} /> : null}
|
||||||
|
{!isAdmin && route.page === "statistics" ? <StatisticsPage currentUser={currentUser} onNavigate={navigate} /> : null}
|
||||||
|
{!isAdmin && route.page === "projects" ? <ProjectsPage currentUser={currentUser} onNavigate={navigate} onStartTimer={startQuickTimer} /> : null}
|
||||||
{!isAdmin && route.page === "todos" ? <TodosPage onNavigate={navigate} /> : null}
|
{!isAdmin && route.page === "todos" ? <TodosPage onNavigate={navigate} /> : null}
|
||||||
{!isAdmin && route.page === "recurring" ? <RecurringBillingsPage /> : null}
|
{!isAdmin && route.page === "recurring" ? <RecurringBillingsPage /> : null}
|
||||||
{!isAdmin && route.page === "zammad-sync" ? <ZammadSyncPage currentUser={currentUser} /> : null}
|
{!isAdmin && route.page === "zammad-sync" ? <ZammadSyncPage currentUser={currentUser} /> : null}
|
||||||
{route.page === "faq" ? <FaqPage /> : null}
|
{route.page === "faq" ? <FaqPage /> : null}
|
||||||
{route.page === "profile" ? <ProfilePage currentUser={currentUser} onUserUpdated={setCurrentUser} /> : null}
|
{route.page === "profile" ? <ProfilePage currentUser={currentUser} onUserUpdated={setCurrentUser} /> : null}
|
||||||
{route.page === "admin-users" && isAdmin ? <AdminUsersPage currentUser={currentUser} /> : null}
|
{route.page === "admin-users" && isAdmin ? <AdminUsersPage currentUser={currentUser} /> : null}
|
||||||
|
{route.page === "admin-organizations" && isAdmin ? <AdminOrganizationsPage /> : null}
|
||||||
{route.page === "admin-users" && !isAdmin ? (
|
{route.page === "admin-users" && !isAdmin ? (
|
||||||
<TimerPage
|
<TimerPage
|
||||||
timers={timers}
|
timers={timers}
|
||||||
@@ -1661,7 +1730,7 @@ export function App() {
|
|||||||
returnPath={ticketReturnPaths[route.ticketId] ?? openTickets.find((ticket) => ticket.ticketId === route.ticketId)?.returnPath ?? "/analysis"}
|
returnPath={ticketReturnPaths[route.ticketId] ?? openTickets.find((ticket) => ticket.ticketId === route.ticketId)?.returnPath ?? "/analysis"}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
{isAdmin && route.page !== "admin-users" && route.page !== "profile" && route.page !== "faq" ? <AdminUsersPage currentUser={currentUser} /> : null}
|
{isAdmin && route.page !== "admin-users" && route.page !== "admin-organizations" && route.page !== "profile" && route.page !== "faq" ? <AdminUsersPage currentUser={currentUser} /> : null}
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
</SidebarInset>
|
</SidebarInset>
|
||||||
|
|||||||
+302
-7
@@ -7,6 +7,7 @@ import type {
|
|||||||
Organization,
|
Organization,
|
||||||
PeriodOverview,
|
PeriodOverview,
|
||||||
PeriodType,
|
PeriodType,
|
||||||
|
ProjectTeamspaceSubentry,
|
||||||
ProjectSummary,
|
ProjectSummary,
|
||||||
RecurringBilling,
|
RecurringBilling,
|
||||||
StatisticsOverview,
|
StatisticsOverview,
|
||||||
@@ -18,6 +19,7 @@ import type {
|
|||||||
TodoPriority,
|
TodoPriority,
|
||||||
TodoStatus,
|
TodoStatus,
|
||||||
WorkType,
|
WorkType,
|
||||||
|
WorkSegment,
|
||||||
ZammadSessionArticleQueueItem
|
ZammadSessionArticleQueueItem
|
||||||
} from "./types";
|
} from "./types";
|
||||||
import type { TimerEntry, TimerPhase } from "./timers";
|
import type { TimerEntry, TimerPhase } from "./timers";
|
||||||
@@ -31,10 +33,12 @@ type CreateSessionPayload = {
|
|||||||
startedAt: string;
|
startedAt: string;
|
||||||
endedAt: string;
|
endedAt: string;
|
||||||
durationSeconds: number;
|
durationSeconds: number;
|
||||||
|
workSegments?: WorkSegment[];
|
||||||
zammadLookup?: boolean;
|
zammadLookup?: boolean;
|
||||||
projectTeamspaceEntryId?: string | null;
|
projectTeamspaceEntryId?: string | null;
|
||||||
billingStatus?: BillingStatus;
|
billingStatus?: BillingStatus;
|
||||||
createZammadArticle?: boolean;
|
createZammadArticle?: boolean;
|
||||||
|
zammadArticleDateHeader?: boolean;
|
||||||
zammadArticleTypeId?: number;
|
zammadArticleTypeId?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -125,6 +129,83 @@ export type ZammadSyncJob = {
|
|||||||
finishedAt: string | null;
|
finishedAt: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type TeamspaceProjectIdSyncItem = {
|
||||||
|
entryId: string;
|
||||||
|
projectId: string;
|
||||||
|
ticketNumber: string;
|
||||||
|
teamspaceProject: string;
|
||||||
|
previousApiProjectId: string | null;
|
||||||
|
apiProjectId: string | null;
|
||||||
|
apiProjectUrl: string | null;
|
||||||
|
ttMonthBillableMinutes: number;
|
||||||
|
teamspaceMonthMinutes: number;
|
||||||
|
teamspaceMonthBillableMinutes: number;
|
||||||
|
teamspaceMonthEntryCount: number;
|
||||||
|
teamspaceMonthDeltaMinutes: number;
|
||||||
|
teamspaceMonthPositions: TeamspaceProjectIdSyncPosition[];
|
||||||
|
status: "updated" | "current" | "missing" | "ambiguous" | "excluded";
|
||||||
|
message: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TeamspaceProjectIdSyncPosition = {
|
||||||
|
apiProjectId: string;
|
||||||
|
projectId: string | null;
|
||||||
|
label: string;
|
||||||
|
isSubentry: boolean;
|
||||||
|
billingType?: string | null;
|
||||||
|
minutes: number;
|
||||||
|
billableMinutes: number;
|
||||||
|
entryCount: number;
|
||||||
|
days?: Array<{
|
||||||
|
day: string;
|
||||||
|
minutes: number;
|
||||||
|
billableMinutes: number;
|
||||||
|
entryCount: number;
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TeamspaceProjectIdSyncResult = {
|
||||||
|
month: string;
|
||||||
|
checkedEntries: number;
|
||||||
|
matched: number;
|
||||||
|
updated: number;
|
||||||
|
alreadyCurrent: number;
|
||||||
|
missing: number;
|
||||||
|
ambiguous: number;
|
||||||
|
cleared: number;
|
||||||
|
remoteProjectsScanned: number;
|
||||||
|
remoteProjectIds: number;
|
||||||
|
remoteSubprojectsScanned: number;
|
||||||
|
subentriesSynced: number;
|
||||||
|
subentriesRemoved: number;
|
||||||
|
teamspaceTimeEntriesScanned: number;
|
||||||
|
teamspaceTimeEntriesMapped: number;
|
||||||
|
teamspaceTimeBillableMinutes: number;
|
||||||
|
teamspaceTimeDeltaMinutes: number;
|
||||||
|
duplicateRemoteProjectIds: string[];
|
||||||
|
items: TeamspaceProjectIdSyncItem[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TeamspaceProjectSubentrySyncResult = {
|
||||||
|
projectId: string;
|
||||||
|
checkedEntries: number;
|
||||||
|
remoteProjectsScanned: number;
|
||||||
|
subentriesSynced: number;
|
||||||
|
subentriesRemoved: number;
|
||||||
|
monthsSynced: string[];
|
||||||
|
teamspaceTimeEntriesScanned: number;
|
||||||
|
teamspaceTimeEntriesMapped: number;
|
||||||
|
teamspaceTimeBillableMinutes: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TeamspaceAllSubentrySyncResult = {
|
||||||
|
checkedProjects: number;
|
||||||
|
checkedEntries: number;
|
||||||
|
remoteProjectsScanned: number;
|
||||||
|
subentriesSynced: number;
|
||||||
|
subentriesRemoved: number;
|
||||||
|
};
|
||||||
|
|
||||||
export type TicketZammadMailSessionItem = {
|
export type TicketZammadMailSessionItem = {
|
||||||
articleId: number;
|
articleId: number;
|
||||||
ticketNumber: string;
|
ticketNumber: string;
|
||||||
@@ -248,9 +329,14 @@ export function updateCurrentUser(payload: {
|
|||||||
currentPassword?: string;
|
currentPassword?: string;
|
||||||
newPassword?: string;
|
newPassword?: string;
|
||||||
zammadApiToken?: string;
|
zammadApiToken?: string;
|
||||||
|
teamspaceDeviceId?: string;
|
||||||
|
teamspaceDevicePassword?: string;
|
||||||
zammadEmailSessionWebhookEnabled?: boolean;
|
zammadEmailSessionWebhookEnabled?: boolean;
|
||||||
sessionBillingPromptEnabled?: boolean;
|
sessionBillingPromptEnabled?: boolean;
|
||||||
|
sessionWorkSegmentsEnabled?: boolean;
|
||||||
|
teamspaceTimeTrackingEnabled?: boolean;
|
||||||
zammadSessionNoteDefaultEnabled?: boolean;
|
zammadSessionNoteDefaultEnabled?: boolean;
|
||||||
|
zammadSessionNoteDateHeaderEnabled?: boolean;
|
||||||
zammadTimeUnitRoundingEnabled?: boolean;
|
zammadTimeUnitRoundingEnabled?: boolean;
|
||||||
zammadTimeUnitRoundingMinutes?: number;
|
zammadTimeUnitRoundingMinutes?: number;
|
||||||
zammadAtossEmailSyncEnabled?: boolean;
|
zammadAtossEmailSyncEnabled?: boolean;
|
||||||
@@ -355,6 +441,37 @@ export function updateAdminUser(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type TeamspaceInitialActualsSyncResult = {
|
||||||
|
userId: string;
|
||||||
|
fromMonth: string;
|
||||||
|
toMonth: string;
|
||||||
|
checkedEntries: number;
|
||||||
|
matchedEntries: number;
|
||||||
|
updatedProjectIds: number;
|
||||||
|
missingEntries: number;
|
||||||
|
ambiguousEntries: number;
|
||||||
|
monthsChecked: number;
|
||||||
|
monthsSkippedClosed: number;
|
||||||
|
teamspaceTimeEntriesScanned: number;
|
||||||
|
updatedManualEntries: number;
|
||||||
|
importedManualMinutes: number;
|
||||||
|
existingDayBillingMinutes: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function syncAdminUserTeamspaceInitialActuals(userId: string, payload: { fromMonth?: string; toMonth?: string }) {
|
||||||
|
return request<{ result: TeamspaceInitialActualsSyncResult }>(`/api/admin/users/${userId}/teamspace-initial-actuals/sync`, {
|
||||||
|
method: "POST",
|
||||||
|
timeoutMs: 10 * 60 * 1000,
|
||||||
|
body: JSON.stringify(payload)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function unlockAdminUserTeamspaceInitialActuals(userId: string) {
|
||||||
|
return request<{ user: AdminUser }>(`/api/admin/users/${userId}/teamspace-initial-actuals/unlock`, {
|
||||||
|
method: "POST"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export async function downloadDatabaseExport() {
|
export async function downloadDatabaseExport() {
|
||||||
const response = await fetch("/api/admin/export/database", {
|
const response = await fetch("/api/admin/export/database", {
|
||||||
credentials: "same-origin"
|
credentials: "same-origin"
|
||||||
@@ -421,6 +538,33 @@ export function getOrganizations(search = "") {
|
|||||||
return request<{ organizations: Organization[] }>(`/api/organizations${params.size ? `?${params}` : ""}`);
|
return request<{ organizations: Organization[] }>(`/api/organizations${params.size ? `?${params}` : ""}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getAdminOrganizations(options: {
|
||||||
|
search?: string;
|
||||||
|
page?: number;
|
||||||
|
pageSize?: number | "all";
|
||||||
|
missingAssignment?: boolean;
|
||||||
|
} = {}) {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
|
||||||
|
if (options.search?.trim()) {
|
||||||
|
params.set("search", options.search.trim());
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options.page) {
|
||||||
|
params.set("page", String(options.page));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options.pageSize) {
|
||||||
|
params.set("pageSize", String(options.pageSize));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options.missingAssignment) {
|
||||||
|
params.set("missingAssignment", "true");
|
||||||
|
}
|
||||||
|
|
||||||
|
return request<{ organizations: Organization[]; total: number; page: number; pageSize: number | "all" }>(`/api/admin/organizations${params.size ? `?${params}` : ""}`);
|
||||||
|
}
|
||||||
|
|
||||||
export function getZammadSettings() {
|
export function getZammadSettings() {
|
||||||
return request<{ settings: { baseUrl: string; hasApiKey: boolean } }>("/api/admin/zammad/settings");
|
return request<{ settings: { baseUrl: string; hasApiKey: boolean } }>("/api/admin/zammad/settings");
|
||||||
}
|
}
|
||||||
@@ -432,8 +576,82 @@ export function saveZammadSettings(payload: { baseUrl: string; apiKey?: string }
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getTeamspaceSettings() {
|
||||||
|
return request<{ settings: { baseUrl: string; credentialsUserId: string | null } }>("/api/admin/teamspace/settings");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function saveTeamspaceSettings(payload: { baseUrl: string; credentialsUserId?: string | null }) {
|
||||||
|
return request<{ settings: { baseUrl: string; credentialsUserId: string | null } }>("/api/admin/teamspace/settings", {
|
||||||
|
method: "PUT",
|
||||||
|
body: JSON.stringify(payload)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export type TeamspaceProjectExclusion = {
|
||||||
|
teamspace_project: string;
|
||||||
|
teamspace_api_project_id: string | null;
|
||||||
|
teamspace_api_project_ids: string[] | null;
|
||||||
|
teamspace_name: string | null;
|
||||||
|
customer_name: string | null;
|
||||||
|
atoss_support_default: boolean;
|
||||||
|
atoss_support_valid_from: string | null;
|
||||||
|
atoss_support_valid_to: string | null;
|
||||||
|
customer_owner_option_id: string | null;
|
||||||
|
hotline_contract_option_id: string | null;
|
||||||
|
note: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type OrganizationAttributeOption = {
|
||||||
|
id: string;
|
||||||
|
zammad_attribute_id: string;
|
||||||
|
attribute_name: "customer_owner" | "hotline_contract";
|
||||||
|
option_key: string;
|
||||||
|
display_name: string;
|
||||||
|
active: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TeamspaceAssignmentRule = {
|
||||||
|
id?: string;
|
||||||
|
name: string;
|
||||||
|
active: boolean;
|
||||||
|
priority: number;
|
||||||
|
customer_owner_option_id: string | null;
|
||||||
|
hotline_contract_option_id: string | null;
|
||||||
|
support_enabled: boolean;
|
||||||
|
consulting_enabled: boolean;
|
||||||
|
teamspace_project: string;
|
||||||
|
created_at?: string;
|
||||||
|
updated_at?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function getOrganizationAttributeOptions() {
|
||||||
|
return request<{ options: OrganizationAttributeOption[] }>("/api/admin/zammad/organization-attribute-options");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getTeamspaceAssignmentRules() {
|
||||||
|
return request<{ rules: TeamspaceAssignmentRule[] }>("/api/admin/teamspace/assignment-rules");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function saveTeamspaceAssignmentRules(rules: TeamspaceAssignmentRule[]) {
|
||||||
|
return request<{ rules: TeamspaceAssignmentRule[] }>("/api/admin/teamspace/assignment-rules", {
|
||||||
|
method: "PUT",
|
||||||
|
body: JSON.stringify({ rules })
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getTeamspaceProjectExclusions() {
|
||||||
|
return request<{ exclusions: TeamspaceProjectExclusion[] }>("/api/admin/teamspace/project-exclusions");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function saveTeamspaceProjectExclusions(projects: Array<Pick<TeamspaceProjectExclusion, "teamspace_project" | "atoss_support_default" | "atoss_support_valid_from" | "atoss_support_valid_to" | "customer_owner_option_id" | "hotline_contract_option_id">>) {
|
||||||
|
return request<{ exclusions: TeamspaceProjectExclusion[] }>("/api/admin/teamspace/project-exclusions", {
|
||||||
|
method: "PUT",
|
||||||
|
body: JSON.stringify({ projects })
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export function syncZammadOrganizations(payload: { baseUrl?: string; apiKey?: string }) {
|
export function syncZammadOrganizations(payload: { baseUrl?: string; apiKey?: string }) {
|
||||||
return request<{ synced: number; skipped: number; removed: number; unlinkedTickets: number; unlinkedSessions: number }>("/api/admin/zammad/organizations/sync", {
|
return request<{ synced: number; attributeOptions: number; skipped: number; removed: number; unlinkedTickets: number; unlinkedSessions: number }>("/api/admin/zammad/organizations/sync", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: JSON.stringify(payload)
|
body: JSON.stringify(payload)
|
||||||
});
|
});
|
||||||
@@ -478,25 +696,46 @@ async function runZammadSyncJob(url: string, body: Record<string, unknown>, onJo
|
|||||||
return { jobId: job.id, result };
|
return { jobId: job.id, result };
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function syncExistingZammadTickets(fields: ZammadSyncExistingFields, options: { dryRun?: boolean; ticketNumbers?: string[] } = {}) {
|
export async function syncExistingZammadTickets(fields: ZammadSyncExistingFields, options: { dryRun?: boolean; ticketNumbers?: string[] } = {}, onJobStarted?: (job: ZammadSyncJob) => void) {
|
||||||
const { result } = await runZammadSyncJob("/api/zammad-sync/jobs/existing", {
|
const { result } = await runZammadSyncJob("/api/zammad-sync/jobs/existing", {
|
||||||
fields,
|
fields,
|
||||||
dryRun: options.dryRun ?? true,
|
dryRun: options.dryRun ?? true,
|
||||||
ticketNumbers: options.ticketNumbers
|
ticketNumbers: options.ticketNumbers
|
||||||
});
|
}, onJobStarted);
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function importOwnZammadConsultingTickets(options: { dryRun?: boolean; ticketNumbers?: string[] } = {}) {
|
export async function importOwnZammadConsultingTickets(options: { dryRun?: boolean; ticketNumbers?: string[] } = {}, onJobStarted?: (job: ZammadSyncJob) => void) {
|
||||||
const { result } = await runZammadSyncJob("/api/zammad-sync/jobs/consulting-import", {
|
const { result } = await runZammadSyncJob("/api/zammad-sync/jobs/consulting-import", {
|
||||||
dryRun: options.dryRun ?? true,
|
dryRun: options.dryRun ?? true,
|
||||||
ticketNumbers: options.ticketNumbers
|
ticketNumbers: options.ticketNumbers
|
||||||
});
|
}, onJobStarted);
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function syncExistingZammadTicketsDirect(fields: ZammadSyncExistingFields, onJobStarted?: (job: ZammadSyncJob) => void) {
|
||||||
|
return runZammadSyncJob("/api/zammad-sync/jobs/existing", {
|
||||||
|
fields,
|
||||||
|
dryRun: false
|
||||||
|
}, onJobStarted);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function importOwnZammadConsultingTicketsDirect(onJobStarted?: (job: ZammadSyncJob) => void) {
|
||||||
|
return runZammadSyncJob("/api/zammad-sync/jobs/consulting-import", {
|
||||||
|
dryRun: false
|
||||||
|
}, onJobStarted);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function syncZammadMailSessionsDirect(daysBack: number, perPage: number, onJobStarted?: (job: ZammadSyncJob) => void) {
|
||||||
|
return runZammadSyncJob("/api/zammad-sync/jobs/mail-sessions", {
|
||||||
|
dryRun: false,
|
||||||
|
daysBack,
|
||||||
|
perPage
|
||||||
|
}, onJobStarted);
|
||||||
|
}
|
||||||
|
|
||||||
export function previewExistingZammadTickets(fields: ZammadSyncExistingFields, onJobStarted?: (job: ZammadSyncJob) => void) {
|
export function previewExistingZammadTickets(fields: ZammadSyncExistingFields, onJobStarted?: (job: ZammadSyncJob) => void) {
|
||||||
return runZammadSyncJob("/api/zammad-sync/jobs/existing", {
|
return runZammadSyncJob("/api/zammad-sync/jobs/existing", {
|
||||||
fields,
|
fields,
|
||||||
@@ -537,6 +776,41 @@ export function applyZammadMailSessionsFromPreview(previewJobId: string, article
|
|||||||
}, onJobStarted);
|
}, onJobStarted);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function syncTeamspaceProjectIds(month?: string) {
|
||||||
|
return request<{ result: TeamspaceProjectIdSyncResult }>("/api/teamspace/project-ids/sync", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify(month ? { month } : {}),
|
||||||
|
timeoutMs: 5 * 60 * 1000
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function syncProjectTeamspaceSubentries(projectId: string, months?: string[]) {
|
||||||
|
return request<{ result: TeamspaceProjectSubentrySyncResult }>(`/api/projects/${projectId}/teamspace-subentries/sync`, {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify(months?.length ? { months } : {}),
|
||||||
|
timeoutMs: 5 * 60 * 1000
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function syncAllTeamspaceSubentries() {
|
||||||
|
return request<{ result: TeamspaceAllSubentrySyncResult }>("/api/teamspace/subentries/sync", {
|
||||||
|
method: "POST",
|
||||||
|
timeoutMs: 5 * 60 * 1000
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateProjectTeamspaceSubentry(
|
||||||
|
subentryId: string,
|
||||||
|
payload: {
|
||||||
|
alias: string;
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
return request<{ subentry: ProjectTeamspaceSubentry }>(`/api/project-teamspace-subentries/${subentryId}`, {
|
||||||
|
method: "PATCH",
|
||||||
|
body: JSON.stringify(payload)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export function getRecurringBillings() {
|
export function getRecurringBillings() {
|
||||||
return request<{ recurringBillings: RecurringBilling[] }>("/api/recurring-billings");
|
return request<{ recurringBillings: RecurringBilling[] }>("/api/recurring-billings");
|
||||||
}
|
}
|
||||||
@@ -546,6 +820,9 @@ export function createRecurringBilling(payload: {
|
|||||||
organizationId: string;
|
organizationId: string;
|
||||||
activity: string;
|
activity: string;
|
||||||
workType: WorkType;
|
workType: WorkType;
|
||||||
|
teamspaceProject?: string | null;
|
||||||
|
hourBudgetMinutes?: number | null;
|
||||||
|
manualConsumptionMinutes?: number | null;
|
||||||
recurrenceType: "weekly" | "every_n_weeks";
|
recurrenceType: "weekly" | "every_n_weeks";
|
||||||
intervalValue: number;
|
intervalValue: number;
|
||||||
validFrom: string;
|
validFrom: string;
|
||||||
@@ -571,6 +848,9 @@ export function updateRecurringBilling(
|
|||||||
organizationId: string;
|
organizationId: string;
|
||||||
activity: string;
|
activity: string;
|
||||||
workType: WorkType;
|
workType: WorkType;
|
||||||
|
teamspaceProject?: string | null;
|
||||||
|
hourBudgetMinutes?: number | null;
|
||||||
|
manualConsumptionMinutes?: number | null;
|
||||||
recurrenceType: "weekly" | "every_n_weeks";
|
recurrenceType: "weekly" | "every_n_weeks";
|
||||||
intervalValue: number;
|
intervalValue: number;
|
||||||
validFrom: string;
|
validFrom: string;
|
||||||
@@ -842,6 +1122,13 @@ export function updateTicket(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function updateTicketAtossSupport(ticketId: string, atossSupport: boolean) {
|
||||||
|
return request<{ ticket: { atoss_support: boolean } }>(`/api/tickets/${ticketId}/atoss-support`, {
|
||||||
|
method: "PATCH",
|
||||||
|
body: JSON.stringify({ atossSupport })
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export function updateSessionDetails(
|
export function updateSessionDetails(
|
||||||
sessionId: string,
|
sessionId: string,
|
||||||
payload: {
|
payload: {
|
||||||
@@ -861,10 +1148,18 @@ export function updateSessionDetails(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function updateTicketDayBilling(ticketId: string, day: string, billedMinutes: number | null, projectTeamspaceEntryId?: string | null) {
|
export function updateTicketDayBilling(
|
||||||
|
ticketId: string,
|
||||||
|
day: string,
|
||||||
|
billedMinutes: number | null,
|
||||||
|
projectTeamspaceEntryId?: string | null
|
||||||
|
) {
|
||||||
return request(`/api/tickets/${ticketId}/day-billings/${day}`, {
|
return request(`/api/tickets/${ticketId}/day-billings/${day}`, {
|
||||||
method: "PATCH",
|
method: "PATCH",
|
||||||
body: JSON.stringify({ billedMinutes, projectTeamspaceEntryId: projectTeamspaceEntryId ?? null })
|
body: JSON.stringify({
|
||||||
|
billedMinutes,
|
||||||
|
projectTeamspaceEntryId: projectTeamspaceEntryId ?? null
|
||||||
|
})
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -54,7 +54,7 @@
|
|||||||
--font-sans: var(--font-sans);
|
--font-sans: var(--font-sans);
|
||||||
--breakpoint-xxl: 108.125rem;
|
--breakpoint-xxl: 108.125rem;
|
||||||
--breakpoint-details: 85rem;
|
--breakpoint-details: 85rem;
|
||||||
|
--text-xxs: 0.65rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Default theme styles (used when no data-theme-preset is set or when 'default' is selected).
|
/* Default theme styles (used when no data-theme-preset is set or when 'default' is selected).
|
||||||
@@ -166,6 +166,11 @@ These serve as the fallback; there is no separate default.css file. */
|
|||||||
}
|
}
|
||||||
|
|
||||||
@layer utilities {
|
@layer utilities {
|
||||||
|
.text-xxs {
|
||||||
|
font-size: var(--text-xxs);
|
||||||
|
line-height: 0.65rem;
|
||||||
|
}
|
||||||
|
|
||||||
[data-theme-preset]:not([data-theme-preset="default"]) .shadow-2xs {
|
[data-theme-preset]:not([data-theme-preset="default"]) .shadow-2xs {
|
||||||
box-shadow: var(--shadow-2xs);
|
box-shadow: var(--shadow-2xs);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { CalendarDays, CheckCircle2, Circle, GripVertical, Pencil, Plus, Save, Trash2, X } from "lucide-react";
|
import { CalendarDays, CheckCircle2, Circle, GripVertical, Pencil, Plus, Save, Trash2, X } from "lucide-react";
|
||||||
import { FormEvent, useState } from "react";
|
import { type FormEvent, type ReactNode, useState } from "react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
@@ -40,9 +40,11 @@ type TodoPanelProps = {
|
|||||||
onNavigate?: (to: string) => void;
|
onNavigate?: (to: string) => void;
|
||||||
reorderEnabled?: boolean;
|
reorderEnabled?: boolean;
|
||||||
onReorder?: (draggedTodoId: string, targetTodoId: string) => void;
|
onReorder?: (draggedTodoId: string, targetTodoId: string) => void;
|
||||||
|
toolbar?: ReactNode;
|
||||||
|
compact?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function TodoPanel({ ticketId, todos, onChanged, showCreate = true, showTicketMeta = false, onNavigate, reorderEnabled = false, onReorder }: TodoPanelProps) {
|
export function TodoPanel({ ticketId, todos, onChanged, showCreate = true, showTicketMeta = false, onNavigate, reorderEnabled = false, onReorder, toolbar, compact = false }: TodoPanelProps) {
|
||||||
const [title, setTitle] = useState("");
|
const [title, setTitle] = useState("");
|
||||||
const [description, setDescription] = useState("");
|
const [description, setDescription] = useState("");
|
||||||
const [priority, setPriority] = useState<TodoPriority>("medium");
|
const [priority, setPriority] = useState<TodoPriority>("medium");
|
||||||
@@ -156,97 +158,100 @@ export function TodoPanel({ ticketId, todos, onChanged, showCreate = true, showT
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-3">
|
<div className={compact ? "space-y-2" : "space-y-3"}>
|
||||||
{showCreate && ticketId ? (
|
{toolbar || (showCreate && ticketId) ? (
|
||||||
<div className="flex justify-end">
|
<div className={cn("flex flex-wrap items-center gap-2", toolbar ? "justify-start" : "justify-end")}>
|
||||||
<Dialog open={createOpen} onOpenChange={setCreateOpen}>
|
<div className="min-w-0">{toolbar}</div>
|
||||||
<DialogTrigger asChild>
|
{showCreate && ticketId ? (
|
||||||
<Button type="button" size="sm">
|
<Dialog open={createOpen} onOpenChange={setCreateOpen}>
|
||||||
<Plus className="size-4" />
|
<DialogTrigger asChild>
|
||||||
ToDo anlegen
|
<Button type="button" size="sm">
|
||||||
</Button>
|
<Plus className="size-4" />
|
||||||
</DialogTrigger>
|
ToDo anlegen
|
||||||
<DialogContent>
|
</Button>
|
||||||
<DialogHeader>
|
</DialogTrigger>
|
||||||
<DialogTitle>ToDo anlegen</DialogTitle>
|
<DialogContent>
|
||||||
<DialogDescription>Neue Aufgabe zu diesem Ticket oder Projekt erfassen.</DialogDescription>
|
<DialogHeader>
|
||||||
</DialogHeader>
|
<DialogTitle>ToDo anlegen</DialogTitle>
|
||||||
<form className="space-y-4" onSubmit={submitTodo}>
|
<DialogDescription>Neue Aufgabe zu diesem Ticket oder Projekt erfassen.</DialogDescription>
|
||||||
<div className="space-y-2">
|
</DialogHeader>
|
||||||
<label className="text-sm font-medium" htmlFor={`todo-title-${ticketId}`}>
|
<form className="space-y-4" onSubmit={submitTodo}>
|
||||||
Aufgabe
|
|
||||||
</label>
|
|
||||||
<Input id={`todo-title-${ticketId}`} value={title} onChange={(event) => setTitle(event.currentTarget.value)} autoFocus required />
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<label className="text-sm font-medium" htmlFor={`todo-priority-${ticketId}`}>
|
|
||||||
Priorität
|
|
||||||
</label>
|
|
||||||
<select
|
|
||||||
id={`todo-priority-${ticketId}`}
|
|
||||||
className="h-9 w-full rounded-lg border border-input bg-background px-2 text-sm focus-visible:outline-none focus-visible:ring-3 focus-visible:ring-ring/50"
|
|
||||||
value={priority}
|
|
||||||
onChange={(event) => setPriority(event.currentTarget.value as TodoPriority)}
|
|
||||||
>
|
|
||||||
{Object.entries(priorityLabels).map(([value, label]) => (
|
|
||||||
<option key={value} value={value}>
|
|
||||||
{label}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div className="grid gap-3 sm:grid-cols-2">
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label className="text-sm font-medium" htmlFor={`todo-starts-on-${ticketId}`}>
|
<label className="text-sm font-medium" htmlFor={`todo-title-${ticketId}`}>
|
||||||
Umsetzung von
|
Aufgabe
|
||||||
</label>
|
</label>
|
||||||
<div className="flex gap-2">
|
<Input id={`todo-title-${ticketId}`} value={title} onChange={(event) => setTitle(event.currentTarget.value)} autoFocus required />
|
||||||
<Input id={`todo-starts-on-${ticketId}`} type="date" value={startsOn} onChange={(event) => setStartsOn(event.currentTarget.value)} />
|
</div>
|
||||||
{startsOn ? (
|
<div className="space-y-2">
|
||||||
<Button type="button" size="icon" variant="ghost" className="shrink-0" onClick={() => setStartsOn("")} aria-label="Von-Datum entfernen">
|
<label className="text-sm font-medium" htmlFor={`todo-priority-${ticketId}`}>
|
||||||
<X className="size-4" />
|
Priorität
|
||||||
</Button>
|
</label>
|
||||||
) : null}
|
<select
|
||||||
|
id={`todo-priority-${ticketId}`}
|
||||||
|
className="h-9 w-full rounded-lg border border-input bg-background px-2 text-sm focus-visible:outline-none focus-visible:ring-3 focus-visible:ring-ring/50"
|
||||||
|
value={priority}
|
||||||
|
onChange={(event) => setPriority(event.currentTarget.value as TodoPriority)}
|
||||||
|
>
|
||||||
|
{Object.entries(priorityLabels).map(([value, label]) => (
|
||||||
|
<option key={value} value={value}>
|
||||||
|
{label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-3 sm:grid-cols-2">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-sm font-medium" htmlFor={`todo-starts-on-${ticketId}`}>
|
||||||
|
Umsetzung von
|
||||||
|
</label>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Input id={`todo-starts-on-${ticketId}`} type="date" value={startsOn} onChange={(event) => setStartsOn(event.currentTarget.value)} />
|
||||||
|
{startsOn ? (
|
||||||
|
<Button type="button" size="icon" variant="ghost" className="shrink-0" onClick={() => setStartsOn("")} aria-label="Von-Datum entfernen">
|
||||||
|
<X className="size-4" />
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-sm font-medium" htmlFor={`todo-ends-on-${ticketId}`}>
|
||||||
|
Umsetzung bis
|
||||||
|
</label>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Input id={`todo-ends-on-${ticketId}`} type="date" value={endsOn} onChange={(event) => setEndsOn(event.currentTarget.value)} />
|
||||||
|
{endsOn ? (
|
||||||
|
<Button type="button" size="icon" variant="ghost" className="shrink-0" onClick={() => setEndsOn("")} aria-label="Bis-Datum entfernen">
|
||||||
|
<X className="size-4" />
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label className="text-sm font-medium" htmlFor={`todo-ends-on-${ticketId}`}>
|
<label className="text-sm font-medium" htmlFor={`todo-description-${ticketId}`}>
|
||||||
Umsetzung bis
|
Details
|
||||||
</label>
|
</label>
|
||||||
<div className="flex gap-2">
|
<Textarea
|
||||||
<Input id={`todo-ends-on-${ticketId}`} type="date" value={endsOn} onChange={(event) => setEndsOn(event.currentTarget.value)} />
|
id={`todo-description-${ticketId}`}
|
||||||
{endsOn ? (
|
className="min-h-24"
|
||||||
<Button type="button" size="icon" variant="ghost" className="shrink-0" onClick={() => setEndsOn("")} aria-label="Bis-Datum entfernen">
|
value={description}
|
||||||
<X className="size-4" />
|
onChange={(event) => setDescription(event.currentTarget.value)}
|
||||||
</Button>
|
/>
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<div className="flex justify-end">
|
||||||
<div className="space-y-2">
|
<Button type="submit" disabled={saving}>
|
||||||
<label className="text-sm font-medium" htmlFor={`todo-description-${ticketId}`}>
|
<Plus className="size-4" />
|
||||||
Details
|
{saving ? "Speichert..." : "Anlegen"}
|
||||||
</label>
|
</Button>
|
||||||
<Textarea
|
</div>
|
||||||
id={`todo-description-${ticketId}`}
|
</form>
|
||||||
className="min-h-24"
|
</DialogContent>
|
||||||
value={description}
|
</Dialog>
|
||||||
onChange={(event) => setDescription(event.currentTarget.value)}
|
) : null}
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="flex justify-end">
|
|
||||||
<Button type="submit" disabled={saving}>
|
|
||||||
<Plus className="size-4" />
|
|
||||||
{saving ? "Speichert..." : "Anlegen"}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className={compact ? "space-y-1.5" : "space-y-2"}>
|
||||||
{todos.map((todo) => {
|
{todos.map((todo) => {
|
||||||
const isEditing = editingId === todo.id;
|
const isEditing = editingId === todo.id;
|
||||||
const periodLabel = formatTodoPeriod(todo);
|
const periodLabel = formatTodoPeriod(todo);
|
||||||
@@ -256,7 +261,8 @@ export function TodoPanel({ ticketId, todos, onChanged, showCreate = true, showT
|
|||||||
<div
|
<div
|
||||||
key={todo.id}
|
key={todo.id}
|
||||||
className={cn(
|
className={cn(
|
||||||
"rounded-md border bg-card/50 p-3 transition-colors",
|
"rounded-md border bg-card/50 transition-colors",
|
||||||
|
compact ? "p-2" : "p-3",
|
||||||
overdue && "border-red-500/50 bg-red-500/10",
|
overdue && "border-red-500/50 bg-red-500/10",
|
||||||
todo.status === "done" && "opacity-70",
|
todo.status === "done" && "opacity-70",
|
||||||
reorderEnabled && draggingId === todo.id && "border-primary/50 bg-primary/5"
|
reorderEnabled && draggingId === todo.id && "border-primary/50 bg-primary/5"
|
||||||
@@ -393,7 +399,7 @@ export function TodoPanel({ ticketId, todos, onChanged, showCreate = true, showT
|
|||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{todos.length === 0 ? <p className="rounded-md border bg-muted/20 p-4 text-center text-sm text-muted-foreground">Keine ToDos vorhanden.</p> : null}
|
{todos.length === 0 ? <p className={cn("rounded-md border bg-muted/20 text-center text-sm text-muted-foreground", compact ? "p-3" : "p-4")}>Keine ToDos vorhanden.</p> : null}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import { Icon, type IconNode, type LucideProps } from "lucide-react";
|
||||||
|
|
||||||
|
const listClockIconNode: IconNode = [
|
||||||
|
["path", { d: "M3 6h.01", key: "1es8zl" }],
|
||||||
|
["path", { d: "M3 12h.01", key: "1v72e3" }],
|
||||||
|
["path", { d: "M3 18h.01", key: "1h3o6d" }],
|
||||||
|
["path", { d: "M7 6h10", key: "1b9f9n" }],
|
||||||
|
["path", { d: "M7 12h5", key: "1f8yah" }],
|
||||||
|
["path", { d: "M7 18h3", key: "1ul03r" }],
|
||||||
|
["circle", { cx: "17", cy: "17", r: "4", key: "y9qv7f" }],
|
||||||
|
["path", { d: "M17 15v2l1.5 1.5", key: "d4d9bm" }]
|
||||||
|
];
|
||||||
|
|
||||||
|
export function ListClockIcon(props: LucideProps) {
|
||||||
|
return <Icon iconNode={listClockIconNode} {...props} />;
|
||||||
|
}
|
||||||
@@ -68,7 +68,7 @@ function AccordionContent({
|
|||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className={cn(
|
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
|
className
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -158,12 +158,16 @@ export const helpSections: HelpSection[] = [
|
|||||||
quickItems: [
|
quickItems: [
|
||||||
"Ticketnummer, Organisation und Art sind bearbeitbar.",
|
"Ticketnummer, Organisation und Art sind bearbeitbar.",
|
||||||
"Organisationen kommen aus der lokalen Zammad-Synchronisation.",
|
"Organisationen kommen aus der lokalen Zammad-Synchronisation.",
|
||||||
|
"Atoss Support ist ein bewusster Fallback am gesamten Support-Ticket.",
|
||||||
"Die Art wiederkehrender Sessions kommt aus der Regel."
|
"Die Art wiederkehrender Sessions kommt aus der Regel."
|
||||||
],
|
],
|
||||||
details: [
|
details: [
|
||||||
"Ticketnummern können korrigiert werden, falls beim Starten oder Nachtragen ein falscher Bezug gewählt wurde.",
|
"Ticketnummern können korrigiert werden, falls beim Starten oder Nachtragen ein falscher Bezug gewählt wurde.",
|
||||||
"Die Organisation wird immer über die Suchliste gewählt. Dadurch sind spätere Auswertungen pro Organisation zuverlässig.",
|
"Die Organisation wird immer über die Suchliste gewählt. Dadurch sind spätere Auswertungen pro Organisation zuverlässig.",
|
||||||
"Änderungen an Organisation und Art werden auf vorhandene manuelle Sessions dieses Tickets übernommen.",
|
"Änderungen an Organisation und Art werden auf vorhandene manuelle Sessions dieses Tickets übernommen.",
|
||||||
|
"Zammad-Verwaltung und automatische Teamspace-Zuordnungen werden kompakt als Badges in den Ticketdaten angezeigt.",
|
||||||
|
"Die automatische TS-Zuordnung prüft zuerst ein am Ticket hinterlegtes Projekt und danach die Zuordnungsregeln aus dem Adminbereich. Der Haken Atoss Support greift nur, wenn beides fehlt.",
|
||||||
|
"Die Auswahl Atoss Support wird nur bei Support-Tickets ohne Projekt- oder Regelzuordnung angezeigt. Ist sie aktiviert, werden alle Sessions des Tickets anhand ihres Datums dem dafür im Adminbereich gültigen Atoss-Support-Projekt zugewiesen. Ohne Haken bleibt das Ticket bewusst unzugeordnet und muss manuell geprüft werden.",
|
||||||
"Bei wiederkehrenden Sessions ist die Art nicht direkt an der Session änderbar. Sie wird ausschließlich über die fixe Abrechnungsregel gesteuert.",
|
"Bei wiederkehrenden Sessions ist die Art nicht direkt an der Session änderbar. Sie wird ausschließlich über die fixe Abrechnungsregel gesteuert.",
|
||||||
"Wenn ein Ticket bereits durch einen anderen Benutzer bekannt ist, können dessen Ticketdaten beim Beenden eines Timers übernommen werden. Die Session selbst bleibt trotzdem deinem Benutzer zugeordnet."
|
"Wenn ein Ticket bereits durch einen anderen Benutzer bekannt ist, können dessen Ticketdaten beim Beenden eines Timers übernommen werden. Die Session selbst bleibt trotzdem deinem Benutzer zugeordnet."
|
||||||
]
|
]
|
||||||
@@ -341,7 +345,9 @@ export const helpSections: HelpSection[] = [
|
|||||||
description: "Der Monatsabschluss ist die Kontrollseite für offene Bewertungen und Teamspace-Punkte vor dem Abschluss.",
|
description: "Der Monatsabschluss ist die Kontrollseite für offene Bewertungen und Teamspace-Punkte vor dem Abschluss.",
|
||||||
quickItems: [
|
quickItems: [
|
||||||
"Bewertungen sind Pflicht für den Abschluss.",
|
"Bewertungen sind Pflicht für den Abschluss.",
|
||||||
"Tage ohne Teamspace-Wert können zur Kenntnis genommen werden.",
|
"Alle Prüfpunkte werden pro Ticket in einem Eintrag zusammengeführt.",
|
||||||
|
"Tage ohne Teamspace-Wert können innerhalb des Tickets zur Kenntnis genommen werden.",
|
||||||
|
"Support und ausgenommene Consulting-Projekte werden über Ticket#XXXXXX pro Tag mit der TS-API abgeglichen.",
|
||||||
"Der Monat kann wieder geöffnet werden."
|
"Der Monat kann wieder geöffnet werden."
|
||||||
],
|
],
|
||||||
details: [
|
details: [
|
||||||
@@ -351,7 +357,11 @@ export const helpSections: HelpSection[] = [
|
|||||||
"Zur Kenntnis genommene Einträge können über die Checkbox Zur Kenntnis anzeigen wieder eingeblendet werden.",
|
"Zur Kenntnis genommene Einträge können über die Checkbox Zur Kenntnis anzeigen wieder eingeblendet werden.",
|
||||||
"Die Kenntnisnahme kann pro Eintrag über Zurücknehmen rückgängig gemacht werden.",
|
"Die Kenntnisnahme kann pro Eintrag über Zurücknehmen rückgängig gemacht werden.",
|
||||||
"Die Einstellung, ob zur Kenntnis genommene Einträge angezeigt werden, bleibt im Browser gespeichert.",
|
"Die Einstellung, ob zur Kenntnis genommene Einträge angezeigt werden, bleibt im Browser gespeichert.",
|
||||||
"Offene Bewertungen werden nach Ticket gruppiert. Ein Klick auf Ticket öffnen führt zur Bewertung im Ticket.",
|
"Die Prüfliste führt offene Bewertungen, fehlende Teamspace-Tageswerte sowie Monats- und Tagesabweichungen nach Ticket zusammen. Dadurch erscheint jedes Ticket nur einmal.",
|
||||||
|
"Die Ticketzeile zeigt die Prüfarten als farbige Badges in festen Spalten. Die betroffenen Tage mit TT-Teamspace-Wert, TS-API-Wert und Differenz stehen direkt im Ticketdetail. Ein Klick auf Ticket öffnen führt dorthin.",
|
||||||
|
"Ein hinterlegter TT-Teamspace-Wert ist nur gültig, wenn es an diesem Ticket-Tag mindestens eine als Abgerechnet bewertete Session gibt. Andernfalls zeigt die Prüfliste einen Fehler und nennt zusätzlich vorhandene abrechenbare, nicht abrechenbare oder unbewertete Sessions.",
|
||||||
|
"Der ticketgenaue TS-Abgleich erkennt Teamspace-Unterpositionen, deren Bezeichnung mit Ticket#XXXXXX beginnt. Er vergleicht deren API-Tageswerte mit den in TicketTracker gepflegten Teamspace-Zeiten an den Session-Tagen desselben Tickets.",
|
||||||
|
"Der ticketgenaue Abgleich gilt für Support-Tickets und für Consulting-Tickets auf einem in der Administration ausgenommenen Teamspace-Projekt. Angezeigt werden sowohl fehlende Zeit in Teamspace als auch fehlende Zeit in TicketTracker.",
|
||||||
"Es gibt keinen Tagesabschluss. Tagesansichten dienen nur der Kontrolle."
|
"Es gibt keinen Tagesabschluss. Tagesansichten dienen nur der Kontrolle."
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
@@ -561,6 +571,10 @@ export const helpSections: HelpSection[] = [
|
|||||||
"Admins können bestehende Benutzer bearbeiten, Passwörter setzen und Benutzer deaktivieren.",
|
"Admins können bestehende Benutzer bearbeiten, Passwörter setzen und Benutzer deaktivieren.",
|
||||||
"Der feste Admin-Zugang kann nicht deaktiviert oder zur normalen User-Rolle herabgestuft werden.",
|
"Der feste Admin-Zugang kann nicht deaktiviert oder zur normalen User-Rolle herabgestuft werden.",
|
||||||
"Der Data-Sync speichert Zammad-Zugangsdaten und synchronisiert Organisationen in die lokale Datenbank.",
|
"Der Data-Sync speichert Zammad-Zugangsdaten und synchronisiert Organisationen in die lokale Datenbank.",
|
||||||
|
"Beim Organisationsabgleich werden zuerst die Zammad-Auswahloptionen für Zugeordnet und Hotline synchronisiert. In den globalen Teamspace-Projektausnahmen können diese Optionen anschließend pro Projekt ausgewählt werden.",
|
||||||
|
"Automatische TS-Zuordnungsregeln kombinieren optional Zugeordnet und Hotline mit Support und/oder Consulting sowie einem Zielprojekt. Sind beide Attribute gesetzt, müssen beide passen; bei mehreren Treffern gewinnt die kleinste Priorität.",
|
||||||
|
"Beim Zammad-Sync gilt die Reihenfolge: Ein konkretes TS-Projekt hat Vorrang. Genau ein Projekt wird allen Sessions automatisch zugeordnet; bei mehreren Projekten erfolgt die Auswahl beim Speichern einer Session manuell. Nur ohne konkretes Projekt wird eine passende Regel angewendet und allen Sessions des Tickets zugeordnet.",
|
||||||
|
"Die im Sync ermittelte Regelzuordnung wird im Ticketdetail angezeigt. Greift keine Regel, bleibt das Ticket ohne automatische TS-Zuordnung.",
|
||||||
"Der Zammad-Ticket-Sync aktualisiert Titel, Organisation, Typ, Zammad-Status, Zammad-Priorität sowie Teamspace-Projekte und Budgets je nach gewählter Option.",
|
"Der Zammad-Ticket-Sync aktualisiert Titel, Organisation, Typ, Zammad-Status, Zammad-Priorität sowie Teamspace-Projekte und Budgets je nach gewählter Option.",
|
||||||
"Zammad-Projektwerte können mehrere Teamspace-IDs enthalten. TicketTracker erkennt zusammenhängende IDs und legt entsprechend mehrere Teamspace-Einträge an.",
|
"Zammad-Projektwerte können mehrere Teamspace-IDs enthalten. TicketTracker erkennt zusammenhängende IDs und legt entsprechend mehrere Teamspace-Einträge an.",
|
||||||
"Beim Budget-Sync bleiben geschützte TicketTracker-Budgets unverändert. Die Option Budget-Schutz im Sync ignorieren überschreibt auch geschützte Budgets mit Zammad-Werten.",
|
"Beim Budget-Sync bleiben geschützte TicketTracker-Budgets unverändert. Die Option Budget-Schutz im Sync ignorieren überschreibt auch geschützte Budgets mit Zammad-Werten.",
|
||||||
|
|||||||
+31
-1
@@ -1,4 +1,4 @@
|
|||||||
import type { BillingStatus } from "./types";
|
import type { BillingStatus, WorkSegment } from "./types";
|
||||||
|
|
||||||
export type TimerPhase = "running" | "paused";
|
export type TimerPhase = "running" | "paused";
|
||||||
|
|
||||||
@@ -16,6 +16,7 @@ export type TimerEntry = {
|
|||||||
startedAt: number;
|
startedAt: number;
|
||||||
pausedTotalMs: number;
|
pausedTotalMs: number;
|
||||||
pausedAt: number | null;
|
pausedAt: number | null;
|
||||||
|
workSegments?: WorkSegment[];
|
||||||
phase: TimerPhase;
|
phase: TimerPhase;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -58,6 +59,34 @@ export function resumeEntry(timer: TimerEntry, now = Date.now()): TimerEntry {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeWorkSegments(value: unknown): WorkSegment[] {
|
||||||
|
if (!Array.isArray(value)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return value
|
||||||
|
.map((segment) => {
|
||||||
|
if (!segment || typeof segment !== "object") {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const raw = segment as Partial<WorkSegment>;
|
||||||
|
if (typeof raw.startedAt !== "string" || Number.isNaN(new Date(raw.startedAt).getTime())) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (raw.endedAt !== null && raw.endedAt !== undefined && (typeof raw.endedAt !== "string" || Number.isNaN(new Date(raw.endedAt).getTime()))) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
startedAt: new Date(raw.startedAt).toISOString(),
|
||||||
|
endedAt: raw.endedAt ? new Date(raw.endedAt).toISOString() : null,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.filter((segment): segment is WorkSegment => Boolean(segment));
|
||||||
|
}
|
||||||
|
|
||||||
export function normalizeTimers(timers: TimerEntry[]) {
|
export function normalizeTimers(timers: TimerEntry[]) {
|
||||||
let runningSeen = false;
|
let runningSeen = false;
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
@@ -70,6 +99,7 @@ export function normalizeTimers(timers: TimerEntry[]) {
|
|||||||
activity: typeof timer.activity === "string" ? timer.activity : "",
|
activity: typeof timer.activity === "string" ? timer.activity : "",
|
||||||
zammadArticleBody: typeof timer.zammadArticleBody === "string" ? timer.zammadArticleBody : "",
|
zammadArticleBody: typeof timer.zammadArticleBody === "string" ? timer.zammadArticleBody : "",
|
||||||
billingStatus: timer.billingStatus === "billed" ? "billable" : timer.billingStatus === "billable" || timer.billingStatus === "non_billable" ? timer.billingStatus : null,
|
billingStatus: timer.billingStatus === "billed" ? "billable" : timer.billingStatus === "billable" || timer.billingStatus === "non_billable" ? timer.billingStatus : null,
|
||||||
|
workSegments: normalizeWorkSegments(timer.workSegments),
|
||||||
};
|
};
|
||||||
|
|
||||||
if (timer.phase !== "running") {
|
if (timer.phase !== "running") {
|
||||||
|
|||||||
+161
-2
@@ -12,9 +12,14 @@ export type AuthUser = {
|
|||||||
active: boolean;
|
active: boolean;
|
||||||
zammad_user_id: string | null;
|
zammad_user_id: string | null;
|
||||||
has_zammad_api_token: boolean;
|
has_zammad_api_token: boolean;
|
||||||
|
teamspace_user_id: string | null;
|
||||||
|
has_teamspace_credentials: boolean;
|
||||||
zammad_email_session_webhook_enabled: boolean;
|
zammad_email_session_webhook_enabled: boolean;
|
||||||
session_billing_prompt_enabled: boolean;
|
session_billing_prompt_enabled: boolean;
|
||||||
|
session_work_segments_enabled: boolean;
|
||||||
|
teamspace_time_tracking_enabled: boolean;
|
||||||
zammad_session_note_default_enabled: boolean;
|
zammad_session_note_default_enabled: boolean;
|
||||||
|
zammad_session_note_date_header_enabled: boolean;
|
||||||
zammad_time_unit_rounding_enabled: boolean;
|
zammad_time_unit_rounding_enabled: boolean;
|
||||||
zammad_time_unit_rounding_minutes: number;
|
zammad_time_unit_rounding_minutes: number;
|
||||||
zammad_atoss_email_sync_enabled: boolean;
|
zammad_atoss_email_sync_enabled: boolean;
|
||||||
@@ -70,10 +75,19 @@ export type DashboardDayTicket = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export type AdminUser = AuthUser & {
|
export type AdminUser = AuthUser & {
|
||||||
|
teamspace_initial_actuals_synced_at: string | null;
|
||||||
|
teamspace_initial_actuals_synced_by: string | null;
|
||||||
|
teamspace_initial_actuals_unlocked_at: string | null;
|
||||||
|
teamspace_initial_actuals_unlocked_by: string | null;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
updated_at: string;
|
updated_at: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type WorkSegment = {
|
||||||
|
startedAt: string;
|
||||||
|
endedAt: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
export type ZammadSessionArticleQueueItem = {
|
export type ZammadSessionArticleQueueItem = {
|
||||||
id: string;
|
id: string;
|
||||||
session_id: string;
|
session_id: string;
|
||||||
@@ -95,7 +109,15 @@ export type Organization = {
|
|||||||
id: string;
|
id: string;
|
||||||
zammad_id: string;
|
zammad_id: string;
|
||||||
name: string;
|
name: string;
|
||||||
|
customer_owner_option_id?: string | null;
|
||||||
|
customer_owner?: string | null;
|
||||||
|
customer_owner_display_name?: string | null;
|
||||||
|
hotline_contract_option_id?: string | null;
|
||||||
|
hotline_contract?: string | null;
|
||||||
|
hotline_contract_display_name?: string | null;
|
||||||
synced_at: string;
|
synced_at: string;
|
||||||
|
created_at?: string;
|
||||||
|
updated_at?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type RecurringBillingSlot = {
|
export type RecurringBillingSlot = {
|
||||||
@@ -116,6 +138,9 @@ export type RecurringBilling = {
|
|||||||
organization_name: string;
|
organization_name: string;
|
||||||
activity: string;
|
activity: string;
|
||||||
work_type: WorkType;
|
work_type: WorkType;
|
||||||
|
teamspace_project: string | null;
|
||||||
|
hour_budget_minutes: number | null;
|
||||||
|
manual_consumption_minutes: number;
|
||||||
recurrence_type: "weekly" | "every_n_weeks";
|
recurrence_type: "weekly" | "every_n_weeks";
|
||||||
interval_value: number;
|
interval_value: number;
|
||||||
valid_from: string;
|
valid_from: string;
|
||||||
@@ -135,6 +160,12 @@ export type TicketMeta = {
|
|||||||
organization_name: string | null;
|
organization_name: string | null;
|
||||||
customer_name: string | null;
|
customer_name: string | null;
|
||||||
work_type: WorkType | null;
|
work_type: WorkType | null;
|
||||||
|
atoss_support?: boolean;
|
||||||
|
can_manage_atoss_support?: boolean;
|
||||||
|
teamspace_assignment_rule_id?: string | null;
|
||||||
|
teamspace_assignment_rule_name?: string | null;
|
||||||
|
teamspace_assignment_project?: string | null;
|
||||||
|
teamspace_assignment_synced_at?: string | null;
|
||||||
zammad_ticket_id?: string | null;
|
zammad_ticket_id?: string | null;
|
||||||
zammad_group_id?: string | null;
|
zammad_group_id?: string | null;
|
||||||
zammad_priority_id?: string | null;
|
zammad_priority_id?: string | null;
|
||||||
@@ -201,6 +232,7 @@ export type ProjectSummary = {
|
|||||||
starts_on: string | null;
|
starts_on: string | null;
|
||||||
ends_on: string | null;
|
ends_on: string | null;
|
||||||
hour_budget_minutes: number | null;
|
hour_budget_minutes: number | null;
|
||||||
|
teamspace_budget_excluded?: boolean;
|
||||||
manual_consumption_minutes: number;
|
manual_consumption_minutes: number;
|
||||||
teamspace_consumption_minutes?: number;
|
teamspace_consumption_minutes?: number;
|
||||||
consumption_minutes?: number;
|
consumption_minutes?: number;
|
||||||
@@ -218,6 +250,7 @@ export type ProjectSummary = {
|
|||||||
teamspace_entry_count?: number;
|
teamspace_entry_count?: number;
|
||||||
teamspace_entries?: ProjectTeamspaceEntry[];
|
teamspace_entries?: ProjectTeamspaceEntry[];
|
||||||
unassigned_teamspace_session_count?: number;
|
unassigned_teamspace_session_count?: number;
|
||||||
|
unassigned_teamspace_subentry_count?: number;
|
||||||
close_review_required?: boolean;
|
close_review_required?: boolean;
|
||||||
hidden_for_user?: boolean;
|
hidden_for_user?: boolean;
|
||||||
};
|
};
|
||||||
@@ -226,9 +259,13 @@ export type ProjectTeamspaceEntry = {
|
|||||||
id: string;
|
id: string;
|
||||||
project_id?: string;
|
project_id?: string;
|
||||||
teamspace_project: string | null;
|
teamspace_project: string | null;
|
||||||
|
teamspace_api_project_id?: string | null;
|
||||||
|
teamspace_api_project_url?: string | null;
|
||||||
teamspace_description?: string | null;
|
teamspace_description?: string | null;
|
||||||
|
subentries?: ProjectTeamspaceSubentry[];
|
||||||
is_default: boolean;
|
is_default: boolean;
|
||||||
hour_budget_minutes: number | null;
|
hour_budget_minutes: number | null;
|
||||||
|
teamspace_budget_excluded?: boolean;
|
||||||
budget_sync_disabled?: boolean;
|
budget_sync_disabled?: boolean;
|
||||||
manual_consumption_minutes: number;
|
manual_consumption_minutes: number;
|
||||||
teamspace_consumption_minutes?: number;
|
teamspace_consumption_minutes?: number;
|
||||||
@@ -236,6 +273,27 @@ export type ProjectTeamspaceEntry = {
|
|||||||
display_order?: 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 TodoPriority = "low" | "medium" | "high" | "urgent";
|
||||||
export type TodoStatus = "open" | "done";
|
export type TodoStatus = "open" | "done";
|
||||||
|
|
||||||
@@ -283,6 +341,44 @@ export type TicketSummary = {
|
|||||||
requires_closure: boolean;
|
requires_closure: boolean;
|
||||||
closed: boolean;
|
closed: boolean;
|
||||||
closed_at: string | null;
|
closed_at: string | null;
|
||||||
|
teamspace_month_billable_minutes?: number;
|
||||||
|
teamspace_month_delta_minutes?: number;
|
||||||
|
teamspace_month_actuals?: TeamspaceMonthActual[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TeamspaceMonthActual = {
|
||||||
|
entry_id: string;
|
||||||
|
teamspace_project: string | null;
|
||||||
|
teamspace_description: string | null;
|
||||||
|
teamspace_api_project_id: string | null;
|
||||||
|
teamspace_minutes: number;
|
||||||
|
teamspace_billable_minutes: number;
|
||||||
|
teamspace_time_entry_count: number;
|
||||||
|
delta_minutes: number;
|
||||||
|
positions: TeamspaceMonthActualPosition[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TeamspaceMonthActualPosition = {
|
||||||
|
apiProjectId: string;
|
||||||
|
projectId: string | null;
|
||||||
|
mainProjectId?: string | null;
|
||||||
|
label: string;
|
||||||
|
description?: string | null;
|
||||||
|
ticketNumber?: string | null;
|
||||||
|
customerName?: string | null;
|
||||||
|
isSubentry: boolean;
|
||||||
|
billingType?: string | null;
|
||||||
|
minutes: number;
|
||||||
|
billableMinutes: number;
|
||||||
|
entryCount: number;
|
||||||
|
days?: TeamspaceMonthActualPositionDay[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TeamspaceMonthActualPositionDay = {
|
||||||
|
day: string;
|
||||||
|
minutes: number;
|
||||||
|
billableMinutes: number;
|
||||||
|
entryCount: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type OpenSession = {
|
export type OpenSession = {
|
||||||
@@ -334,6 +430,27 @@ export type TicketDayBilling = {
|
|||||||
billed_minutes: number;
|
billed_minutes: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type TicketTeamspaceDay = {
|
||||||
|
day: string;
|
||||||
|
tracked_minutes: number;
|
||||||
|
crm_billed_minutes: number;
|
||||||
|
teamspace_entry_count: number;
|
||||||
|
delta_minutes: number;
|
||||||
|
session_count: number;
|
||||||
|
billed_session_count: number;
|
||||||
|
billable_session_count: number;
|
||||||
|
non_billable_session_count: number;
|
||||||
|
unrated_session_count: number;
|
||||||
|
has_tt_value: boolean;
|
||||||
|
missing_tt_value: boolean;
|
||||||
|
tt_value_without_billed_session: boolean;
|
||||||
|
acknowledged: boolean;
|
||||||
|
acknowledged_at: string | null;
|
||||||
|
comparison_enabled: boolean;
|
||||||
|
teamspace_excluded: boolean;
|
||||||
|
work_type: WorkType | null;
|
||||||
|
};
|
||||||
|
|
||||||
export type TicketAvailableMonth = {
|
export type TicketAvailableMonth = {
|
||||||
month: string;
|
month: string;
|
||||||
session_count: number;
|
session_count: number;
|
||||||
@@ -353,6 +470,7 @@ export type SessionEntry = {
|
|||||||
ended_at: string;
|
ended_at: string;
|
||||||
duration_seconds: number;
|
duration_seconds: number;
|
||||||
rounded_minutes: number;
|
rounded_minutes: number;
|
||||||
|
work_segments: WorkSegment[];
|
||||||
billing_status: BillingStatus;
|
billing_status: BillingStatus;
|
||||||
billing_updated_at: string | null;
|
billing_updated_at: string | null;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
@@ -384,6 +502,12 @@ export type TicketPeriod = {
|
|||||||
organization_name: string | null;
|
organization_name: string | null;
|
||||||
customer_name: string | null;
|
customer_name: string | null;
|
||||||
work_type: WorkType | null;
|
work_type: WorkType | null;
|
||||||
|
atoss_support?: boolean;
|
||||||
|
can_manage_atoss_support?: boolean;
|
||||||
|
teamspace_assignment_rule_id?: string | null;
|
||||||
|
teamspace_assignment_rule_name?: string | null;
|
||||||
|
teamspace_assignment_project?: string | null;
|
||||||
|
teamspace_assignment_synced_at?: string | null;
|
||||||
zammad_ticket_id?: string | null;
|
zammad_ticket_id?: string | null;
|
||||||
zammad_group_id?: string | null;
|
zammad_group_id?: string | null;
|
||||||
zammad_priority_id?: string | null;
|
zammad_priority_id?: string | null;
|
||||||
@@ -402,8 +526,12 @@ export type TicketPeriod = {
|
|||||||
};
|
};
|
||||||
sessions: SessionEntry[];
|
sessions: SessionEntry[];
|
||||||
dayBillings: TicketDayBilling[];
|
dayBillings: TicketDayBilling[];
|
||||||
|
ticketTeamspaceDays?: TicketTeamspaceDay[];
|
||||||
project?: ProjectSummary | null;
|
project?: ProjectSummary | null;
|
||||||
todos?: TicketTodo[];
|
todos?: TicketTodo[];
|
||||||
|
teamspaceMonthActuals?: TeamspaceMonthActual[];
|
||||||
|
teamspaceMonthBillableMinutes?: number;
|
||||||
|
teamspaceMonthDeltaMinutes?: number;
|
||||||
canClose: boolean;
|
canClose: boolean;
|
||||||
openCount: number;
|
openCount: number;
|
||||||
ticketOpenCount: number;
|
ticketOpenCount: number;
|
||||||
@@ -444,6 +572,9 @@ export type StatisticsGroup = {
|
|||||||
open_sessions: number;
|
open_sessions: number;
|
||||||
crm_billed_minutes: number;
|
crm_billed_minutes: number;
|
||||||
crm_delta_minutes: number;
|
crm_delta_minutes: number;
|
||||||
|
teamspace_month_billable_minutes?: number;
|
||||||
|
teamspace_month_delta_minutes?: number;
|
||||||
|
teamspace_month_actuals?: TeamspaceMonthActual[];
|
||||||
};
|
};
|
||||||
|
|
||||||
export type StatisticsTicket = {
|
export type StatisticsTicket = {
|
||||||
@@ -464,6 +595,9 @@ export type StatisticsTicket = {
|
|||||||
open_sessions: number;
|
open_sessions: number;
|
||||||
crm_billed_minutes: number;
|
crm_billed_minutes: number;
|
||||||
crm_delta_minutes: number;
|
crm_delta_minutes: number;
|
||||||
|
teamspace_month_billable_minutes?: number;
|
||||||
|
teamspace_month_delta_minutes?: number;
|
||||||
|
teamspace_month_actuals?: TeamspaceMonthActual[];
|
||||||
};
|
};
|
||||||
|
|
||||||
export type StatisticsOpenSession = {
|
export type StatisticsOpenSession = {
|
||||||
@@ -489,15 +623,35 @@ export type StatisticsCrmDay = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export type StatisticsTeamspaceDay = {
|
export type StatisticsTeamspaceDay = {
|
||||||
ticket_id: string;
|
ticket_id: string | null;
|
||||||
ticket_number: string;
|
ticket_number: string | null;
|
||||||
organization_name: string;
|
organization_name: string;
|
||||||
day: string;
|
day: string;
|
||||||
tracked_minutes: number;
|
tracked_minutes: number;
|
||||||
crm_billed_minutes: number;
|
crm_billed_minutes: number;
|
||||||
|
teamspace_entry_count: number;
|
||||||
delta_minutes: number;
|
delta_minutes: number;
|
||||||
session_count: number;
|
session_count: number;
|
||||||
project_teamspace_project: string | null;
|
project_teamspace_project: string | null;
|
||||||
|
api_project_id?: string | null;
|
||||||
|
teamspace_project_id?: string | null;
|
||||||
|
teamspace_excluded?: boolean;
|
||||||
|
work_type?: WorkType | null;
|
||||||
|
tt_value_without_billed_session?: boolean;
|
||||||
|
billed_session_count?: number;
|
||||||
|
billable_session_count?: number;
|
||||||
|
non_billable_session_count?: number;
|
||||||
|
unrated_session_count?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type StatisticsTeamspaceMonthMismatch = {
|
||||||
|
ticket_id: string;
|
||||||
|
ticket_number: string;
|
||||||
|
organization_name: string;
|
||||||
|
crm_billed_minutes: number;
|
||||||
|
teamspace_month_billable_minutes: number;
|
||||||
|
teamspace_month_delta_minutes: number;
|
||||||
|
teamspace_month_actuals: TeamspaceMonthActual[];
|
||||||
};
|
};
|
||||||
|
|
||||||
export type StatisticsProjectFollowup = {
|
export type StatisticsProjectFollowup = {
|
||||||
@@ -508,6 +662,7 @@ export type StatisticsProjectFollowup = {
|
|||||||
organization_name: string | null;
|
organization_name: string | null;
|
||||||
teamspace_project: string | null;
|
teamspace_project: string | null;
|
||||||
hour_budget_minutes: number | null;
|
hour_budget_minutes: number | null;
|
||||||
|
teamspace_budget_excluded?: boolean;
|
||||||
consumption_minutes: number;
|
consumption_minutes: number;
|
||||||
remaining_minutes: number | null;
|
remaining_minutes: number | null;
|
||||||
latest_tt_update: string;
|
latest_tt_update: string;
|
||||||
@@ -554,6 +709,8 @@ export type StatisticsOverview = {
|
|||||||
activeDays: number;
|
activeDays: number;
|
||||||
crmBilledMinutes: number;
|
crmBilledMinutes: number;
|
||||||
crmDeltaMinutes: number;
|
crmDeltaMinutes: number;
|
||||||
|
teamspaceApiBillableMinutes?: number;
|
||||||
|
teamspaceApiDeltaMinutes?: number;
|
||||||
};
|
};
|
||||||
dailySeries: StatisticsDailyBucket[];
|
dailySeries: StatisticsDailyBucket[];
|
||||||
organizations: StatisticsGroup[];
|
organizations: StatisticsGroup[];
|
||||||
@@ -564,6 +721,8 @@ export type StatisticsOverview = {
|
|||||||
missingCrmDays: StatisticsCrmDay[];
|
missingCrmDays: StatisticsCrmDay[];
|
||||||
acknowledgedMissingCrmDays: StatisticsCrmDay[];
|
acknowledgedMissingCrmDays: StatisticsCrmDay[];
|
||||||
crmMismatches: StatisticsCrmDay[];
|
crmMismatches: StatisticsCrmDay[];
|
||||||
|
teamspaceMonthMismatches: StatisticsTeamspaceMonthMismatch[];
|
||||||
|
ticketTeamspaceMismatches: StatisticsTeamspaceDay[];
|
||||||
};
|
};
|
||||||
teamspaceDays: StatisticsTeamspaceDay[];
|
teamspaceDays: StatisticsTeamspaceDay[];
|
||||||
};
|
};
|
||||||
@@ -0,0 +1,330 @@
|
|||||||
|
import { FormEvent, useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
import { Building2, DatabaseZap, RefreshCcw, Save, Search } from "lucide-react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { NativeSelect, NativeSelectOption } from "@/components/ui/native-select";
|
||||||
|
import { Pagination, PaginationContent, PaginationItem, PaginationNext, PaginationPrevious } from "@/components/ui/pagination";
|
||||||
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||||
|
import { HelpLink } from "@/components/HelpLink";
|
||||||
|
import { getAdminOrganizations, getZammadSettings, saveZammadSettings, syncZammadOrganizations } from "../api";
|
||||||
|
import { formatDateTime } from "../format";
|
||||||
|
import type { Organization } from "../types";
|
||||||
|
|
||||||
|
type PageSize = 10 | 20 | 50 | 100 | "all";
|
||||||
|
|
||||||
|
const pageSizeOptions: PageSize[] = [10, 20, 50, 100, "all"];
|
||||||
|
|
||||||
|
function pageSizeFromValue(value: string): PageSize {
|
||||||
|
if (value === "all") {
|
||||||
|
return "all";
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsed = Number(value);
|
||||||
|
return pageSizeOptions.includes(parsed as PageSize) ? parsed as PageSize : 20;
|
||||||
|
}
|
||||||
|
|
||||||
|
function emptyText(value: string | null | undefined) {
|
||||||
|
return value?.trim() || "leer";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AdminOrganizationsPage() {
|
||||||
|
const [organizations, setOrganizations] = useState<Organization[]>([]);
|
||||||
|
const [total, setTotal] = useState(0);
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
|
const [pageSize, setPageSize] = useState<PageSize>(20);
|
||||||
|
const [search, setSearch] = useState("");
|
||||||
|
const [missingAssignment, setMissingAssignment] = useState(false);
|
||||||
|
const [zammadBaseUrl, setZammadBaseUrl] = useState("");
|
||||||
|
const [zammadApiKey, setZammadApiKey] = useState("");
|
||||||
|
const [hasZammadApiKey, setHasZammadApiKey] = useState(false);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [savingZammadSettings, setSavingZammadSettings] = useState(false);
|
||||||
|
const [syncingOrganizations, setSyncingOrganizations] = useState(false);
|
||||||
|
const loadRequestId = useRef(0);
|
||||||
|
|
||||||
|
const pageCount = pageSize === "all" ? 1 : Math.max(1, Math.ceil(total / pageSize));
|
||||||
|
const safePage = Math.min(page, pageCount);
|
||||||
|
const rangeStart = total === 0 ? 0 : pageSize === "all" ? 1 : (safePage - 1) * pageSize + 1;
|
||||||
|
const rangeEnd = pageSize === "all" ? total : Math.min(safePage * pageSize, total);
|
||||||
|
const activeFilterCount = useMemo(
|
||||||
|
() => [search.trim(), missingAssignment].filter(Boolean).length,
|
||||||
|
[missingAssignment, search]
|
||||||
|
);
|
||||||
|
|
||||||
|
async function loadSettings() {
|
||||||
|
const result = await getZammadSettings();
|
||||||
|
setZammadBaseUrl(result.settings.baseUrl);
|
||||||
|
setHasZammadApiKey(result.settings.hasApiKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadOrganizations() {
|
||||||
|
const requestId = loadRequestId.current + 1;
|
||||||
|
loadRequestId.current = requestId;
|
||||||
|
setLoading(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await getAdminOrganizations({
|
||||||
|
search,
|
||||||
|
page: safePage,
|
||||||
|
pageSize,
|
||||||
|
missingAssignment
|
||||||
|
});
|
||||||
|
|
||||||
|
if (requestId === loadRequestId.current) {
|
||||||
|
setOrganizations(result.organizations);
|
||||||
|
setTotal(result.total);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
if (requestId === loadRequestId.current) {
|
||||||
|
toast.error("Organisationen konnten nicht geladen werden", {
|
||||||
|
description: error instanceof Error ? error.message : "Unbekannter Fehler"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (requestId === loadRequestId.current) {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void loadSettings();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setPage(1);
|
||||||
|
}, [search, pageSize, missingAssignment]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void loadOrganizations();
|
||||||
|
}, [safePage, pageSize, search, missingAssignment]);
|
||||||
|
|
||||||
|
async function saveZammadAccess() {
|
||||||
|
setSavingZammadSettings(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await saveZammadSettings({
|
||||||
|
baseUrl: zammadBaseUrl,
|
||||||
|
apiKey: zammadApiKey.trim() || undefined
|
||||||
|
});
|
||||||
|
setZammadBaseUrl(result.settings.baseUrl);
|
||||||
|
setHasZammadApiKey(result.settings.hasApiKey);
|
||||||
|
setZammadApiKey("");
|
||||||
|
toast.success("Zammad-Zugang gespeichert", {
|
||||||
|
description: result.settings.hasApiKey ? "URL und API-Key sind hinterlegt." : "URL wurde gespeichert."
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
toast.error("Zammad-Zugang konnte nicht gespeichert werden", {
|
||||||
|
description: error instanceof Error ? error.message : "Unbekannter Fehler"
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setSavingZammadSettings(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function syncOrganizations(event: FormEvent) {
|
||||||
|
event.preventDefault();
|
||||||
|
setSyncingOrganizations(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await syncZammadOrganizations({
|
||||||
|
baseUrl: zammadBaseUrl,
|
||||||
|
apiKey: zammadApiKey.trim() || undefined
|
||||||
|
});
|
||||||
|
toast.success("Organisationen synchronisiert", {
|
||||||
|
description: `${result.attributeOptions} Attributoptionen und ${result.synced} Organisationen gespeichert, ${result.removed} Organisationen entfernt, ${result.unlinkedTickets + result.unlinkedSessions} Verknüpfung(en) gelöst.`
|
||||||
|
});
|
||||||
|
setHasZammadApiKey(true);
|
||||||
|
setZammadApiKey("");
|
||||||
|
await loadOrganizations();
|
||||||
|
} catch (error) {
|
||||||
|
toast.error("Zammad-Sync fehlgeschlagen", {
|
||||||
|
description: error instanceof Error ? error.message : "Unbekannter Fehler"
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setSyncingOrganizations(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex flex-col gap-3 lg:flex-row lg:items-end lg:justify-between">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-xl font-semibold tracking-normal sm:text-2xl">Organisationsabgleich</h2>
|
||||||
|
<p className="text-sm text-muted-foreground">Zammad-Organisationen synchronisieren, prüfen und nach fehlenden Zuordnungen filtern.</p>
|
||||||
|
</div>
|
||||||
|
<Badge variant={activeFilterCount > 0 ? "secondary" : "outline"}>{activeFilterCount} Filter aktiv</Badge>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="gap-3 p-4 sm:flex-row sm:items-start sm:justify-between sm:space-y-0">
|
||||||
|
<div>
|
||||||
|
<CardTitle>Zammad-Sync</CardTitle>
|
||||||
|
<CardDescription>Zugang speichern und Organisationen aus Zammad in die lokale Datenbank übernehmen.</CardDescription>
|
||||||
|
</div>
|
||||||
|
<HelpLink anchor="organisationen" label="Hilfe zum Organisationsabgleich" />
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="px-4 pb-4">
|
||||||
|
<form className="grid gap-3 lg:grid-cols-[minmax(220px,1fr)_minmax(220px,1fr)_auto_auto] lg:items-end" onSubmit={syncOrganizations}>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-sm font-medium" htmlFor="zammad-base-url">Zammad URL</label>
|
||||||
|
<Input
|
||||||
|
id="zammad-base-url"
|
||||||
|
placeholder="https://zammad.example.de"
|
||||||
|
value={zammadBaseUrl}
|
||||||
|
onChange={(event) => setZammadBaseUrl(event.currentTarget.value)}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-sm font-medium" htmlFor="zammad-api-key">API-Key</label>
|
||||||
|
<Input
|
||||||
|
id="zammad-api-key"
|
||||||
|
type="password"
|
||||||
|
placeholder={hasZammadApiKey ? "••••••••••••••••" : ""}
|
||||||
|
value={zammadApiKey}
|
||||||
|
onChange={(event) => setZammadApiKey(event.currentTarget.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Button type="button" variant="secondary" disabled={savingZammadSettings} onClick={() => void saveZammadAccess()}>
|
||||||
|
<Save className="size-4" />
|
||||||
|
{savingZammadSettings ? "Speichert..." : "Zugang speichern"}
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" disabled={syncingOrganizations}>
|
||||||
|
<DatabaseZap className="size-4" />
|
||||||
|
{syncingOrganizations ? "Synchronisiert..." : "Synchronisieren"}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="gap-3 p-4 xl:flex-row xl:items-end xl:justify-between xl:space-y-0">
|
||||||
|
<div>
|
||||||
|
<CardTitle>Organisationen</CardTitle>
|
||||||
|
<CardDescription>{loading ? "Lädt..." : `${rangeStart}-${rangeEnd} von ${total} Organisation(en)`}</CardDescription>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-2 md:grid-cols-[minmax(220px,1fr)_auto_auto] md:items-end">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<label className="text-xs font-medium text-muted-foreground" htmlFor="organization-search">Suche</label>
|
||||||
|
<div className="relative">
|
||||||
|
<Search className="pointer-events-none absolute left-2.5 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
|
||||||
|
<Input
|
||||||
|
id="organization-search"
|
||||||
|
className="pl-8"
|
||||||
|
placeholder="Name, ID, Zuordnung, Hotline..."
|
||||||
|
value={search}
|
||||||
|
onChange={(event) => setSearch(event.currentTarget.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<label className="flex h-9 items-center gap-2 rounded-md border bg-background px-3 text-sm">
|
||||||
|
<Checkbox checked={missingAssignment} onCheckedChange={(checked) => setMissingAssignment(checked === true)} />
|
||||||
|
Zuordnung fehlt
|
||||||
|
</label>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<label className="text-xs font-medium text-muted-foreground" htmlFor="organization-page-size">Anzeigen</label>
|
||||||
|
<NativeSelect
|
||||||
|
id="organization-page-size"
|
||||||
|
className="w-28"
|
||||||
|
value={String(pageSize)}
|
||||||
|
onChange={(event) => setPageSize(pageSizeFromValue(event.currentTarget.value))}
|
||||||
|
>
|
||||||
|
{pageSizeOptions.map((option) => (
|
||||||
|
<NativeSelectOption key={String(option)} value={String(option)}>
|
||||||
|
{option === "all" ? "Alle" : option}
|
||||||
|
</NativeSelectOption>
|
||||||
|
))}
|
||||||
|
</NativeSelect>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-3 px-4 pb-4">
|
||||||
|
<div className="overflow-x-auto rounded-md border">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead className="min-w-20">Zammad-ID</TableHead>
|
||||||
|
<TableHead className="min-w-[260px]">Organisation</TableHead>
|
||||||
|
<TableHead className="min-w-[180px]">Zugeordnet</TableHead>
|
||||||
|
<TableHead className="min-w-[160px]">Hotline</TableHead>
|
||||||
|
<TableHead className="min-w-[150px]">Synchronisiert</TableHead>
|
||||||
|
<TableHead className="min-w-[150px]">Geändert</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{organizations.map((organization) => (
|
||||||
|
<TableRow key={organization.zammad_id}>
|
||||||
|
<TableCell className="font-mono text-xs">{organization.zammad_id}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<div className="flex min-w-0 items-center gap-2">
|
||||||
|
<Building2 className="size-4 shrink-0 text-muted-foreground" />
|
||||||
|
<span className="truncate font-medium">{organization.name}</span>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className={organization.customer_owner ? "" : "text-muted-foreground"}>{emptyText(organization.customer_owner)}</TableCell>
|
||||||
|
<TableCell className={organization.hotline_contract ? "" : "text-muted-foreground"}>{emptyText(organization.hotline_contract)}</TableCell>
|
||||||
|
<TableCell className="text-xs text-muted-foreground">{formatDateTime(organization.synced_at)}</TableCell>
|
||||||
|
<TableCell className="text-xs text-muted-foreground">{organization.updated_at ? formatDateTime(organization.updated_at) : "-"}</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
{organizations.length === 0 ? (
|
||||||
|
<div className="grid place-items-center gap-2 px-4 py-10 text-center text-sm text-muted-foreground">
|
||||||
|
<Building2 className="size-8" />
|
||||||
|
Keine Organisationen für die aktuellen Filter.
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{pageSize !== "all" ? (
|
||||||
|
<Pagination className="justify-end">
|
||||||
|
<PaginationContent>
|
||||||
|
<PaginationItem>
|
||||||
|
<PaginationPrevious
|
||||||
|
href="#"
|
||||||
|
text="Zurück"
|
||||||
|
aria-disabled={safePage <= 1}
|
||||||
|
className={safePage <= 1 ? "pointer-events-none opacity-50" : undefined}
|
||||||
|
onClick={(event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
setPage((current) => Math.max(1, current - 1));
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</PaginationItem>
|
||||||
|
<PaginationItem>
|
||||||
|
<span className="flex h-9 items-center px-3 text-sm text-muted-foreground">
|
||||||
|
Seite {safePage} von {pageCount}
|
||||||
|
</span>
|
||||||
|
</PaginationItem>
|
||||||
|
<PaginationItem>
|
||||||
|
<PaginationNext
|
||||||
|
href="#"
|
||||||
|
text="Weiter"
|
||||||
|
aria-disabled={safePage >= pageCount}
|
||||||
|
className={safePage >= pageCount ? "pointer-events-none opacity-50" : undefined}
|
||||||
|
onClick={(event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
setPage((current) => Math.min(pageCount, current + 1));
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</PaginationItem>
|
||||||
|
</PaginationContent>
|
||||||
|
</Pagination>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<Button type="button" variant="secondary" size="sm" disabled={loading} onClick={() => void loadOrganizations()}>
|
||||||
|
<RefreshCcw className="size-4" />
|
||||||
|
Aktualisieren
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { ChangeEvent, FormEvent, useEffect, useState } from "react";
|
import { ChangeEvent, FormEvent, useEffect, useState } from "react";
|
||||||
import { CheckCircle2, DatabaseBackup, DatabaseZap, Save, Shield, Upload, UserPlus } from "lucide-react";
|
import { CheckCircle2, DatabaseBackup, Plus, RotateCcw, Save, Shield, TimerReset, Trash2, Unplug, Upload, UserPlus } from "lucide-react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
|
||||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||||
@@ -14,12 +14,21 @@ import {
|
|||||||
createAdminUser,
|
createAdminUser,
|
||||||
downloadDatabaseExport,
|
downloadDatabaseExport,
|
||||||
getAdminUsers,
|
getAdminUsers,
|
||||||
getZammadSettings,
|
getOrganizationAttributeOptions,
|
||||||
|
getTeamspaceAssignmentRules,
|
||||||
|
getTeamspaceSettings,
|
||||||
|
getTeamspaceProjectExclusions,
|
||||||
restoreDatabaseBackup,
|
restoreDatabaseBackup,
|
||||||
saveZammadSettings,
|
saveTeamspaceSettings,
|
||||||
syncZammadOrganizations,
|
saveTeamspaceAssignmentRules,
|
||||||
|
saveTeamspaceProjectExclusions,
|
||||||
|
syncAdminUserTeamspaceInitialActuals,
|
||||||
|
unlockAdminUserTeamspaceInitialActuals,
|
||||||
updateAdminUser,
|
updateAdminUser,
|
||||||
validateDatabaseBackup,
|
validateDatabaseBackup,
|
||||||
|
type TeamspaceProjectExclusion,
|
||||||
|
type TeamspaceAssignmentRule,
|
||||||
|
type OrganizationAttributeOption,
|
||||||
type RestoreValidationResult
|
type RestoreValidationResult
|
||||||
} from "../api";
|
} from "../api";
|
||||||
import type { AdminUser, AuthUser } from "../types";
|
import type { AdminUser, AuthUser } from "../types";
|
||||||
@@ -51,11 +60,15 @@ export function AdminUsersPage({ currentUser }: { currentUser: AuthUser }) {
|
|||||||
const [users, setUsers] = useState<AdminUser[]>([]);
|
const [users, setUsers] = useState<AdminUser[]>([]);
|
||||||
const [forms, setForms] = useState<Record<string, UserFormState>>({});
|
const [forms, setForms] = useState<Record<string, UserFormState>>({});
|
||||||
const [newUser, setNewUser] = useState<UserFormState>(emptyForm);
|
const [newUser, setNewUser] = useState<UserFormState>(emptyForm);
|
||||||
const [zammadBaseUrl, setZammadBaseUrl] = useState("");
|
const [teamspaceBaseUrl, setTeamspaceBaseUrl] = useState("");
|
||||||
const [zammadApiKey, setZammadApiKey] = useState("");
|
const [teamspaceCredentialsUserId, setTeamspaceCredentialsUserId] = useState("");
|
||||||
const [hasZammadApiKey, setHasZammadApiKey] = useState(false);
|
const [teamspaceProjectExclusions, setTeamspaceProjectExclusions] = useState<TeamspaceProjectExclusion[]>([]);
|
||||||
const [syncingOrganizations, setSyncingOrganizations] = useState(false);
|
const [organizationAttributeOptions, setOrganizationAttributeOptions] = useState<OrganizationAttributeOption[]>([]);
|
||||||
const [savingZammadSettings, setSavingZammadSettings] = useState(false);
|
const [teamspaceAssignmentRules, setTeamspaceAssignmentRules] = useState<TeamspaceAssignmentRule[]>([]);
|
||||||
|
const [newTeamspaceProjectExclusion, setNewTeamspaceProjectExclusion] = useState("");
|
||||||
|
const [savingTeamspaceSettings, setSavingTeamspaceSettings] = useState(false);
|
||||||
|
const [savingTeamspaceProjectExclusions, setSavingTeamspaceProjectExclusions] = useState(false);
|
||||||
|
const [savingTeamspaceAssignmentRules, setSavingTeamspaceAssignmentRules] = useState(false);
|
||||||
const [exportingDatabase, setExportingDatabase] = useState(false);
|
const [exportingDatabase, setExportingDatabase] = useState(false);
|
||||||
const [restoreSql, setRestoreSql] = useState("");
|
const [restoreSql, setRestoreSql] = useState("");
|
||||||
const [restoreValidation, setRestoreValidation] = useState<RestoreValidationResult | null>(null);
|
const [restoreValidation, setRestoreValidation] = useState<RestoreValidationResult | null>(null);
|
||||||
@@ -63,17 +76,29 @@ export function AdminUsersPage({ currentUser }: { currentUser: AuthUser }) {
|
|||||||
const [restoringDatabase, setRestoringDatabase] = useState(false);
|
const [restoringDatabase, setRestoringDatabase] = useState(false);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [savingId, setSavingId] = useState<string | null>(null);
|
const [savingId, setSavingId] = useState<string | null>(null);
|
||||||
|
const [syncingInitialActualsId, setSyncingInitialActualsId] = useState<string | null>(null);
|
||||||
|
const [unlockingInitialActualsId, setUnlockingInitialActualsId] = useState<string | null>(null);
|
||||||
|
const [initialActualsFromMonthByUser, setInitialActualsFromMonthByUser] = useState<Record<string, string>>({});
|
||||||
const [creating, setCreating] = useState(false);
|
const [creating, setCreating] = useState(false);
|
||||||
|
|
||||||
async function load(options: { preserveEdits?: boolean } = {}) {
|
async function load(options: { preserveEdits?: boolean } = {}) {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const [usersResult, zammadSettingsResult] = await Promise.all([getAdminUsers(), getZammadSettings()]);
|
const [usersResult, teamspaceSettingsResult, teamspaceProjectExclusionsResult, organizationAttributeOptionsResult, teamspaceAssignmentRulesResult] = await Promise.all([
|
||||||
|
getAdminUsers(),
|
||||||
|
getTeamspaceSettings(),
|
||||||
|
getTeamspaceProjectExclusions(),
|
||||||
|
getOrganizationAttributeOptions(),
|
||||||
|
getTeamspaceAssignmentRules()
|
||||||
|
]);
|
||||||
setUsers(usersResult.users);
|
setUsers(usersResult.users);
|
||||||
|
|
||||||
if (!options.preserveEdits) {
|
if (!options.preserveEdits) {
|
||||||
setZammadBaseUrl(zammadSettingsResult.settings.baseUrl);
|
setTeamspaceBaseUrl(teamspaceSettingsResult.settings.baseUrl);
|
||||||
setHasZammadApiKey(zammadSettingsResult.settings.hasApiKey);
|
setTeamspaceCredentialsUserId(teamspaceSettingsResult.settings.credentialsUserId ?? "");
|
||||||
|
setTeamspaceProjectExclusions(teamspaceProjectExclusionsResult.exclusions);
|
||||||
|
setOrganizationAttributeOptions(organizationAttributeOptionsResult.options);
|
||||||
|
setTeamspaceAssignmentRules(teamspaceAssignmentRulesResult.rules);
|
||||||
}
|
}
|
||||||
|
|
||||||
setForms((current) =>
|
setForms((current) =>
|
||||||
@@ -166,49 +191,211 @@ export function AdminUsersPage({ currentUser }: { currentUser: AuthUser }) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function syncOrganizations(event: FormEvent) {
|
function initialActualsLocked(user: AdminUser) {
|
||||||
event.preventDefault();
|
return Boolean(
|
||||||
setSyncingOrganizations(true);
|
user.teamspace_initial_actuals_synced_at &&
|
||||||
|
(!user.teamspace_initial_actuals_unlocked_at || user.teamspace_initial_actuals_unlocked_at <= user.teamspace_initial_actuals_synced_at)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function initialActualsStatus(user: AdminUser) {
|
||||||
|
if (initialActualsLocked(user)) {
|
||||||
|
return `ausgeführt ${new Date(user.teamspace_initial_actuals_synced_at as string).toLocaleDateString("de-DE")}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (user.teamspace_initial_actuals_unlocked_at) {
|
||||||
|
return "entsperrt";
|
||||||
|
}
|
||||||
|
|
||||||
|
return "bereit";
|
||||||
|
}
|
||||||
|
|
||||||
|
async function syncInitialActuals(user: AdminUser) {
|
||||||
|
if (!window.confirm(`Teamspace-Istzeiten für ${user.display_name} initial als manuelle TS-Zeiten übernehmen? Vorhandene Tageswerte bleiben unverändert.`)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setSyncingInitialActualsId(user.id);
|
||||||
try {
|
try {
|
||||||
const result = await syncZammadOrganizations({
|
const result = await syncAdminUserTeamspaceInitialActuals(user.id, {
|
||||||
baseUrl: zammadBaseUrl,
|
fromMonth: initialActualsFromMonthByUser[user.id] || undefined
|
||||||
apiKey: zammadApiKey.trim() || undefined
|
|
||||||
});
|
});
|
||||||
toast.success("Organisationen synchronisiert", {
|
toast.success("TS-Istzeiten übernommen", {
|
||||||
description: `${result.synced} gespeichert, ${result.removed} entfernt, ${result.unlinkedTickets + result.unlinkedSessions} Verknüpfung(en) gelöst.`
|
description: `${result.result.updatedManualEntries} TS-Eintrag(e), ${Math.round(result.result.importedManualMinutes / 60 * 100) / 100} Std., Zeitraum ${result.result.fromMonth} bis ${result.result.toMonth}.`
|
||||||
});
|
});
|
||||||
setHasZammadApiKey(true);
|
await load();
|
||||||
setZammadApiKey("");
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
toast.error("Zammad-Sync fehlgeschlagen", {
|
toast.error("TS-Istzeiten konnten nicht übernommen werden", {
|
||||||
description: error instanceof Error ? error.message : "Unbekannter Fehler"
|
description: error instanceof Error ? error.message : "Unbekannter Fehler"
|
||||||
});
|
});
|
||||||
} finally {
|
} finally {
|
||||||
setSyncingOrganizations(false);
|
setSyncingInitialActualsId(null);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function saveZammadAccess() {
|
async function unlockInitialActuals(user: AdminUser) {
|
||||||
setSavingZammadSettings(true);
|
if (!window.confirm(`Initialen Teamspace-Istzeiten-Sync für ${user.display_name} wieder freigeben?`)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setUnlockingInitialActualsId(user.id);
|
||||||
try {
|
try {
|
||||||
const result = await saveZammadSettings({
|
await unlockAdminUserTeamspaceInitialActuals(user.id);
|
||||||
baseUrl: zammadBaseUrl,
|
toast.success("Initialsync entsperrt");
|
||||||
apiKey: zammadApiKey.trim() || undefined
|
await load({ preserveEdits: true });
|
||||||
});
|
|
||||||
setZammadBaseUrl(result.settings.baseUrl);
|
|
||||||
setHasZammadApiKey(result.settings.hasApiKey);
|
|
||||||
setZammadApiKey("");
|
|
||||||
toast.success("Zammad-Zugang gespeichert", {
|
|
||||||
description: result.settings.hasApiKey ? "URL und API-Key sind hinterlegt." : "URL wurde gespeichert."
|
|
||||||
});
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
toast.error("Zammad-Zugang konnte nicht gespeichert werden", {
|
toast.error("Initialsync konnte nicht entsperrt werden", {
|
||||||
description: error instanceof Error ? error.message : "Unbekannter Fehler"
|
description: error instanceof Error ? error.message : "Unbekannter Fehler"
|
||||||
});
|
});
|
||||||
} finally {
|
} finally {
|
||||||
setSavingZammadSettings(false);
|
setUnlockingInitialActualsId(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveTeamspaceAccess() {
|
||||||
|
setSavingTeamspaceSettings(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await saveTeamspaceSettings({
|
||||||
|
baseUrl: teamspaceBaseUrl,
|
||||||
|
credentialsUserId: teamspaceCredentialsUserId || null
|
||||||
|
});
|
||||||
|
setTeamspaceBaseUrl(result.settings.baseUrl);
|
||||||
|
setTeamspaceCredentialsUserId(result.settings.credentialsUserId ?? "");
|
||||||
|
toast.success("Teamspace-Zugang gespeichert", {
|
||||||
|
description: "URL ist hinterlegt."
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
toast.error("Teamspace-Zugang konnte nicht gespeichert werden", {
|
||||||
|
description: error instanceof Error ? error.message : "Unbekannter Fehler"
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setSavingTeamspaceSettings(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function addTeamspaceExclusion() {
|
||||||
|
const project = newTeamspaceProjectExclusion.trim();
|
||||||
|
|
||||||
|
if (!project) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const exists = teamspaceProjectExclusions.some((entry) => entry.teamspace_project.toLocaleLowerCase("de") === project.toLocaleLowerCase("de"));
|
||||||
|
|
||||||
|
if (exists) {
|
||||||
|
toast.info("Teamspace-Projekt ist bereits in der Liste");
|
||||||
|
setNewTeamspaceProjectExclusion("");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setTeamspaceProjectExclusions((current) => [
|
||||||
|
...current,
|
||||||
|
{
|
||||||
|
teamspace_project: project,
|
||||||
|
teamspace_name: null,
|
||||||
|
customer_name: null,
|
||||||
|
teamspace_api_project_id: null,
|
||||||
|
teamspace_api_project_ids: [],
|
||||||
|
atoss_support_default: false,
|
||||||
|
atoss_support_valid_from: null,
|
||||||
|
atoss_support_valid_to: null,
|
||||||
|
customer_owner_option_id: null,
|
||||||
|
hotline_contract_option_id: null,
|
||||||
|
note: ""
|
||||||
|
}
|
||||||
|
]);
|
||||||
|
setNewTeamspaceProjectExclusion("");
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeTeamspaceExclusion(project: string) {
|
||||||
|
setTeamspaceProjectExclusions((current) => current.filter((entry) => entry.teamspace_project !== project));
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateTeamspaceExclusion(project: string, patch: Partial<TeamspaceProjectExclusion>) {
|
||||||
|
setTeamspaceProjectExclusions((current) =>
|
||||||
|
current.map((entry) => entry.teamspace_project === project ? { ...entry, ...patch } : entry)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveTeamspaceExclusions() {
|
||||||
|
setSavingTeamspaceProjectExclusions(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const projects = teamspaceProjectExclusions
|
||||||
|
.map((entry) => ({
|
||||||
|
teamspace_project: entry.teamspace_project.trim(),
|
||||||
|
atoss_support_default: entry.atoss_support_default,
|
||||||
|
atoss_support_valid_from: entry.atoss_support_valid_from || null,
|
||||||
|
atoss_support_valid_to: entry.atoss_support_valid_to || null,
|
||||||
|
customer_owner_option_id: entry.customer_owner_option_id || null,
|
||||||
|
hotline_contract_option_id: entry.hotline_contract_option_id || null
|
||||||
|
}))
|
||||||
|
.filter((entry) => entry.teamspace_project);
|
||||||
|
const result = await saveTeamspaceProjectExclusions(projects);
|
||||||
|
setTeamspaceProjectExclusions(result.exclusions);
|
||||||
|
toast.success("Teamspace-Ausnahmen gespeichert", {
|
||||||
|
description: `${result.exclusions.length} Projektnummer(n) geprüft und gespeichert.`
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
toast.error("Teamspace-Ausnahmen konnten nicht gespeichert werden", {
|
||||||
|
description: error instanceof Error ? error.message : "Unbekannter Fehler"
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setSavingTeamspaceProjectExclusions(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function addTeamspaceAssignmentRule() {
|
||||||
|
const highestPriority = teamspaceAssignmentRules.reduce((highest, rule) => Math.max(highest, rule.priority), 0);
|
||||||
|
const defaultTarget = teamspaceProjectExclusions[0] ?? null;
|
||||||
|
setTeamspaceAssignmentRules((current) => [
|
||||||
|
...current,
|
||||||
|
{
|
||||||
|
name: `Regel ${current.length + 1}`,
|
||||||
|
active: true,
|
||||||
|
priority: highestPriority + 10,
|
||||||
|
customer_owner_option_id: defaultTarget?.customer_owner_option_id ?? null,
|
||||||
|
hotline_contract_option_id: defaultTarget?.hotline_contract_option_id ?? null,
|
||||||
|
support_enabled: true,
|
||||||
|
consulting_enabled: false,
|
||||||
|
teamspace_project: defaultTarget?.teamspace_project ?? ""
|
||||||
|
}
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateTeamspaceAssignmentRule(index: number, patch: Partial<TeamspaceAssignmentRule>) {
|
||||||
|
setTeamspaceAssignmentRules((current) => current.map((rule, currentIndex) => currentIndex === index ? { ...rule, ...patch } : rule));
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeTeamspaceAssignmentRule(index: number) {
|
||||||
|
setTeamspaceAssignmentRules((current) => current.filter((_, currentIndex) => currentIndex !== index));
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateTeamspaceAssignmentRuleTarget(index: number, teamspaceProject: string) {
|
||||||
|
const target = teamspaceProjectExclusions.find((entry) => entry.teamspace_project === teamspaceProject);
|
||||||
|
updateTeamspaceAssignmentRule(index, {
|
||||||
|
teamspace_project: teamspaceProject,
|
||||||
|
customer_owner_option_id: target?.customer_owner_option_id ?? null,
|
||||||
|
hotline_contract_option_id: target?.hotline_contract_option_id ?? null
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveAssignmentRules() {
|
||||||
|
setSavingTeamspaceAssignmentRules(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await saveTeamspaceAssignmentRules(teamspaceAssignmentRules);
|
||||||
|
setTeamspaceAssignmentRules(result.rules);
|
||||||
|
toast.success("Automatische Zuordnungsregeln gespeichert", {
|
||||||
|
description: `${result.rules.length} Regel(n) gespeichert. Kleinere Prioritätswerte werden zuerst geprüft.`
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
toast.error("Zuordnungsregeln konnten nicht gespeichert werden", {
|
||||||
|
description: error instanceof Error ? error.message : "Unbekannter Fehler"
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setSavingTeamspaceAssignmentRules(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -317,6 +504,16 @@ export function AdminUsersPage({ currentUser }: { currentUser: AuthUser }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const regularUserCount = users.filter((user) => user.role === "user").length;
|
const regularUserCount = users.filter((user) => user.role === "user").length;
|
||||||
|
const customerOwnerOptions = organizationAttributeOptions.filter((option) => option.attribute_name === "customer_owner");
|
||||||
|
const hotlineContractOptions = organizationAttributeOptions.filter((option) => option.attribute_name === "hotline_contract");
|
||||||
|
|
||||||
|
function organizationAttributeOptionLabel(option: OrganizationAttributeOption) {
|
||||||
|
const label = option.display_name === option.option_key
|
||||||
|
? option.display_name
|
||||||
|
: `${option.display_name} (${option.option_key})`;
|
||||||
|
|
||||||
|
return option.active ? label : `${label} · nicht mehr in Zammad`;
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
@@ -325,51 +522,268 @@ export function AdminUsersPage({ currentUser }: { currentUser: AuthUser }) {
|
|||||||
<p className="text-sm text-muted-foreground">Systemverwaltung für Benutzer, Zammad-Organisationen und restore-fähige Backups.</p>
|
<p className="text-sm text-muted-foreground">Systemverwaltung für Benutzer, Zammad-Organisationen und restore-fähige Backups.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid min-w-0 gap-4 2xl:grid-cols-[minmax(0,1.2fr)_minmax(340px,0.8fr)]">
|
<div className="grid min-w-0 gap-4">
|
||||||
<Card className="min-w-0">
|
<Card className="min-w-0">
|
||||||
<CardHeader className="p-4">
|
<CardHeader className="p-4">
|
||||||
<div className="flex items-start justify-between gap-3">
|
<div className="flex items-start justify-between gap-3">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<DatabaseZap className="size-5 text-muted-foreground" />
|
<Unplug className="size-5 text-muted-foreground" />
|
||||||
<CardTitle>Data-Sync</CardTitle>
|
<CardTitle>Teamspace API</CardTitle>
|
||||||
</div>
|
</div>
|
||||||
<HelpLink anchor="organisationen" label="Hilfe zum Zammad-Sync" />
|
<HelpLink anchor="admin" label="Hilfe zu Teamspace API" />
|
||||||
</div>
|
</div>
|
||||||
<CardDescription>Zammad-Zugang speichern und Organisationen in die lokale TicketTracker-Datenbank übernehmen.</CardDescription>
|
<CardDescription>Teamspace-Zugang für Adminabfragen und globale Projekt-Ausnahmen verwalten.</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="px-4 pb-4">
|
<CardContent className="px-4 pb-4">
|
||||||
<form className="grid gap-3 xl:grid-cols-2" onSubmit={syncOrganizations}>
|
<div className="space-y-4">
|
||||||
<div className="space-y-2">
|
<div className="grid gap-3 xl:grid-cols-[minmax(220px,1fr)_minmax(220px,1fr)_auto] xl:items-end">
|
||||||
<label className="text-sm font-medium" htmlFor="zammad-base-url">Zammad URL</label>
|
<div className="space-y-2">
|
||||||
<Input
|
<label className="text-sm font-medium" htmlFor="teamspace-base-url">Teamspace URL</label>
|
||||||
id="zammad-base-url"
|
<Input
|
||||||
placeholder="https://zammad.example.de"
|
id="teamspace-base-url"
|
||||||
value={zammadBaseUrl}
|
placeholder="https://app1.teamspace.de"
|
||||||
onChange={(event) => setZammadBaseUrl(event.currentTarget.value)}
|
value={teamspaceBaseUrl}
|
||||||
required
|
onChange={(event) => setTeamspaceBaseUrl(event.currentTarget.value)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label className="text-sm font-medium" htmlFor="zammad-api-key">API-Key</label>
|
<label className="text-sm font-medium" htmlFor="teamspace-credentials-user">Credentials für Adminabfragen</label>
|
||||||
<Input
|
<select
|
||||||
id="zammad-api-key"
|
id="teamspace-credentials-user"
|
||||||
type="password"
|
className="h-10 w-full rounded-md border border-input bg-background px-3 text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||||
placeholder={hasZammadApiKey ? "••••••••••••••••" : ""}
|
value={teamspaceCredentialsUserId}
|
||||||
value={zammadApiKey}
|
onChange={(event) => setTeamspaceCredentialsUserId(event.currentTarget.value)}
|
||||||
onChange={(event) => setZammadApiKey(event.currentTarget.value)}
|
>
|
||||||
/>
|
<option value="">Bitte wählen</option>
|
||||||
</div>
|
{users.filter((user) => user.active && user.has_teamspace_credentials).map((user) => (
|
||||||
<div className="flex flex-wrap gap-2 xl:col-span-2">
|
<option key={user.id} value={user.id}>
|
||||||
<Button type="button" variant="secondary" disabled={savingZammadSettings} onClick={() => void saveZammadAccess()}>
|
{user.display_name} ({user.username})
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<p className="text-xs text-muted-foreground">Dieser Benutzer wird für Admin-Checks gegen Teamspace verwendet.</p>
|
||||||
|
</div>
|
||||||
|
<Button type="button" variant="secondary" disabled={savingTeamspaceSettings} onClick={() => void saveTeamspaceAccess()}>
|
||||||
<Save className="size-4" />
|
<Save className="size-4" />
|
||||||
{savingZammadSettings ? "Speichert..." : "Zugang speichern"}
|
{savingTeamspaceSettings ? "Speichert..." : "Einstellungen speichern"}
|
||||||
</Button>
|
|
||||||
<Button type="submit" disabled={syncingOrganizations}>
|
|
||||||
<DatabaseZap className="size-4" />
|
|
||||||
{syncingOrganizations ? "Synchronisiert..." : "Organisationen synchronisieren"}
|
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
<div className="mt-4 space-y-3 rounded-md border bg-muted/20 p-3">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium">TS-Projekte ohne Differenz-/Budgetprüfung</p>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Sync und Gesamtverbrauch laufen weiter, aber TS/TT-Differenzen, Budgetbalken und Budgetverbrauch werden für diese TS-Projekte ausgeblendet. Zugeordnet und Hotline stammen aus den Zammad-Organisationsattributen.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-2 sm:flex-row">
|
||||||
|
<Input
|
||||||
|
id="teamspace-project-exclusion-new"
|
||||||
|
placeholder="BP-2026-A-023"
|
||||||
|
value={newTeamspaceProjectExclusion}
|
||||||
|
onChange={(event) => setNewTeamspaceProjectExclusion(event.currentTarget.value)}
|
||||||
|
onKeyDown={(event) => {
|
||||||
|
if (event.key === "Enter") {
|
||||||
|
event.preventDefault();
|
||||||
|
addTeamspaceExclusion();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Button type="button" variant="secondary" onClick={addTeamspaceExclusion}>
|
||||||
|
<Plus className="size-4" />
|
||||||
|
Hinzufügen
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div className="overflow-x-auto rounded-md border bg-background">
|
||||||
|
{teamspaceProjectExclusions.length === 0 ? (
|
||||||
|
<p className="px-3 py-4 text-sm text-muted-foreground">Noch keine Teamspace-Projekte ausgenommen.</p>
|
||||||
|
) : (
|
||||||
|
<div className="min-w-[1120px] divide-y">
|
||||||
|
{teamspaceProjectExclusions.map((entry) => (
|
||||||
|
<div key={entry.teamspace_project} className="grid grid-cols-[180px_minmax(280px,1fr)_minmax(240px,0.9fr)_210px_210px_2.5rem] items-center gap-4 px-4 py-3">
|
||||||
|
<div className="font-mono text-sm font-semibold">{entry.teamspace_project}</div>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="truncate text-sm font-medium">{entry.teamspace_name ?? "Name wird beim Speichern aus Teamspace geladen"}</p>
|
||||||
|
{entry.teamspace_api_project_ids?.length ? <p className="text-xs text-muted-foreground">{entry.teamspace_api_project_ids.length} API-ID(s)</p> : null}
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0 text-sm text-muted-foreground">
|
||||||
|
<p className="truncate">{entry.customer_name ?? "Kunde wird beim Speichern aus Teamspace geladen"}</p>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<label className="text-xs text-muted-foreground" htmlFor={`customer-owner-${entry.teamspace_project}`}>Zugeordnet</label>
|
||||||
|
<select
|
||||||
|
id={`customer-owner-${entry.teamspace_project}`}
|
||||||
|
className="h-9 w-full rounded-md border border-input bg-background px-3 text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||||
|
value={entry.customer_owner_option_id ?? ""}
|
||||||
|
onChange={(event) => updateTeamspaceExclusion(entry.teamspace_project, { customer_owner_option_id: event.currentTarget.value || null })}
|
||||||
|
>
|
||||||
|
<option value="">Nicht zugeordnet</option>
|
||||||
|
{customerOwnerOptions.map((option) => (
|
||||||
|
<option key={option.id} value={option.id} disabled={!option.active && option.id !== entry.customer_owner_option_id}>
|
||||||
|
{organizationAttributeOptionLabel(option)}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<label className="text-xs text-muted-foreground" htmlFor={`hotline-contract-${entry.teamspace_project}`}>Hotline</label>
|
||||||
|
<select
|
||||||
|
id={`hotline-contract-${entry.teamspace_project}`}
|
||||||
|
className="h-9 w-full rounded-md border border-input bg-background px-3 text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||||
|
value={entry.hotline_contract_option_id ?? ""}
|
||||||
|
onChange={(event) => updateTeamspaceExclusion(entry.teamspace_project, { hotline_contract_option_id: event.currentTarget.value || null })}
|
||||||
|
>
|
||||||
|
<option value="">Nicht zugeordnet</option>
|
||||||
|
{hotlineContractOptions.map((option) => (
|
||||||
|
<option key={option.id} value={option.id} disabled={!option.active && option.id !== entry.hotline_contract_option_id}>
|
||||||
|
{organizationAttributeOptionLabel(option)}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<Button type="button" variant="ghost" size="icon" className="justify-self-end" onClick={() => removeTeamspaceExclusion(entry.teamspace_project)}>
|
||||||
|
<Trash2 className="size-4" />
|
||||||
|
<span className="sr-only">Teamspace-Projekt entfernen</span>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<Button type="button" variant="secondary" disabled={savingTeamspaceProjectExclusions} onClick={() => void saveTeamspaceExclusions()}>
|
||||||
|
<Save className="size-4" />
|
||||||
|
{savingTeamspaceProjectExclusions ? "Speichert..." : "Ausnahmen speichern"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-3 rounded-md border bg-muted/20 p-3">
|
||||||
|
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium">Automatische TS-Zuordnung</p>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Angegebene Bedingungen innerhalb einer Regel gelten gemeinsam. Beim Auswählen des Zielprojekts werden dessen hinterlegte Zugeordnet-/Hotline-Werte übernommen und können danach angepasst werden. Die erste passende aktive Regel gewinnt. Regeln werden beim Zammad-Sync nur angewendet, wenn das Ticket keine konkrete TS-Projektnummer besitzt; anschließend werden alle Sessions dem Zielprojekt zugeordnet.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button type="button" variant="secondary" onClick={addTeamspaceAssignmentRule}>
|
||||||
|
<Plus className="size-4" />
|
||||||
|
Regel hinzufügen
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div className="overflow-x-auto rounded-md border bg-background">
|
||||||
|
{teamspaceAssignmentRules.length === 0 ? (
|
||||||
|
<p className="px-3 py-4 text-sm text-muted-foreground">Noch keine automatische Zuordnungsregel angelegt.</p>
|
||||||
|
) : (
|
||||||
|
<div className="min-w-[1580px] divide-y">
|
||||||
|
{teamspaceAssignmentRules.map((rule, index) => {
|
||||||
|
const targetExists = teamspaceProjectExclusions.some((entry) => entry.teamspace_project === rule.teamspace_project);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={rule.id ?? `new-${index}`} className="grid grid-cols-[230px_100px_220px_220px_300px_240px_110px_2.5rem] items-end gap-3 px-4 py-3">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<label className="text-xs text-muted-foreground" htmlFor={`assignment-rule-name-${rule.id ?? index}`}>Regelname</label>
|
||||||
|
<Input
|
||||||
|
id={`assignment-rule-name-${rule.id ?? index}`}
|
||||||
|
className="h-9"
|
||||||
|
value={rule.name}
|
||||||
|
onChange={(event) => updateTeamspaceAssignmentRule(index, { name: event.currentTarget.value })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<label className="text-xs text-muted-foreground" htmlFor={`assignment-rule-priority-${rule.id ?? index}`}>Priorität</label>
|
||||||
|
<Input
|
||||||
|
id={`assignment-rule-priority-${rule.id ?? index}`}
|
||||||
|
className="h-9"
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
max={100000}
|
||||||
|
value={rule.priority}
|
||||||
|
onChange={(event) => updateTeamspaceAssignmentRule(index, { priority: Number(event.currentTarget.value) || 0 })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<label className="text-xs text-muted-foreground" htmlFor={`assignment-rule-owner-${rule.id ?? index}`}>Zugeordnet ist</label>
|
||||||
|
<select
|
||||||
|
id={`assignment-rule-owner-${rule.id ?? index}`}
|
||||||
|
className="h-9 w-full rounded-md border border-input bg-background px-3 text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||||
|
value={rule.customer_owner_option_id ?? ""}
|
||||||
|
onChange={(event) => updateTeamspaceAssignmentRule(index, { customer_owner_option_id: event.currentTarget.value || null })}
|
||||||
|
>
|
||||||
|
<option value="">Bedingung nicht verwenden</option>
|
||||||
|
{customerOwnerOptions.map((option) => (
|
||||||
|
<option key={option.id} value={option.id} disabled={!option.active && option.id !== rule.customer_owner_option_id}>
|
||||||
|
{organizationAttributeOptionLabel(option)}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<label className="text-xs text-muted-foreground" htmlFor={`assignment-rule-hotline-${rule.id ?? index}`}>Hotline ist</label>
|
||||||
|
<select
|
||||||
|
id={`assignment-rule-hotline-${rule.id ?? index}`}
|
||||||
|
className="h-9 w-full rounded-md border border-input bg-background px-3 text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||||
|
value={rule.hotline_contract_option_id ?? ""}
|
||||||
|
onChange={(event) => updateTeamspaceAssignmentRule(index, { hotline_contract_option_id: event.currentTarget.value || null })}
|
||||||
|
>
|
||||||
|
<option value="">Bedingung nicht verwenden</option>
|
||||||
|
{hotlineContractOptions.map((option) => (
|
||||||
|
<option key={option.id} value={option.id} disabled={!option.active && option.id !== rule.hotline_contract_option_id}>
|
||||||
|
{organizationAttributeOptionLabel(option)}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="flex h-9 items-center gap-4 rounded-md border bg-background px-3">
|
||||||
|
<label className="flex items-center gap-2 text-sm">
|
||||||
|
<Switch
|
||||||
|
checked={rule.support_enabled}
|
||||||
|
onCheckedChange={(checked) => updateTeamspaceAssignmentRule(index, { support_enabled: checked })}
|
||||||
|
/>
|
||||||
|
Support
|
||||||
|
</label>
|
||||||
|
<label className="flex items-center gap-2 text-sm">
|
||||||
|
<Switch
|
||||||
|
checked={rule.consulting_enabled}
|
||||||
|
onCheckedChange={(checked) => updateTeamspaceAssignmentRule(index, { consulting_enabled: checked })}
|
||||||
|
/>
|
||||||
|
Consulting
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<label className="text-xs text-muted-foreground" htmlFor={`assignment-rule-target-${rule.id ?? index}`}>Ziel-TS-Projekt</label>
|
||||||
|
<select
|
||||||
|
id={`assignment-rule-target-${rule.id ?? index}`}
|
||||||
|
className="h-9 w-full rounded-md border border-input bg-background px-3 font-mono text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||||
|
value={rule.teamspace_project}
|
||||||
|
onChange={(event) => updateTeamspaceAssignmentRuleTarget(index, event.currentTarget.value)}
|
||||||
|
>
|
||||||
|
<option value="">Bitte wählen</option>
|
||||||
|
{!targetExists && rule.teamspace_project ? <option value={rule.teamspace_project}>{rule.teamspace_project} · nicht mehr vorhanden</option> : null}
|
||||||
|
{teamspaceProjectExclusions.map((entry) => (
|
||||||
|
<option key={entry.teamspace_project} value={entry.teamspace_project}>{entry.teamspace_project}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<label className="flex h-9 items-center gap-2 text-sm">
|
||||||
|
<Switch checked={rule.active} onCheckedChange={(checked) => updateTeamspaceAssignmentRule(index, { active: checked })} />
|
||||||
|
Aktiv
|
||||||
|
</label>
|
||||||
|
<Button type="button" variant="ghost" size="icon" onClick={() => removeTeamspaceAssignmentRule(index)}>
|
||||||
|
<Trash2 className="size-4" />
|
||||||
|
<span className="sr-only">Zuordnungsregel entfernen</span>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
|
<Button type="button" variant="secondary" disabled={savingTeamspaceAssignmentRules} onClick={() => void saveAssignmentRules()}>
|
||||||
|
<Save className="size-4" />
|
||||||
|
{savingTeamspaceAssignmentRules ? "Speichert..." : "Zuordnungsregeln speichern"}
|
||||||
|
</Button>
|
||||||
|
<p className="text-xs text-muted-foreground">Zielprojekte müssen zuvor in der Ausnahmen-Liste gespeichert worden sein.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
@@ -488,6 +902,7 @@ export function AdminUsersPage({ currentUser }: { currentUser: AuthUser }) {
|
|||||||
<TableHead>Rolle</TableHead>
|
<TableHead>Rolle</TableHead>
|
||||||
<TableHead>Status</TableHead>
|
<TableHead>Status</TableHead>
|
||||||
<TableHead>Neues Passwort</TableHead>
|
<TableHead>Neues Passwort</TableHead>
|
||||||
|
<TableHead>TS-Istzeiten</TableHead>
|
||||||
<TableHead />
|
<TableHead />
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
@@ -522,6 +937,45 @@ export function AdminUsersPage({ currentUser }: { currentUser: AuthUser }) {
|
|||||||
onChange={(event) => updateForm(user.id, { password: event.currentTarget.value })}
|
onChange={(event) => updateForm(user.id, { password: event.currentTarget.value })}
|
||||||
/>
|
/>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{isFixedAdmin ? (
|
||||||
|
<span className="text-sm text-muted-foreground">-</span>
|
||||||
|
) : (
|
||||||
|
<div className="grid min-w-[280px] gap-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Badge variant={initialActualsLocked(user) ? "secondary" : "outline"}>{initialActualsStatus(user)}</Badge>
|
||||||
|
{!user.has_teamspace_credentials || !user.teamspace_user_id ? <Badge variant="warning">TS-Zugang fehlt</Badge> : null}
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-[minmax(120px,1fr)_auto_auto] gap-2">
|
||||||
|
<Input
|
||||||
|
className="h-8"
|
||||||
|
type="month"
|
||||||
|
value={initialActualsFromMonthByUser[user.id] ?? ""}
|
||||||
|
onChange={(event) => setInitialActualsFromMonthByUser((current) => ({ ...current, [user.id]: event.currentTarget.value }))}
|
||||||
|
disabled={initialActualsLocked(user) || syncingInitialActualsId === user.id}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="secondary"
|
||||||
|
disabled={initialActualsLocked(user) || !user.has_teamspace_credentials || !user.teamspace_user_id || syncingInitialActualsId === user.id}
|
||||||
|
onClick={() => void syncInitialActuals(user)}
|
||||||
|
>
|
||||||
|
<TimerReset className="size-4" />
|
||||||
|
{syncingInitialActualsId === user.id ? "Läuft..." : "Sync"}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
disabled={!initialActualsLocked(user) || unlockingInitialActualsId === user.id}
|
||||||
|
onClick={() => void unlockInitialActuals(user)}
|
||||||
|
>
|
||||||
|
<RotateCcw className="size-4" />
|
||||||
|
Entsperren
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
<TableCell className="text-right">
|
<TableCell className="text-right">
|
||||||
<Button size="sm" onClick={() => void saveUser(user)} disabled={savingId === user.id}>
|
<Button size="sm" onClick={() => void saveUser(user)} disabled={savingId === user.id}>
|
||||||
<Save className="size-4" />
|
<Save className="size-4" />
|
||||||
@@ -557,6 +1011,41 @@ export function AdminUsersPage({ currentUser }: { currentUser: AuthUser }) {
|
|||||||
value={form.password}
|
value={form.password}
|
||||||
onChange={(event) => updateForm(user.id, { password: event.currentTarget.value })}
|
onChange={(event) => updateForm(user.id, { password: event.currentTarget.value })}
|
||||||
/>
|
/>
|
||||||
|
{!isFixedAdmin ? (
|
||||||
|
<div className="space-y-2 rounded-md border bg-muted/20 p-2">
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<span className="text-sm font-medium">TS-Istzeiten</span>
|
||||||
|
<Badge variant={initialActualsLocked(user) ? "secondary" : "outline"}>{initialActualsStatus(user)}</Badge>
|
||||||
|
{!user.has_teamspace_credentials || !user.teamspace_user_id ? <Badge variant="warning">TS-Zugang fehlt</Badge> : null}
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-2 sm:grid-cols-[minmax(140px,1fr)_auto_auto]">
|
||||||
|
<Input
|
||||||
|
type="month"
|
||||||
|
value={initialActualsFromMonthByUser[user.id] ?? ""}
|
||||||
|
onChange={(event) => setInitialActualsFromMonthByUser((current) => ({ ...current, [user.id]: event.currentTarget.value }))}
|
||||||
|
disabled={initialActualsLocked(user) || syncingInitialActualsId === user.id}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="secondary"
|
||||||
|
disabled={initialActualsLocked(user) || !user.has_teamspace_credentials || !user.teamspace_user_id || syncingInitialActualsId === user.id}
|
||||||
|
onClick={() => void syncInitialActuals(user)}
|
||||||
|
>
|
||||||
|
<TimerReset className="size-4" />
|
||||||
|
{syncingInitialActualsId === user.id ? "Läuft..." : "Sync"}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
disabled={!initialActualsLocked(user) || unlockingInitialActualsId === user.id}
|
||||||
|
onClick={() => void unlockInitialActuals(user)}
|
||||||
|
>
|
||||||
|
<RotateCcw className="size-4" />
|
||||||
|
Entsperren
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
<div className="flex items-center justify-between gap-2">
|
<div className="flex items-center justify-between gap-2">
|
||||||
<label className="flex items-center gap-2 text-sm">
|
<label className="flex items-center gap-2 text-sm">
|
||||||
<Switch checked={form.active} disabled={isFixedAdmin} onCheckedChange={(active) => updateForm(user.id, { active })} />
|
<Switch checked={form.active} disabled={isFixedAdmin} onCheckedChange={(active) => updateForm(user.id, { active })} />
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { ArrowDown, ArrowUp, ChevronLeft, ChevronRight, Clock3, Download, ExternalLink, RotateCcw, SlidersHorizontal, Ticket, UserCheck, Users } from "lucide-react";
|
import { AlertCircle, ArrowDown, ArrowUp, ChevronLeft, ChevronRight, Clock3, Download, ExternalLink, RotateCcw, SlidersHorizontal, Ticket, UserCheck, Users } from "lucide-react";
|
||||||
import type { LucideIcon } from "lucide-react";
|
import type { LucideIcon } from "lucide-react";
|
||||||
import { Fragment, useEffect, useMemo, useRef, useState } from "react";
|
import { Fragment, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
@@ -14,9 +14,10 @@ import { HelpLink } from "@/components/HelpLink";
|
|||||||
import { MultiFilterDropdown, type MultiFilterOption } from "@/components/MultiFilterDropdown";
|
import { MultiFilterDropdown, type MultiFilterOption } from "@/components/MultiFilterDropdown";
|
||||||
import { downloadMonthExport, getPeriodOverview } from "../api";
|
import { downloadMonthExport, getPeriodOverview } from "../api";
|
||||||
import { currentDay, currentMonth, formatDateTime, formatMinutes } from "../format";
|
import { currentDay, currentMonth, formatDateTime, formatMinutes } from "../format";
|
||||||
import type { PeriodOverview, PeriodType, TicketSummary } from "../types";
|
import type { AuthUser, PeriodOverview, PeriodType, TicketSummary } from "../types";
|
||||||
|
|
||||||
type AnalysisPageProps = {
|
type AnalysisPageProps = {
|
||||||
|
currentUser: AuthUser;
|
||||||
onNavigate: (to: string) => void;
|
onNavigate: (to: string) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -167,6 +168,41 @@ function TicketStatus({ ticket }: { ticket: TicketSummary }) {
|
|||||||
return <Badge variant="success">bewertet</Badge>;
|
return <Badge variant="success">bewertet</Badge>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function formatSignedMinutes(minutes: number) {
|
||||||
|
if (minutes === 0) {
|
||||||
|
return formatMinutes(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${minutes > 0 ? "+" : "-"}${formatMinutes(Math.abs(minutes))}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function teamspaceMonthDeltaMinutes(ticket: TicketSummary) {
|
||||||
|
const actuals = ticket.teamspace_month_actuals ?? [];
|
||||||
|
|
||||||
|
if (actuals.length === 0) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
const tsMinutes = ticket.teamspace_month_billable_minutes ?? actuals.reduce((sum, actual) => sum + actual.teamspace_billable_minutes, 0);
|
||||||
|
return ticket.teamspace_month_delta_minutes ?? tsMinutes - ticket.crm_billed_minutes;
|
||||||
|
}
|
||||||
|
|
||||||
|
function TeamspaceDeviationIcon({ ticket }: { ticket: TicketSummary }) {
|
||||||
|
const deltaMinutes = teamspaceMonthDeltaMinutes(ticket);
|
||||||
|
|
||||||
|
if (deltaMinutes === 0) {
|
||||||
|
return <span className="inline-block size-4" aria-hidden="true" />;
|
||||||
|
}
|
||||||
|
|
||||||
|
const title = deltaMinutes > 0 ? `${formatMinutes(deltaMinutes)} mehr in TS API` : `${formatMinutes(Math.abs(deltaMinutes))} mehr in TT-Teamspace`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span title={`${formatSignedMinutes(deltaMinutes)} · ${title}`} aria-label={`Teamspace-Abweichung: ${title}`}>
|
||||||
|
<AlertCircle className="mx-auto size-4 text-amber-500" />
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function ticketStatusKey(ticket: TicketSummary): "open" | "evaluated" {
|
function ticketStatusKey(ticket: TicketSummary): "open" | "evaluated" {
|
||||||
return (ticket.ticket_open_count ?? ticket.open_count) > 0 ? "open" : "evaluated";
|
return (ticket.ticket_open_count ?? ticket.open_count) > 0 ? "open" : "evaluated";
|
||||||
}
|
}
|
||||||
@@ -327,7 +363,7 @@ function groupTicketSections(tickets: TicketSummary[], groupBy: TicketGroupBy):
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function AnalysisPage({ onNavigate }: AnalysisPageProps) {
|
export function AnalysisPage({ currentUser, onNavigate }: AnalysisPageProps) {
|
||||||
const [periodType, setPeriodType] = useState<PeriodType>("month");
|
const [periodType, setPeriodType] = useState<PeriodType>("month");
|
||||||
const [period, setPeriod] = useState(currentMonth());
|
const [period, setPeriod] = useState(currentMonth());
|
||||||
const [overview, setOverview] = useState<PeriodOverview | null>(null);
|
const [overview, setOverview] = useState<PeriodOverview | null>(null);
|
||||||
@@ -438,11 +474,17 @@ export function AnalysisPage({ onNavigate }: AnalysisPageProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const totals = overview?.totals;
|
const totals = overview?.totals;
|
||||||
|
const teamspaceTimeTrackingEnabled = currentUser.teamspace_time_tracking_enabled ?? true;
|
||||||
const periodLabel = periodType === "month" ? "Monat" : "Tag";
|
const periodLabel = periodType === "month" ? "Monat" : "Tag";
|
||||||
const metricCards: Array<{ label: string; value: string | number; detail?: string; icon: LucideIcon }> = [
|
const metricCards: Array<{ label: string; value: string | number; detail?: string; icon: LucideIcon }> = [
|
||||||
{ label: "Tickets", value: totals?.tickets ?? 0, icon: Ticket },
|
{ label: "Tickets", value: totals?.tickets ?? 0, icon: Ticket },
|
||||||
{ label: "Sessions", value: totals?.sessions ?? 0, icon: Users },
|
{ label: "Sessions", value: totals?.sessions ?? 0, icon: Users },
|
||||||
{ label: "Abgerechnet", value: formatMinutes(totals?.minutes ?? 0), detail: `Teamspace ${formatMinutes(totals?.crmBilledMinutes ?? 0)}`, icon: Clock3 },
|
{
|
||||||
|
label: "Abgerechnet",
|
||||||
|
value: formatMinutes(totals?.minutes ?? 0),
|
||||||
|
detail: teamspaceTimeTrackingEnabled ? `Teamspace ${formatMinutes(totals?.crmBilledMinutes ?? 0)}` : "Ist aus Teamspace",
|
||||||
|
icon: Clock3
|
||||||
|
},
|
||||||
{ label: "Abrechenbar", value: formatMinutes(totals?.billableMinutes ?? 0), icon: UserCheck },
|
{ label: "Abrechenbar", value: formatMinutes(totals?.billableMinutes ?? 0), icon: UserCheck },
|
||||||
{ label: "Offen", value: totals?.openSessions ?? 0, detail: formatMinutes(totals?.openMinutes ?? 0), icon: UserCheck }
|
{ label: "Offen", value: totals?.openSessions ?? 0, detail: formatMinutes(totals?.openMinutes ?? 0), icon: UserCheck }
|
||||||
];
|
];
|
||||||
@@ -683,6 +725,9 @@ export function AnalysisPage({ onNavigate }: AnalysisPageProps) {
|
|||||||
<TableHeader>
|
<TableHeader>
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableHead>Ticket</TableHead>
|
<TableHead>Ticket</TableHead>
|
||||||
|
<TableHead className="w-10 text-center">
|
||||||
|
<span className="sr-only">Teamspace-Abweichung</span>
|
||||||
|
</TableHead>
|
||||||
<TableHead>Organisation</TableHead>
|
<TableHead>Organisation</TableHead>
|
||||||
<TableHead>Art</TableHead>
|
<TableHead>Art</TableHead>
|
||||||
<TableHead>Sessions</TableHead>
|
<TableHead>Sessions</TableHead>
|
||||||
@@ -723,6 +768,9 @@ export function AnalysisPage({ onNavigate }: AnalysisPageProps) {
|
|||||||
</div>
|
</div>
|
||||||
{ticket.title ? <p className="max-w-[220px] truncate text-xs text-muted-foreground">{ticket.title}</p> : null}
|
{ticket.title ? <p className="max-w-[220px] truncate text-xs text-muted-foreground">{ticket.title}</p> : null}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
|
<TableCell className="text-center">
|
||||||
|
<TeamspaceDeviationIcon ticket={ticket} />
|
||||||
|
</TableCell>
|
||||||
<TableCell>{ticket.customer_name ?? "-"}</TableCell>
|
<TableCell>{ticket.customer_name ?? "-"}</TableCell>
|
||||||
<TableCell>{workTypeLabel(ticket.work_type)}</TableCell>
|
<TableCell>{workTypeLabel(ticket.work_type)}</TableCell>
|
||||||
<TableCell>{ticket.session_count}</TableCell>
|
<TableCell>{ticket.session_count}</TableCell>
|
||||||
@@ -778,7 +826,10 @@ export function AnalysisPage({ onNavigate }: AnalysisPageProps) {
|
|||||||
{ticket.title ? <p className="truncate text-xs text-muted-foreground">{ticket.title}</p> : null}
|
{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>
|
<p className="text-sm text-muted-foreground">{ticket.customer_name ?? "Keine Organisation"} · {workTypeLabel(ticket.work_type)} · {ticket.session_count} Session(s)</p>
|
||||||
</div>
|
</div>
|
||||||
<TicketStatus ticket={ticket} />
|
<div className="flex shrink-0 items-center gap-2">
|
||||||
|
<TeamspaceDeviationIcon ticket={ticket} />
|
||||||
|
<TicketStatus ticket={ticket} />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-2 gap-2 text-sm">
|
<div className="grid grid-cols-2 gap-2 text-sm">
|
||||||
<div className="rounded-md bg-muted/50 p-2">
|
<div className="rounded-md bg-muted/50 p-2">
|
||||||
|
|||||||
@@ -66,9 +66,10 @@ import type {
|
|||||||
DashboardFavorite,
|
DashboardFavorite,
|
||||||
DashboardPreference,
|
DashboardPreference,
|
||||||
DashboardSortDirection,
|
DashboardSortDirection,
|
||||||
DashboardWidget,
|
DashboardWidget,
|
||||||
DashboardWidgetType,
|
DashboardWidgetType,
|
||||||
PeriodOverview,
|
AuthUser,
|
||||||
|
PeriodOverview,
|
||||||
ProjectTeamspaceEntry,
|
ProjectTeamspaceEntry,
|
||||||
ProjectSummary,
|
ProjectSummary,
|
||||||
RecurringBilling,
|
RecurringBilling,
|
||||||
@@ -78,6 +79,7 @@ import type {
|
|||||||
} from "../types";
|
} from "../types";
|
||||||
|
|
||||||
type DashboardPageProps = {
|
type DashboardPageProps = {
|
||||||
|
currentUser: AuthUser;
|
||||||
onNavigate: (to: string) => void;
|
onNavigate: (to: string) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -720,7 +722,7 @@ function projectFilterGroup(filter: string) {
|
|||||||
|
|
||||||
function matchesSingleProjectFilter(project: ProjectSummary, filterBy: string, fixedBillingTickets: Set<string>) {
|
function matchesSingleProjectFilter(project: ProjectSummary, filterBy: string, fixedBillingTickets: Set<string>) {
|
||||||
const consumptionMinutes = project.consumption_minutes ?? 0;
|
const consumptionMinutes = project.consumption_minutes ?? 0;
|
||||||
const budgetMinutes = project.hour_budget_minutes ?? 0;
|
const budgetMinutes = projectBudgetExcluded(project) ? 0 : project.hour_budget_minutes ?? 0;
|
||||||
const percent = budgetMinutes > 0 ? consumptionMinutes / budgetMinutes : 0;
|
const percent = budgetMinutes > 0 ? consumptionMinutes / budgetMinutes : 0;
|
||||||
const hasFixedBilling = Number(project.recurring_session_count ?? 0) > 0 || hasFixedBillingForTicket(fixedBillingTickets, project.ticket_id, project.ticket_number);
|
const hasFixedBilling = Number(project.recurring_session_count ?? 0) > 0 || hasFixedBillingForTicket(fixedBillingTickets, project.ticket_id, project.ticket_number);
|
||||||
|
|
||||||
@@ -790,6 +792,10 @@ function matchesProjectFilters(project: ProjectSummary, filters: string[], fixed
|
|||||||
}
|
}
|
||||||
|
|
||||||
function projectConsumptionPercent(project: ProjectSummary) {
|
function projectConsumptionPercent(project: ProjectSummary) {
|
||||||
|
if (projectBudgetExcluded(project)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
const budgetMinutes = project.hour_budget_minutes ?? 0;
|
const budgetMinutes = project.hour_budget_minutes ?? 0;
|
||||||
|
|
||||||
if (budgetMinutes <= 0) {
|
if (budgetMinutes <= 0) {
|
||||||
@@ -819,12 +825,20 @@ function projectTeamspaceEntries(project: ProjectSummary): ProjectTeamspaceEntry
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function projectBudgetExcluded(project: ProjectSummary) {
|
||||||
|
return Boolean(project.teamspace_budget_excluded || projectTeamspaceEntries(project).some((entry) => entry.teamspace_budget_excluded));
|
||||||
|
}
|
||||||
|
|
||||||
function projectTeamspaceEntryLabel(entry: ProjectTeamspaceEntry) {
|
function projectTeamspaceEntryLabel(entry: ProjectTeamspaceEntry) {
|
||||||
return entry.teamspace_description?.trim() || entry.teamspace_project || (entry.is_default ? "Standard" : "Ohne Teamspace-ID");
|
return entry.teamspace_description?.trim() || entry.teamspace_project || (entry.is_default ? "Standard" : "Ohne Teamspace-ID");
|
||||||
}
|
}
|
||||||
|
|
||||||
function budgetedProjectTeamspaceEntries(project: ProjectSummary) {
|
function budgetedProjectTeamspaceEntries(project: ProjectSummary) {
|
||||||
return projectTeamspaceEntries(project).filter((entry) => Number(entry.hour_budget_minutes ?? 0) > 0);
|
if (projectBudgetExcluded(project)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return projectTeamspaceEntries(project).filter((entry) => !entry.teamspace_budget_excluded && Number(entry.hour_budget_minutes ?? 0) > 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
function openRatingFilterGroup(filter: string) {
|
function openRatingFilterGroup(filter: string) {
|
||||||
@@ -885,7 +899,7 @@ function sortedFavorites(favorites: DashboardFavorite[], metas: Record<string, T
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function DashboardPage({ onNavigate }: DashboardPageProps) {
|
export function DashboardPage({ currentUser, onNavigate }: DashboardPageProps) {
|
||||||
const gridRef = useRef<HTMLDivElement | null>(null);
|
const gridRef = useRef<HTMLDivElement | null>(null);
|
||||||
const [preference, setPreference] = useState<DashboardPreference>(() => normalizePreference({}));
|
const [preference, setPreference] = useState<DashboardPreference>(() => normalizePreference({}));
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
@@ -1007,16 +1021,17 @@ export function DashboardPage({ onNavigate }: DashboardPageProps) {
|
|||||||
};
|
};
|
||||||
}, [favoriteMetas, preference.favorites]);
|
}, [favoriteMetas, preference.favorites]);
|
||||||
|
|
||||||
const maxGridRow = useMemo(
|
const maxGridRow = useMemo(
|
||||||
() => Math.max(6, dragPreview ? dragPreview.y + dragPreview.h + 1 : 0, ...preference.widgets.map((widget) => widget.y + widget.h + 1)),
|
() => Math.max(6, dragPreview ? dragPreview.y + dragPreview.h + 1 : 0, ...preference.widgets.map((widget) => widget.y + widget.h + 1)),
|
||||||
[dragPreview, preference.widgets]
|
[dragPreview, preference.widgets]
|
||||||
);
|
);
|
||||||
const dashboardStats = useMemo(() => {
|
const teamspaceTimeTrackingEnabled = currentUser.teamspace_time_tracking_enabled ?? true;
|
||||||
|
const dashboardStats = useMemo(() => {
|
||||||
const monthTotals = monthOverview?.totals;
|
const monthTotals = monthOverview?.totals;
|
||||||
const dayTotals = todayOverview?.totals;
|
const dayTotals = todayOverview?.totals;
|
||||||
const overBudget = projects.filter((project) => project.hour_budget_minutes !== null && project.hour_budget_minutes !== undefined && (project.consumption_minutes ?? 0) > project.hour_budget_minutes);
|
const overBudget = projects.filter((project) => !projectBudgetExcluded(project) && project.hour_budget_minutes !== null && project.hour_budget_minutes !== undefined && (project.consumption_minutes ?? 0) > project.hour_budget_minutes);
|
||||||
const nearlyFull = projects.filter((project) => {
|
const nearlyFull = projects.filter((project) => {
|
||||||
if (!project.hour_budget_minutes || project.hour_budget_minutes <= 0) {
|
if (projectBudgetExcluded(project) || !project.hour_budget_minutes || project.hour_budget_minutes <= 0) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1048,17 +1063,18 @@ export function DashboardPage({ onNavigate }: DashboardPageProps) {
|
|||||||
dayTotals,
|
dayTotals,
|
||||||
statistics: statisticsOverview,
|
statistics: statisticsOverview,
|
||||||
openRatings: monthOverview?.openSessions ?? [],
|
openRatings: monthOverview?.openSessions ?? [],
|
||||||
todayTickets: todayOverview?.tickets ?? [],
|
todayTickets: todayOverview?.tickets ?? [],
|
||||||
todayOpenRatings: todayOverview?.openSessions ?? [],
|
todayOpenRatings: todayOverview?.openSessions ?? [],
|
||||||
dayControlTickets,
|
dayControlTickets,
|
||||||
fixedBillingTickets,
|
fixedBillingTickets,
|
||||||
teamspaceDelta: (monthTotals?.minutes ?? 0) - (monthTotals?.crmBilledMinutes ?? 0),
|
teamspaceTimeTrackingEnabled,
|
||||||
overBudget,
|
teamspaceDelta: teamspaceTimeTrackingEnabled ? (monthTotals?.minutes ?? 0) - (monthTotals?.crmBilledMinutes ?? 0) : 0,
|
||||||
|
overBudget,
|
||||||
nearlyFull,
|
nearlyFull,
|
||||||
openTodos: todos.filter((todo) => todo.status === "open"),
|
openTodos: todos.filter((todo) => todo.status === "open"),
|
||||||
overdueTodos: todos.filter((todo) => todo.status === "open" && todo.ends_on && todo.ends_on < currentDay())
|
overdueTodos: todos.filter((todo) => todo.status === "open" && todo.ends_on && todo.ends_on < currentDay())
|
||||||
};
|
};
|
||||||
}, [dayControlTickets, monthOverview, projects, recurringBillings, statisticsOverview, todayOverview, todos]);
|
}, [dayControlTickets, monthOverview, projects, recurringBillings, statisticsOverview, teamspaceTimeTrackingEnabled, todayOverview, todos]);
|
||||||
|
|
||||||
function updateWidget(widgetId: string, changes: Partial<DashboardWidget>) {
|
function updateWidget(widgetId: string, changes: Partial<DashboardWidget>) {
|
||||||
setPreference((current) => ({
|
setPreference((current) => ({
|
||||||
@@ -1384,9 +1400,10 @@ function useDashboardStatsPlaceholder() {
|
|||||||
monthTotals: null as PeriodOverview["totals"] | null | undefined,
|
monthTotals: null as PeriodOverview["totals"] | null | undefined,
|
||||||
dayTotals: null as PeriodOverview["totals"] | null | undefined,
|
dayTotals: null as PeriodOverview["totals"] | null | undefined,
|
||||||
statistics: null as StatisticsOverview | null,
|
statistics: null as StatisticsOverview | null,
|
||||||
openRatings: [] as PeriodOverview["openSessions"],
|
openRatings: [] as PeriodOverview["openSessions"],
|
||||||
fixedBillingTickets: new Set<string>(),
|
fixedBillingTickets: new Set<string>(),
|
||||||
teamspaceDelta: 0,
|
teamspaceTimeTrackingEnabled: true,
|
||||||
|
teamspaceDelta: 0,
|
||||||
overBudget: [] as ProjectSummary[],
|
overBudget: [] as ProjectSummary[],
|
||||||
nearlyFull: [] as ProjectSummary[],
|
nearlyFull: [] as ProjectSummary[],
|
||||||
todayTickets: [] as PeriodOverview["tickets"],
|
todayTickets: [] as PeriodOverview["tickets"],
|
||||||
@@ -1559,17 +1576,22 @@ function WidgetContent(props: WidgetContentProps) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (widget.type === "teamspace") {
|
if (widget.type === "teamspace") {
|
||||||
const delta = stats.teamspaceDelta;
|
const delta = stats.teamspaceDelta;
|
||||||
return (
|
return (
|
||||||
<MetricGrid
|
<MetricGrid
|
||||||
items={[
|
items={stats.teamspaceTimeTrackingEnabled
|
||||||
{ label: "TS-Zeit Monat", value: formatMinutes(stats.monthTotals?.crmBilledMinutes ?? 0), icon: LayoutDashboard },
|
? [
|
||||||
{ label: "Abgerechnet - TS", value: `${delta >= 0 ? "+" : "-"}${formatMinutes(Math.abs(delta))}`, icon: ArrowUp, tone: delta === 0 ? "normal" : delta > 0 ? "warning" : "good" }
|
{ label: "TS-Zeit Monat", value: formatMinutes(stats.monthTotals?.crmBilledMinutes ?? 0), icon: LayoutDashboard },
|
||||||
]}
|
{ label: "Abgerechnet - TS", value: `${delta >= 0 ? "+" : "-"}${formatMinutes(Math.abs(delta))}`, icon: ArrowUp, tone: delta === 0 ? "normal" : delta > 0 ? "warning" : "good" }
|
||||||
/>
|
]
|
||||||
);
|
: [
|
||||||
}
|
{ label: "TS-Erfassung", value: "Aus", icon: LayoutDashboard, tone: "normal" },
|
||||||
|
{ label: "Monatsprüfung", value: "Status", icon: CheckCircle2, tone: "good" }
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (widget.type === "openRatings") {
|
if (widget.type === "openRatings") {
|
||||||
const filterValues = readFilterValues(widget.config, ["today", "older3", "fixedBilling", "manualBilling"]);
|
const filterValues = readFilterValues(widget.config, ["today", "older3", "fixedBilling", "manualBilling"]);
|
||||||
@@ -1684,6 +1706,7 @@ type DayControlListItem = {
|
|||||||
|
|
||||||
function DayControlWidget({ stats, projects, todos, onNavigate }: WidgetContentProps) {
|
function DayControlWidget({ stats, projects, todos, onNavigate }: WidgetContentProps) {
|
||||||
const today = currentDay();
|
const today = currentDay();
|
||||||
|
const teamspaceTimeTrackingEnabled = stats.teamspaceTimeTrackingEnabled;
|
||||||
const overdueTodos = stableSort(
|
const overdueTodos = stableSort(
|
||||||
todos.filter((todo) => todo.status === "open" && todo.ends_on && todo.ends_on < today),
|
todos.filter((todo) => todo.status === "open" && todo.ends_on && todo.ends_on < today),
|
||||||
(left, right) => compareOptionalDate(left.ends_on, right.ends_on, "asc") || compareText(left.title, right.title)
|
(left, right) => compareOptionalDate(left.ends_on, right.ends_on, "asc") || compareText(left.title, right.title)
|
||||||
@@ -1701,12 +1724,21 @@ function DayControlWidget({ stats, projects, todos, onNavigate }: WidgetContentP
|
|||||||
);
|
);
|
||||||
const todayTickets = stableSort(stats.todayTickets, (left, right) => compareText(left.ticket_number, right.ticket_number));
|
const todayTickets = stableSort(stats.todayTickets, (left, right) => compareText(left.ticket_number, right.ticket_number));
|
||||||
const todayOpenRatings = stableSort(stats.todayOpenRatings, (left, right) => compareOptionalDate(left.started_at, right.started_at, "asc") || compareText(left.ticket_number, right.ticket_number));
|
const todayOpenRatings = stableSort(stats.todayOpenRatings, (left, right) => compareOptionalDate(left.started_at, right.started_at, "asc") || compareText(left.ticket_number, right.ticket_number));
|
||||||
const teamspaceReviewTickets = todayTickets.filter((ticket) => ticket.open_count === 0 && teamspaceDeltaMinutes(ticket) > 0);
|
const teamspaceReviewTickets = teamspaceTimeTrackingEnabled ? todayTickets.filter((ticket) => ticket.open_count === 0 && teamspaceDeltaMinutes(ticket) > 0) : [];
|
||||||
const afterworkTickets = todayTickets.filter((ticket) => ticket.open_count > 0 || teamspaceDeltaMinutes(ticket) > 0 || (ticket.total_minutes === 0 && (ticket.billable_count > 0 || ticket.non_billable_count > 0)));
|
const afterworkTickets = todayTickets.filter((ticket) =>
|
||||||
|
ticket.open_count > 0
|
||||||
|
|| (teamspaceTimeTrackingEnabled && teamspaceDeltaMinutes(ticket) > 0)
|
||||||
|
|| (ticket.total_minutes === 0 && (ticket.billable_count > 0 || ticket.non_billable_count > 0))
|
||||||
|
);
|
||||||
const budgetAttention = stableSort(
|
const budgetAttention = stableSort(
|
||||||
projects.filter((project) => {
|
projects.filter((project) => {
|
||||||
const percent = projectConsumptionPercent(project);
|
const percent = projectConsumptionPercent(project);
|
||||||
return !project.closed_at && (Boolean(percent && percent >= 90) || Number(project.unassigned_teamspace_session_count ?? 0) > 0 || Boolean(project.close_review_required));
|
return !project.closed_at && (
|
||||||
|
Boolean(percent && percent >= 90)
|
||||||
|
|| Number(project.unassigned_teamspace_session_count ?? 0) > 0
|
||||||
|
|| Number(project.unassigned_teamspace_subentry_count ?? 0) > 0
|
||||||
|
|| Boolean(project.close_review_required)
|
||||||
|
);
|
||||||
}),
|
}),
|
||||||
(left, right) => (compareNumber(projectConsumptionPercent(right) ?? 0, projectConsumptionPercent(left) ?? 0)) || compareText(left.ticket_number, right.ticket_number)
|
(left, right) => (compareNumber(projectConsumptionPercent(right) ?? 0, projectConsumptionPercent(left) ?? 0)) || compareText(left.ticket_number, right.ticket_number)
|
||||||
);
|
);
|
||||||
@@ -1748,12 +1780,14 @@ function DayControlWidget({ stats, projects, todos, onNavigate }: WidgetContentP
|
|||||||
done: waitingTickets.length === 0,
|
done: waitingTickets.length === 0,
|
||||||
action: waitingTickets.length === 0 ? "Keine fälligen Wiedervorlagen." : `${waitingTickets.length} Wiedervorlage(n) bearbeiten oder neues Datum setzen.`
|
action: waitingTickets.length === 0 ? "Keine fälligen Wiedervorlagen." : `${waitingTickets.length} Wiedervorlage(n) bearbeiten oder neues Datum setzen.`
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "teamspace",
|
key: "teamspace",
|
||||||
label: "Teamspace geprüft",
|
label: teamspaceTimeTrackingEnabled ? "Teamspace geprüft" : "TS-Erfassung aus",
|
||||||
done: teamspaceReviewTickets.length === 0,
|
done: teamspaceReviewTickets.length === 0,
|
||||||
action: teamspaceReviewTickets.length === 0 ? "Teamspace passt für heute." : `${teamspaceReviewTickets.length} Teamspace-Abgleich(e) korrigieren.`
|
action: teamspaceTimeTrackingEnabled
|
||||||
},
|
? teamspaceReviewTickets.length === 0 ? "Teamspace passt für heute." : `${teamspaceReviewTickets.length} Teamspace-Abgleich(e) korrigieren.`
|
||||||
|
: "Monatsprüfung läuft über Session-Status."
|
||||||
|
},
|
||||||
{
|
{
|
||||||
key: "budget",
|
key: "budget",
|
||||||
label: "Budget geprüft",
|
label: "Budget geprüft",
|
||||||
@@ -1791,8 +1825,8 @@ function DayControlWidget({ stats, projects, todos, onNavigate }: WidgetContentP
|
|||||||
key: `teamspace-review-${ticket.id}`,
|
key: `teamspace-review-${ticket.id}`,
|
||||||
tone: "info" as const,
|
tone: "info" as const,
|
||||||
title: ticket.title || ticket.ticket_number,
|
title: ticket.title || ticket.ticket_number,
|
||||||
detail: `${ticket.ticket_number} · TT ${formatMinutes(ticket.total_minutes)} · Teamspace ${formatMinutes(ticket.crm_billed_minutes ?? 0)}`,
|
detail: `${ticket.ticket_number} · TT ${formatMinutes(ticket.total_minutes)} · Teamspace ${formatMinutes(ticket.crm_billed_minutes ?? 0)}`,
|
||||||
note: dayControlTodaySessionReason(ticket),
|
note: dayControlTodaySessionReason(ticket, teamspaceTimeTrackingEnabled),
|
||||||
onClick: () => onNavigate(`/tickets/${ticket.id}?month=${currentMonth()}`)
|
onClick: () => onNavigate(`/tickets/${ticket.id}?month=${currentMonth()}`)
|
||||||
}))
|
}))
|
||||||
].slice(0, 8);
|
].slice(0, 8);
|
||||||
@@ -1836,8 +1870,8 @@ function DayControlWidget({ stats, projects, todos, onNavigate }: WidgetContentP
|
|||||||
items={afterworkTickets.slice(0, 4).map((ticket) => ({
|
items={afterworkTickets.slice(0, 4).map((ticket) => ({
|
||||||
key: `today-ticket-${ticket.id}`,
|
key: `today-ticket-${ticket.id}`,
|
||||||
title: ticket.title || ticket.ticket_number,
|
title: ticket.title || ticket.ticket_number,
|
||||||
detail: `${ticket.ticket_number} · ${formatMinutes(ticket.total_minutes + ticket.non_billable_minutes + ticket.open_minutes)} · ${ticket.session_count} Session(s)`,
|
detail: `${ticket.ticket_number} · ${formatMinutes(ticket.total_minutes + ticket.non_billable_minutes + ticket.open_minutes)} · ${ticket.session_count} Session(s)`,
|
||||||
note: dayControlTodaySessionReason(ticket),
|
note: dayControlTodaySessionReason(ticket, teamspaceTimeTrackingEnabled),
|
||||||
onClick: () => onNavigate(`/tickets/${ticket.id}?month=${currentMonth()}`)
|
onClick: () => onNavigate(`/tickets/${ticket.id}?month=${currentMonth()}`)
|
||||||
}))}
|
}))}
|
||||||
/>
|
/>
|
||||||
@@ -1860,17 +1894,20 @@ function DayControlWidget({ stats, projects, todos, onNavigate }: WidgetContentP
|
|||||||
items={budgetAttention.slice(0, 4).map((project) => {
|
items={budgetAttention.slice(0, 4).map((project) => {
|
||||||
const percent = projectConsumptionPercent(project);
|
const percent = projectConsumptionPercent(project);
|
||||||
const hasUnassignedSessions = Number(project.unassigned_teamspace_session_count ?? 0) > 0;
|
const hasUnassignedSessions = Number(project.unassigned_teamspace_session_count ?? 0) > 0;
|
||||||
|
const hasUnassignedSubentries = Number(project.unassigned_teamspace_subentry_count ?? 0) > 0;
|
||||||
const detail = project.close_review_required
|
const detail = project.close_review_required
|
||||||
? project.zammad_merged_from_ticket_id || project.zammad_state_id !== "4" ? "Merge prüfen" : "Abschluss prüfen"
|
? project.zammad_merged_from_ticket_id || project.zammad_state_id !== "4" ? "Merge prüfen" : "Abschluss prüfen"
|
||||||
: hasUnassignedSessions
|
: hasUnassignedSessions
|
||||||
? `${project.unassigned_teamspace_session_count} Session(s) ohne TS-Zuordnung`
|
? `${project.unassigned_teamspace_session_count} Session(s) ohne TS-Zuordnung`
|
||||||
|
: hasUnassignedSubentries
|
||||||
|
? `${project.unassigned_teamspace_subentry_count} Session(s) ohne Unterposition`
|
||||||
: `${percent ?? 0}% Kontingent verbraucht`;
|
: `${percent ?? 0}% Kontingent verbraucht`;
|
||||||
const note = project.close_review_required
|
const note = project.close_review_required
|
||||||
? project.zammad_merged_from_ticket_id || project.zammad_state_id !== "4"
|
? project.zammad_merged_from_ticket_id || project.zammad_state_id !== "4"
|
||||||
? "Lokale Projektdaten aus dem alten Ticket prüfen und in Zammad nachtragen."
|
? "Lokale Projektdaten aus dem alten Ticket prüfen und in Zammad nachtragen."
|
||||||
: "Abschluss in TT/Teamspace prüfen und ggf. Umbuchungen klären."
|
: "Abschluss in TT/Teamspace prüfen und ggf. Umbuchungen klären."
|
||||||
: hasUnassignedSessions
|
: hasUnassignedSessions || hasUnassignedSubentries
|
||||||
? "Sessions einem Teamspace-Projekt zuordnen."
|
? "Sessions über die Session-Bearbeitung zuordnen."
|
||||||
: "Kontingent kritisch: Budget, Verbrauch und weitere Arbeit prüfen.";
|
: "Kontingent kritisch: Budget, Verbrauch und weitere Arbeit prüfen.";
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -1888,18 +1925,18 @@ function DayControlWidget({ stats, projects, todos, onNavigate }: WidgetContentP
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function dayControlTodaySessionReason(ticket: PeriodOverview["tickets"][number]) {
|
function dayControlTodaySessionReason(ticket: PeriodOverview["tickets"][number], teamspaceTimeTrackingEnabled = true) {
|
||||||
if (ticket.open_count > 0) {
|
if (ticket.open_count > 0) {
|
||||||
return `${ticket.open_count} offene Bewertung(en): Bewertungsstatus setzen.`;
|
return `${ticket.open_count} offene Bewertung(en): Bewertungsstatus setzen.`;
|
||||||
}
|
}
|
||||||
|
|
||||||
const crmMinutes = ticket.crm_billed_minutes ?? 0;
|
const crmMinutes = ticket.crm_billed_minutes ?? 0;
|
||||||
const deltaMinutes = teamspaceDeltaMinutes(ticket);
|
const deltaMinutes = teamspaceDeltaMinutes(ticket);
|
||||||
if (ticket.total_minutes > 0 && deltaMinutes > 0) {
|
if (teamspaceTimeTrackingEnabled && ticket.total_minutes > 0 && deltaMinutes > 0) {
|
||||||
return `Teamspace nachtragen: ${formatMinutes(deltaMinutes)} fehlen.`;
|
return `Teamspace nachtragen: ${formatMinutes(deltaMinutes)} fehlen.`;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (ticket.total_minutes > 0 && deltaMinutes === 0) {
|
if (teamspaceTimeTrackingEnabled && ticket.total_minutes > 0 && deltaMinutes === 0) {
|
||||||
return `Teamspace ok: ${formatMinutes(crmMinutes)} erfasst.`;
|
return `Teamspace ok: ${formatMinutes(crmMinutes)} erfasst.`;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2324,7 +2361,8 @@ function ProjectBudgetWidget({ widget, editing, projects, stats, onNavigate, onU
|
|||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
{visible.map((project) => {
|
{visible.map((project) => {
|
||||||
const consumptionMinutes = project.consumption_minutes ?? 0;
|
const consumptionMinutes = project.consumption_minutes ?? 0;
|
||||||
const budgetMinutes = project.hour_budget_minutes ?? 0;
|
const budgetExcluded = projectBudgetExcluded(project);
|
||||||
|
const budgetMinutes = budgetExcluded ? 0 : project.hour_budget_minutes ?? 0;
|
||||||
const percent = projectConsumptionPercent(project);
|
const percent = projectConsumptionPercent(project);
|
||||||
const budgetedEntries = budgetedProjectTeamspaceEntries(project);
|
const budgetedEntries = budgetedProjectTeamspaceEntries(project);
|
||||||
const showBudgetedTeamspaceEntries = budgetedEntries.length > 1;
|
const showBudgetedTeamspaceEntries = budgetedEntries.length > 1;
|
||||||
@@ -2333,7 +2371,7 @@ function ProjectBudgetWidget({ widget, editing, projects, stats, onNavigate, onU
|
|||||||
<button key={project.id} type="button" className="block w-full min-w-0 rounded-md px-2 py-1.5 text-left hover:bg-muted" onClick={() => onNavigate(`/tickets/${project.ticket_id}?month=${currentMonth()}`)}>
|
<button key={project.id} type="button" className="block w-full min-w-0 rounded-md px-2 py-1.5 text-left hover:bg-muted" onClick={() => onNavigate(`/tickets/${project.ticket_id}?month=${currentMonth()}`)}>
|
||||||
<span className="block truncate text-xs font-medium">{project.title || project.ticket_number}</span>
|
<span className="block truncate text-xs font-medium">{project.title || project.ticket_number}</span>
|
||||||
<span className="block truncate text-[11px] text-muted-foreground">
|
<span className="block truncate text-[11px] text-muted-foreground">
|
||||||
{project.ticket_number} · {showBudgetedTeamspaceEntries ? `${budgetedEntries.length} Budgets · ` : ""}{budgetMinutes > 0 ? `${decimalHours(consumptionMinutes)} / ${decimalHours(budgetMinutes)} · ${percent ?? 0}%` : "ohne Kontingent"}
|
{project.ticket_number} · {budgetExcluded ? `${decimalHours(consumptionMinutes)} · Budgetprüfung ausgenommen` : `${showBudgetedTeamspaceEntries ? `${budgetedEntries.length} Budgets · ` : ""}${budgetMinutes > 0 ? `${decimalHours(consumptionMinutes)} / ${decimalHours(budgetMinutes)} · ${percent ?? 0}%` : "ohne Kontingent"}`}
|
||||||
</span>
|
</span>
|
||||||
<span className="mt-1 flex flex-wrap gap-1">
|
<span className="mt-1 flex flex-wrap gap-1">
|
||||||
<ZammadStateBadge stateId={project.zammad_state_id} />
|
<ZammadStateBadge stateId={project.zammad_state_id} />
|
||||||
|
|||||||
@@ -0,0 +1,179 @@
|
|||||||
|
import { useMemo, useState } from "react";
|
||||||
|
import type { Dispatch, SetStateAction } from "react";
|
||||||
|
import { FolderGit, Pause, Play, RotateCcw, Square, Timer } from "lucide-react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { deleteActiveTimer, updateActiveTimer } from "../api";
|
||||||
|
import { formatDateTime, formatTimer } from "../format";
|
||||||
|
import { activeElapsedMs, mergeTimerActivityDrafts, removeTimerActivityDraft, type TimerEntry } from "../timers";
|
||||||
|
import type { AuthUser } from "../types";
|
||||||
|
|
||||||
|
type DashboardTimersPageProps = {
|
||||||
|
timers: TimerEntry[];
|
||||||
|
setTimers: Dispatch<SetStateAction<TimerEntry[]>>;
|
||||||
|
selectedTimerId: string | null;
|
||||||
|
setSelectedTimerId: Dispatch<SetStateAction<string | null>>;
|
||||||
|
tick: number;
|
||||||
|
currentUser: AuthUser;
|
||||||
|
onFinishTimer: (timerId: string) => void;
|
||||||
|
onOpenActiveTimer: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function DashboardTimersPage({
|
||||||
|
timers,
|
||||||
|
setTimers,
|
||||||
|
selectedTimerId,
|
||||||
|
setSelectedTimerId,
|
||||||
|
tick,
|
||||||
|
currentUser,
|
||||||
|
onFinishTimer,
|
||||||
|
onOpenActiveTimer
|
||||||
|
}: DashboardTimersPageProps) {
|
||||||
|
const [busyTimerId, setBusyTimerId] = useState<string | null>(null);
|
||||||
|
const sortedTimers = useMemo(
|
||||||
|
() =>
|
||||||
|
[...timers].sort((first, second) => {
|
||||||
|
if (first.phase === second.phase) {
|
||||||
|
return first.ticketNumber.localeCompare(second.ticketNumber);
|
||||||
|
}
|
||||||
|
|
||||||
|
return first.phase === "running" ? -1 : 1;
|
||||||
|
}),
|
||||||
|
[timers]
|
||||||
|
);
|
||||||
|
const runningTimer = timers.find((timer) => timer.phase === "running") ?? null;
|
||||||
|
|
||||||
|
async function setTimerPhase(timer: TimerEntry, phase: "running" | "paused") {
|
||||||
|
setBusyTimerId(timer.id);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await updateActiveTimer(timer.id, { phase });
|
||||||
|
setTimers(mergeTimerActivityDrafts(currentUser.id, result.timers));
|
||||||
|
setSelectedTimerId(timer.id);
|
||||||
|
} catch (error) {
|
||||||
|
toast.error("Timer konnte nicht aktualisiert werden", {
|
||||||
|
description: error instanceof Error ? error.message : "Unbekannter Fehler"
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setBusyTimerId(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function discardTimer(timer: TimerEntry) {
|
||||||
|
const confirmed = window.confirm(`Timer ${timer.ticketNumber} verwerfen? Alle bisherigen Änderungen und Notizen an diesem Timer gehen verloren.`);
|
||||||
|
|
||||||
|
if (!confirmed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setBusyTimerId(timer.id);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await deleteActiveTimer(timer.id);
|
||||||
|
removeTimerActivityDraft(currentUser.id, timer.id);
|
||||||
|
setTimers(mergeTimerActivityDrafts(currentUser.id, result.timers));
|
||||||
|
toast.success("Timer verworfen", {
|
||||||
|
description: timer.ticketNumber
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
toast.error("Timer konnte nicht verworfen werden", {
|
||||||
|
description: error instanceof Error ? error.message : "Unbekannter Fehler"
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setBusyTimerId(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex flex-col gap-2 sm:flex-row sm:items-end sm:justify-between">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-xl font-semibold tracking-normal sm:text-2xl">Timer-Übersicht</h2>
|
||||||
|
<p className="text-sm text-muted-foreground">Aktiven Timer und pausierte Timer zentral umschalten.</p>
|
||||||
|
</div>
|
||||||
|
{runningTimer ? <Badge variant="success">Aktiv: {runningTimer.ticketNumber}</Badge> : timers.length > 0 ? <Badge variant="warning">Alle pausiert</Badge> : <Badge variant="info">Kein Timer</Badge>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-3">
|
||||||
|
{sortedTimers.map((timer) => {
|
||||||
|
const isRunning = timer.phase === "running";
|
||||||
|
const isSelected = selectedTimerId === timer.id;
|
||||||
|
const elapsedSeconds = Math.floor(activeElapsedMs(timer, tick) / 1000);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card key={timer.id} className={isSelected ? "border-primary/60" : ""}>
|
||||||
|
<CardContent className="grid gap-3 p-4 lg:grid-cols-[minmax(0,1fr)_180px_auto] lg:items-center">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={isRunning ? "min-w-0 text-left underline-offset-4 hover:underline" : "min-w-0 cursor-default text-left"}
|
||||||
|
onClick={() => {
|
||||||
|
setSelectedTimerId(timer.id);
|
||||||
|
|
||||||
|
if (isRunning) {
|
||||||
|
onOpenActiveTimer();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="flex min-w-0 flex-wrap items-center gap-2">
|
||||||
|
<span className={isRunning ? "size-2 rounded-full bg-emerald-500" : "size-2 rounded-full bg-muted-foreground/50"} />
|
||||||
|
<span className="font-semibold">{timer.ticketNumber}</span>
|
||||||
|
<Badge variant={isRunning ? "success" : "secondary"}>{isRunning ? "aktiv" : "pausiert"}</Badge>
|
||||||
|
{timer.workType ? <Badge variant="outline">{timer.workType === "support" ? "Support" : "Consulting"}</Badge> : null}
|
||||||
|
</div>
|
||||||
|
<p className="mt-1 truncate text-sm text-muted-foreground">
|
||||||
|
{timer.isProject ? <FolderGit className="mr-1 inline size-3.5" /> : null}
|
||||||
|
{timer.organizationName ?? timer.projectName ?? "Ticketdaten noch nicht geladen"}
|
||||||
|
</p>
|
||||||
|
{timer.activity.trim() ? <p className="mt-1 line-clamp-2 text-sm">{timer.activity}</p> : null}
|
||||||
|
<p className="mt-1 text-xs text-muted-foreground">Gestartet: {formatDateTime(new Date(timer.startedAt).toISOString())}</p>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div className="rounded-md border bg-muted/20 px-3 py-2">
|
||||||
|
<p className="text-xs text-muted-foreground">Gemessen</p>
|
||||||
|
<p className="font-mono text-2xl font-semibold tracking-normal">{formatTimer(elapsedSeconds)}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap gap-2 lg:justify-end">
|
||||||
|
{isRunning ? (
|
||||||
|
<Button type="button" variant="secondary" disabled={busyTimerId === timer.id} onClick={() => void setTimerPhase(timer, "paused")}>
|
||||||
|
<Pause className="size-4" />
|
||||||
|
Pausieren
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<Button type="button" disabled={busyTimerId === timer.id} onClick={() => void setTimerPhase(timer, "running")}>
|
||||||
|
<Play className="size-4" />
|
||||||
|
Aktivieren
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<Button type="button" variant="destructive" onClick={() => onFinishTimer(timer.id)}>
|
||||||
|
<Square className="size-4" />
|
||||||
|
Stoppen
|
||||||
|
</Button>
|
||||||
|
<Button type="button" variant="ghost" disabled={busyTimerId === timer.id} onClick={() => void discardTimer(timer)}>
|
||||||
|
<RotateCcw className="size-4" />
|
||||||
|
Verwerfen
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
{sortedTimers.length === 0 ? (
|
||||||
|
<Card>
|
||||||
|
<CardContent className="grid place-items-center gap-2 p-8 text-center">
|
||||||
|
<Timer className="size-8 text-muted-foreground" />
|
||||||
|
<div>
|
||||||
|
<p className="font-medium">Keine Timer vorbereitet</p>
|
||||||
|
<p className="text-sm text-muted-foreground">Starte oben einen Timer oder nutze den Schnellstart in der Kopfzeile.</p>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -7,12 +7,10 @@ import {
|
|||||||
FileWarning,
|
FileWarning,
|
||||||
Lock,
|
Lock,
|
||||||
LockOpen,
|
LockOpen,
|
||||||
Sparkles,
|
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { useEffect, useMemo, useRef, useState } from "react";
|
import { useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
|
||||||
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion";
|
|
||||||
import { Alert } from "@/components/ui/alert";
|
import { Alert } from "@/components/ui/alert";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
@@ -21,16 +19,15 @@ import { Checkbox } from "@/components/ui/checkbox";
|
|||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { CopyTicketButton } from "@/components/CopyTicketButton";
|
import { CopyTicketButton } from "@/components/CopyTicketButton";
|
||||||
import { HelpLink } from "@/components/HelpLink";
|
import { HelpLink } from "@/components/HelpLink";
|
||||||
import { acknowledgeMissingTicketDayBilling, closePeriod, getStatisticsOverview, reopenPeriod } from "../api";
|
import { closePeriod, getStatisticsOverview, reopenPeriod } from "../api";
|
||||||
import { currentMonth, currentWeek, formatDate, formatDateTime, formatMinutes } from "../format";
|
import { currentMonth, currentWeek, formatDateTime, formatMinutes } from "../format";
|
||||||
import type { StatisticsCrmDay, StatisticsOpenSession, StatisticsOverview, StatisticsTeamspaceDay } from "../types";
|
import type { AuthUser, StatisticsCrmDay, StatisticsOpenSession, StatisticsOverview, StatisticsTeamspaceDay, StatisticsTeamspaceMonthMismatch } from "../types";
|
||||||
|
|
||||||
type MonthlyClosePageProps = {
|
type MonthlyClosePageProps = {
|
||||||
|
currentUser: AuthUser;
|
||||||
onNavigate: (to: string) => void;
|
onNavigate: (to: string) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
const trackedTextClass = "text-sky-700 dark:text-sky-300";
|
|
||||||
const teamspaceTextClass = "text-emerald-700 dark:text-emerald-300";
|
|
||||||
const showAcknowledgedMissingCrmStorageKey = "tickettracker.monthlyClose.showAcknowledgedMissingCrm";
|
const showAcknowledgedMissingCrmStorageKey = "tickettracker.monthlyClose.showAcknowledgedMissingCrm";
|
||||||
const monthlyCloseViewModeStorageKey = "tickettracker.monthlyClose.viewMode";
|
const monthlyCloseViewModeStorageKey = "tickettracker.monthlyClose.viewMode";
|
||||||
|
|
||||||
@@ -44,16 +41,16 @@ function readMonthlyCloseViewMode(): MonthlyCloseViewMode {
|
|||||||
return window.localStorage.getItem(monthlyCloseViewModeStorageKey) === "week" ? "week" : "month";
|
return window.localStorage.getItem(monthlyCloseViewModeStorageKey) === "week" ? "week" : "month";
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatSignedMinutes(minutes: number) {
|
function formatMissingTarget(minutes: number, teamspaceTimeTrackingEnabled: boolean) {
|
||||||
if (minutes === 0) {
|
if (minutes === 0) {
|
||||||
return "ausgeglichen";
|
return "ausgeglichen";
|
||||||
}
|
}
|
||||||
|
|
||||||
return `${minutes > 0 ? "+" : "-"}${formatMinutes(Math.abs(minutes))}`;
|
if (minutes > 0) {
|
||||||
}
|
return `${formatMinutes(minutes)} mehr in TS API`;
|
||||||
|
}
|
||||||
|
|
||||||
function formatTeamspaceDelta(minutes: number) {
|
return `${formatMinutes(Math.abs(minutes))} mehr ${teamspaceTimeTrackingEnabled ? "in TT-Teamspace" : "in den abgerechneten Zeiten"}`;
|
||||||
return formatSignedMinutes(minutes);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function StatusCard({ label, value, detail, tone }: { label: string; value: string | number; detail: string; tone?: "ok" | "warn" | "neutral" }) {
|
function StatusCard({ label, value, detail, tone }: { label: string; value: string | number; detail: string; tone?: "ok" | "warn" | "neutral" }) {
|
||||||
@@ -64,60 +61,177 @@ function StatusCard({ label, value, detail, tone }: { label: string; value: stri
|
|||||||
<CardContent className="p-3">
|
<CardContent className="p-3">
|
||||||
<p className="text-xs font-medium text-muted-foreground">{label}</p>
|
<p className="text-xs font-medium text-muted-foreground">{label}</p>
|
||||||
<p className={`text-xl font-semibold tracking-normal ${valueClassName}`}>{value}</p>
|
<p className={`text-xl font-semibold tracking-normal ${valueClassName}`}>{value}</p>
|
||||||
<p className="text-xs text-muted-foreground">{detail}</p>
|
<p className="text-xs text-muted-foreground whitespace-pre-line">{detail}</p>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
type OpenTicketGroup = {
|
type MonthlyCloseTicketDay = {
|
||||||
|
day: string;
|
||||||
|
missingCrm: StatisticsCrmDay | null;
|
||||||
|
ticketMismatch: StatisticsTeamspaceDay | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
type MonthlyCloseTicketGroup = {
|
||||||
ticketId: string;
|
ticketId: string;
|
||||||
ticketNumber: string;
|
ticketNumber: string;
|
||||||
organizationName: string;
|
organizationName: string;
|
||||||
month: string;
|
month: string;
|
||||||
openCount: number;
|
openCount: number;
|
||||||
totalMinutes: number;
|
totalMinutes: number;
|
||||||
|
mismatch: StatisticsTeamspaceMonthMismatch | null;
|
||||||
|
days: MonthlyCloseTicketDay[];
|
||||||
};
|
};
|
||||||
|
|
||||||
function groupOpenSessions(sessions: StatisticsOpenSession[]) {
|
type MutableMonthlyCloseTicketGroup = Omit<MonthlyCloseTicketGroup, "days"> & {
|
||||||
const groups = new Map<string, OpenTicketGroup>();
|
days: Map<string, MonthlyCloseTicketDay>;
|
||||||
|
};
|
||||||
|
|
||||||
for (const session of sessions) {
|
const monthlyCloseTicketGridClassName =
|
||||||
const existing = groups.get(session.ticket_id);
|
"xl:grid-cols-[minmax(10rem,1fr)_minmax(4.25rem,7rem)_minmax(4.25rem,7rem)_minmax(4.25rem,7rem)_minmax(4.25rem,7rem)_minmax(4.25rem,7rem)_11.5rem]";
|
||||||
|
|
||||||
|
function buildMonthlyCloseTicketGroups(
|
||||||
|
sessions: StatisticsOpenSession[],
|
||||||
|
monthMismatches: StatisticsTeamspaceMonthMismatch[],
|
||||||
|
ticketDayMismatches: StatisticsTeamspaceDay[],
|
||||||
|
missingCrmDays: StatisticsCrmDay[],
|
||||||
|
fallbackMonth: string
|
||||||
|
) {
|
||||||
|
const groups = new Map<string, MutableMonthlyCloseTicketGroup>();
|
||||||
|
|
||||||
|
function ensureGroup(ticketId: string, ticketNumber: string, organizationName: string, month: string) {
|
||||||
|
const existing = groups.get(ticketId);
|
||||||
|
|
||||||
if (existing) {
|
if (existing) {
|
||||||
existing.openCount += 1;
|
if (!existing.organizationName && organizationName) {
|
||||||
existing.totalMinutes += session.rounded_minutes;
|
existing.organizationName = organizationName;
|
||||||
|
}
|
||||||
|
|
||||||
|
return existing;
|
||||||
|
}
|
||||||
|
|
||||||
|
const group: MutableMonthlyCloseTicketGroup = {
|
||||||
|
ticketId,
|
||||||
|
ticketNumber,
|
||||||
|
organizationName,
|
||||||
|
month,
|
||||||
|
openCount: 0,
|
||||||
|
totalMinutes: 0,
|
||||||
|
mismatch: null,
|
||||||
|
days: new Map()
|
||||||
|
};
|
||||||
|
groups.set(ticketId, group);
|
||||||
|
return group;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const session of sessions) {
|
||||||
|
const group = ensureGroup(session.ticket_id, session.ticket_number, session.organization_name, session.started_at.slice(0, 7));
|
||||||
|
group.openCount += 1;
|
||||||
|
group.totalMinutes += session.rounded_minutes;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const mismatch of monthMismatches) {
|
||||||
|
ensureGroup(mismatch.ticket_id, mismatch.ticket_number, mismatch.organization_name, fallbackMonth).mismatch = mismatch;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const mismatch of ticketDayMismatches) {
|
||||||
|
if (!mismatch.ticket_id || !mismatch.ticket_number) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
groups.set(session.ticket_id, {
|
const group = ensureGroup(mismatch.ticket_id, mismatch.ticket_number, mismatch.organization_name, mismatch.day.slice(0, 7));
|
||||||
ticketId: session.ticket_id,
|
const day = group.days.get(mismatch.day) ?? { day: mismatch.day, missingCrm: null, ticketMismatch: null };
|
||||||
ticketNumber: session.ticket_number,
|
day.ticketMismatch = mismatch;
|
||||||
organizationName: session.organization_name,
|
group.days.set(mismatch.day, day);
|
||||||
month: session.started_at.slice(0, 7),
|
|
||||||
openCount: 1,
|
|
||||||
totalMinutes: session.rounded_minutes
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return Array.from(groups.values()).sort((left, right) => left.ticketNumber.localeCompare(right.ticketNumber, "de", { numeric: true, sensitivity: "base" }));
|
for (const missingCrm of missingCrmDays) {
|
||||||
|
const group = ensureGroup(missingCrm.ticket_id, missingCrm.ticket_number, missingCrm.organization_name, missingCrm.day.slice(0, 7));
|
||||||
|
const day = group.days.get(missingCrm.day) ?? { day: missingCrm.day, missingCrm: null, ticketMismatch: null };
|
||||||
|
day.missingCrm = missingCrm;
|
||||||
|
group.days.set(missingCrm.day, day);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Array.from(groups.values())
|
||||||
|
.map((group): MonthlyCloseTicketGroup => ({
|
||||||
|
...group,
|
||||||
|
days: Array.from(group.days.values()).sort((left, right) => left.day.localeCompare(right.day))
|
||||||
|
}))
|
||||||
|
.sort((left, right) => left.ticketNumber.localeCompare(right.ticketNumber, "de", { numeric: true, sensitivity: "base" }));
|
||||||
}
|
}
|
||||||
|
|
||||||
function OpenTicketItem({ group, onNavigate }: { group: OpenTicketGroup; onNavigate: (to: string) => void }) {
|
function MonthlyCloseTicketItem({
|
||||||
|
group,
|
||||||
|
onNavigate
|
||||||
|
}: {
|
||||||
|
group: MonthlyCloseTicketGroup;
|
||||||
|
onNavigate: (to: string) => void;
|
||||||
|
}) {
|
||||||
|
const missingDayCount = group.days.filter((day) => day.missingCrm && !day.missingCrm.acknowledged).length;
|
||||||
|
const acknowledgedDayCount = group.days.filter((day) => day.missingCrm?.acknowledged).length;
|
||||||
|
const mismatchDayCount = group.days.filter((day) => day.ticketMismatch && day.ticketMismatch.delta_minutes !== 0).length;
|
||||||
|
const invalidTeamspaceValueDayCount = group.days.filter((day) => day.ticketMismatch?.tt_value_without_billed_session).length;
|
||||||
|
const monthDeltaMinutes = group.mismatch?.teamspace_month_delta_minutes ?? 0;
|
||||||
|
const monthDeltaLabel = monthDeltaMinutes > 0
|
||||||
|
? `TS +${formatMinutes(monthDeltaMinutes)}`
|
||||||
|
: `TT +${formatMinutes(Math.abs(monthDeltaMinutes))}`;
|
||||||
|
const metricClassName = (visible: boolean) => `${visible ? "flex" : "hidden xl:flex"} min-h-7 items-center justify-between gap-2 rounded-md bg-muted/30 px-2 xl:min-h-5 xl:justify-center xl:rounded-none xl:bg-transparent xl:px-0`;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="rounded-md border bg-background px-3 py-2">
|
<div className={`grid grid-cols-1 items-center gap-1.5 rounded-md border bg-background px-3 py-2.5 sm:grid-cols-2 xl:gap-2 ${monthlyCloseTicketGridClassName}`}>
|
||||||
<div className="flex items-start justify-between gap-3">
|
<div className="min-w-0 sm:col-span-2 xl:col-span-1">
|
||||||
<div className="min-w-0 space-y-1">
|
<p className="font-medium">{group.ticketNumber}</p>
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
<p className="truncate text-xs text-muted-foreground">{group.organizationName}</p>
|
||||||
<span className="font-medium">{group.ticketNumber}</span>
|
</div>
|
||||||
<CopyTicketButton ticketNumber={group.ticketNumber} />
|
<div className={metricClassName(group.openCount > 0)}>
|
||||||
<Badge variant="warning">{group.openCount} offen</Badge>
|
<span className="text-[10px] font-medium uppercase tracking-wide text-muted-foreground xl:hidden">Offene Bewertungen</span>
|
||||||
</div>
|
{group.openCount > 0 ? (
|
||||||
<p className="truncate text-sm text-muted-foreground">{group.organizationName}</p>
|
<Badge className="bg-amber-500/12 text-amber-800 dark:bg-amber-500/20 dark:text-amber-200" title={`${group.openCount} offene Bewertung(en) · ${formatMinutes(group.totalMinutes)}`}>
|
||||||
<p className={`text-xs font-medium ${trackedTextClass}`}>{formatMinutes(group.totalMinutes)} offen zu bewerten</p>
|
{group.openCount}
|
||||||
</div>
|
</Badge>
|
||||||
<Button className="h-8 shrink-0" variant="secondary" size="sm" onClick={() => onNavigate(`/tickets/${group.ticketId}?month=${group.month}`)}>
|
) : null}
|
||||||
|
</div>
|
||||||
|
<div className={metricClassName(Boolean(group.mismatch))}>
|
||||||
|
<span className="text-[10px] font-medium uppercase tracking-wide text-muted-foreground xl:hidden">Monatsabweichung</span>
|
||||||
|
{group.mismatch ? (
|
||||||
|
<Badge className="bg-orange-500/12 text-orange-800 dark:bg-orange-500/20 dark:text-orange-200" title="Monatsabweichung zwischen TT-Teamspace und TS-API">
|
||||||
|
{monthDeltaLabel}
|
||||||
|
</Badge>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<div className={metricClassName(missingDayCount > 0 || acknowledgedDayCount > 0)}>
|
||||||
|
<span className="text-[10px] font-medium uppercase tracking-wide text-muted-foreground xl:hidden">Fehlender TT-Wert</span>
|
||||||
|
<span className="flex flex-wrap items-center justify-end gap-1 xl:flex-col xl:justify-center">
|
||||||
|
{missingDayCount > 0 ? (
|
||||||
|
<Badge className="bg-sky-500/12 text-sky-800 dark:bg-sky-500/20 dark:text-sky-200" title={`${missingDayCount} Tag(e) ohne TT-Teamspace-Wert`}>
|
||||||
|
TT {missingDayCount}
|
||||||
|
</Badge>
|
||||||
|
) : null}
|
||||||
|
{acknowledgedDayCount > 0 ? (
|
||||||
|
<Badge className="bg-emerald-500/12 text-emerald-800 dark:bg-emerald-500/20 dark:text-emerald-200" title={`${acknowledgedDayCount} zur Kenntnis genommene(r) Tag(e)`}>
|
||||||
|
✓ {acknowledgedDayCount}
|
||||||
|
</Badge>
|
||||||
|
) : null}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className={metricClassName(mismatchDayCount > 0)}>
|
||||||
|
<span className="text-[10px] font-medium uppercase tracking-wide text-muted-foreground xl:hidden">Tagesabweichung</span>
|
||||||
|
{mismatchDayCount > 0 ? (
|
||||||
|
<Badge className="bg-violet-500/12 text-violet-800 dark:bg-violet-500/20 dark:text-violet-200" title={`${mismatchDayCount} Tag(e) mit Abweichung zwischen TT und TS`}>
|
||||||
|
{mismatchDayCount}
|
||||||
|
</Badge>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<div className={metricClassName(invalidTeamspaceValueDayCount > 0)}>
|
||||||
|
<span className="text-[10px] font-medium uppercase tracking-wide text-muted-foreground xl:hidden">TT-Wert ohne Abrechnung</span>
|
||||||
|
{invalidTeamspaceValueDayCount > 0 ? (
|
||||||
|
<Badge variant="destructive" title={`${invalidTeamspaceValueDayCount} Tag(e) mit TT-Teamspace-Wert, aber ohne abgerechnete Session`}>{invalidTeamspaceValueDayCount}</Badge>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<div className="flex shrink-0 items-center justify-end gap-1.5 pt-0.5 sm:col-span-2 xl:col-span-1 xl:pt-0">
|
||||||
|
<CopyTicketButton ticketNumber={group.ticketNumber} />
|
||||||
|
<Button className="h-8" variant="secondary" size="sm" onClick={() => onNavigate(`/tickets/${group.ticketId}?month=${group.month}`)}>
|
||||||
Ticket öffnen
|
Ticket öffnen
|
||||||
<ExternalLink className="size-4" />
|
<ExternalLink className="size-4" />
|
||||||
</Button>
|
</Button>
|
||||||
@@ -126,78 +240,7 @@ function OpenTicketItem({ group, onNavigate }: { group: OpenTicketGroup; onNavig
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function CrmDayItem({
|
export function MonthlyClosePage({ currentUser, onNavigate }: MonthlyClosePageProps) {
|
||||||
item,
|
|
||||||
busy,
|
|
||||||
onSetAcknowledgement,
|
|
||||||
onNavigate
|
|
||||||
}: {
|
|
||||||
item: StatisticsCrmDay;
|
|
||||||
busy: boolean;
|
|
||||||
onSetAcknowledgement: (item: StatisticsCrmDay, acknowledged: boolean) => void;
|
|
||||||
onNavigate: (to: string) => void;
|
|
||||||
}) {
|
|
||||||
const isAcknowledged = Boolean(item.acknowledged);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={`rounded-md border bg-background px-3 py-2 ${isAcknowledged ? "opacity-75" : ""}`}>
|
|
||||||
<div className="flex items-start justify-between gap-3">
|
|
||||||
<div className="min-w-0 space-y-1">
|
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
|
||||||
<span className="font-medium">{item.ticket_number}</span>
|
|
||||||
<CopyTicketButton ticketNumber={item.ticket_number} />
|
|
||||||
<Badge variant="outline">{formatDate(`${item.day}T00:00:00`)}</Badge>
|
|
||||||
<Badge variant={isAcknowledged ? "success" : "warning"}>{isAcknowledged ? "zur Kenntnis" : "offen"}</Badge>
|
|
||||||
</div>
|
|
||||||
<p className="truncate text-sm text-muted-foreground">{item.organization_name}</p>
|
|
||||||
<p className="flex flex-wrap gap-x-3 gap-y-1 text-xs">
|
|
||||||
<span className={trackedTextClass}>Abgerechnet {formatMinutes(item.tracked_minutes)}</span>
|
|
||||||
<span className={teamspaceTextClass}>Teamspace {formatMinutes(item.crm_billed_minutes)}</span>
|
|
||||||
{typeof item.delta_minutes === "number" ? <span className="text-muted-foreground">Differenz {formatTeamspaceDelta(item.delta_minutes)}</span> : null}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="flex shrink-0 flex-wrap justify-end gap-2">
|
|
||||||
<Button className="h-8" variant={isAcknowledged ? "secondary" : "ghost"} size="sm" disabled={busy} onClick={() => onSetAcknowledgement(item, !isAcknowledged)}>
|
|
||||||
{isAcknowledged ? "Zurücknehmen" : "Zur Kenntnis"}
|
|
||||||
</Button>
|
|
||||||
<Button className="h-8" variant="secondary" size="sm" onClick={() => onNavigate(`/tickets/${item.ticket_id}?month=${item.day.slice(0, 7)}`)}>
|
|
||||||
Tag öffnen
|
|
||||||
<ExternalLink className="size-4" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function TeamspaceDayItem({ item, onNavigate }: { item: StatisticsTeamspaceDay; onNavigate: (to: string) => void }) {
|
|
||||||
return (
|
|
||||||
<div className="grid gap-3 rounded-md border bg-background px-3 py-2 lg:grid-cols-[minmax(8rem,0.75fr)_minmax(0,1.65fr)_minmax(8rem,0.6fr)] lg:items-center">
|
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
|
||||||
<Badge variant="outline">{formatDate(`${item.day}T00:00:00`)}</Badge>
|
|
||||||
</div>
|
|
||||||
<div className="min-w-0 space-y-1">
|
|
||||||
<div className="flex min-w-0 flex-wrap items-center gap-2">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="font-medium underline-offset-4 hover:underline"
|
|
||||||
onClick={() => onNavigate(`/tickets/${item.ticket_id}?month=${item.day.slice(0, 7)}`)}
|
|
||||||
>
|
|
||||||
{item.ticket_number}
|
|
||||||
</button>
|
|
||||||
<CopyTicketButton ticketNumber={item.ticket_number} />
|
|
||||||
{item.project_teamspace_project ? <Badge variant="secondary">{item.project_teamspace_project}</Badge> : null}
|
|
||||||
</div>
|
|
||||||
<p className="truncate text-sm text-muted-foreground">{item.organization_name}</p>
|
|
||||||
</div>
|
|
||||||
<div className="text-sm">
|
|
||||||
<Badge variant="success">{formatMinutes(item.crm_billed_minutes)}</Badge>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function MonthlyClosePage({ onNavigate }: MonthlyClosePageProps) {
|
|
||||||
const [viewMode, setViewMode] = useState<MonthlyCloseViewMode>(() => readMonthlyCloseViewMode());
|
const [viewMode, setViewMode] = useState<MonthlyCloseViewMode>(() => readMonthlyCloseViewMode());
|
||||||
const [month, setMonth] = useState(currentMonth());
|
const [month, setMonth] = useState(currentMonth());
|
||||||
const [week, setWeek] = useState(currentWeek());
|
const [week, setWeek] = useState(currentWeek());
|
||||||
@@ -205,7 +248,6 @@ export function MonthlyClosePage({ onNavigate }: MonthlyClosePageProps) {
|
|||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [closing, setClosing] = useState(false);
|
const [closing, setClosing] = useState(false);
|
||||||
const [reopening, setReopening] = useState(false);
|
const [reopening, setReopening] = useState(false);
|
||||||
const [acknowledgingKeys, setAcknowledgingKeys] = useState<Set<string>>(() => new Set());
|
|
||||||
const [showAcknowledgedMissingCrm, setShowAcknowledgedMissingCrm] = useState(false);
|
const [showAcknowledgedMissingCrm, setShowAcknowledgedMissingCrm] = useState(false);
|
||||||
const [showAcknowledgedMissingCrmLoaded, setShowAcknowledgedMissingCrmLoaded] = useState(false);
|
const [showAcknowledgedMissingCrmLoaded, setShowAcknowledgedMissingCrmLoaded] = useState(false);
|
||||||
const loadRequestId = useRef(0);
|
const loadRequestId = useRef(0);
|
||||||
@@ -214,6 +256,7 @@ export function MonthlyClosePage({ onNavigate }: MonthlyClosePageProps) {
|
|||||||
const periodAttentionLabel = viewMode === "month" ? "diesen Monat" : "diese Woche";
|
const periodAttentionLabel = viewMode === "month" ? "diesen Monat" : "diese Woche";
|
||||||
const periodInLabel = viewMode === "month" ? "diesem Monat" : "dieser Woche";
|
const periodInLabel = viewMode === "month" ? "diesem Monat" : "dieser Woche";
|
||||||
const isMonthView = viewMode === "month";
|
const isMonthView = viewMode === "month";
|
||||||
|
const teamspaceTimeTrackingEnabled = currentUser.teamspace_time_tracking_enabled ?? true;
|
||||||
|
|
||||||
async function load() {
|
async function load() {
|
||||||
const requestId = loadRequestId.current + 1;
|
const requestId = loadRequestId.current + 1;
|
||||||
@@ -316,52 +359,47 @@ export function MonthlyClosePage({ onNavigate }: MonthlyClosePageProps) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function setMissingCrmAcknowledgement(item: StatisticsCrmDay, acknowledged: boolean) {
|
|
||||||
const key = `${item.ticket_id}:${item.day}`;
|
|
||||||
setAcknowledgingKeys((current) => new Set(current).add(key));
|
|
||||||
|
|
||||||
try {
|
|
||||||
await acknowledgeMissingTicketDayBilling(item.ticket_id, item.day, acknowledged);
|
|
||||||
toast.success(acknowledged ? "Teamspace-Prüfung abgehakt" : "Kenntnisnahme zurückgenommen", {
|
|
||||||
description: `${item.ticket_number} am ${formatDate(`${item.day}T00:00:00`)}`
|
|
||||||
});
|
|
||||||
await load();
|
|
||||||
} catch (error) {
|
|
||||||
toast.error(acknowledged ? "Konnte nicht abgehakt werden" : "Konnte nicht zurückgenommen werden", {
|
|
||||||
description: error instanceof Error ? error.message : "Unbekannter Fehler"
|
|
||||||
});
|
|
||||||
} finally {
|
|
||||||
setAcknowledgingKeys((current) => {
|
|
||||||
const next = new Set(current);
|
|
||||||
next.delete(key);
|
|
||||||
return next;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const openSessions = stats?.attention.openSessions ?? [];
|
const openSessions = stats?.attention.openSessions ?? [];
|
||||||
const missingCrmDays = stats?.attention.missingCrmDays ?? [];
|
const missingCrmDays = stats?.attention.missingCrmDays ?? [];
|
||||||
const acknowledgedMissingCrmDays = stats?.attention.acknowledgedMissingCrmDays ?? [];
|
const acknowledgedMissingCrmDays = stats?.attention.acknowledgedMissingCrmDays ?? [];
|
||||||
const teamspaceDays = stats?.teamspaceDays ?? [];
|
const teamspaceMonthMismatches = stats?.attention.teamspaceMonthMismatches ?? [];
|
||||||
const openTicketGroups = useMemo(() => groupOpenSessions(openSessions), [openSessions]);
|
const ticketTeamspaceMismatches = stats?.attention.ticketTeamspaceMismatches ?? [];
|
||||||
|
const billedMinutes = stats?.totals.billedMinutes ?? 0;
|
||||||
|
const ttTeamspaceMinutes = stats?.totals.crmBilledMinutes ?? 0;
|
||||||
|
const teamspaceApiBillableMinutes = stats?.totals.teamspaceApiBillableMinutes ?? 0;
|
||||||
|
const teamspaceApiDeltaMinutes = stats?.totals.teamspaceApiDeltaMinutes ?? 0;
|
||||||
const visibleMissingCrmDays = useMemo(() => {
|
const visibleMissingCrmDays = useMemo(() => {
|
||||||
const days = showAcknowledgedMissingCrm ? [...missingCrmDays, ...acknowledgedMissingCrmDays] : [...missingCrmDays];
|
const days = showAcknowledgedMissingCrm ? [...missingCrmDays, ...acknowledgedMissingCrmDays] : [...missingCrmDays];
|
||||||
return days.sort((left, right) => left.day.localeCompare(right.day) || left.ticket_number.localeCompare(right.ticket_number, "de", { numeric: true, sensitivity: "base" }));
|
return days.sort((left, right) => left.day.localeCompare(right.day) || left.ticket_number.localeCompare(right.ticket_number, "de", { numeric: true, sensitivity: "base" }));
|
||||||
}, [acknowledgedMissingCrmDays, missingCrmDays, showAcknowledgedMissingCrm]);
|
}, [acknowledgedMissingCrmDays, missingCrmDays, showAcknowledgedMissingCrm]);
|
||||||
|
const activeReviewTicketGroups = useMemo(
|
||||||
|
() => buildMonthlyCloseTicketGroups(openSessions, teamspaceMonthMismatches, ticketTeamspaceMismatches, missingCrmDays, month),
|
||||||
|
[missingCrmDays, month, openSessions, teamspaceMonthMismatches, ticketTeamspaceMismatches]
|
||||||
|
);
|
||||||
|
const visibleReviewTicketGroups = useMemo(
|
||||||
|
() => buildMonthlyCloseTicketGroups(openSessions, teamspaceMonthMismatches, ticketTeamspaceMismatches, visibleMissingCrmDays, month),
|
||||||
|
[month, openSessions, teamspaceMonthMismatches, ticketTeamspaceMismatches, visibleMissingCrmDays]
|
||||||
|
);
|
||||||
|
const teamspaceAttentionTicketCount = activeReviewTicketGroups.filter((group) => group.mismatch || group.days.length > 0).length;
|
||||||
|
const missingCrmTicketCount = new Set(missingCrmDays.map((item) => item.ticket_id)).size;
|
||||||
|
const ticketTeamspaceDeviationCount = ticketTeamspaceMismatches.filter((item) => item.delta_minutes !== 0).length;
|
||||||
|
const invalidTeamspaceValueDayCount = ticketTeamspaceMismatches.filter((item) => item.tt_value_without_billed_session).length;
|
||||||
const canClose = Boolean(isMonthView && stats && !stats.closed && stats.totals.sessions > 0 && openSessions.length === 0);
|
const canClose = Boolean(isMonthView && stats && !stats.closed && stats.totals.sessions > 0 && openSessions.length === 0);
|
||||||
const checklist = useMemo(
|
const checklist = useMemo(
|
||||||
() => [
|
() => [
|
||||||
{
|
{
|
||||||
label: "Offene Bewertungen",
|
label: "Tickets zu prüfen",
|
||||||
ok: openSessions.length === 0,
|
ok: activeReviewTicketGroups.length === 0,
|
||||||
value: openSessions.length,
|
value: activeReviewTicketGroups.length,
|
||||||
detail: openSessions.length === 0 ? "Keine offenen Bewertungen." : `${openTicketGroups.length} Ticket(s) bearbeiten`
|
detail: activeReviewTicketGroups.length === 0 ? "Keine offenen Prüfpunkte." : `${activeReviewTicketGroups.length} Ticket(s) mit mindestens einem Prüfpunkt`
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "Teamspace geprüft",
|
label: "Teamspace geprüft",
|
||||||
ok: missingCrmDays.length === 0,
|
ok: teamspaceAttentionTicketCount === 0,
|
||||||
value: missingCrmDays.length,
|
value: teamspaceAttentionTicketCount,
|
||||||
detail: missingCrmDays.length === 0 ? "Keine offenen Teamspace-Prüfpunkte." : `${acknowledgedMissingCrmDays.length} zur Kenntnis genommen`
|
detail: teamspaceAttentionTicketCount === 0
|
||||||
|
? "Keine offenen Teamspace-Prüfpunkte."
|
||||||
|
: `${missingCrmDays.length} fehlende Tageswerte, ${ticketTeamspaceDeviationCount} Tagesabweichungen, ${invalidTeamspaceValueDayCount} TS-Werte ohne Abrechnung, ${teamspaceMonthMismatches.length} Monatsabweichungen`
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: isMonthView ? "Monat abschließbar" : "Wochenansicht",
|
label: isMonthView ? "Monat abschließbar" : "Wochenansicht",
|
||||||
@@ -370,7 +408,7 @@ export function MonthlyClosePage({ onNavigate }: MonthlyClosePageProps) {
|
|||||||
detail: isMonthView ? (stats?.closed ? "Monat ist gesperrt." : canClose ? "Kann abgeschlossen werden." : "Bewertungen prüfen.") : "Abschluss bleibt monatlich."
|
detail: isMonthView ? (stats?.closed ? "Monat ist gesperrt." : canClose ? "Kann abgeschlossen werden." : "Bewertungen prüfen.") : "Abschluss bleibt monatlich."
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
[acknowledgedMissingCrmDays.length, canClose, isMonthView, openSessions.length, openTicketGroups.length, missingCrmDays.length, stats?.closed]
|
[activeReviewTicketGroups.length, canClose, invalidTeamspaceValueDayCount, isMonthView, missingCrmDays.length, stats?.closed, teamspaceAttentionTicketCount, teamspaceMonthMismatches.length, ticketTeamspaceDeviationCount]
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -378,7 +416,7 @@ export function MonthlyClosePage({ onNavigate }: MonthlyClosePageProps) {
|
|||||||
<div className="flex flex-col gap-3 xl:flex-row xl:items-end xl:justify-between">
|
<div className="flex flex-col gap-3 xl:flex-row xl:items-end xl:justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-2xl font-semibold tracking-normal">Monatsabschluss</h2>
|
<h2 className="text-2xl font-semibold tracking-normal">Monatsabschluss</h2>
|
||||||
<p className="text-sm text-muted-foreground">Offene Bewertungen und Teamspace-Abgleich für {periodAttentionLabel}; der Abschluss selbst bleibt monatlich.</p>
|
<p className="text-sm text-muted-foreground">Offene Bewertungen und TS-API-Abgleich für {periodAttentionLabel}; der Abschluss selbst bleibt monatlich.</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid gap-2 sm:grid-cols-[auto_minmax(220px,300px)_auto] sm:items-end">
|
<div className="grid gap-2 sm:grid-cols-[auto_minmax(220px,300px)_auto] sm:items-end">
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
@@ -439,10 +477,29 @@ export function MonthlyClosePage({ onNavigate }: MonthlyClosePageProps) {
|
|||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-5">
|
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-5">
|
||||||
<StatusCard label="Sessions" value={stats?.totals.sessions ?? 0} detail={`${formatMinutes(stats?.totals.minutes ?? 0)} abgerechnet`} />
|
<StatusCard
|
||||||
<StatusCard label="Teamspace" value={formatMinutes(stats?.totals.crmBilledMinutes ?? 0)} detail={`Differenz ${formatTeamspaceDelta(stats?.totals.crmDeltaMinutes ?? 0)}`} />
|
label="Tickettracker-Werte"
|
||||||
<StatusCard label="Offene Bewertungen" value={openSessions.length} detail={`${openTicketGroups.length} Ticket(s)`} tone={openSessions.length > 0 ? "warn" : "ok"} />
|
value={stats?.totals.sessions ?? 0}
|
||||||
<StatusCard label="Ohne Teamspace" value={missingCrmDays.length} detail={`${acknowledgedMissingCrmDays.length} zur Kenntnis genommen`} tone={missingCrmDays.length > 0 ? "warn" : "ok"} />
|
detail={`${formatMinutes(stats?.totals.minutes ?? 0)} abgerechnet`}
|
||||||
|
/>
|
||||||
|
<StatusCard
|
||||||
|
label="TT-Teamspace"
|
||||||
|
value={formatMinutes(ttTeamspaceMinutes)}
|
||||||
|
detail={`aus TS API ${formatMinutes(teamspaceApiBillableMinutes)}\n${formatMissingTarget(teamspaceApiDeltaMinutes, teamspaceTimeTrackingEnabled)}`}
|
||||||
|
tone={teamspaceApiDeltaMinutes !== 0 ? "warn" : "ok"}
|
||||||
|
/>
|
||||||
|
<StatusCard
|
||||||
|
label="Tickets zu prüfen"
|
||||||
|
value={activeReviewTicketGroups.length}
|
||||||
|
detail={`${openSessions.length} offene Bewertung(en)\n${teamspaceAttentionTicketCount} Ticket(s) mit TS-Prüfpunkten`}
|
||||||
|
tone={activeReviewTicketGroups.length > 0 ? "warn" : "ok"}
|
||||||
|
/>
|
||||||
|
<StatusCard
|
||||||
|
label="Ohne TT-Teamspace"
|
||||||
|
value={missingCrmTicketCount}
|
||||||
|
detail={`${missingCrmDays.length} Tag(e) · ${acknowledgedMissingCrmDays.length} zur Kenntnis`}
|
||||||
|
tone={missingCrmDays.length > 0 ? "warn" : "ok"}
|
||||||
|
/>
|
||||||
<StatusCard
|
<StatusCard
|
||||||
label="Status"
|
label="Status"
|
||||||
value={isMonthView ? (stats?.closed ? "geschlossen" : canClose ? "bereit" : "offen") : "Ansicht"}
|
value={isMonthView ? (stats?.closed ? "geschlossen" : canClose ? "bereit" : "offen") : "Ansicht"}
|
||||||
@@ -478,8 +535,11 @@ export function MonthlyClosePage({ onNavigate }: MonthlyClosePageProps) {
|
|||||||
<CardHeader className="p-4">
|
<CardHeader className="p-4">
|
||||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
|
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
|
||||||
<div>
|
<div>
|
||||||
<CardTitle>Aufräumen</CardTitle>
|
<CardTitle className="flex items-center gap-2">
|
||||||
<CardDescription>Alles, was für {periodAttentionLabel} noch Aufmerksamkeit braucht.</CardDescription>
|
<FileWarning className="size-4 text-muted-foreground" />
|
||||||
|
Prüfliste nach Ticket
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>Offene Bewertungen, fehlende Tageswerte sowie Monats- und Tagesabweichungen – je Ticket einmal in festen Statusspalten.</CardDescription>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
<div className="inline-flex h-8 items-center gap-2 rounded-md border bg-background px-3 text-sm">
|
<div className="inline-flex h-8 items-center gap-2 rounded-md border bg-background px-3 text-sm">
|
||||||
@@ -494,63 +554,40 @@ export function MonthlyClosePage({ onNavigate }: MonthlyClosePageProps) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="grid gap-4 px-4 pb-4 xl:grid-cols-2">
|
<CardContent className="space-y-3 px-4 pb-4">
|
||||||
<section className="space-y-2">
|
<div className="flex flex-wrap items-center justify-between gap-2 text-xs text-muted-foreground">
|
||||||
<div className="flex min-h-8 items-center gap-2 text-sm font-medium">
|
<span>{visibleReviewTicketGroups.length} Ticket(s) angezeigt</span>
|
||||||
<FileWarning className="size-4 text-muted-foreground" />
|
<span>Tagesdetails stehen im jeweiligen Ticket</span>
|
||||||
Offene Bewertungen
|
</div>
|
||||||
|
{visibleReviewTicketGroups.length > 0 ? (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className={`hidden items-end gap-2 border border-transparent px-3 text-[11px] font-medium leading-tight uppercase tracking-wide text-muted-foreground xl:grid ${monthlyCloseTicketGridClassName}`}>
|
||||||
|
<span>Ticket</span>
|
||||||
|
<span className="text-center">Offene<br />Bewertungen</span>
|
||||||
|
<span className="text-center">Monatsabweichung<br />TT / TS</span>
|
||||||
|
<span className="text-center">Fehlender<br />TT-Wert</span>
|
||||||
|
<span className="text-center">Tagesabweichung<br />TT / TS</span>
|
||||||
|
<span className="text-center">TT-Wert ohne<br />Abrechnung</span>
|
||||||
|
<span className="sr-only">Aktionen</span>
|
||||||
|
</div>
|
||||||
|
{visibleReviewTicketGroups.map((group) => (
|
||||||
|
<MonthlyCloseTicketItem
|
||||||
|
key={group.ticketId}
|
||||||
|
group={group}
|
||||||
|
onNavigate={onNavigate}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
{openTicketGroups.map((group) => (
|
) : (
|
||||||
<OpenTicketItem key={group.ticketId} group={group} onNavigate={onNavigate} />
|
<p className="rounded-md border bg-muted/20 p-3 text-sm text-muted-foreground">
|
||||||
))}
|
{acknowledgedMissingCrmDays.length > 0 && !showAcknowledgedMissingCrm
|
||||||
{openTicketGroups.length === 0 ? <p className="rounded-md border bg-muted/20 p-3 text-sm text-muted-foreground">Keine offenen Bewertungen.</p> : null}
|
? "Keine offenen Prüfpunkte. Zur Kenntnis genommene Tageswerte können eingeblendet werden."
|
||||||
</section>
|
: "Keine offenen Prüfpunkte für diesen Zeitraum."}
|
||||||
|
</p>
|
||||||
<section className="space-y-2">
|
)}
|
||||||
<div className="flex min-h-8 items-center gap-2 text-sm font-medium">
|
|
||||||
<Sparkles className="size-4 text-muted-foreground" />
|
|
||||||
Tage ohne Teamspace-Wert
|
|
||||||
</div>
|
|
||||||
{visibleMissingCrmDays.map((item) => (
|
|
||||||
<CrmDayItem
|
|
||||||
key={`${item.ticket_id}-${item.day}-missing`}
|
|
||||||
item={item}
|
|
||||||
busy={acknowledgingKeys.has(`${item.ticket_id}:${item.day}`)}
|
|
||||||
onSetAcknowledgement={setMissingCrmAcknowledgement}
|
|
||||||
onNavigate={onNavigate}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
{visibleMissingCrmDays.length === 0 ? (
|
|
||||||
<p className="rounded-md border bg-muted/20 p-3 text-sm text-muted-foreground">
|
|
||||||
{acknowledgedMissingCrmDays.length > 0 ? "Alle offenen Punkte sind zur Kenntnis genommen." : "Alle abrechenbaren Tage haben einen Teamspace-Wert."}
|
|
||||||
</p>
|
|
||||||
) : null}
|
|
||||||
</section>
|
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Accordion type="single" collapsible className="rounded-md border bg-card px-4">
|
|
||||||
<AccordionItem value="teamspace-entries" className="border-b-0">
|
|
||||||
<AccordionTrigger className="py-3 hover:no-underline">
|
|
||||||
<span className="flex min-w-0 flex-wrap items-center gap-2">
|
|
||||||
<span>Teamspace-Einträge</span>
|
|
||||||
<Badge variant="outline">{teamspaceDays.length} Eintrag(e)</Badge>
|
|
||||||
</span>
|
|
||||||
</AccordionTrigger>
|
|
||||||
<AccordionContent className="space-y-2 pb-4">
|
|
||||||
{teamspaceDays.length > 0 ? (
|
|
||||||
<div className="max-h-[28rem] space-y-2 overflow-y-auto pr-1">
|
|
||||||
{teamspaceDays.map((item) => (
|
|
||||||
<TeamspaceDayItem key={`${item.ticket_id}-${item.day}`} item={item} onNavigate={onNavigate} />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<p className="rounded-md border bg-muted/20 p-3 text-sm text-muted-foreground">Für {periodAttentionLabel} sind noch keine Teamspace-Werte zu abgerechneten Ticket-Tagen vorhanden.</p>
|
|
||||||
)}
|
|
||||||
</AccordionContent>
|
|
||||||
</AccordionItem>
|
|
||||||
</Accordion>
|
|
||||||
|
|
||||||
{stats && stats.totals.sessions === 0 ? (
|
{stats && stats.totals.sessions === 0 ? (
|
||||||
<Card>
|
<Card>
|
||||||
<CardContent className="grid place-items-center gap-2 p-8 text-center">
|
<CardContent className="grid place-items-center gap-2 p-8 text-center">
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/com
|
|||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Switch } from "@/components/ui/switch";
|
import { Switch } from "@/components/ui/switch";
|
||||||
import { HelpLink } from "@/components/HelpLink";
|
import { HelpLink } from "@/components/HelpLink";
|
||||||
|
import { ListClockIcon } from "@/components/icons/ListClockIcon";
|
||||||
import { getZammadSessionArticleQueue, retryZammadSessionArticleQueue, updateCurrentUser } from "../api";
|
import { getZammadSessionArticleQueue, retryZammadSessionArticleQueue, updateCurrentUser } from "../api";
|
||||||
import { formatDateTime, formatMinutes } from "../format";
|
import { formatDateTime, formatMinutes } from "../format";
|
||||||
import type { AuthUser, ZammadSessionArticleQueueItem } from "../types";
|
import type { AuthUser, ZammadSessionArticleQueueItem } from "../types";
|
||||||
@@ -24,10 +25,16 @@ export function ProfilePage({ currentUser, onUserUpdated }: ProfilePageProps) {
|
|||||||
const [confirmPassword, setConfirmPassword] = useState("");
|
const [confirmPassword, setConfirmPassword] = useState("");
|
||||||
const [zammadApiToken, setZammadApiToken] = useState("");
|
const [zammadApiToken, setZammadApiToken] = useState("");
|
||||||
const [zammadTokenDirty, setZammadTokenDirty] = useState(false);
|
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 [zammadEmailSessionWebhookEnabled, setZammadEmailSessionWebhookEnabled] = useState(currentUser.zammad_email_session_webhook_enabled);
|
||||||
const [zammadAtossEmailSyncEnabled, setZammadAtossEmailSyncEnabled] = useState(currentUser.zammad_atoss_email_sync_enabled);
|
const [zammadAtossEmailSyncEnabled, setZammadAtossEmailSyncEnabled] = useState(currentUser.zammad_atoss_email_sync_enabled);
|
||||||
const [sessionBillingPromptEnabled, setSessionBillingPromptEnabled] = useState(currentUser.session_billing_prompt_enabled);
|
const [sessionBillingPromptEnabled, setSessionBillingPromptEnabled] = useState(currentUser.session_billing_prompt_enabled);
|
||||||
|
const [sessionWorkSegmentsEnabled, setSessionWorkSegmentsEnabled] = useState(currentUser.session_work_segments_enabled);
|
||||||
|
const [teamspaceTimeTrackingEnabled, setTeamspaceTimeTrackingEnabled] = useState(currentUser.teamspace_time_tracking_enabled ?? true);
|
||||||
const [zammadSessionNoteDefaultEnabled, setZammadSessionNoteDefaultEnabled] = useState(currentUser.zammad_session_note_default_enabled);
|
const [zammadSessionNoteDefaultEnabled, setZammadSessionNoteDefaultEnabled] = useState(currentUser.zammad_session_note_default_enabled);
|
||||||
|
const [zammadSessionNoteDateHeaderEnabled, setZammadSessionNoteDateHeaderEnabled] = useState(currentUser.zammad_session_note_date_header_enabled);
|
||||||
const [zammadTimeUnitRoundingEnabled, setZammadTimeUnitRoundingEnabled] = useState(currentUser.zammad_time_unit_rounding_enabled);
|
const [zammadTimeUnitRoundingEnabled, setZammadTimeUnitRoundingEnabled] = useState(currentUser.zammad_time_unit_rounding_enabled);
|
||||||
const [zammadTimeUnitRoundingMinutes, setZammadTimeUnitRoundingMinutes] = useState(currentUser.zammad_time_unit_rounding_minutes);
|
const [zammadTimeUnitRoundingMinutes, setZammadTimeUnitRoundingMinutes] = useState(currentUser.zammad_time_unit_rounding_minutes);
|
||||||
const [zammadArticleTypeId, setZammadArticleTypeId] = useState<ZammadArticleTypeId>(currentUser.zammad_article_type_id ?? 6);
|
const [zammadArticleTypeId, setZammadArticleTypeId] = useState<ZammadArticleTypeId>(currentUser.zammad_article_type_id ?? 6);
|
||||||
@@ -46,10 +53,16 @@ export function ProfilePage({ currentUser, onUserUpdated }: ProfilePageProps) {
|
|||||||
setDisplayName(currentUser.display_name);
|
setDisplayName(currentUser.display_name);
|
||||||
setZammadApiToken("");
|
setZammadApiToken("");
|
||||||
setZammadTokenDirty(false);
|
setZammadTokenDirty(false);
|
||||||
|
setTeamspaceDeviceId("");
|
||||||
|
setTeamspaceDevicePassword("");
|
||||||
|
setTeamspaceCredentialsDirty(false);
|
||||||
setZammadEmailSessionWebhookEnabled(currentUser.zammad_email_session_webhook_enabled);
|
setZammadEmailSessionWebhookEnabled(currentUser.zammad_email_session_webhook_enabled);
|
||||||
setZammadAtossEmailSyncEnabled(currentUser.zammad_atoss_email_sync_enabled);
|
setZammadAtossEmailSyncEnabled(currentUser.zammad_atoss_email_sync_enabled);
|
||||||
setSessionBillingPromptEnabled(currentUser.session_billing_prompt_enabled);
|
setSessionBillingPromptEnabled(currentUser.session_billing_prompt_enabled);
|
||||||
|
setSessionWorkSegmentsEnabled(currentUser.session_work_segments_enabled);
|
||||||
|
setTeamspaceTimeTrackingEnabled(currentUser.teamspace_time_tracking_enabled ?? true);
|
||||||
setZammadSessionNoteDefaultEnabled(currentUser.zammad_session_note_default_enabled);
|
setZammadSessionNoteDefaultEnabled(currentUser.zammad_session_note_default_enabled);
|
||||||
|
setZammadSessionNoteDateHeaderEnabled(currentUser.zammad_session_note_date_header_enabled);
|
||||||
setZammadTimeUnitRoundingEnabled(currentUser.zammad_time_unit_rounding_enabled);
|
setZammadTimeUnitRoundingEnabled(currentUser.zammad_time_unit_rounding_enabled);
|
||||||
setZammadTimeUnitRoundingMinutes(currentUser.zammad_time_unit_rounding_minutes);
|
setZammadTimeUnitRoundingMinutes(currentUser.zammad_time_unit_rounding_minutes);
|
||||||
setZammadArticleTypeId(currentUser.zammad_article_type_id ?? 6);
|
setZammadArticleTypeId(currentUser.zammad_article_type_id ?? 6);
|
||||||
@@ -134,12 +147,16 @@ export function ProfilePage({ currentUser, onUserUpdated }: ProfilePageProps) {
|
|||||||
currentPassword: wantsPasswordChange ? currentPassword : undefined,
|
currentPassword: wantsPasswordChange ? currentPassword : undefined,
|
||||||
newPassword: wantsPasswordChange ? newPassword : undefined,
|
newPassword: wantsPasswordChange ? newPassword : undefined,
|
||||||
...(currentUser.role === "user" && zammadTokenDirty ? { zammadApiToken } : {}),
|
...(currentUser.role === "user" && zammadTokenDirty ? { zammadApiToken } : {}),
|
||||||
|
...(currentUser.role === "user" && teamspaceCredentialsDirty ? { teamspaceDeviceId, teamspaceDevicePassword } : {}),
|
||||||
...(currentUser.role === "user"
|
...(currentUser.role === "user"
|
||||||
? {
|
? {
|
||||||
zammadEmailSessionWebhookEnabled,
|
zammadEmailSessionWebhookEnabled,
|
||||||
zammadAtossEmailSyncEnabled,
|
zammadAtossEmailSyncEnabled,
|
||||||
sessionBillingPromptEnabled,
|
sessionBillingPromptEnabled,
|
||||||
|
sessionWorkSegmentsEnabled,
|
||||||
|
teamspaceTimeTrackingEnabled,
|
||||||
zammadSessionNoteDefaultEnabled,
|
zammadSessionNoteDefaultEnabled,
|
||||||
|
zammadSessionNoteDateHeaderEnabled,
|
||||||
zammadTimeUnitRoundingEnabled,
|
zammadTimeUnitRoundingEnabled,
|
||||||
zammadTimeUnitRoundingMinutes,
|
zammadTimeUnitRoundingMinutes,
|
||||||
zammadArticleTypeId,
|
zammadArticleTypeId,
|
||||||
@@ -160,6 +177,9 @@ export function ProfilePage({ currentUser, onUserUpdated }: ProfilePageProps) {
|
|||||||
setConfirmPassword("");
|
setConfirmPassword("");
|
||||||
setZammadApiToken("");
|
setZammadApiToken("");
|
||||||
setZammadTokenDirty(false);
|
setZammadTokenDirty(false);
|
||||||
|
setTeamspaceDeviceId("");
|
||||||
|
setTeamspaceDevicePassword("");
|
||||||
|
setTeamspaceCredentialsDirty(false);
|
||||||
toast.success("Profil gespeichert");
|
toast.success("Profil gespeichert");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
toast.error("Profil konnte nicht gespeichert werden", {
|
toast.error("Profil konnte nicht gespeichert werden", {
|
||||||
@@ -301,6 +321,86 @@ export function ProfilePage({ currentUser, onUserUpdated }: ProfilePageProps) {
|
|||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</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="mt-4 flex items-center justify-between gap-3 rounded-md border bg-background p-3">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<MailPlus className="size-4 text-muted-foreground" />
|
<MailPlus className="size-4 text-muted-foreground" />
|
||||||
@@ -349,6 +449,22 @@ export function ProfilePage({ currentUser, onUserUpdated }: ProfilePageProps) {
|
|||||||
onCheckedChange={setSessionBillingPromptEnabled}
|
onCheckedChange={setSessionBillingPromptEnabled}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="mt-3 flex items-center justify-between gap-3 rounded-md border bg-background p-3">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<ListClockIcon className="size-4 text-muted-foreground" />
|
||||||
|
<div>
|
||||||
|
<label className="text-sm font-medium" htmlFor="profile-session-work-segments">
|
||||||
|
Arbeitsbereiche bei Pausen speichern
|
||||||
|
</label>
|
||||||
|
<p className="text-xs text-muted-foreground">Beim Pausieren und Fortsetzen die konkreten Von-bis-Zeiten an der Session merken.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Switch
|
||||||
|
id="profile-session-work-segments"
|
||||||
|
checked={sessionWorkSegmentsEnabled}
|
||||||
|
onCheckedChange={setSessionWorkSegmentsEnabled}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
<div className="mt-3 space-y-3 rounded-md border bg-background p-3">
|
<div className="mt-3 space-y-3 rounded-md border bg-background p-3">
|
||||||
<div className="flex items-center justify-between gap-3">
|
<div className="flex items-center justify-between gap-3">
|
||||||
<div>
|
<div>
|
||||||
@@ -454,6 +570,24 @@ export function ProfilePage({ currentUser, onUserUpdated }: ProfilePageProps) {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="mt-3 rounded-md border bg-background p-3">
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<FileText className="size-4 text-muted-foreground" />
|
||||||
|
<div>
|
||||||
|
<label className="text-sm font-medium" htmlFor="profile-zammad-date-header">
|
||||||
|
Datum bei nachgetragenen Sessions voranstellen
|
||||||
|
</label>
|
||||||
|
<p className="text-xs text-muted-foreground">Bei Einträgen vor heute wird nur in der Zammad-Notiz das Datum mit Leerzeile eingefügt.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Switch
|
||||||
|
id="profile-zammad-date-header"
|
||||||
|
checked={zammadSessionNoteDateHeaderEnabled}
|
||||||
|
onCheckedChange={setZammadSessionNoteDateHeaderEnabled}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
{zammadSessionNoteDefaultEnabled ? (
|
{zammadSessionNoteDefaultEnabled ? (
|
||||||
<div className="mt-3 space-y-3">
|
<div className="mt-3 space-y-3">
|
||||||
<div className="rounded-md border bg-background p-3">
|
<div className="rounded-md border bg-background p-3">
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { ArrowDown, ArrowUp, CheckCircle2, Eye, EyeOff, MoreHorizontal, Pencil, Play, Plus, RotateCcw, Save, Search, SlidersHorizontal, Star, Trash2 } from "lucide-react";
|
import { AlertCircle, ArrowDown, ArrowUp, CheckCircle2, Eye, EyeOff, MoreHorizontal, Pencil, Play, Plus, RotateCcw, Save, Search, SlidersHorizontal, Star, Trash2 } from "lucide-react";
|
||||||
import { FormEvent, useEffect, useMemo, useState } from "react";
|
import { FormEvent, useEffect, useMemo, useState } from "react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
|
||||||
@@ -19,7 +19,7 @@ import { acknowledgeProjectReview, addFavoriteTicket, createProject, getProjects
|
|||||||
import { currentMonth, formatMinutes } from "../format";
|
import { currentMonth, formatMinutes } from "../format";
|
||||||
import { projectHasRecurringBilling } from "../recurring-billings";
|
import { projectHasRecurringBilling } from "../recurring-billings";
|
||||||
import { ticketPattern } from "../timers";
|
import { ticketPattern } from "../timers";
|
||||||
import type { ProjectSummary, ProjectTeamspaceEntry, TicketMeta } from "../types";
|
import type { AuthUser, ProjectSummary, ProjectTeamspaceEntry, TicketMeta } from "../types";
|
||||||
import {
|
import {
|
||||||
matchesZammadPriorityFilter,
|
matchesZammadPriorityFilter,
|
||||||
matchesZammadStateFilter,
|
matchesZammadStateFilter,
|
||||||
@@ -36,6 +36,7 @@ import {
|
|||||||
} from "../zammad-meta";
|
} from "../zammad-meta";
|
||||||
|
|
||||||
type ProjectsPageProps = {
|
type ProjectsPageProps = {
|
||||||
|
currentUser: AuthUser;
|
||||||
onNavigate: (to: string) => void;
|
onNavigate: (to: string) => void;
|
||||||
onStartTimer: (ticketNumber: string) => Promise<boolean>;
|
onStartTimer: (ticketNumber: string) => Promise<boolean>;
|
||||||
};
|
};
|
||||||
@@ -94,6 +95,7 @@ const projectStaticFilterOptions: ProjectFilterOption[] = [
|
|||||||
{ value: "open", label: "TT Offen" },
|
{ value: "open", label: "TT Offen" },
|
||||||
{ value: "closed", label: "TT Geschl." },
|
{ value: "closed", label: "TT Geschl." },
|
||||||
{ value: "hidden", label: "Ausgebl." },
|
{ value: "hidden", label: "Ausgebl." },
|
||||||
|
{ value: "teamspaceExcluded", label: "TS ausgenommen" },
|
||||||
{ value: "budgeted", label: "Mit Kontingent" },
|
{ value: "budgeted", label: "Mit Kontingent" },
|
||||||
{ value: "withoutBudget", label: "Ohne Kontingent" },
|
{ value: "withoutBudget", label: "Ohne Kontingent" },
|
||||||
{ value: "remaining", label: "Mit Rest" },
|
{ value: "remaining", label: "Mit Rest" },
|
||||||
@@ -346,7 +348,11 @@ function projectTeamspaceEntryLabel(entry: { teamspace_project: string | null; t
|
|||||||
}
|
}
|
||||||
|
|
||||||
function budgetedProjectTeamspaceEntries(project: ProjectSummary) {
|
function budgetedProjectTeamspaceEntries(project: ProjectSummary) {
|
||||||
return projectTeamspaceEntries(project).filter((entry) => Number(entry.hour_budget_minutes ?? 0) > 0);
|
if (projectBudgetExcluded(project)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return projectTeamspaceEntries(project).filter((entry) => !entry.teamspace_budget_excluded && Number(entry.hour_budget_minutes ?? 0) > 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
function projectEntryConsumption(entry: ProjectTeamspaceEntry) {
|
function projectEntryConsumption(entry: ProjectTeamspaceEntry) {
|
||||||
@@ -411,21 +417,19 @@ function projectOrganizationKey(project: ProjectSummary) {
|
|||||||
return project.organization_id ? `id:${project.organization_id}` : `name:${projectOrganizationLabel(project)}`;
|
return project.organization_id ? `id:${project.organization_id}` : `name:${projectOrganizationLabel(project)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function projectBudget(project: ProjectSummary, standardTeamspaceOnly = false) {
|
function projectBudgetExcluded(project: ProjectSummary) {
|
||||||
if (standardTeamspaceOnly) {
|
return Boolean(project.teamspace_budget_excluded || projectTeamspaceEntries(project).some((entry) => entry.teamspace_budget_excluded));
|
||||||
return defaultProjectTeamspaceEntry(project)?.hour_budget_minutes ?? null;
|
}
|
||||||
|
|
||||||
|
function projectBudget(project: ProjectSummary, _standardTeamspaceOnly = false) {
|
||||||
|
if (projectBudgetExcluded(project)) {
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return project.hour_budget_minutes ?? null;
|
return project.hour_budget_minutes ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function projectConsumption(project: ProjectSummary, standardTeamspaceOnly = false) {
|
function projectConsumption(project: ProjectSummary, _standardTeamspaceOnly = false) {
|
||||||
if (standardTeamspaceOnly) {
|
|
||||||
const defaultEntry = defaultProjectTeamspaceEntry(project);
|
|
||||||
|
|
||||||
return defaultEntry ? projectEntryConsumption(defaultEntry) : 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
return Number(project.consumption_minutes ?? 0);
|
return Number(project.consumption_minutes ?? 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -496,6 +500,10 @@ function projectFilterGroup(filter: string) {
|
|||||||
return "visibility";
|
return "visibility";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (filter === "teamspaceExcluded") {
|
||||||
|
return "teamspaceExclusion";
|
||||||
|
}
|
||||||
|
|
||||||
if (filter.startsWith("zammadState:")) {
|
if (filter.startsWith("zammadState:")) {
|
||||||
return "zammadState";
|
return "zammadState";
|
||||||
}
|
}
|
||||||
@@ -520,6 +528,10 @@ function matchesSingleProjectFilter(project: ProjectSummary, filter: string, sta
|
|||||||
return Boolean(project.hidden_for_user);
|
return Boolean(project.hidden_for_user);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (filter === "teamspaceExcluded") {
|
||||||
|
return projectBudgetExcluded(project);
|
||||||
|
}
|
||||||
|
|
||||||
if (filter.startsWith("zammadState:")) {
|
if (filter.startsWith("zammadState:")) {
|
||||||
return matchesZammadStateFilter(project.zammad_state_id, filter.slice("zammadState:".length) as Parameters<typeof matchesZammadStateFilter>[1]);
|
return matchesZammadStateFilter(project.zammad_state_id, filter.slice("zammadState:".length) as Parameters<typeof matchesZammadStateFilter>[1]);
|
||||||
}
|
}
|
||||||
@@ -555,6 +567,12 @@ function matchesProjectFilters(project: ProjectSummary, filters: string[], organ
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const showTeamspaceExcluded = filters.includes("teamspaceExcluded");
|
||||||
|
|
||||||
|
if (projectBudgetExcluded(project) !== showTeamspaceExcluded) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
return groupedProjectFilterMatch(filters, projectFilterGroup, (filter) => matchesSingleProjectFilter(project, filter, standardTeamspaceOnly));
|
return groupedProjectFilterMatch(filters, projectFilterGroup, (filter) => matchesSingleProjectFilter(project, filter, standardTeamspaceOnly));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -746,6 +764,7 @@ type ProjectFormFieldsProps = {
|
|||||||
form: ProjectForm;
|
form: ProjectForm;
|
||||||
setForm: (form: ProjectForm) => void;
|
setForm: (form: ProjectForm) => void;
|
||||||
organizationLocked: boolean;
|
organizationLocked: boolean;
|
||||||
|
showManualConsumption?: boolean;
|
||||||
lookupStatus: "idle" | "loading" | "found" | "not-found";
|
lookupStatus: "idle" | "loading" | "found" | "not-found";
|
||||||
zammadLookupBusy?: boolean;
|
zammadLookupBusy?: boolean;
|
||||||
saving: boolean;
|
saving: boolean;
|
||||||
@@ -767,10 +786,9 @@ function normalizeProjectTeamspaceEntryForms(entries: ProjectTeamspaceEntryForm[
|
|||||||
function parseProjectTeamspaceEntryForms(entries: ProjectTeamspaceEntryForm[]) {
|
function parseProjectTeamspaceEntryForms(entries: ProjectTeamspaceEntryForm[]) {
|
||||||
const normalizedEntries = normalizeProjectTeamspaceEntryForms(entries);
|
const normalizedEntries = normalizeProjectTeamspaceEntryForms(entries);
|
||||||
const parsedEntries = normalizedEntries.map((entry) => {
|
const parsedEntries = normalizedEntries.map((entry) => {
|
||||||
const hourBudgetMinutes = parseHoursInput(entry.hourBudget);
|
|
||||||
const manualConsumptionMinutes = parseHoursInput(entry.manualConsumption);
|
const manualConsumptionMinutes = parseHoursInput(entry.manualConsumption);
|
||||||
|
|
||||||
if (hourBudgetMinutes === undefined || manualConsumptionMinutes === undefined) {
|
if (manualConsumptionMinutes === undefined) {
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -779,8 +797,8 @@ function parseProjectTeamspaceEntryForms(entries: ProjectTeamspaceEntryForm[]) {
|
|||||||
teamspaceProject: entry.teamspaceProject.trim() || null,
|
teamspaceProject: entry.teamspaceProject.trim() || null,
|
||||||
teamspaceDescription: entry.teamspaceDescription.trim() || null,
|
teamspaceDescription: entry.teamspaceDescription.trim() || null,
|
||||||
isDefault: entry.isDefault,
|
isDefault: entry.isDefault,
|
||||||
hourBudgetMinutes,
|
hourBudgetMinutes: null,
|
||||||
budgetSyncDisabled: entry.budgetSyncDisabled,
|
budgetSyncDisabled: false,
|
||||||
manualConsumptionMinutes: manualConsumptionMinutes ?? 0
|
manualConsumptionMinutes: manualConsumptionMinutes ?? 0
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
@@ -806,10 +824,12 @@ function defaultProjectTeamspacePayload(entries: ReturnType<typeof parseProjectT
|
|||||||
|
|
||||||
function ProjectTeamspaceEntriesFields({
|
function ProjectTeamspaceEntriesFields({
|
||||||
entries,
|
entries,
|
||||||
onChange
|
onChange,
|
||||||
|
showManualConsumption = true
|
||||||
}: {
|
}: {
|
||||||
entries: ProjectTeamspaceEntryForm[];
|
entries: ProjectTeamspaceEntryForm[];
|
||||||
onChange: (entries: ProjectTeamspaceEntryForm[]) => void;
|
onChange: (entries: ProjectTeamspaceEntryForm[]) => void;
|
||||||
|
showManualConsumption?: boolean;
|
||||||
}) {
|
}) {
|
||||||
const normalizedEntries = normalizeProjectTeamspaceEntryForms(entries);
|
const normalizedEntries = normalizeProjectTeamspaceEntryForms(entries);
|
||||||
|
|
||||||
@@ -836,7 +856,15 @@ function ProjectTeamspaceEntriesFields({
|
|||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{normalizedEntries.map((entry, index) => (
|
{normalizedEntries.map((entry, index) => (
|
||||||
<div key={entry.clientId} className="grid gap-2 rounded-lg border bg-muted/10 p-3 lg:grid-cols-[minmax(180px,1fr)_minmax(160px,0.9fr)_130px_150px_130px_120px_36px] lg:items-end">
|
<div
|
||||||
|
key={entry.clientId}
|
||||||
|
className={cn(
|
||||||
|
"grid gap-2 rounded-lg border bg-muted/10 p-3 lg:items-end",
|
||||||
|
showManualConsumption
|
||||||
|
? "lg:grid-cols-[minmax(180px,1fr)_minmax(160px,0.9fr)_110px_120px_36px]"
|
||||||
|
: "lg:grid-cols-[minmax(180px,1fr)_minmax(160px,0.9fr)_120px_36px]"
|
||||||
|
)}
|
||||||
|
>
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<label className="text-xs font-medium text-muted-foreground" htmlFor={`project-teamspace-${entry.clientId}`}>
|
<label className="text-xs font-medium text-muted-foreground" htmlFor={`project-teamspace-${entry.clientId}`}>
|
||||||
Teamspace-Projekt {entry.isDefault ? "(Standard)" : ""}
|
Teamspace-Projekt {entry.isDefault ? "(Standard)" : ""}
|
||||||
@@ -858,32 +886,19 @@ function ProjectTeamspaceEntriesFields({
|
|||||||
onChange={(event) => updateEntry(entry.clientId, { teamspaceDescription: event.currentTarget.value })}
|
onChange={(event) => updateEntry(entry.clientId, { teamspaceDescription: event.currentTarget.value })}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-1.5">
|
{showManualConsumption ? (
|
||||||
<label className="text-xs font-medium text-muted-foreground" htmlFor={`project-budget-${entry.clientId}`}>
|
<div className="space-y-1.5">
|
||||||
Budget h
|
<label className="text-xs font-medium text-muted-foreground" htmlFor={`project-teamspace-manual-${entry.clientId}`}>
|
||||||
</label>
|
Manuell h
|
||||||
<Input
|
</label>
|
||||||
id={`project-budget-${entry.clientId}`}
|
<Input
|
||||||
inputMode="decimal"
|
id={`project-teamspace-manual-${entry.clientId}`}
|
||||||
value={entry.hourBudget}
|
inputMode="decimal"
|
||||||
onChange={(event) => updateEntry(entry.clientId, { hourBudget: event.currentTarget.value, budgetSyncDisabled: true })}
|
value={entry.manualConsumption}
|
||||||
/>
|
onChange={(event) => updateEntry(entry.clientId, { manualConsumption: event.currentTarget.value })}
|
||||||
</div>
|
/>
|
||||||
<label className="flex min-h-9 items-center gap-2 rounded-md border bg-background px-3 text-xs">
|
</div>
|
||||||
<Checkbox checked={entry.budgetSyncDisabled} onCheckedChange={(checked) => updateEntry(entry.clientId, { budgetSyncDisabled: checked === true })} />
|
) : null}
|
||||||
<span>Budget schützen</span>
|
|
||||||
</label>
|
|
||||||
<div className="space-y-1.5">
|
|
||||||
<label className="text-xs font-medium text-muted-foreground" htmlFor={`project-manual-${entry.clientId}`}>
|
|
||||||
Manueller Verbrauch h
|
|
||||||
</label>
|
|
||||||
<Input
|
|
||||||
id={`project-manual-${entry.clientId}`}
|
|
||||||
inputMode="decimal"
|
|
||||||
value={entry.manualConsumption}
|
|
||||||
onChange={(event) => updateEntry(entry.clientId, { manualConsumption: event.currentTarget.value })}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<Button type="button" size="sm" variant={entry.isDefault ? "default" : "secondary"} onClick={() => setDefault(entry.clientId)}>
|
<Button type="button" size="sm" variant={entry.isDefault ? "default" : "secondary"} onClick={() => setDefault(entry.clientId)}>
|
||||||
{entry.isDefault ? "Standard" : "Als Standard"}
|
{entry.isDefault ? "Standard" : "Als Standard"}
|
||||||
</Button>
|
</Button>
|
||||||
@@ -894,14 +909,11 @@ function ProjectTeamspaceEntriesFields({
|
|||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
Der Standard-Eintrag bekommt die aus Teamspace gebuchten Tageswerte. Manueller Verbrauch zählt nur im Projektverbrauch.
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function ProjectFormFields({ form, setForm, organizationLocked, lookupStatus, zammadLookupBusy = false, saving, submitLabel, onZammadLookup, onSubmit }: ProjectFormFieldsProps) {
|
function ProjectFormFields({ form, setForm, organizationLocked, showManualConsumption = true, lookupStatus, zammadLookupBusy = false, saving, submitLabel, onZammadLookup, onSubmit }: ProjectFormFieldsProps) {
|
||||||
return (
|
return (
|
||||||
<form className="space-y-4" onSubmit={onSubmit}>
|
<form className="space-y-4" onSubmit={onSubmit}>
|
||||||
<div className="grid gap-3 lg:grid-cols-[180px_minmax(240px,1fr)_180px]">
|
<div className="grid gap-3 lg:grid-cols-[180px_minmax(240px,1fr)_180px]">
|
||||||
@@ -975,7 +987,11 @@ function ProjectFormFields({ form, setForm, organizationLocked, lookupStatus, za
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ProjectTeamspaceEntriesFields entries={form.teamspaceEntries} onChange={(teamspaceEntries) => setForm({ ...form, teamspaceEntries })} />
|
<ProjectTeamspaceEntriesFields
|
||||||
|
entries={form.teamspaceEntries}
|
||||||
|
onChange={(teamspaceEntries) => setForm({ ...form, teamspaceEntries })}
|
||||||
|
showManualConsumption={showManualConsumption}
|
||||||
|
/>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label className="text-sm font-medium" htmlFor="project-description">
|
<label className="text-sm font-medium" htmlFor="project-description">
|
||||||
@@ -994,7 +1010,7 @@ function ProjectFormFields({ form, setForm, organizationLocked, lookupStatus, za
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ProjectsPage({ onNavigate, onStartTimer }: ProjectsPageProps) {
|
export function ProjectsPage({ currentUser, onNavigate, onStartTimer }: ProjectsPageProps) {
|
||||||
const [projects, setProjects] = useState<ProjectSummary[]>([]);
|
const [projects, setProjects] = useState<ProjectSummary[]>([]);
|
||||||
const [hiddenProjectCount, setHiddenProjectCount] = useState(0);
|
const [hiddenProjectCount, setHiddenProjectCount] = useState(0);
|
||||||
const [totalProjectCount, setTotalProjectCount] = useState(0);
|
const [totalProjectCount, setTotalProjectCount] = useState(0);
|
||||||
@@ -1011,6 +1027,7 @@ export function ProjectsPage({ onNavigate, onStartTimer }: ProjectsPageProps) {
|
|||||||
const [savingProjectId, setSavingProjectId] = useState<string | null>(null);
|
const [savingProjectId, setSavingProjectId] = useState<string | null>(null);
|
||||||
const [savingProjectStatusId, setSavingProjectStatusId] = useState<string | null>(null);
|
const [savingProjectStatusId, setSavingProjectStatusId] = useState<string | null>(null);
|
||||||
const [viewSettings, setViewSettings] = useState<ProjectViewSettings>(() => readStoredProjectViewSettings());
|
const [viewSettings, setViewSettings] = useState<ProjectViewSettings>(() => readStoredProjectViewSettings());
|
||||||
|
const teamspaceTimeTrackingEnabled = currentUser.teamspace_time_tracking_enabled ?? true;
|
||||||
|
|
||||||
async function load() {
|
async function load() {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
@@ -1189,8 +1206,8 @@ export function ProjectsPage({ onNavigate, onStartTimer }: ProjectsPageProps) {
|
|||||||
...entry,
|
...entry,
|
||||||
teamspaceProject: entry.teamspaceProject || result.ticket?.project_name || "",
|
teamspaceProject: entry.teamspaceProject || result.ticket?.project_name || "",
|
||||||
teamspaceDescription: entry.teamspaceDescription,
|
teamspaceDescription: entry.teamspaceDescription,
|
||||||
hourBudget: entry.hourBudget || formatHoursInput(result.ticket?.project_hour_budget_minutes ?? null),
|
hourBudget: "",
|
||||||
budgetSyncDisabled: entry.hourBudget ? entry.budgetSyncDisabled : false
|
budgetSyncDisabled: false
|
||||||
}
|
}
|
||||||
: entry;
|
: entry;
|
||||||
});
|
});
|
||||||
@@ -1626,6 +1643,7 @@ export function ProjectsPage({ onNavigate, onStartTimer }: ProjectsPageProps) {
|
|||||||
form={form}
|
form={form}
|
||||||
setForm={setForm}
|
setForm={setForm}
|
||||||
organizationLocked={organizationLocked}
|
organizationLocked={organizationLocked}
|
||||||
|
showManualConsumption={teamspaceTimeTrackingEnabled}
|
||||||
lookupStatus={lookupStatus}
|
lookupStatus={lookupStatus}
|
||||||
zammadLookupBusy={zammadLookupBusy}
|
zammadLookupBusy={zammadLookupBusy}
|
||||||
saving={saving}
|
saving={saving}
|
||||||
@@ -1663,8 +1681,12 @@ export function ProjectsPage({ onNavigate, onStartTimer }: ProjectsPageProps) {
|
|||||||
const budgetMinutes = projectBudget(project, viewSettings.standardTeamspaceOnly);
|
const budgetMinutes = projectBudget(project, viewSettings.standardTeamspaceOnly);
|
||||||
const remaining = projectRemaining(project, viewSettings.standardTeamspaceOnly);
|
const remaining = projectRemaining(project, viewSettings.standardTeamspaceOnly);
|
||||||
const usedPercent = projectConsumptionPercent(project, viewSettings.standardTeamspaceOnly);
|
const usedPercent = projectConsumptionPercent(project, viewSettings.standardTeamspaceOnly);
|
||||||
|
const budgetExceeded = budgetMinutes !== null && budgetMinutes > 0 && consumptionMinutes > budgetMinutes;
|
||||||
const showBudgetedTeamspaceEntries = !viewSettings.standardTeamspaceOnly && budgetedTeamspaceEntries.length > 1;
|
const showBudgetedTeamspaceEntries = !viewSettings.standardTeamspaceOnly && budgetedTeamspaceEntries.length > 1;
|
||||||
|
const budgetExcluded = projectBudgetExcluded(project);
|
||||||
const canEditProject = project.can_edit === true;
|
const canEditProject = project.can_edit === true;
|
||||||
|
const unassignedTeamspaceEntryCount = Number(project.unassigned_teamspace_session_count ?? 0);
|
||||||
|
const unassignedTeamspaceSubentryCount = 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={project.id} className="border-b last:border-b-0">
|
<div key={project.id} className="border-b last:border-b-0">
|
||||||
@@ -1688,7 +1710,8 @@ export function ProjectsPage({ onNavigate, onStartTimer }: ProjectsPageProps) {
|
|||||||
{project.closed_at ? <Badge variant="secondary">Geschlossen</Badge> : <Badge variant="success">Offen</Badge>}
|
{project.closed_at ? <Badge variant="secondary">Geschlossen</Badge> : <Badge variant="success">Offen</Badge>}
|
||||||
{!canEditProject ? <Badge variant="outline">geteilt</Badge> : null}
|
{!canEditProject ? <Badge variant="outline">geteilt</Badge> : null}
|
||||||
{project.close_review_required ? <Badge variant="warning">TS/TT prüfen</Badge> : null}
|
{project.close_review_required ? <Badge variant="warning">TS/TT prüfen</Badge> : null}
|
||||||
{(project.unassigned_teamspace_session_count ?? 0) > 0 ? <Badge variant="warning">{project.unassigned_teamspace_session_count} ohne TS</Badge> : null}
|
{unassignedTeamspaceEntryCount > 0 ? <Badge variant="warning">{unassignedTeamspaceEntryCount} ohne TS</Badge> : null}
|
||||||
|
{unassignedTeamspaceSubentryCount > 0 ? <Badge variant="warning">{unassignedTeamspaceSubentryCount} ohne Pos.</Badge> : null}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-wrap gap-1.5">
|
<div className="flex flex-wrap gap-1.5">
|
||||||
<p className="w-full truncate text-sm font-medium xl:hidden">Zammad</p>
|
<p className="w-full truncate text-sm font-medium xl:hidden">Zammad</p>
|
||||||
@@ -1700,16 +1723,28 @@ export function ProjectsPage({ onNavigate, onStartTimer }: ProjectsPageProps) {
|
|||||||
</div>
|
</div>
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<p className="truncate text-sm font-medium xl:hidden">Verbrauch/Gesamt</p>
|
<p className="truncate text-sm font-medium xl:hidden">Verbrauch/Gesamt</p>
|
||||||
<p className={cn("text-sm font-semibold", remaining !== null && remaining < 0 ? "text-destructive" : undefined)}>
|
{budgetExcluded ? (
|
||||||
{formatDecimalHours(consumptionMinutes)} / {budgetMinutes ? formatDecimalHours(budgetMinutes) : "offen"}
|
<>
|
||||||
</p>
|
<p className="text-sm font-semibold">{formatDecimalHours(consumptionMinutes)}</p>
|
||||||
|
<p className="text-xs text-muted-foreground">Budgetprüfung ausgenommen</p>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<p className={cn("inline-flex items-center gap-1 text-sm font-semibold", remaining !== null && remaining < 0 ? "text-destructive" : undefined)}>
|
||||||
|
{budgetExceeded ? <AlertCircle className="size-3.5 text-amber-500" aria-label="Budgetüberschreitung" /> : null}
|
||||||
|
{formatDecimalHours(consumptionMinutes)} / {budgetMinutes ? formatDecimalHours(budgetMinutes) : "offen"}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
{showBudgetedTeamspaceEntries ? <p className="text-xs text-muted-foreground">{budgetedTeamspaceEntries.length} Budgets</p> : null}
|
{showBudgetedTeamspaceEntries ? <p className="text-xs text-muted-foreground">{budgetedTeamspaceEntries.length} Budgets</p> : null}
|
||||||
</div>
|
</div>
|
||||||
<div className="min-w-0 text-sm">
|
<div className="min-w-0 text-sm">
|
||||||
<p className="truncate font-medium xl:hidden">Verbrauch %</p>
|
<p className="truncate font-medium xl:hidden">Verbrauch %</p>
|
||||||
<p className={cn("font-semibold", usedPercent !== null && usedPercent >= 100 ? "text-blue-500" : usedPercent !== null && usedPercent >= 90 ? "text-red-500" : undefined)}>
|
{budgetExcluded ? (
|
||||||
{usedPercent !== null ? `${usedPercent}%` : "-"}
|
<p className="text-xs text-muted-foreground">keine Prüfung</p>
|
||||||
</p>
|
) : (
|
||||||
|
<p className={cn("font-semibold", usedPercent !== null && usedPercent >= 100 ? "text-blue-500" : usedPercent !== null && usedPercent >= 90 ? "text-red-500" : undefined)}>
|
||||||
|
{usedPercent !== null ? `${usedPercent}%` : "-"}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-end">
|
<div className="flex justify-end">
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
@@ -1769,18 +1804,24 @@ export function ProjectsPage({ onNavigate, onStartTimer }: ProjectsPageProps) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2 px-4 pb-3">
|
<div className="space-y-2 px-4 pb-3">
|
||||||
{usedPercent !== null ? (
|
{usedPercent !== null ? (
|
||||||
<div className="h-1.5 overflow-hidden rounded-full bg-muted" title={`${usedPercent}% Verbrauch`} aria-label={`${usedPercent}% Verbrauch`}>
|
<div className="flex items-center gap-1">
|
||||||
<div className={cn("h-full rounded-full", projectConsumptionBarClass(usedPercent))} style={{ width: `${Math.min(100, usedPercent)}%` }} />
|
<div className="h-1.5 flex-1 overflow-hidden rounded-full bg-muted" title={`${usedPercent}% Verbrauch`} aria-label={`${usedPercent}% Verbrauch`}>
|
||||||
</div>
|
<div className={cn("h-full rounded-full", projectConsumptionBarClass(usedPercent))} style={{ width: `${Math.min(100, usedPercent)}%` }} />
|
||||||
) : (
|
</div>
|
||||||
|
{budgetExceeded ? <AlertCircle className="size-3.5 shrink-0 text-amber-500" aria-label="Budgetüberschreitung" /> : null}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
<div className="h-1.5 overflow-hidden rounded-full bg-muted/30">
|
<div className="h-1.5 overflow-hidden rounded-full bg-muted/30">
|
||||||
<div className="h-full rounded-full bg-muted-foreground/10" style={{ width: "0%" }} />
|
<div className="h-full rounded-full bg-muted-foreground/10" style={{ width: "0%" }} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{(project.unassigned_teamspace_session_count ?? 0) > 0 ? (
|
{unassignedTeamspaceEntryCount > 0 || unassignedTeamspaceSubentryCount > 0 ? (
|
||||||
<p className="rounded-md border border-amber-500/30 bg-amber-500/10 px-2 py-1 text-xs text-amber-700 dark:text-amber-300">
|
<p className="rounded-md border border-amber-500/30 bg-amber-500/10 px-2 py-1 text-xs text-amber-700 dark:text-amber-300">
|
||||||
{project.unassigned_teamspace_session_count} Session(s) haben keinen Teamspace-Eintrag.
|
{[
|
||||||
|
unassignedTeamspaceEntryCount > 0 ? `${unassignedTeamspaceEntryCount} Session(s) ohne Teamspace-Projekt` : null,
|
||||||
|
unassignedTeamspaceSubentryCount > 0 ? `${unassignedTeamspaceSubentryCount} Session(s) ohne Unterposition` : null
|
||||||
|
].filter(Boolean).join(" · ")}
|
||||||
</p>
|
</p>
|
||||||
) : null}
|
) : null}
|
||||||
{project.close_review_required ? (
|
{project.close_review_required ? (
|
||||||
@@ -1788,8 +1829,8 @@ export function ProjectsPage({ onNavigate, onStartTimer }: ProjectsPageProps) {
|
|||||||
<span>
|
<span>
|
||||||
{project.zammad_merged_from_ticket_id || project.zammad_state_id !== "4"
|
{project.zammad_merged_from_ticket_id || project.zammad_state_id !== "4"
|
||||||
? project.zammad_merged_from_ticket_id
|
? project.zammad_merged_from_ticket_id
|
||||||
? `Dieses Projekt wurde aus ${project.zammad_merged_from_ticket_number ?? `Zammad-ID ${project.zammad_merged_from_ticket_id}`} übernommen. Teamspace-Projekte und Budget bleiben lokal erhalten; bitte in Zammad prüfen und nachtragen.`
|
? `Dieses Projekt wurde aus ${project.zammad_merged_from_ticket_number ?? `Zammad-ID ${project.zammad_merged_from_ticket_id}`} übernommen. Teamspace-Projekte bleiben lokal erhalten; bitte in Zammad prüfen und nachtragen.`
|
||||||
: "Dieses Projekt steht zur TS/TT-Prüfung, obwohl das Zammad-Ticket offen ist. Teamspace-Projekte und Budget bleiben lokal erhalten; bitte in Zammad prüfen und nachtragen."
|
: "Dieses Projekt steht zur TS/TT-Prüfung, obwohl das Zammad-Ticket offen ist. Teamspace-Projekte bleiben lokal erhalten; bitte in Zammad prüfen und nachtragen."
|
||||||
: "Zammad meldet das Consulting-Ticket als geschlossen. Bitte Zeiten in Teamspace und TicketTracker prüfen und das Projekt danach schließen."}
|
: "Zammad meldet das Consulting-Ticket als geschlossen. Bitte Zeiten in Teamspace und TicketTracker prüfen und das Projekt danach schließen."}
|
||||||
</span>
|
</span>
|
||||||
<Button
|
<Button
|
||||||
@@ -1805,12 +1846,15 @@ export function ProjectsPage({ onNavigate, onStartTimer }: ProjectsPageProps) {
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
{showBudgetedTeamspaceEntries ? (
|
{budgetExcluded ? (
|
||||||
|
<p className="text-xs text-muted-foreground">Für dieses Teamspace-Projekt werden keine Differenzen und kein Budgetverbrauch ausgewertet. Der Verbrauch zählt weiter in Summen.</p>
|
||||||
|
) : showBudgetedTeamspaceEntries ? (
|
||||||
<div className="grid gap-1.5 md:grid-cols-2">
|
<div className="grid gap-1.5 md:grid-cols-2">
|
||||||
{budgetedTeamspaceEntries.map((entry) => {
|
{budgetedTeamspaceEntries.map((entry) => {
|
||||||
const entryPercent = projectEntryPercent(entry);
|
const entryPercent = projectEntryPercent(entry);
|
||||||
const entryConsumption = projectEntryConsumption(entry);
|
const entryConsumption = projectEntryConsumption(entry);
|
||||||
const label = projectTeamspaceEntryLabel(entry);
|
const label = projectTeamspaceEntryLabel(entry);
|
||||||
|
const entryBudgetExceeded = Number(entry.hour_budget_minutes ?? 0) > 0 && entryConsumption > Number(entry.hour_budget_minutes ?? 0);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={entry.id} className="min-w-0 rounded-md border bg-background/50 px-2 py-1">
|
<div key={entry.id} className="min-w-0 rounded-md border bg-background/50 px-2 py-1">
|
||||||
@@ -1818,11 +1862,11 @@ export function ProjectsPage({ onNavigate, onStartTimer }: ProjectsPageProps) {
|
|||||||
<span className="truncate text-muted-foreground">
|
<span className="truncate text-muted-foreground">
|
||||||
{label}
|
{label}
|
||||||
{entry.is_default ? " · Standard" : ""}
|
{entry.is_default ? " · Standard" : ""}
|
||||||
{entry.budget_sync_disabled ? " · Budget geschützt" : ""}
|
|
||||||
</span>
|
|
||||||
<span className="shrink-0 font-medium">
|
|
||||||
{formatMinutes(entryConsumption)} / {entry.hour_budget_minutes ? formatMinutes(entry.hour_budget_minutes) : "offen"}
|
|
||||||
</span>
|
</span>
|
||||||
|
<span className="inline-flex shrink-0 items-center gap-1 font-medium">
|
||||||
|
{entryBudgetExceeded ? <AlertCircle className="size-3.5 text-amber-500" aria-label="Budgetüberschreitung" /> : null}
|
||||||
|
{formatMinutes(entryConsumption)} / {entry.hour_budget_minutes ? formatMinutes(entry.hour_budget_minutes) : "offen"}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="h-1 overflow-hidden rounded-full bg-muted">
|
<div className="h-1 overflow-hidden rounded-full bg-muted">
|
||||||
<div
|
<div
|
||||||
@@ -1854,10 +1898,11 @@ export function ProjectsPage({ onNavigate, onStartTimer }: ProjectsPageProps) {
|
|||||||
<Input type="date" value={editForm.startsOn} onChange={(event) => setEditForm({ ...editForm, startsOn: event.currentTarget.value })} />
|
<Input type="date" value={editForm.startsOn} onChange={(event) => setEditForm({ ...editForm, startsOn: event.currentTarget.value })} />
|
||||||
<Input type="date" value={editForm.endsOn} onChange={(event) => setEditForm({ ...editForm, endsOn: event.currentTarget.value })} />
|
<Input type="date" value={editForm.endsOn} onChange={(event) => setEditForm({ ...editForm, endsOn: event.currentTarget.value })} />
|
||||||
</div>
|
</div>
|
||||||
<ProjectTeamspaceEntriesFields
|
<ProjectTeamspaceEntriesFields
|
||||||
entries={editForm.teamspaceEntries}
|
entries={editForm.teamspaceEntries}
|
||||||
onChange={(teamspaceEntries) => setEditForm({ ...editForm, teamspaceEntries })}
|
onChange={(teamspaceEntries) => setEditForm({ ...editForm, teamspaceEntries })}
|
||||||
/>
|
showManualConsumption={teamspaceTimeTrackingEnabled}
|
||||||
|
/>
|
||||||
<Textarea className="min-h-24" value={editForm.description} onChange={(event) => setEditForm({ ...editForm, description: event.currentTarget.value })} />
|
<Textarea className="min-h-24" value={editForm.description} onChange={(event) => setEditForm({ ...editForm, description: event.currentTarget.value })} />
|
||||||
<div className="flex flex-wrap justify-end gap-2">
|
<div className="flex flex-wrap justify-end gap-2">
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@@ -26,6 +26,9 @@ type RecurringFormState = {
|
|||||||
organizationName: string | null;
|
organizationName: string | null;
|
||||||
activity: string;
|
activity: string;
|
||||||
workType: WorkType;
|
workType: WorkType;
|
||||||
|
teamspaceProject: string;
|
||||||
|
hourBudgetHours: string;
|
||||||
|
manualConsumptionHours: string;
|
||||||
recurrenceType: "weekly" | "every_n_weeks";
|
recurrenceType: "weekly" | "every_n_weeks";
|
||||||
intervalValue: number;
|
intervalValue: number;
|
||||||
validFrom: string;
|
validFrom: string;
|
||||||
@@ -50,6 +53,9 @@ function emptyRecurringForm(): RecurringFormState {
|
|||||||
organizationName: null,
|
organizationName: null,
|
||||||
activity: "",
|
activity: "",
|
||||||
workType: "support",
|
workType: "support",
|
||||||
|
teamspaceProject: "",
|
||||||
|
hourBudgetHours: "",
|
||||||
|
manualConsumptionHours: "",
|
||||||
recurrenceType: "weekly",
|
recurrenceType: "weekly",
|
||||||
intervalValue: 1,
|
intervalValue: 1,
|
||||||
validFrom: currentDay(),
|
validFrom: currentDay(),
|
||||||
@@ -118,6 +124,9 @@ export function RecurringBillingsPage() {
|
|||||||
organizationName: billing.organization_name,
|
organizationName: billing.organization_name,
|
||||||
activity: billing.activity,
|
activity: billing.activity,
|
||||||
workType: billing.work_type,
|
workType: billing.work_type,
|
||||||
|
teamspaceProject: billing.teamspace_project ?? "",
|
||||||
|
hourBudgetHours: billing.hour_budget_minutes === null ? "" : durationHoursLabel(billing.hour_budget_minutes),
|
||||||
|
manualConsumptionHours: billing.manual_consumption_minutes > 0 ? durationHoursLabel(billing.manual_consumption_minutes) : "",
|
||||||
recurrenceType: billing.recurrence_type,
|
recurrenceType: billing.recurrence_type,
|
||||||
intervalValue: billing.recurrence_type === "weekly" ? 1 : billing.interval_value,
|
intervalValue: billing.recurrence_type === "weekly" ? 1 : billing.interval_value,
|
||||||
validFrom: billing.valid_from,
|
validFrom: billing.valid_from,
|
||||||
@@ -162,8 +171,26 @@ export function RecurringBillingsPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let slots: Array<{ id?: string; weekday: number | null; startTime: string; durationMinutes: number }>;
|
let slots: Array<{ id?: string; weekday: number | null; startTime: string; durationMinutes: number }>;
|
||||||
|
let hourBudgetMinutes: number | null;
|
||||||
|
let manualConsumptionMinutes: number;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
const parseOptionalHours = (value: string, label: string) => {
|
||||||
|
if (!value.trim()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const hours = Number(value.replace(",", "."));
|
||||||
|
|
||||||
|
if (!Number.isFinite(hours) || hours < 0) {
|
||||||
|
throw new Error(`${label} prüfen`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Math.round(hours * 60);
|
||||||
|
};
|
||||||
|
|
||||||
|
hourBudgetMinutes = parseOptionalHours(recurringForm.hourBudgetHours, "Budget");
|
||||||
|
manualConsumptionMinutes = parseOptionalHours(recurringForm.manualConsumptionHours, "Manuelle Stunden") ?? 0;
|
||||||
slots = recurringForm.slots.map((slot) => {
|
slots = recurringForm.slots.map((slot) => {
|
||||||
const hours = Number(slot.durationHours.replace(",", "."));
|
const hours = Number(slot.durationHours.replace(",", "."));
|
||||||
|
|
||||||
@@ -191,6 +218,9 @@ export function RecurringBillingsPage() {
|
|||||||
organizationId: recurringForm.organizationId,
|
organizationId: recurringForm.organizationId,
|
||||||
activity: recurringForm.activity,
|
activity: recurringForm.activity,
|
||||||
workType: recurringForm.workType,
|
workType: recurringForm.workType,
|
||||||
|
teamspaceProject: recurringForm.teamspaceProject.trim() || null,
|
||||||
|
hourBudgetMinutes,
|
||||||
|
manualConsumptionMinutes,
|
||||||
recurrenceType: recurringForm.recurrenceType,
|
recurrenceType: recurringForm.recurrenceType,
|
||||||
intervalValue: recurringForm.recurrenceType === "weekly" ? 1 : recurringForm.intervalValue,
|
intervalValue: recurringForm.recurrenceType === "weekly" ? 1 : recurringForm.intervalValue,
|
||||||
validFrom: recurringForm.validFrom,
|
validFrom: recurringForm.validFrom,
|
||||||
@@ -355,6 +385,44 @@ export function RecurringBillingsPage() {
|
|||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-3 lg:grid-cols-[minmax(220px,1fr)_180px_180px]">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-sm font-medium" htmlFor="recurring-teamspace-project">
|
||||||
|
TS-Projekt (optional)
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
id="recurring-teamspace-project"
|
||||||
|
value={recurringForm.teamspaceProject}
|
||||||
|
onChange={(event) => setRecurringForm({ ...recurringForm, teamspaceProject: event.currentTarget.value })}
|
||||||
|
placeholder="Teamspace-Projekt"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-sm font-medium" htmlFor="recurring-budget-hours">
|
||||||
|
Budget in Stunden (optional)
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
id="recurring-budget-hours"
|
||||||
|
inputMode="decimal"
|
||||||
|
value={recurringForm.hourBudgetHours}
|
||||||
|
onChange={(event) => setRecurringForm({ ...recurringForm, hourBudgetHours: event.currentTarget.value })}
|
||||||
|
placeholder="z. B. 20"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-sm font-medium" htmlFor="recurring-manual-hours">
|
||||||
|
Manuelle Stunden (optional)
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
id="recurring-manual-hours"
|
||||||
|
inputMode="decimal"
|
||||||
|
value={recurringForm.manualConsumptionHours}
|
||||||
|
onChange={(event) => setRecurringForm({ ...recurringForm, manualConsumptionHours: event.currentTarget.value })}
|
||||||
|
placeholder="z. B. 2,5"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label className="text-sm font-medium" htmlFor="recurring-activity">
|
<label className="text-sm font-medium" htmlFor="recurring-activity">
|
||||||
Tätigkeit
|
Tätigkeit
|
||||||
@@ -473,6 +541,9 @@ export function RecurringBillingsPage() {
|
|||||||
{billing.valid_until ? ` bis ${formatDateLabel(billing.valid_until)}` : ""}
|
{billing.valid_until ? ` bis ${formatDateLabel(billing.valid_until)}` : ""}
|
||||||
</p>
|
</p>
|
||||||
<div className="mt-1 flex flex-wrap gap-1">
|
<div className="mt-1 flex flex-wrap gap-1">
|
||||||
|
{billing.teamspace_project ? <Badge variant="secondary">TS {billing.teamspace_project}</Badge> : null}
|
||||||
|
{billing.hour_budget_minutes !== null ? <Badge variant="outline">Budget {formatMinutes(billing.hour_budget_minutes)}</Badge> : null}
|
||||||
|
{billing.manual_consumption_minutes > 0 ? <Badge variant="outline">Manuell {formatMinutes(billing.manual_consumption_minutes)}</Badge> : null}
|
||||||
{billing.slots.map((slot) => (
|
{billing.slots.map((slot) => (
|
||||||
<Badge key={slot.id} variant="outline">
|
<Badge key={slot.id} variant="outline">
|
||||||
{slot.weekday === null ? "Starttag" : weekdays.find((weekday) => weekday.value === slot.weekday)?.label} · {slot.start_time?.slice(0, 5)} · {formatMinutes(slot.duration_minutes)}
|
{slot.weekday === null ? "Starttag" : weekdays.find((weekday) => weekday.value === slot.weekday)?.label} · {slot.start_time?.slice(0, 5)} · {formatMinutes(slot.duration_minutes)}
|
||||||
|
|||||||
@@ -27,9 +27,10 @@ import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@
|
|||||||
import { HelpLink } from "@/components/HelpLink";
|
import { HelpLink } from "@/components/HelpLink";
|
||||||
import { getProjectFollowups, getStatisticsOverview } from "../api";
|
import { getProjectFollowups, getStatisticsOverview } from "../api";
|
||||||
import { currentMonth, formatDate, formatDateTime, formatMinutes } from "../format";
|
import { currentMonth, formatDate, formatDateTime, formatMinutes } from "../format";
|
||||||
import type { StatisticsDailyBucket, StatisticsGroup, StatisticsOverview, StatisticsProjectFollowup, StatisticsProjectFollowupOverview, StatisticsTicket, WorkType } from "../types";
|
import type { AuthUser, StatisticsDailyBucket, StatisticsGroup, StatisticsOverview, StatisticsProjectFollowup, StatisticsProjectFollowupOverview, StatisticsTicket, TeamspaceMonthActual, WorkType } from "../types";
|
||||||
|
|
||||||
type StatisticsPageProps = {
|
type StatisticsPageProps = {
|
||||||
|
currentUser: AuthUser;
|
||||||
onNavigate: (to: string) => void;
|
onNavigate: (to: string) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -75,6 +76,63 @@ function formatTeamspaceDelta(minutes: number) {
|
|||||||
return formatSignedMinutes(minutes);
|
return formatSignedMinutes(minutes);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function teamspaceActualLabel(actual: TeamspaceMonthActual) {
|
||||||
|
return actual.teamspace_description?.trim() || actual.teamspace_project || actual.teamspace_api_project_id || "Teamspace";
|
||||||
|
}
|
||||||
|
|
||||||
|
function TeamspaceMonthActualsLine({
|
||||||
|
actuals,
|
||||||
|
tsMinutes,
|
||||||
|
deltaMinutes
|
||||||
|
}: {
|
||||||
|
actuals: TeamspaceMonthActual[] | undefined;
|
||||||
|
tsMinutes: number | undefined;
|
||||||
|
deltaMinutes: number | undefined;
|
||||||
|
}) {
|
||||||
|
const visibleActuals = actuals ?? [];
|
||||||
|
|
||||||
|
if (visibleActuals.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const effectiveTsMinutes = tsMinutes ?? visibleActuals.reduce((sum, actual) => sum + actual.teamspace_billable_minutes, 0);
|
||||||
|
const effectiveDeltaMinutes = deltaMinutes ?? visibleActuals.reduce((sum, actual) => sum + actual.delta_minutes, 0);
|
||||||
|
const positions = visibleActuals.flatMap((actual) =>
|
||||||
|
(actual.positions ?? []).map((position) => ({
|
||||||
|
...position,
|
||||||
|
entryLabel: teamspaceActualLabel(actual)
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mt-1 space-y-1 text-[11px] text-muted-foreground">
|
||||||
|
<div className="flex flex-wrap items-center gap-1.5">
|
||||||
|
<Badge variant={effectiveDeltaMinutes === 0 ? "success" : "warning"} className="h-5 px-1.5 text-[10px]">
|
||||||
|
TS API {formatMinutes(effectiveTsMinutes)} · Diff {formatTeamspaceDelta(effectiveDeltaMinutes)}
|
||||||
|
</Badge>
|
||||||
|
{visibleActuals
|
||||||
|
.filter((actual) => actual.delta_minutes !== 0)
|
||||||
|
.slice(0, 3)
|
||||||
|
.map((actual) => (
|
||||||
|
<span key={actual.entry_id} className="rounded-sm bg-muted px-1.5 py-0.5">
|
||||||
|
{teamspaceActualLabel(actual)}: {formatTeamspaceDelta(actual.delta_minutes)}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{positions.length > 0 ? (
|
||||||
|
<div className="flex flex-wrap gap-x-2 gap-y-0.5">
|
||||||
|
{positions.slice(0, 4).map((position) => (
|
||||||
|
<span key={`${position.entryLabel}-${position.apiProjectId}`} title={`${position.entryLabel} · API ${position.apiProjectId}`}>
|
||||||
|
{position.isSubentry ? `Pos. ${position.projectId ?? position.apiProjectId}` : "Hauptprojekt"}: {position.label} · {formatMinutes(position.billableMinutes)}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
{positions.length > 4 ? <span>+{positions.length - 4} weitere</span> : null}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function deltaTextClass(minutes: number) {
|
function deltaTextClass(minutes: number) {
|
||||||
return minutes < 0 ? "text-red-700 dark:text-red-300" : "text-emerald-700 dark:text-emerald-300";
|
return minutes < 0 ? "text-red-700 dark:text-red-300" : "text-emerald-700 dark:text-emerald-300";
|
||||||
}
|
}
|
||||||
@@ -89,27 +147,27 @@ function DeltaBadge({ minutes }: { minutes: number }) {
|
|||||||
|
|
||||||
function deltaDescription(minutes: number) {
|
function deltaDescription(minutes: number) {
|
||||||
if (minutes > 0) {
|
if (minutes > 0) {
|
||||||
return "Teamspace liegt über der abgerechneten Zeit";
|
return "TT-Teamspace liegt über der Session-Zeit";
|
||||||
}
|
}
|
||||||
|
|
||||||
if (minutes < 0) {
|
if (minutes < 0) {
|
||||||
return "Teamspace liegt unter der abgerechneten Zeit";
|
return "TT-Teamspace liegt unter der Session-Zeit";
|
||||||
}
|
}
|
||||||
|
|
||||||
return "Teamspace und abgerechnete Zeit sind ausgeglichen";
|
return "TT-Teamspace und Session-Zeit sind ausgeglichen";
|
||||||
}
|
}
|
||||||
|
|
||||||
function TimeComparison({ trackedMinutes, crmMinutes, sessions }: { trackedMinutes: number; crmMinutes: number; sessions?: number }) {
|
function TimeComparison({ trackedMinutes, crmMinutes, sessions }: { trackedMinutes: number; crmMinutes: number; sessions?: number }) {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-0.5 text-sm">
|
<div className="space-y-0.5 text-sm">
|
||||||
<div className={`font-semibold ${trackedTextClass}`}>Abgerechnet {formatMinutes(trackedMinutes)}</div>
|
<div className={`font-semibold ${trackedTextClass}`}>Session-Zeit {formatMinutes(trackedMinutes)}</div>
|
||||||
<div className={`font-semibold ${teamspaceTextClass}`}>Teamspace {formatMinutes(crmMinutes)}</div>
|
<div className={`font-semibold ${teamspaceTextClass}`}>TT-Teamspace {formatMinutes(crmMinutes)}</div>
|
||||||
{typeof sessions === "number" ? <div className="text-xs text-muted-foreground">{sessions} Session(s)</div> : null}
|
{typeof sessions === "number" ? <div className="text-xs text-muted-foreground">{sessions} Session(s)</div> : null}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function ColorLegend() {
|
function ColorLegend({ showDifference = true }: { showDifference?: boolean }) {
|
||||||
return (
|
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">
|
<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="inline-flex items-center gap-1.5">
|
||||||
@@ -118,7 +176,7 @@ function ColorLegend() {
|
|||||||
</span>
|
</span>
|
||||||
<span className="inline-flex items-center gap-1.5">
|
<span className="inline-flex items-center gap-1.5">
|
||||||
<span className={`h-0.5 w-4 ${teamspaceDotClass}`} />
|
<span className={`h-0.5 w-4 ${teamspaceDotClass}`} />
|
||||||
<span className={teamspaceTextClass}>Teamspace</span>
|
<span className={teamspaceTextClass}>TT-Teamspace</span>
|
||||||
</span>
|
</span>
|
||||||
<span className="inline-flex items-center gap-1.5">
|
<span className="inline-flex items-center gap-1.5">
|
||||||
<span className="size-2 rounded-sm bg-slate-500 dark:bg-slate-400" />
|
<span className="size-2 rounded-sm bg-slate-500 dark:bg-slate-400" />
|
||||||
@@ -128,14 +186,18 @@ function ColorLegend() {
|
|||||||
<span className="size-2 rounded-sm bg-indigo-500 dark:bg-indigo-400" />
|
<span className="size-2 rounded-sm bg-indigo-500 dark:bg-indigo-400" />
|
||||||
<span className={nonBillableTextClass}>Nicht abrechenbar</span>
|
<span className={nonBillableTextClass}>Nicht abrechenbar</span>
|
||||||
</span>
|
</span>
|
||||||
<span className="inline-flex items-center gap-1.5">
|
{showDifference ? (
|
||||||
<span className="size-2 rounded-full bg-emerald-500 dark:bg-emerald-400" />
|
<>
|
||||||
<span className="text-emerald-700 dark:text-emerald-300">Differenz plus oder ausgeglichen</span>
|
<span className="inline-flex items-center gap-1.5">
|
||||||
</span>
|
<span className="size-2 rounded-full bg-emerald-500 dark:bg-emerald-400" />
|
||||||
<span className="inline-flex items-center gap-1.5">
|
<span className="text-emerald-700 dark:text-emerald-300">Differenz plus oder ausgeglichen</span>
|
||||||
<span className="size-2 rounded-full bg-red-500 dark:bg-red-400" />
|
</span>
|
||||||
<span className="text-red-700 dark:text-red-300">Differenz minus</span>
|
<span className="inline-flex items-center gap-1.5">
|
||||||
</span>
|
<span className="size-2 rounded-full bg-red-500 dark:bg-red-400" />
|
||||||
|
<span className="text-red-700 dark:text-red-300">Differenz minus</span>
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
<span className="inline-flex items-center gap-1.5">
|
<span className="inline-flex items-center gap-1.5">
|
||||||
<span className="size-2 rounded-full bg-amber-500 dark:bg-amber-400" />
|
<span className="size-2 rounded-full bg-amber-500 dark:bg-amber-400" />
|
||||||
<span className={openTextClass}>offen</span>
|
<span className={openTextClass}>offen</span>
|
||||||
@@ -210,14 +272,14 @@ function buildMonthSeries(month: string, buckets: StatisticsDailyBucket[]) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function DailyEffortChart({ month, buckets }: { month: string; buckets: StatisticsDailyBucket[] }) {
|
function DailyEffortChart({ month, buckets, showTeamspaceLine = true }: { month: string; buckets: StatisticsDailyBucket[]; showTeamspaceLine?: boolean }) {
|
||||||
const series = buildMonthSeries(month, buckets);
|
const series = buildMonthSeries(month, buckets);
|
||||||
const width = 900;
|
const width = 900;
|
||||||
const height = 220;
|
const height = 220;
|
||||||
const padding = { top: 18, right: 20, bottom: 36, left: 64 };
|
const padding = { top: 18, right: 20, bottom: 36, left: 64 };
|
||||||
const innerWidth = width - padding.left - padding.right;
|
const innerWidth = width - padding.left - padding.right;
|
||||||
const innerHeight = height - padding.top - padding.bottom;
|
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 ticks = Array.from({ length: 5 }).map((_, index) => maxMinutes - (index * maxMinutes) / 4);
|
||||||
const labelStep = Math.max(1, Math.ceil(series.length / 9));
|
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));
|
const barWidth = Math.max(5, Math.min(16, innerWidth / Math.max(series.length, 1) / 2));
|
||||||
@@ -231,11 +293,11 @@ function DailyEffortChart({ month, buckets }: { month: string; buckets: Statisti
|
|||||||
crmY: padding.top + (1 - bucket.crmBilledMinutes / maxMinutes) * innerHeight
|
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 (
|
return (
|
||||||
<div className="h-56 w-full overflow-hidden rounded-md border bg-background">
|
<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">
|
<svg viewBox={`0 0 ${width} ${height}`} className="h-full w-full">
|
||||||
{ticks.map((tick) => {
|
{ticks.map((tick) => {
|
||||||
const y = padding.top + (1 - tick / maxMinutes) * innerHeight;
|
const y = padding.top + (1 - tick / maxMinutes) * innerHeight;
|
||||||
@@ -258,11 +320,11 @@ function DailyEffortChart({ month, buckets }: { month: string; buckets: Statisti
|
|||||||
) : null;
|
) : null;
|
||||||
})}
|
})}
|
||||||
{crmPath ? <path d={crmPath} fill="none" stroke="currentColor" strokeWidth="2.5" className={teamspaceStrokeClass} /> : null}
|
{crmPath ? <path d={crmPath} fill="none" stroke="currentColor" strokeWidth="2.5" className={teamspaceStrokeClass} /> : null}
|
||||||
{points
|
{showTeamspaceLine ? points
|
||||||
.filter((point) => point.bucket.crmBilledMinutes > 0)
|
.filter((point) => point.bucket.crmBilledMinutes > 0)
|
||||||
.map((point) => (
|
.map((point) => (
|
||||||
<circle key={`${point.bucket.day}-crm`} cx={point.x} cy={point.crmY} r="3" className={`fill-background ${teamspaceStrokeClass}`} stroke="currentColor" strokeWidth="2" />
|
<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) =>
|
{points.map((point, index) =>
|
||||||
index % labelStep === 0 || index === points.length - 1 ? (
|
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]">
|
<text key={`${point.bucket.day}-label`} x={point.x} y={height - 11} textAnchor="middle" className="fill-muted-foreground text-[11px]">
|
||||||
@@ -312,7 +374,17 @@ function MetricCard({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function OrganizationRow({ organization, maxMinutes, totalMinutes }: { organization: StatisticsGroup; maxMinutes: number; totalMinutes: number }) {
|
function OrganizationRow({
|
||||||
|
organization,
|
||||||
|
maxMinutes,
|
||||||
|
totalMinutes,
|
||||||
|
showDifference = true
|
||||||
|
}: {
|
||||||
|
organization: StatisticsGroup;
|
||||||
|
maxMinutes: number;
|
||||||
|
totalMinutes: number;
|
||||||
|
showDifference?: boolean;
|
||||||
|
}) {
|
||||||
const monthlyShare = percentage(organization.total_minutes, totalMinutes);
|
const monthlyShare = percentage(organization.total_minutes, totalMinutes);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -327,9 +399,11 @@ function OrganizationRow({ organization, maxMinutes, totalMinutes }: { organizat
|
|||||||
<TableCell>
|
<TableCell>
|
||||||
<TimeComparison trackedMinutes={organization.total_minutes} crmMinutes={organization.crm_billed_minutes} />
|
<TimeComparison trackedMinutes={organization.total_minutes} crmMinutes={organization.crm_billed_minutes} />
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
{showDifference ? (
|
||||||
<DeltaBadge minutes={organization.crm_delta_minutes} />
|
<TableCell>
|
||||||
</TableCell>
|
<DeltaBadge minutes={organization.crm_delta_minutes} />
|
||||||
|
</TableCell>
|
||||||
|
) : null}
|
||||||
<TableCell className="min-w-[150px]">
|
<TableCell className="min-w-[150px]">
|
||||||
<Progress value={percentage(organization.total_minutes, maxMinutes)} />
|
<Progress value={percentage(organization.total_minutes, maxMinutes)} />
|
||||||
<div className="mt-1 text-xs text-muted-foreground">{monthlyShare}% vom Monat</div>
|
<div className="mt-1 text-xs text-muted-foreground">{monthlyShare}% vom Monat</div>
|
||||||
@@ -338,7 +412,17 @@ function OrganizationRow({ organization, maxMinutes, totalMinutes }: { organizat
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function TicketHotspot({ ticket, month, onNavigate }: { ticket: StatisticsTicket; month: string; onNavigate: (to: string) => void }) {
|
function TicketHotspot({
|
||||||
|
ticket,
|
||||||
|
month,
|
||||||
|
showDifference = true,
|
||||||
|
onNavigate
|
||||||
|
}: {
|
||||||
|
ticket: StatisticsTicket;
|
||||||
|
month: string;
|
||||||
|
showDifference?: boolean;
|
||||||
|
onNavigate: (to: string) => void;
|
||||||
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className="grid gap-2 rounded-md border bg-background p-3 sm:grid-cols-[1fr_auto] sm:items-center">
|
<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">
|
<div className="min-w-0">
|
||||||
@@ -349,12 +433,17 @@ function TicketHotspot({ ticket, month, onNavigate }: { ticket: StatisticsTicket
|
|||||||
<p className="truncate text-sm text-muted-foreground">{ticket.organization_name ?? "Keine Organisation"}</p>
|
<p className="truncate text-sm text-muted-foreground">{ticket.organization_name ?? "Keine Organisation"}</p>
|
||||||
<div className="mt-1 flex flex-wrap gap-x-3 gap-y-1 text-xs">
|
<div className="mt-1 flex flex-wrap gap-x-3 gap-y-1 text-xs">
|
||||||
<span className={trackedTextClass}>Abgerechnet {formatMinutes(ticket.total_minutes)}</span>
|
<span className={trackedTextClass}>Abgerechnet {formatMinutes(ticket.total_minutes)}</span>
|
||||||
<span className={teamspaceTextClass}>Teamspace {formatMinutes(ticket.crm_billed_minutes)}</span>
|
<span className={teamspaceTextClass}>TT-Teamspace {formatMinutes(ticket.crm_billed_minutes)}</span>
|
||||||
<span className="text-muted-foreground">{ticket.billed_sessions} abgerechnet</span>
|
<span className="text-muted-foreground">{ticket.billed_sessions} abgerechnet</span>
|
||||||
<span className="text-muted-foreground">{ticket.billable_sessions} abrechenbar</span>
|
<span className="text-muted-foreground">{ticket.billable_sessions} abrechenbar</span>
|
||||||
<span>{ticket.active_days} Tag(e)</span>
|
<span>{ticket.active_days} Tag(e)</span>
|
||||||
<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>
|
||||||
|
<TeamspaceMonthActualsLine
|
||||||
|
actuals={ticket.teamspace_month_actuals}
|
||||||
|
tsMinutes={ticket.teamspace_month_billable_minutes}
|
||||||
|
deltaMinutes={ticket.teamspace_month_delta_minutes}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<Button variant="secondary" size="sm" onClick={() => onNavigate(`/tickets/${ticket.ticket_id}?month=${month}`)}>
|
<Button variant="secondary" size="sm" onClick={() => onNavigate(`/tickets/${ticket.ticket_id}?month=${month}`)}>
|
||||||
Öffnen
|
Öffnen
|
||||||
@@ -376,14 +465,17 @@ function daysSince(value: string) {
|
|||||||
|
|
||||||
function ProjectFollowupRow({ project, onNavigate }: { project: StatisticsProjectFollowup; onNavigate: (to: string) => void }) {
|
function ProjectFollowupRow({ project, onNavigate }: { project: StatisticsProjectFollowup; onNavigate: (to: string) => void }) {
|
||||||
const inactiveDays = daysSince(project.latest_tt_update);
|
const inactiveDays = daysSince(project.latest_tt_update);
|
||||||
const hasBudget = project.hour_budget_minutes !== null;
|
const budgetExcluded = Boolean(project.teamspace_budget_excluded);
|
||||||
|
const hasBudget = !budgetExcluded && project.hour_budget_minutes !== null;
|
||||||
|
|
||||||
return (
|
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="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="min-w-0 space-y-2">
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
<span className="font-semibold">{project.ticket_number}</span>
|
<span className="font-semibold">{project.ticket_number}</span>
|
||||||
{hasBudget ? (
|
{budgetExcluded ? (
|
||||||
|
<Badge variant="outline">Budgetprüfung ausgenommen</Badge>
|
||||||
|
) : hasBudget ? (
|
||||||
<Badge variant={project.remaining_minutes !== null && project.remaining_minutes > 0 ? "warning" : "secondary"}>
|
<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"}
|
{project.remaining_minutes !== null && project.remaining_minutes > 0 ? `${formatMinutes(project.remaining_minutes)} offen` : "Budget verbraucht"}
|
||||||
</Badge>
|
</Badge>
|
||||||
@@ -402,7 +494,7 @@ function ProjectFollowupRow({ project, onNavigate }: { project: StatisticsProjec
|
|||||||
<div className="flex flex-wrap gap-x-3 gap-y-1 text-xs text-muted-foreground">
|
<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>
|
<span>Letztes TT-Update {formatDateTime(project.latest_tt_update)}</span>
|
||||||
{inactiveDays !== null ? <span>{inactiveDays} Tag(e) inaktiv</span> : null}
|
{inactiveDays !== null ? <span>{inactiveDays} Tag(e) inaktiv</span> : null}
|
||||||
<span>Budget {hasBudget ? formatMinutes(project.hour_budget_minutes!) : "nicht gesetzt"}</span>
|
<span>Budget {budgetExcluded ? "Prüfung ausgenommen" : hasBudget ? formatMinutes(project.hour_budget_minutes!) : "nicht gesetzt"}</span>
|
||||||
<span>Verbraucht {formatMinutes(project.consumption_minutes)}</span>
|
<span>Verbraucht {formatMinutes(project.consumption_minutes)}</span>
|
||||||
<span>{project.open_todos} offene ToDo(s)</span>
|
<span>{project.open_todos} offene ToDo(s)</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -421,7 +513,7 @@ function ProjectFollowupRow({ project, onNavigate }: { project: StatisticsProjec
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function StatisticsPage({ onNavigate }: StatisticsPageProps) {
|
export function StatisticsPage({ currentUser, onNavigate }: StatisticsPageProps) {
|
||||||
const [month, setMonth] = useState(currentMonth());
|
const [month, setMonth] = useState(currentMonth());
|
||||||
const [stats, setStats] = useState<StatisticsOverview | null>(null);
|
const [stats, setStats] = useState<StatisticsOverview | null>(null);
|
||||||
const [followups, setFollowups] = useState<StatisticsProjectFollowupOverview | null>(null);
|
const [followups, setFollowups] = useState<StatisticsProjectFollowupOverview | null>(null);
|
||||||
@@ -523,6 +615,7 @@ export function StatisticsPage({ onNavigate }: StatisticsPageProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const totals = stats?.totals;
|
const totals = stats?.totals;
|
||||||
|
const teamspaceTimeTrackingEnabled = currentUser.teamspace_time_tracking_enabled ?? true;
|
||||||
const billedSessions = totals?.billedSessions ?? 0;
|
const billedSessions = totals?.billedSessions ?? 0;
|
||||||
const billedMinutes = totals?.billedMinutes ?? 0;
|
const billedMinutes = totals?.billedMinutes ?? 0;
|
||||||
const billableSessions = totals?.billableSessions ?? 0;
|
const billableSessions = totals?.billableSessions ?? 0;
|
||||||
@@ -558,7 +651,11 @@ export function StatisticsPage({ onNavigate }: StatisticsPageProps) {
|
|||||||
<div className="flex flex-col gap-3 xl:flex-row xl:items-end xl:justify-between">
|
<div className="flex flex-col gap-3 xl:flex-row xl:items-end xl:justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-2xl font-semibold tracking-normal">Statistiken</h2>
|
<h2 className="text-2xl font-semibold tracking-normal">Statistiken</h2>
|
||||||
<p className="text-sm text-muted-foreground">Monatswerte, Teamspace-Abgleich, Organisationen und offene Nacharbeit.</p>
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{teamspaceTimeTrackingEnabled
|
||||||
|
? "Monatswerte, TT-Teamspace-Abgleich, Organisationen und offene Nacharbeit."
|
||||||
|
: "Monatswerte, Session-Status, Organisationen und Teamspace-Istwerte."}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid gap-1 sm:w-[300px]">
|
<div className="grid gap-1 sm:w-[300px]">
|
||||||
<div className="flex items-center justify-between gap-2">
|
<div className="flex items-center justify-between gap-2">
|
||||||
@@ -605,9 +702,11 @@ export function StatisticsPage({ onNavigate }: StatisticsPageProps) {
|
|||||||
helpLabel="Hilfe zu Bewertungen"
|
helpLabel="Hilfe zu Bewertungen"
|
||||||
/>
|
/>
|
||||||
<MetricCard
|
<MetricCard
|
||||||
label="Teamspace"
|
label="TT-Teamspace"
|
||||||
value={formatMinutes(totals?.crmBilledMinutes ?? 0)}
|
value={formatMinutes(totals?.crmBilledMinutes ?? 0)}
|
||||||
detail={<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}
|
icon={TrendingUp}
|
||||||
valueClassName={teamspaceTextClass}
|
valueClassName={teamspaceTextClass}
|
||||||
helpAnchor="statistik-teamspace"
|
helpAnchor="statistik-teamspace"
|
||||||
@@ -633,17 +732,21 @@ export function StatisticsPage({ onNavigate }: StatisticsPageProps) {
|
|||||||
<CardHeader className="gap-3 p-4 sm:flex-row sm:items-start sm:justify-between sm:space-y-0">
|
<CardHeader className="gap-3 p-4 sm:flex-row sm:items-start sm:justify-between sm:space-y-0">
|
||||||
<div>
|
<div>
|
||||||
<CardTitle>Monatsverlauf und Abrechnungsqualität</CardTitle>
|
<CardTitle>Monatsverlauf und Abrechnungsqualität</CardTitle>
|
||||||
<CardDescription>Abgerechnete Zeit pro Tag, Teamspace-Linie und Bewertungsstand in einem Überblick.</CardDescription>
|
<CardDescription>
|
||||||
|
{teamspaceTimeTrackingEnabled
|
||||||
|
? "Abgerechnete Zeit pro Tag, TT-Teamspace-Linie und Bewertungsstand in einem Überblick."
|
||||||
|
: "Abgerechnete Zeit pro Tag und Bewertungsstand in einem Überblick."}
|
||||||
|
</CardDescription>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Badge variant="outline">{crmCoverage}% Teamspace-Abdeckung</Badge>
|
{teamspaceTimeTrackingEnabled ? <Badge variant="outline">{crmCoverage}% TT-Teamspace-Abdeckung</Badge> : <Badge variant="outline">Teamspace als Ist-Quelle</Badge>}
|
||||||
<HelpLink anchor="statistik-verlauf" label="Hilfe zu Monatsverlauf und Abrechnungsqualität" />
|
<HelpLink anchor="statistik-verlauf" label="Hilfe zu Monatsverlauf und Abrechnungsqualität" />
|
||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="grid gap-4 px-4 pb-4 xl:grid-cols-[1.55fr_1fr]">
|
<CardContent className="grid gap-4 px-4 pb-4 xl:grid-cols-[1.55fr_1fr]">
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<DailyEffortChart month={month} buckets={stats?.dailySeries ?? []} />
|
<DailyEffortChart month={month} buckets={stats?.dailySeries ?? []} showTeamspaceLine={teamspaceTimeTrackingEnabled} />
|
||||||
<ColorLegend />
|
<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}
|
{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>
|
||||||
|
|
||||||
@@ -661,17 +764,27 @@ export function StatisticsPage({ onNavigate }: StatisticsPageProps) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2 rounded-md border bg-muted/20 p-3">
|
{teamspaceTimeTrackingEnabled ? (
|
||||||
<div className="flex items-start justify-between gap-3">
|
<div className="space-y-2 rounded-md border bg-muted/20 p-3">
|
||||||
<div>
|
<div className="flex items-start justify-between gap-3">
|
||||||
<p className="text-sm font-medium">Teamspace-Abgleich</p>
|
<div>
|
||||||
<p className="text-xs text-muted-foreground">{deltaDescription(totals?.crmDeltaMinutes ?? 0)}</p>
|
<p className="text-sm font-medium">TT-Teamspace-Abgleich</p>
|
||||||
</div>
|
<p className="text-xs text-muted-foreground">{deltaDescription(totals?.crmDeltaMinutes ?? 0)}</p>
|
||||||
<DeltaBadge minutes={totals?.crmDeltaMinutes ?? 0} />
|
</div>
|
||||||
</div>
|
<DeltaBadge minutes={totals?.crmDeltaMinutes ?? 0} />
|
||||||
<TimeComparison trackedMinutes={totals?.minutes ?? 0} crmMinutes={totals?.crmBilledMinutes ?? 0} sessions={totals?.billedSessions ?? 0} />
|
</div>
|
||||||
<div className="text-xs text-muted-foreground">Teamspace-Abdeckung: {crmCoverage}% der abgerechneten Zeit</div>
|
<TimeComparison trackedMinutes={totals?.minutes ?? 0} crmMinutes={totals?.crmBilledMinutes ?? 0} sessions={totals?.billedSessions ?? 0} />
|
||||||
</div>
|
<div className="text-xs text-muted-foreground">TT-Teamspace-Abdeckung: {crmCoverage}% der Session-Zeit</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2 rounded-md border bg-muted/20 p-3">
|
||||||
|
<p className="text-sm font-medium">Teamspace als Ist-Quelle</p>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Für diesen Nutzer werden keine Teamspace-Zeiten in TT erfasst. Die Monatsprüfung bewertet nur den Session-Status.
|
||||||
|
</p>
|
||||||
|
<TimeComparison trackedMinutes={totals?.minutes ?? 0} crmMinutes={totals?.crmBilledMinutes ?? 0} sessions={totals?.billedSessions ?? 0} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-2 text-sm">
|
<div className="grid grid-cols-2 gap-2 text-sm">
|
||||||
<div className="rounded-md border bg-muted/20 p-3">
|
<div className="rounded-md border bg-muted/20 p-3">
|
||||||
@@ -699,7 +812,7 @@ export function StatisticsPage({ onNavigate }: StatisticsPageProps) {
|
|||||||
<p className="truncate font-semibold">{topOrganization.organization_name}</p>
|
<p className="truncate font-semibold">{topOrganization.organization_name}</p>
|
||||||
<p className="flex flex-wrap gap-x-3 gap-y-1 text-xs">
|
<p className="flex flex-wrap gap-x-3 gap-y-1 text-xs">
|
||||||
<span className={trackedTextClass}>Abgerechnet {formatMinutes(topOrganization.total_minutes)}</span>
|
<span className={trackedTextClass}>Abgerechnet {formatMinutes(topOrganization.total_minutes)}</span>
|
||||||
<span className={teamspaceTextClass}>Teamspace {formatMinutes(topOrganization.crm_billed_minutes)}</span>
|
<span className={teamspaceTextClass}>TT-Teamspace {formatMinutes(topOrganization.crm_billed_minutes)}</span>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
@@ -715,7 +828,11 @@ export function StatisticsPage({ onNavigate }: StatisticsPageProps) {
|
|||||||
<CardHeader className="gap-3 p-4 sm:flex-row sm:items-start sm:justify-between sm:space-y-0">
|
<CardHeader className="gap-3 p-4 sm:flex-row sm:items-start sm:justify-between sm:space-y-0">
|
||||||
<div>
|
<div>
|
||||||
<CardTitle>Statistik pro Organisation</CardTitle>
|
<CardTitle>Statistik pro Organisation</CardTitle>
|
||||||
<CardDescription>Kundenaufwand inklusive Teamspace-Differenz und Anteil an der abgerechneten Monatszeit.</CardDescription>
|
<CardDescription>
|
||||||
|
{teamspaceTimeTrackingEnabled
|
||||||
|
? "Kundenaufwand inklusive Teamspace-Differenz und Anteil an der abgerechneten Monatszeit."
|
||||||
|
: "Kundenaufwand und Anteil an der abgerechneten Monatszeit."}
|
||||||
|
</CardDescription>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Badge variant="outline">
|
<Badge variant="outline">
|
||||||
@@ -732,7 +849,7 @@ export function StatisticsPage({ onNavigate }: StatisticsPageProps) {
|
|||||||
<TableHead>Organisation</TableHead>
|
<TableHead>Organisation</TableHead>
|
||||||
<TableHead>Abgerechnet</TableHead>
|
<TableHead>Abgerechnet</TableHead>
|
||||||
<TableHead>Zeiten</TableHead>
|
<TableHead>Zeiten</TableHead>
|
||||||
<TableHead>Differenz</TableHead>
|
{teamspaceTimeTrackingEnabled ? <TableHead>Differenz</TableHead> : null}
|
||||||
<TableHead>Anteil abgerechnet</TableHead>
|
<TableHead>Anteil abgerechnet</TableHead>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
@@ -740,10 +857,11 @@ export function StatisticsPage({ onNavigate }: StatisticsPageProps) {
|
|||||||
{visibleOrganizations.map((organization) => (
|
{visibleOrganizations.map((organization) => (
|
||||||
<OrganizationRow
|
<OrganizationRow
|
||||||
key={organization.organization_id ?? organization.organization_name}
|
key={organization.organization_id ?? organization.organization_name}
|
||||||
organization={organization}
|
organization={organization}
|
||||||
maxMinutes={maxOrganizationMinutes}
|
maxMinutes={maxOrganizationMinutes}
|
||||||
totalMinutes={totals?.minutes ?? 0}
|
totalMinutes={totals?.minutes ?? 0}
|
||||||
/>
|
showDifference={teamspaceTimeTrackingEnabled}
|
||||||
|
/>
|
||||||
))}
|
))}
|
||||||
</TableBody>
|
</TableBody>
|
||||||
</Table>
|
</Table>
|
||||||
@@ -756,14 +874,14 @@ export function StatisticsPage({ onNavigate }: StatisticsPageProps) {
|
|||||||
<p className="truncate font-medium">{organization.organization_name ?? "Keine Organisation"}</p>
|
<p className="truncate font-medium">{organization.organization_name ?? "Keine Organisation"}</p>
|
||||||
<p className="text-xs text-muted-foreground">{organization.tickets} Ticket(s) · {organization.billed_sessions} abgerechnet · {organization.billable_sessions} abrechenbar</p>
|
<p className="text-xs text-muted-foreground">{organization.tickets} Ticket(s) · {organization.billed_sessions} abgerechnet · {organization.billable_sessions} abrechenbar</p>
|
||||||
</div>
|
</div>
|
||||||
<DeltaBadge minutes={organization.crm_delta_minutes} />
|
{teamspaceTimeTrackingEnabled ? <DeltaBadge minutes={organization.crm_delta_minutes} /> : null}
|
||||||
</div>
|
</div>
|
||||||
<Progress value={percentage(organization.total_minutes, maxOrganizationMinutes)} />
|
<Progress value={percentage(organization.total_minutes, maxOrganizationMinutes)} />
|
||||||
<div className="flex flex-wrap gap-x-3 gap-y-1 text-xs">
|
<div className="flex flex-wrap gap-x-3 gap-y-1 text-xs">
|
||||||
<span className={trackedTextClass}>Abgerechnet {formatMinutes(organization.total_minutes)}</span>
|
<span className={trackedTextClass}>Abgerechnet {formatMinutes(organization.total_minutes)}</span>
|
||||||
<span className={teamspaceTextClass}>Teamspace {formatMinutes(organization.crm_billed_minutes)}</span>
|
<span className={teamspaceTextClass}>TT-Teamspace {formatMinutes(organization.crm_billed_minutes)}</span>
|
||||||
<span className="text-muted-foreground">{percentage(organization.total_minutes, totals?.minutes ?? 0)}% vom Monat</span>
|
<span className="text-muted-foreground">{percentage(organization.total_minutes, totals?.minutes ?? 0)}% vom Monat</span>
|
||||||
<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>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
@@ -825,12 +943,12 @@ export function StatisticsPage({ onNavigate }: StatisticsPageProps) {
|
|||||||
<p className="font-semibold">{workTypeLabel(group.work_type)}</p>
|
<p className="font-semibold">{workTypeLabel(group.work_type)}</p>
|
||||||
<p className="text-xs text-muted-foreground">{group.tickets} Ticket(s), {group.billed_sessions} abgerechnet, {group.billable_sessions} abrechenbar</p>
|
<p className="text-xs text-muted-foreground">{group.tickets} Ticket(s), {group.billed_sessions} abgerechnet, {group.billable_sessions} abrechenbar</p>
|
||||||
</div>
|
</div>
|
||||||
<DeltaBadge minutes={group.crm_delta_minutes} />
|
{teamspaceTimeTrackingEnabled ? <DeltaBadge minutes={group.crm_delta_minutes} /> : null}
|
||||||
</div>
|
</div>
|
||||||
<Progress value={percentage(group.total_minutes, totals?.minutes ?? 0)} />
|
<Progress value={percentage(group.total_minutes, totals?.minutes ?? 0)} />
|
||||||
<div className="flex flex-wrap gap-x-3 gap-y-1 text-xs">
|
<div className="flex flex-wrap gap-x-3 gap-y-1 text-xs">
|
||||||
<span className={trackedTextClass}>Abgerechnet {formatMinutes(group.total_minutes)}</span>
|
<span className={trackedTextClass}>Abgerechnet {formatMinutes(group.total_minutes)}</span>
|
||||||
<span className={teamspaceTextClass}>Teamspace {formatMinutes(group.crm_billed_minutes)}</span>
|
<span className={teamspaceTextClass}>TT-Teamspace {formatMinutes(group.crm_billed_minutes)}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid gap-2 text-xs text-muted-foreground sm:grid-cols-3">
|
<div className="grid gap-2 text-xs text-muted-foreground sm:grid-cols-3">
|
||||||
<span>Abr. {group.billed_sessions} / {formatMinutes(group.billed_minutes)}</span>
|
<span>Abr. {group.billed_sessions} / {formatMinutes(group.billed_minutes)}</span>
|
||||||
@@ -863,7 +981,7 @@ export function StatisticsPage({ onNavigate }: StatisticsPageProps) {
|
|||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-2 px-4 pb-4">
|
<CardContent className="space-y-2 px-4 pb-4">
|
||||||
{(stats?.tickets ?? []).map((ticket) => (
|
{(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}
|
{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>
|
</CardContent>
|
||||||
|
|||||||
+1133
-292
File diff suppressed because it is too large.
Load diff
@@ -17,7 +17,7 @@ import type { CreateSessionResult } from "../api";
|
|||||||
import { formatTimer } from "../format";
|
import { formatTimer } from "../format";
|
||||||
import { isRecurringBillingTicketNumber, ticketHasRecurringBilling } from "../recurring-billings";
|
import { isRecurringBillingTicketNumber, ticketHasRecurringBilling } from "../recurring-billings";
|
||||||
import { activeElapsedMs, mergeTimerActivityDrafts, removeTimerActivityDraft, ticketPattern, type TimerEntry, writeTimerActivityDraft } from "../timers";
|
import { activeElapsedMs, mergeTimerActivityDrafts, removeTimerActivityDraft, ticketPattern, type TimerEntry, writeTimerActivityDraft } from "../timers";
|
||||||
import type { AuthUser, BillingStatus, RecurringBilling, TicketMeta, WorkType } from "../types";
|
import type { AuthUser, BillingStatus, RecurringBilling, TicketMeta, WorkSegment, WorkType } from "../types";
|
||||||
import { zammadArticleTypeFromValue, zammadArticleTypeOptions, zammadArticleTypeValue, type ZammadArticleTypeId } from "../zammad-article-types";
|
import { zammadArticleTypeFromValue, zammadArticleTypeOptions, zammadArticleTypeValue, type ZammadArticleTypeId } from "../zammad-article-types";
|
||||||
|
|
||||||
type ManualSessionFormState = {
|
type ManualSessionFormState = {
|
||||||
@@ -59,6 +59,10 @@ function emptyManualSession(): ManualSessionFormState {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function hasActivityContent(activity: string, zammadArticleBody: string | null | undefined) {
|
||||||
|
return activity.trim().length > 0 || Boolean(zammadArticleBody);
|
||||||
|
}
|
||||||
|
|
||||||
type FinishedSession = {
|
type FinishedSession = {
|
||||||
timerId: string;
|
timerId: string;
|
||||||
ticketNumber: string;
|
ticketNumber: string;
|
||||||
@@ -73,6 +77,7 @@ type FinishedSession = {
|
|||||||
endedAt: string;
|
endedAt: string;
|
||||||
durationSeconds: number;
|
durationSeconds: number;
|
||||||
roundedMinutes: number;
|
roundedMinutes: number;
|
||||||
|
workSegments: WorkSegment[];
|
||||||
};
|
};
|
||||||
|
|
||||||
function ticketTeamspaceEntries(ticket: TicketMeta | null | undefined) {
|
function ticketTeamspaceEntries(ticket: TicketMeta | null | undefined) {
|
||||||
@@ -100,6 +105,8 @@ type TimerPageProps = {
|
|||||||
setSelectedTimerId: Dispatch<SetStateAction<string | null>>;
|
setSelectedTimerId: Dispatch<SetStateAction<string | null>>;
|
||||||
tick: number;
|
tick: number;
|
||||||
currentUser: AuthUser;
|
currentUser: AuthUser;
|
||||||
|
finishRequestId?: string | null;
|
||||||
|
onFinishRequestHandled?: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
function BillingStatusSelect({
|
function BillingStatusSelect({
|
||||||
@@ -293,7 +300,7 @@ function InlineImageActivityEditor({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function TimerPage({ timers, setTimers, selectedTimerId, setSelectedTimerId, tick, currentUser }: TimerPageProps) {
|
export function TimerPage({ timers, setTimers, selectedTimerId, setSelectedTimerId, tick, currentUser, finishRequestId, onFinishRequestHandled }: TimerPageProps) {
|
||||||
const [ticketNumber, setTicketNumber] = useState("");
|
const [ticketNumber, setTicketNumber] = useState("");
|
||||||
const [finished, setFinished] = useState<FinishedSession | null>(null);
|
const [finished, setFinished] = useState<FinishedSession | null>(null);
|
||||||
const [finishingId, setFinishingId] = useState<string | null>(null);
|
const [finishingId, setFinishingId] = useState<string | null>(null);
|
||||||
@@ -321,6 +328,8 @@ export function TimerPage({ timers, setTimers, selectedTimerId, setSelectedTimer
|
|||||||
const runningTimer = timers.find((timer) => timer.phase === "running") ?? null;
|
const runningTimer = timers.find((timer) => timer.phase === "running") ?? null;
|
||||||
const selectedManualTeamspaceEntry = ticketTeamspaceEntries(manualLookupTicket).find((entry) => entry.id === manualSession.projectTeamspaceEntryId) ?? null;
|
const selectedManualTeamspaceEntry = ticketTeamspaceEntries(manualLookupTicket).find((entry) => entry.id === manualSession.projectTeamspaceEntryId) ?? null;
|
||||||
const selectedFinishedTeamspaceEntry = ticketTeamspaceEntries(finished?.ticket).find((entry) => entry.id === projectTeamspaceEntryId) ?? null;
|
const selectedFinishedTeamspaceEntry = ticketTeamspaceEntries(finished?.ticket).find((entry) => entry.id === projectTeamspaceEntryId) ?? null;
|
||||||
|
const manualRequiresTeamspaceEntryForForm = requiresTeamspaceEntry(manualLookupTicket);
|
||||||
|
const finishedRequiresTeamspaceEntryForForm = requiresTeamspaceEntry(finished?.ticket);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
@@ -674,6 +683,7 @@ export function TimerPage({ timers, setTimers, selectedTimerId, setSelectedTimer
|
|||||||
});
|
});
|
||||||
setTimers(mergeTimerActivityDrafts(currentUser.id, timerResult.timers));
|
setTimers(mergeTimerActivityDrafts(currentUser.id, timerResult.timers));
|
||||||
setSelectedTimerId(timer.id);
|
setSelectedTimerId(timer.id);
|
||||||
|
const finishedTimer = timerResult.timers.find((entry) => entry.id === timer.id) ?? timer;
|
||||||
|
|
||||||
const result = await lookupTicket(timer.ticketNumber);
|
const result = await lookupTicket(timer.ticketNumber);
|
||||||
const ticket = result.ticket;
|
const ticket = result.ticket;
|
||||||
@@ -700,12 +710,13 @@ export function TimerPage({ timers, setTimers, selectedTimerId, setSelectedTimer
|
|||||||
zammadArticleBody: timerZammadArticleBody,
|
zammadArticleBody: timerZammadArticleBody,
|
||||||
billingStatus: timer.billingStatus,
|
billingStatus: timer.billingStatus,
|
||||||
promptBillingStatus: currentUser.session_billing_prompt_enabled && timer.billingStatus === null,
|
promptBillingStatus: currentUser.session_billing_prompt_enabled && timer.billingStatus === null,
|
||||||
createZammadArticle: currentUser.zammad_session_note_default_enabled && timerActivity.trim().length > 0,
|
createZammadArticle: currentUser.zammad_session_note_default_enabled && hasActivityContent(timerActivity, timerZammadArticleBody),
|
||||||
zammadArticleTypeId: currentUser.zammad_article_type_id,
|
zammadArticleTypeId: currentUser.zammad_article_type_id,
|
||||||
startedAt: new Date(timer.startedAt).toISOString(),
|
startedAt: new Date(timer.startedAt).toISOString(),
|
||||||
endedAt: new Date(now).toISOString(),
|
endedAt: new Date(now).toISOString(),
|
||||||
durationSeconds,
|
durationSeconds,
|
||||||
roundedMinutes
|
roundedMinutes,
|
||||||
|
workSegments: currentUser.session_work_segments_enabled ? finishedTimer.workSegments ?? [] : []
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
toast.error("Ticket konnte nicht geprüft werden", {
|
toast.error("Ticket konnte nicht geprüft werden", {
|
||||||
@@ -716,6 +727,22 @@ export function TimerPage({ timers, setTimers, selectedTimerId, setSelectedTimer
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!finishRequestId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const timerExists = timers.some((timer) => timer.id === finishRequestId);
|
||||||
|
onFinishRequestHandled?.();
|
||||||
|
|
||||||
|
if (!timerExists) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setSelectedTimerId(finishRequestId);
|
||||||
|
void finishTimer(finishRequestId);
|
||||||
|
}, [finishRequestId]);
|
||||||
|
|
||||||
async function submitSession(event: FormEvent) {
|
async function submitSession(event: FormEvent) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
|
|
||||||
@@ -762,6 +789,7 @@ export function TimerPage({ timers, setTimers, selectedTimerId, setSelectedTimer
|
|||||||
startedAt: finished.startedAt,
|
startedAt: finished.startedAt,
|
||||||
endedAt: finished.endedAt,
|
endedAt: finished.endedAt,
|
||||||
durationSeconds: finished.durationSeconds,
|
durationSeconds: finished.durationSeconds,
|
||||||
|
workSegments: finished.workSegments,
|
||||||
organizationId: effectiveOrganizationId,
|
organizationId: effectiveOrganizationId,
|
||||||
activity: activityValue.activity,
|
activity: activityValue.activity,
|
||||||
zammadArticleBody: activityValue.zammadArticleBody || null,
|
zammadArticleBody: activityValue.zammadArticleBody || null,
|
||||||
@@ -769,7 +797,7 @@ export function TimerPage({ timers, setTimers, selectedTimerId, setSelectedTimer
|
|||||||
zammadLookup: Boolean(finished.ticket?.zammad_ticket_id),
|
zammadLookup: Boolean(finished.ticket?.zammad_ticket_id),
|
||||||
projectTeamspaceEntryId: finishedRequiresTeamspaceEntry ? projectTeamspaceEntryId : null,
|
projectTeamspaceEntryId: finishedRequiresTeamspaceEntry ? projectTeamspaceEntryId : null,
|
||||||
billingStatus: finished.billingStatus,
|
billingStatus: finished.billingStatus,
|
||||||
createZammadArticle: finished.createZammadArticle && (activityValue.activity.trim().length > 0 || Boolean(activityValue.zammadArticleBody)),
|
createZammadArticle: finished.createZammadArticle && hasActivityContent(activityValue.activity, activityValue.zammadArticleBody),
|
||||||
zammadArticleTypeId: finished.zammadArticleTypeId
|
zammadArticleTypeId: finished.zammadArticleTypeId
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -825,7 +853,9 @@ export function TimerPage({ timers, setTimers, selectedTimerId, setSelectedTimer
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (requiresTeamspaceEntry(manualLookupTicket) && !manualSession.projectTeamspaceEntryId) {
|
const manualRequiresTeamspaceEntry = requiresTeamspaceEntry(manualLookupTicket);
|
||||||
|
|
||||||
|
if (manualRequiresTeamspaceEntry && !manualSession.projectTeamspaceEntryId) {
|
||||||
toast.error("Teamspace-Eintrag wählen", {
|
toast.error("Teamspace-Eintrag wählen", {
|
||||||
description: "Dieses Projekt hat mehrere Teamspace-Einträge."
|
description: "Dieses Projekt hat mehrere Teamspace-Einträge."
|
||||||
});
|
});
|
||||||
@@ -864,9 +894,10 @@ export function TimerPage({ timers, setTimers, selectedTimerId, setSelectedTimer
|
|||||||
endedAt: endedAt.toISOString(),
|
endedAt: endedAt.toISOString(),
|
||||||
durationSeconds,
|
durationSeconds,
|
||||||
zammadLookup: Boolean(manualLookupTicket?.zammad_ticket_id),
|
zammadLookup: Boolean(manualLookupTicket?.zammad_ticket_id),
|
||||||
projectTeamspaceEntryId: requiresTeamspaceEntry(manualLookupTicket) ? manualSession.projectTeamspaceEntryId : null,
|
projectTeamspaceEntryId: manualRequiresTeamspaceEntry ? manualSession.projectTeamspaceEntryId : null,
|
||||||
billingStatus: manualSession.billingStatus,
|
billingStatus: manualSession.billingStatus,
|
||||||
createZammadArticle: manualSession.createZammadArticle && (activityValue.activity.trim().length > 0 || Boolean(activityValue.zammadArticleBody)),
|
createZammadArticle: manualSession.createZammadArticle && hasActivityContent(activityValue.activity, activityValue.zammadArticleBody),
|
||||||
|
zammadArticleDateHeader: true,
|
||||||
zammadArticleTypeId: manualSession.zammadArticleTypeId
|
zammadArticleTypeId: manualSession.zammadArticleTypeId
|
||||||
});
|
});
|
||||||
toast.success("Session nachgetragen", {
|
toast.success("Session nachgetragen", {
|
||||||
@@ -889,6 +920,8 @@ export function TimerPage({ timers, setTimers, selectedTimerId, setSelectedTimer
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const finishedEffectiveWorkType = finished?.ticket?.organization_id && finished.ticket.work_type ? finished.ticket.work_type : workType;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<section className="space-y-4">
|
<section className="space-y-4">
|
||||||
@@ -1117,7 +1150,7 @@ export function TimerPage({ timers, setTimers, selectedTimerId, setSelectedTimer
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{requiresTeamspaceEntry(manualLookupTicket) ? (
|
{manualRequiresTeamspaceEntryForForm ? (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label className="text-sm font-medium" htmlFor="manual-teamspace-entry">Teamspace-Eintrag</label>
|
<label className="text-sm font-medium" htmlFor="manual-teamspace-entry">Teamspace-Eintrag</label>
|
||||||
<select
|
<select
|
||||||
@@ -1293,7 +1326,7 @@ export function TimerPage({ timers, setTimers, selectedTimerId, setSelectedTimer
|
|||||||
...current,
|
...current,
|
||||||
activity,
|
activity,
|
||||||
zammadArticleBody,
|
zammadArticleBody,
|
||||||
createZammadArticle: activity.trim().length > 0 || zammadArticleBody ? current.createZammadArticle : false
|
createZammadArticle: hasActivityContent(activity, zammadArticleBody) ? current.createZammadArticle : false
|
||||||
} : current);
|
} : current);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
@@ -1302,7 +1335,7 @@ export function TimerPage({ timers, setTimers, selectedTimerId, setSelectedTimer
|
|||||||
<div className="min-w-0 space-y-3 rounded-md border bg-muted/30 p-3">
|
<div className="min-w-0 space-y-3 rounded-md border bg-muted/30 p-3">
|
||||||
<div className="flex min-w-0 items-start gap-3">
|
<div className="flex min-w-0 items-start gap-3">
|
||||||
{(() => {
|
{(() => {
|
||||||
const hasZammadContent = finished.activity.trim().length > 0 || Boolean(finished.zammadArticleBody);
|
const hasZammadContent = hasActivityContent(finished.activity, finished.zammadArticleBody);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -1310,7 +1343,7 @@ export function TimerPage({ timers, setTimers, selectedTimerId, setSelectedTimer
|
|||||||
id="finished-zammad-article"
|
id="finished-zammad-article"
|
||||||
checked={finished.createZammadArticle && hasZammadContent}
|
checked={finished.createZammadArticle && hasZammadContent}
|
||||||
disabled={!hasZammadContent}
|
disabled={!hasZammadContent}
|
||||||
onCheckedChange={(checked) => setFinished((current) => (current ? { ...current, createZammadArticle: checked === true && (current.activity.trim().length > 0 || Boolean(current.zammadArticleBody)) } : current))}
|
onCheckedChange={(checked) => setFinished((current) => (current ? { ...current, createZammadArticle: checked === true && hasActivityContent(current.activity, current.zammadArticleBody) } : current))}
|
||||||
/>
|
/>
|
||||||
<div className="min-w-0 space-y-1">
|
<div className="min-w-0 space-y-1">
|
||||||
<label className="break-words text-sm font-medium" htmlFor="finished-zammad-article">
|
<label className="break-words text-sm font-medium" htmlFor="finished-zammad-article">
|
||||||
@@ -1379,7 +1412,7 @@ export function TimerPage({ timers, setTimers, selectedTimerId, setSelectedTimer
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{requiresTeamspaceEntry(finished?.ticket) ? (
|
{finishedRequiresTeamspaceEntryForForm ? (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label className="text-sm font-medium" htmlFor="finished-teamspace-entry">Teamspace-Eintrag</label>
|
<label className="text-sm font-medium" htmlFor="finished-teamspace-entry">Teamspace-Eintrag</label>
|
||||||
<select
|
<select
|
||||||
|
|||||||
File diff suppressed because it is too large.
Load diff
@@ -1,37 +0,0 @@
|
|||||||
[
|
|
||||||
{
|
|
||||||
"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
|
|
||||||
}
|
|
||||||
]
|
|
||||||
Whitespace-only changes.
-211
@@ -1,211 +0,0 @@
|
|||||||
[
|
|
||||||
{
|
|
||||||
"id": 66333,
|
|
||||||
"ticket_id": 6910,
|
|
||||||
"type_id": 1,
|
|
||||||
"sender_id": 1,
|
|
||||||
"from": "\"Michael Böhmländer via Zammad Helpdesk Support\" <consulting@aikava.de>",
|
|
||||||
"to": "tobias.ritter@primed-halberstadt.de",
|
|
||||||
"cc": "",
|
|
||||||
"subject": "Primed Halberstadt Medizintechnik GmbH / WF + neuer Standort Tschechien / 17459",
|
|
||||||
"reply_to": null,
|
|
||||||
"message_id": "<20260925061659.6910.ebeeffdd-6e76-46c8-a41e-36c38f4a3f5d@aikava.zammad.com>",
|
|
||||||
"message_id_md5": "fa072c137010607ae161cc50262d5fa5",
|
|
||||||
"in_reply_to": "<FR0P281MB2831BA848411868C36FA216BB9812@FR0P281MB2831.DEUP281.PROD.OUTLOOK.COM>",
|
|
||||||
"content_type": "text/html",
|
|
||||||
"body": "Hallo Herr Ritter,<div><br></div><div>ich bin bisher noch nicht zum testen gekommen. Ich habe mich eingeloggt und den QR Code gescannt. Beim anschließenden Login mit</div><div><br></div><div>Username</div><div>PasswordTOTP (so wie es in der Seite steht; also Passwort direkt gefolgt vom Token aus der App)</div><div><br></div><div>erhalte ich aber Login failed. Habe ich etwas falsch gemacht?<br><br><div data-signature=\"true\" data-signature-id=\"3\"><div><b>Michael Böhmländer</b></div><div>Senior Software Consultant<br>Geplante Abwesenheiten:<br>\n</div><div><br></div><div><b>AIKAVA GmbH</b></div><div>Galgenfeld 7 | 93413 Cham</div><div><br></div><div>T: +49 9971 88999 40</div><div><a href=\"mailto:consulting.atoss@aikava.de\" title=\"mailto:consulting.atoss@aikava.de\">consulting@aikava.de</a></div><div>\n<a href=\"https://aikava.de/\" rel=\"nofollow noreferrer noopener\" target=\"_blank\">aikava.de</a><br>\n</div><div><br></div><div><p>Amtsgericht Regensburg HRB 19554<br></p></div><div>Geschäftsführung Marco Christener</div><div><b><br></b></div><div><i>This e-mail may contain confidential and/or privileged information. If you are not the intended recipient or have received this e-mail in error please notify the sender immediately and delete this e-mail.</i></div></div><div><br><br></div><div><blockquote type=\"cite\">Am Donnerstag, 24. September 2026 um 13:00:24, schrieb Tobias Ritter:<br><br><div>\n<p>Sehr geehrter Hr. Böhmländer,</p><p> </p><p>hat die Anmeldung VPN und ATOSS geklappt ?</p><p> </p><p>Sonst hätten Sie sich sicher gemeldet.</p><p> </p><p>Ich habe ein paar Infos in die Cloud getan.</p><p> </p><p>Bitte entnehmen Sie</p><p> </p><p>KeyUserEinstellgn Checkliste - Primed Halberstadt Medizintechnik GmbH Projekt 20260924</p><p> </p><div>\n<div>\n<div>\n<p>Mit freundlichen Grüßen / Kind regards</p><p>Tobias Ritter</p><p>Betriebsleiter</p><p><span>B: +49 3941- 668 710 | <a href=\"mailto:tobias.ritter@primed-halberstadt.de\">tobias.ritter@primed-halberstadt.de</a></span></p><p>PRIMED Halberstadt Medizintechnik GmbH | <a href=\"http://www.primed-halberstadt.de/\" title=\"www.primed-halberstadt.de\" rel=\"nofollow noreferrer noopener\" target=\"_blank\">www.primed-halberstadt.de</a></p></div></div></div><p> </p><div>\n<div>\n<span class=\"js-signatureMarker\"></span><p><b>Von:</b><span> Ritter, Tobias <br>\n<b>Gesendet:</b> Mittwoch, 23. September 2026 16:07<br>\n<b>An:</b> 'Michael Böhmländer via Zammad Helpdesk Support' <consulting@aikava.de><br>\n<b>Betreff:</b> AW: [Ticket#946896] Primed Halberstadt Medizintechnik GmbH / WF + neuer Standort Tschechien / 17459</span></p></div></div><p> </p><p>Hallo Hr. Böhmländer,</p><p> </p><p>danke für die Nachfrage.</p><p>Ich komme mit der Zusammenstellung der Informationen nicht so voran, wie ich mir das wünschte. Sorry.</p><p>Aber ein wenig habe ich vorbereiten können.</p><p> </p><p>Zugang:</p><p>Bitte einen VPN-Tunnel via Sophos Connect Client aufbauen:</p><p>Dazu melden Sie sich zuerst am Gateway / VPN-Portal an, um das Zertifikat und den Client herunter zu laden.</p><p>Außerdem werden Sie aufgefordert, einen OTP-Token als 2FA einzurichten (Authenticator..).</p><p> </p><p>Die Zugangsdaten erhalten Sie separat.</p><p> </p><p>Wenn diese Anmeldung erfolgreich war und der Tunnel steht, ist es so konfiguriert, dass Sie auf den ATOSS-Dienst via Browser Port 8080 zugreifen können.</p><p> </p><p>Weiter Infos erhalten Sie separat.</p><p> </p><p>Der Link zum VPN-Gateway ist:</p><p> </p><p>Bitte im VPN Portal: <a href=\"https://gw-primed.primed-halberstadt.de:4442/\" rel=\"nofollow noreferrer noopener\" title=\"https://gw-primed.primed-halberstadt.de:4442/\" target=\"_blank\"> https://deu01.safelinks.protection.outlook.com/?url=https%3A%2F%2Fgw-primed.primed-halberstadt.de%3A4442%2F&data=05%7C02%7CTobias.Ritter%40primed-halberstadt.de%7C79dd9da709a6417f9efa08df1486fb3a%7Ccdf1a6302e144edca1dfd90a5e006008%7C0%7C0%7C639252242147688576%7CUnknown%7CTWFpbGZsb3d8eyJFbXB0eU1hcGkiOnRydWUsIlYiOiIwLjAuMDAwMCIsIlAiOiJXaW4zMiIsIkFOIjoiTWFpbCIsIldUIjoyfQ%3D%3D%7C0%7C%7C%7C&sdata=CAsI%2FABdwPu9EtaSgccfmUe3RLF6zyCL4ociily5oQo%3D&reserved=0</a> den Token einrichten. Mit einen Software-Authenticator (bsp: Google Authenticator, Microsoft Authenticator, etc. -> also Handy geht auch.)</p><p> </p><p>Bitte testen Sie dies. Für Rückfragen stehe ich zur Verfügung.</p><p> </p><p>Ein Hinweis: Alle Änderungen im ATOSS möchte ich begleiten. Bitte sprechen Sie sie mit mir ab.</p><p> </p><p>Mit freundlichen Grüßen / Kind regards</p><p>Tobias Ritter</p><p>Betriebsleiter</p><p><span>B: +49 3941- 668 710 | <a href=\"mailto:tobias.ritter@primed-halberstadt.de\">tobias.ritter@primed-halberstadt.de</a></span></p><p>PRIMED Halberstadt Medizintechnik GmbH | <a href=\"http://www.primed-halberstadt.de/\" title=\"www.primed-halberstadt.de\" rel=\"nofollow noreferLine truncated
|
|
||||||
"internal": false,
|
|
||||||
"preferences": {
|
|
||||||
"subtype": "reply",
|
|
||||||
"email_address_id": 7,
|
|
||||||
"delivery_retry": 1,
|
|
||||||
"delivery_channel_id": 24,
|
|
||||||
"delivery_status_message": null,
|
|
||||||
"delivery_status": "success",
|
|
||||||
"delivery_status_date": "2026-09-25T06:17:03.054Z"
|
|
||||||
},
|
|
||||||
"updated_by_id": 1764,
|
|
||||||
"created_by_id": 1764,
|
|
||||||
"origin_by_id": null,
|
|
||||||
"created_at": "2026-09-25T06:16:58.989Z",
|
|
||||||
"updated_at": "2026-09-25T06:17:03.056Z",
|
|
||||||
"detected_language": null,
|
|
||||||
"ai_stored_result_ids": [],
|
|
||||||
"attachments": [],
|
|
||||||
"body_rendering_error": false
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": 66336,
|
|
||||||
"ticket_id": 6916,
|
|
||||||
"type_id": 1,
|
|
||||||
"sender_id": 1,
|
|
||||||
"from": "\"Michael Böhmländer via Zammad Helpdesk Support\" <support@aikava.de>",
|
|
||||||
"to": "kirchhof@hwk-berlin.de",
|
|
||||||
"cc": "",
|
|
||||||
"subject": "Verarbeitungsanpassung",
|
|
||||||
"reply_to": null,
|
|
||||||
"message_id": "<20260925062752.6916.7844e20a-2b4e-4bd5-819e-91042a70d7bb@aikava.zammad.com>",
|
|
||||||
"message_id_md5": "abf1595d00cb49f18dd00cff2f45446e",
|
|
||||||
"in_reply_to": "<VI0PR09MB8591D4B56733592E7AF7FAE988812@VI0PR09MB8591.eurprd09.prod.outlook.com>",
|
|
||||||
"content_type": "text/html",
|
|
||||||
"body": "Hallo Frau Kirchhof,<div><br></div><div>ich habe an den Verarbeitungen nichts gemacht. Das einzige was ich gemacht habe ist, dass ich die (inzwischen nachgetragene Gehenstempelung) testweise am 28.06.2026 entfernt habe und eine Neuberechnung durchgeführt habe. Hier habe ich festgestellt, dass alles so funktioniert wie es soll.</div><div><br></div><div>Den Screenshot, den Sie sehen, wo das Datum der 22.9. ist, ist aus meinem lokalen Testsystem (Mitarbeiter value 2 value 1). Das ist kein Bild aus Ihrem System.</div><div><br></div><div>Viele Grüße<br><br><div data-signature=\"true\" data-signature-id=\"2\"><div>\n<div><b>Michael Böhmländer</b></div>\n<div>Senior Software Consultant<br>Geplante Abwesenheiten:</div>\n</div><div><br></div><div><b>AIKAVA GmbH</b></div><div>Galgenfeld 7 | 93413 Cham</div><div><b><br></b></div><div>T: +49 9971 88999 40</div><div>\n<a href=\"mailto:support.atoss@aikava.de\" title=\"mailto:support.atoss@aikava.de\">support@aikava.de</a><br>\n</div><div>\n<a href=\"https://aikava.de/\" rel=\"nofollow noreferrer noopener\" target=\"_blank\">aikava.de</a><br>\n</div><div><br></div><div><p>Amtsgericht Regensburg HRB 19554<br></p></div><div>Geschäftsführung Marco Christener</div><div><i><br></i></div><div><i>This e-mail may contain confidential and/or privileged information. If you are not the intended recipient or have received this e-mail in error please notify the sender immediately and delete this e-mail.</i></div></div><div><br><br></div><div><blockquote type=\"cite\">Am Donnerstag, 24. September 2026 um 12:35:48, schrieb Christiane Kirchhof:<br><br><div>\n<p>Ein Nachtrag zu meiner Mail von eben.</p><p>Da hier bei Testen und Nachstellen eine rote Fehlermeldung erzeugt wird, fragen wir uns, ob Sie die Regel ( s. Ihre untere Mail) schon ausgeschaltet haben?</p><p>Grundsätzlich sind wir auch weiterhin für eine Fehlermeldung, haben aber Bedenken, dass das Entfernen dieses Regel dann wieder Folgefehler nach sich zieht.</p><p>Leider hatten wir in der Vergangenheit oft sehr unschöne Erfahrungen gemacht…</p><p> </p><p>Oder haben Sie gar nichts gemacht und es funktioniert jetzt dennoch?</p><p> </p><table cellspacing=\"0\" cellpadding=\"0\" style=\"width: 660px;\"><tbody><tr><td valign=\"top\" style=\"width: 660px; font-size: 10pt;\"><span><br>Mit freundlichen Grüßen</span></td></tr><tr style=\"font-size: 11pt;\"><td valign=\"top\" style=\"width: 660px; font-size: 11pt; color: rgb(7, 48, 112);\"><span style=\"color: rgb(7, 48, 112);\"><span style=\" color: rgb(35, 255, 0);\"><span style=\"color: rgb(7, 48, 112);\"><span style=\"color: rgb(7, 48, 112);\">Christiane </span></span></span><span style=\"background-color: rgb(255, 255, 255); color: rgb(35, 255, 0);\"><span style=\"color: rgb(7, 48, 112);\"><b>Kirchhof</b><br></span></span></span></td></tr><tr style=\"font-size: 11pt;\"><td valign=\"top\" style=\"width: 660px; padding: 5px 0px 0px;\">Personal</td></tr><tr style=\"font-size: 11pt;\"><td valign=\"top\" style=\"width: 660px; font-size: 11pt; color: rgb(0, 0, 0); padding: 5px 0px 0px;\">Handwerkskammer Berlin<br>Blücherstraße 68, 10961 Berlin<br>Tel: <a href=\"tel:+49 30 25903 305\">+49 30 25903 305</a> | <span>Fax: <a href=\"tel:+49 30 25903 322\">+49 30 25903 322</a></span><br></td></tr><tr><td valign=\"top\" style=\"width: 660px; font-size: 10pt; color: rgb(0, 0, 0); padding: 5px 0px 0px;\"></td></tr></tbody></table><p><br></p><table style=\"width: 200px;\" cellspacing=\"0\" cellpadding=\"0\"><tbody><tr><td valign=\"bottom\" style=\"width: 40px; font-size: 10pt; text-align: center; padding: 10px 0px;\"><a href=\"https://www.hwk-berlin.de\" rel=\"nofollow noreferrer noopener\" title=\"https://www.hwk-berlin.de\" target=\"_blank\"><img src=\"/api/v1/ticket_attachment/6916/66336/227357?view=inline\" style=\"max-width: 100%;\"></a> </td><td valign=\"middle\" style=\"width: 40px; font-size: 10pt; text-align: center;\"> <a href=\"https://www.facebook.com/hwkberlin/?locale=de_DE\" rel=\"nofollow noreferrer noopener\" title=\"https://www.facebook.com/hwkberlin/?locale=de_DE\" target=\"_blank\"><img src=\"/api/v1/ticket_attachment/6916/66336/227358?view=inline\" style=\"max-width: 100%;\"></a></td><td valign=\"middle\" style=\"width: 40px; font-size: 10pt; text-align: center;\"> <a href=\"https://www.instagram.com/handwerkskammer_berlin/\" rel=\"nofollow noreferrer noopener\" title=\"https://www.instagram.com/handwerkskammer_berlin/\" target=\"_blank\"><img src=\"/api/v1/ticket_attachment/6916/66336/227359?view=inline\" style=\"max-width: 100%;\"></a></td><td valign=\"middle\" style=\"width: 40px; font-size: 10pt; text-align: center;\"><a href=\"https://de.linkedin.com/company/handwerkskammer-berlin\" rel=\"nofollow noreferrer noopener\" title=\"https://de.linkedin.com/company/handwerkskammer-berlin\" target=\"_blank\"><img src=\"/api/v1/ticket_attachment/6916/66336/227360?view=inline\" style=\"max-width: 100%;\"></a> </td><td valign=\"middle\" style=\"wiLine truncated
|
|
||||||
"internal": false,
|
|
||||||
"preferences": {
|
|
||||||
"subtype": "reply",
|
|
||||||
"email_address_id": 6,
|
|
||||||
"delivery_retry": 1,
|
|
||||||
"delivery_channel_id": 23,
|
|
||||||
"delivery_status_message": null,
|
|
||||||
"delivery_status": "success",
|
|
||||||
"delivery_status_date": "2026-09-25T06:27:56.971Z"
|
|
||||||
},
|
|
||||||
"updated_by_id": 1764,
|
|
||||||
"created_by_id": 1764,
|
|
||||||
"origin_by_id": null,
|
|
||||||
"created_at": "2026-09-25T06:27:52.681Z",
|
|
||||||
"updated_at": "2026-09-25T06:27:56.972Z",
|
|
||||||
"detected_language": null,
|
|
||||||
"ai_stored_result_ids": [],
|
|
||||||
"attachments": [],
|
|
||||||
"body_rendering_error": false
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": 66337,
|
|
||||||
"ticket_id": 6973,
|
|
||||||
"type_id": 1,
|
|
||||||
"sender_id": 1,
|
|
||||||
"from": "\"Michael Böhmländer via Zammad Helpdesk Support\" <consulting@aikava.de>",
|
|
||||||
"to": "sophie.ochsenbein@pierre-fabre.com",
|
|
||||||
"cc": "",
|
|
||||||
"subject": "Teilzeitfrei neu konfigurieren",
|
|
||||||
"reply_to": null,
|
|
||||||
"message_id": "<20260925063108.6973.491aa02d-8d2c-4528-9a07-10b362e2f542@aikava.zammad.com>",
|
|
||||||
"message_id_md5": "9a1b9f2c0384d6e7c0b5a9b1ca2ae9e3",
|
|
||||||
"in_reply_to": "",
|
|
||||||
"content_type": "text/html",
|
|
||||||
"body": "Hallo Frau Ochsenbein,<div><br></div><div>ich hatte hier vor einiger Zeit einen Call mit Frau Medina. Leider ist sie aktuell nicht erreichbar und ich würde gerne wissen, wie es um das untenstehende Angebot steht.</div><div><br></div><div>Vielen Dank und beste Grüße<br><br><div data-signature=\"true\" data-signature-id=\"3\"><div><b>Michael Böhmländer</b></div><div>Senior Software Consultant<br>Geplante Abwesenheiten:<br>\n</div><div><br></div><div><b>AIKAVA GmbH</b></div><div>Galgenfeld 7 | 93413 Cham</div><div><br></div><div>T: +49 9971 88999 40</div><div><a href=\"mailto:consulting.atoss@aikava.de\" title=\"mailto:consulting.atoss@aikava.de\">consulting@aikava.de</a></div><div>\n<a href=\"https://aikava.de/\" rel=\"nofollow noreferrer noopener\" target=\"_blank\">aikava.de</a><br>\n</div><div><br></div><div><p>Amtsgericht Regensburg HRB 19554<br></p></div><div>Geschäftsführung Marco Christener</div><div><b><br></b></div><div><i>This e-mail may contain confidential and/or privileged information. If you are not the intended recipient or have received this e-mail in error please notify the sender immediately and delete this e-mail.</i></div></div><br><div>---Anfang der weitergeleiteten Nachricht:---<br><br></div><div><blockquote type=\"cite\"><div>Betreff: Teilzeitfrei neu konfigurieren<br>Datum: 24.09.2026 08:28<br>Von: \"Michael Böhmländer via Zammad Helpdesk Support\" <consulting@aikava.de><br>An: Medina Adriana <adriana.medina.ext@pierre-fabre.com><br><br></div>Hallo Frau Adriana,<div><br></div><div>leider habe ich bisher keine Rückmeldung bzgl. meiner Aufwandseinschätzung erhalten. Konnten Sie das Angebot bereits sichten und möchten Sie es umsetzen?</div><div><br></div><div>Viele Grüße<br><br><div data-signature=\"true\" data-signature-id=\"3\"><div><b>Michael Böhmländer</b></div><div>Senior Software Consultant<br>Geplante Abwesenheiten:<br>\n</div><div><br></div><div><b>AIKAVA GmbH</b></div><div>Galgenfeld 7 | 93413 Cham</div><div><br></div><div>T: +49 9971 88999 40</div><div><a href=\"mailto:consulting.atoss@aikava.de\" title=\"mailto:consulting.atoss@aikava.de\">consulting@aikava.de</a></div><div>\n<a href=\"https://aikava.de/\" rel=\"nofollow noreferrer noopener\" target=\"_blank\">aikava.de</a><br>\n</div><div><br></div><div><p>Amtsgericht Regensburg HRB 19554<br></p></div><div>Geschäftsführung Marco Christener</div><div><b><br></b></div><div><i>This e-mail may contain confidential and/or privileged information. If you are not the intended recipient or have received this e-mail in error please notify the sender immediately and delete this e-mail.</i></div></div><div><br><br></div><div><blockquote type=\"cite\">Am Mittwoch, 16. September 2026 um 08:12:55, schrieb Michael Böhmländer via Zammad Helpdesk Support:<br><br><div></div><div>Hallo Frau Adriana,</div><div><br></div>vielen Dank für Ihre Anfrage. Nach Prüfung Ihrer Anforderungen möchten wir Sie darauf hinweisen, dass deren Umsetzung voraussichtlich einen Aufwand von etwa 6 Stunden erfordert und nur im Rahmen einer kostenpflichtigen Dienstleistung möglich ist.<div><br></div><div>Gerne lassen wir Ihnen ein unverbindliches Angebot zukommen. Da es sich um eine Dienstleistung handelt, für die weniger als 8 Stunden benötigt werden, reicht Ihre Ihre schriftliche Freigabe aus. Die aktuellen Stundensätze entnehmen Sie bitte nachfolgender Tabelle:</div><div><br></div><div><img style=\"width: 1000px; max-width: 100%;\" src=\"/api/v1/ticket_attachment/6973/66337/227371?view=inline\"></div><div><br></div><div>Im vorliegenden Fall gilt der Stundensatz \"Senior Consultant\".</div><div><br></div><div>5,5 h</div><div>- Tagesart, berechneten Wert und freien Saldo anlegen</div><div>- bisherige Verarbeitungsregeln in MAG 40001 beenden</div><div>- neue Verarbeitungsregel konfigurieren</div><div>- Workshop zur Handhabung</div><div><br></div><div>0,5 h</div><div>- Dokumentation</div><div>- Kommunikation Mail/Telefon<br></div><div><br></div>Viele Grüße<br><br><div data-signature=\"true\" data-signature-id=\"3\"><div><b>Michael Böhmländer</b></div><div>Senior Software Consultant<br>Geplante Abwesenheiten:<br>\n</div><div><br></div><div><b>AIKAVA GmbH</b></div><div>Galgenfeld 7 | 93413 Cham</div><div><br></div><div>T: +49 9971 88999 40</div><div><a href=\"mailto:consulting.atoss@aikava.de\" title=\"mailto:consulting.atoss@aikava.de\">consulting@aikava.de</a></div><div>\n<a href=\"https://aikava.de/\" rel=\"nofollow noreferrer noopener\" target=\"_blank\">aikava.de</a><br>\n</div><div><br></div><div><p>Amtsgericht Regensburg HRB 19554<br></p></div><div>Geschäftsführung Marco Christener</div><div><b><br></b></div><div><i>This e-mail may contain confidential and/or privileged information. If you are not the intended recipient or have received this e-mail in error please notify the sender immediately and delete this e-mail.</i></div></div><br></blockquote></div><div><br></div></div></blockquote></div><div><br>Line truncated
|
|
||||||
"internal": false,
|
|
||||||
"preferences": {
|
|
||||||
"subtype": "forward",
|
|
||||||
"email_address_id": 7,
|
|
||||||
"delivery_retry": 1,
|
|
||||||
"delivery_channel_id": 24,
|
|
||||||
"delivery_status_message": null,
|
|
||||||
"delivery_status": "success",
|
|
||||||
"delivery_status_date": "2026-09-25T06:31:11.458Z"
|
|
||||||
},
|
|
||||||
"updated_by_id": 1764,
|
|
||||||
"created_by_id": 1764,
|
|
||||||
"origin_by_id": null,
|
|
||||||
"created_at": "2026-09-25T06:31:08.180Z",
|
|
||||||
"updated_at": "2026-09-25T06:31:11.459Z",
|
|
||||||
"detected_language": null,
|
|
||||||
"ai_stored_result_ids": [],
|
|
||||||
"attachments": [],
|
|
||||||
"body_rendering_error": false
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": 66342,
|
|
||||||
"ticket_id": 6910,
|
|
||||||
"type_id": 1,
|
|
||||||
"sender_id": 1,
|
|
||||||
"from": "\"Michael Böhmländer via Zammad Helpdesk Support\" <consulting@aikava.de>",
|
|
||||||
"to": "support@mdlink.de",
|
|
||||||
"cc": "tobias.ritter@primed-halberstadt.de, ralph.huxol@tw-stassfurt.de",
|
|
||||||
"subject": "[Ticket#202609161013] VPN Login",
|
|
||||||
"reply_to": null,
|
|
||||||
"message_id": "<20260925072842.6910.ded4c986-2030-4cc5-b4e0-08646cc6d72b@aikava.zammad.com>",
|
|
||||||
"message_id_md5": "5e46a2fb0a92173d5dc54e9bf27c96b2",
|
|
||||||
"in_reply_to": "<1790320644.705583.864936388@otter.in.mdlink.de>",
|
|
||||||
"content_type": "text/html",
|
|
||||||
"body": "Hallo Herr Braun,<div><br></div><div>vielen Dank, jetzt hat es funktioniert.</div><div><br></div><div>Viele Grüße<br><br><div data-signature=\"true\" data-signature-id=\"3\"><div><b>Michael Böhmländer</b></div><div>Senior Software Consultant<br>Geplante Abwesenheiten:<br>\n</div><div><br></div><div><b>AIKAVA GmbH</b></div><div>Galgenfeld 7 | 93413 Cham</div><div><br></div><div>T: +49 9971 88999 40</div><div><a href=\"mailto:consulting.atoss@aikava.de\" title=\"mailto:consulting.atoss@aikava.de\">consulting@aikava.de</a></div><div>\n<a href=\"https://aikava.de/\" rel=\"nofollow noreferrer noopener\" target=\"_blank\">aikava.de</a><br>\n</div><div><br></div><div><p>Amtsgericht Regensburg HRB 19554<br></p></div><div>Geschäftsführung Marco Christener</div><div><b><br></b></div><div><i>This e-mail may contain confidential and/or privileged information. If you are not the intended recipient or have received this e-mail in error please notify the sender immediately and delete this e-mail.</i></div></div><div><br><br></div><div><blockquote type=\"cite\">Am Freitag, 25. September 2026 um 09:18:17, schrieb MDlink online service center GmbH:<br><br><div>Sehr geehrter Herr Böhmländer,</div><div><br></div><div>scheinbar wurde der Token vom MFA nicht bestätigt.</div><div>Vorsichtshalber habe ich Ihren bereits hinterlegten MFA-Token gelöscht.</div><div><br></div><div>Bitte melden Sie sich nochmal im VPN-User Portal an, mit:</div><div>- Username</div><div>- Passwort</div><div><br></div><div>Es wird nun ein neuer QR-Code angezeigt. Den alten bitte aus Ihren Authenticator löschen und diesen neuen Importieren.</div><div><br></div><div>Dann unmittelbar im VPN-Portal einloggen mit:</div><div>- Username</div><div>- PasswortTOTP</div><div><br></div><div>Erst dann wird der Token bestätigt.</div><div>Die Sophos hat die \"Eigenart\", dass die Tokens, die nicht durch ein direktes Logins bestätigt werden. Immer wieder neu generiert werden.</div><div><br></div><div>Also wenn Sie zwischen durch, irgendwie auf der Seite neu anmelden (ohne TOTP). Wird der Token neu generiert.</div><div>Erst mit einen gültigen Login mit PasswortTOTP wird der Token final gespeichert.</div><div><br></div><div>Ich sehe in der Firewall nur: \"Fehlerhaftes Passwort\". Das kann auch die oben genannte \"Eigenart\" sein.</div><div><br></div><div>Sofern Sie trotzdem noch dieses Probleme haben, können Sie gerne uns anrufen.</div><div><br></div><div>Mit freundlichen Grüßen</div><div>--</div><div><br></div><div>Urs Braun</div><div><br></div><div>MDlink online service center GmbH mail: <a href=\"mailto:support@mdlink.de\" title=\"mailto:support@mdlink.de\">support@mdlink.de</a></div><div>Lorenzweg 42 Haus 3 phone: +49 391 25568 0</div><div>Magdeburg fax: +49 391 25568 99</div><div>39124 <a href=\"http://www.mdlink.de\" title=\"http://www.mdlink.de\" rel=\"nofollow noreferrer noopener\" target=\"_blank\">http://www.mdlink.de</a></div><div>Germany </div><div><br></div><div>Amtsgericht Stendal HRB 106650</div><div>Geschäftsführer: Olaf Müller</div><div>UST-ID: DE163829671</div><br></blockquote></div><div><br></div></div>",
|
|
||||||
"internal": false,
|
|
||||||
"preferences": {
|
|
||||||
"subtype": "reply",
|
|
||||||
"email_address_id": 7,
|
|
||||||
"delivery_retry": 1,
|
|
||||||
"delivery_channel_id": 24,
|
|
||||||
"delivery_status_message": null,
|
|
||||||
"delivery_status": "success",
|
|
||||||
"delivery_status_date": "2026-09-25T07:28:46.835Z"
|
|
||||||
},
|
|
||||||
"updated_by_id": 1764,
|
|
||||||
"created_by_id": 1764,
|
|
||||||
"origin_by_id": null,
|
|
||||||
"created_at": "2026-09-25T07:28:42.824Z",
|
|
||||||
"updated_at": "2026-09-25T07:28:46.836Z",
|
|
||||||
"detected_language": null,
|
|
||||||
"ai_stored_result_ids": [],
|
|
||||||
"attachments": [],
|
|
||||||
"body_rendering_error": false
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": 66347,
|
|
||||||
"ticket_id": 4086,
|
|
||||||
"type_id": 1,
|
|
||||||
"sender_id": 1,
|
|
||||||
"from": "\"Michael Böhmländer via Zammad Helpdesk Support\" <consulting@aikava.de>",
|
|
||||||
"to": "johannes.fleck@schilling-holz.de",
|
|
||||||
"cc": "",
|
|
||||||
"subject": "Holzwerk Schilling // Neuinstallation ATOSS Time Control",
|
|
||||||
"reply_to": null,
|
|
||||||
"message_id": "<20260925075412.4086.0c72179c-4b24-41b0-b49f-9bc8d12a5ad9@aikava.zammad.com>",
|
|
||||||
"message_id_md5": "a9d395a7ce897a845b62ac47669d4479",
|
|
||||||
"in_reply_to": "<FR7P281MB65338CD0F117F888996AB7C9CB822@FR7P281MB6533.DEUP281.PROD.OUTLOOK.COM>",
|
|
||||||
"content_type": "text/html",
|
|
||||||
"body": "Hallo Herr Fleck,<div><br></div><div>die Software Eclipse ist meines Wissens nach kein Teil von Atoss selbst. Es handelt sich, normalerweise, um eine sogenannte IDE zur Softwareentwicklung für JAVA. Warum genau die Software hier installiert ist, weiß ich leider nicht. Eventuell wurde diese von CompData bei der Installation benötigt. Bitte fragen Sie hier die Kollegen von CompData, ob sie etwas wissen.</div><div><br></div><div>Viele Grüße<br><br><div data-signature=\"true\" data-signature-id=\"3\"><div><b>Michael Böhmländer</b></div><div>Senior Software Consultant<br>Geplante Abwesenheiten:<br>\n</div><div><br></div><div><b>AIKAVA GmbH</b></div><div>Galgenfeld 7 | 93413 Cham</div><div><br></div><div>T: +49 9971 88999 40</div><div><a href=\"mailto:consulting.atoss@aikava.de\" title=\"mailto:consulting.atoss@aikava.de\">consulting@aikava.de</a></div><div>\n<a href=\"https://aikava.de/\" rel=\"nofollow noreferrer noopener\" target=\"_blank\">aikava.de</a><br>\n</div><div><br></div><div><p>Amtsgericht Regensburg HRB 19554<br></p></div><div>Geschäftsführung Marco Christener</div><div><b><br></b></div><div><i>This e-mail may contain confidential and/or privileged information. If you are not the intended recipient or have received this e-mail in error please notify the sender immediately and delete this e-mail.</i></div></div><div><br><br></div><div><blockquote type=\"cite\">Am Mittwoch, 23. September 2026 um 16:03:16, schrieb Johannes Fleck:<br><br><div>\n<p>Hallo Herr Böhmländer,</p><p> </p><p><span>vielleicht können Sie Frau Hopp direkt kontaktieren (<a href=\"mailto:birgit.hopp@schilling-holz.de\">birgit.hopp@schilling-holz.de</a>).</span></p><p> </p><p>Wegen der Fahrer würden wir es so machen, wie unten geschrieben. Falls ich da noch was brauche, melde ich mich.</p><p> </p><p>Ich habe noch etwas anderes. Es läuft wohl auf dem Server eine Software namens Eclipse. Hier gibt es laut Systemanalyse mehrere Schwachstellen. Kann man die Software updaten?</p><p> </p><div>\n<div>\n<p><span style=\"color:#201f1e;\"> </span></p><p><span style=\"color:#201f1e;\"><br>\n<br>\n</span></p><p><span style=\"color:#201f1e;\">Mit freundlichen Grüßen</span></p><p><span style=\"color:#201f1e;\"> </span></p><p><span style=\"color:#201f1e;\">i.A. Johannes Fleck</span></p><p><span style=\"color:#201f1e;\"> </span></p><p><b><span style=\"color:#201f1e;\">Holzwerk Schilling GmbH & Co. KG</span></b><span style=\"color:#201f1e;\"></span></p><p><span style=\"color:#201f1e;\">Klosterstraße 49</span></p><p><span style=\"color:#201f1e;\">D- 88430 Rot an der Rot</span></p><p><span style=\"color:#201f1e;\"> </span></p><p><span style=\"color:#201f1e;\">Tel: +49 8395 9366-23</span></p><p><span style=\"color:#201f1e;\">Fax: +49 8395 9366-20</span></p><p><span style=\"color:#201f1e;\">E-Mail: </span><a href=\"mailto:johannes.fleck@schilling-holz.de\">johannes.fleck@schilling-holz.de</a></p><p><span style=\"color:#201f1e;\">Internet: </span><a href=\"http://www.schilling-holz.de/\" rel=\"nofollow noreferrer noopener\" target=\"_blank\">www.schilling-holz.de</a></p><p><span style=\"color:#201f1e;\">USt.-IdNr.: DE815534186</span></p><p><span style=\"color:#201f1e;\">Amtsgericht Ulm HRA 724237</span></p><p><span style=\"color:#201f1e;\"> </span></p><p><span style=\"color:#201f1e;\">Persönlich haftende Gesellschafterin:</span></p><p><span style=\"color:#201f1e;\">Holzwerk Schilling Verwaltungs GmbH</span></p><p><span style=\"color:#201f1e;\">Sitz: Rot an der Rot</span></p><p><span style=\"color:#201f1e;\">Amtsgericht Ulm HRB 731409</span></p><p><span style=\"color:#201f1e;\">Geschäftsführer: Johannes S. Schilling</span></p></div></div><p> </p><div>\n<div>\n<span class=\"js-signatureMarker\"></span><p><b>Von:</b><span> Michael Böhmländer via Zammad Helpdesk Support <consulting@aikava.de> <br>\n<b>Gesendet:</b> Dienstag, 22. </span><span lang=\"EN-GB\">September 2026 10:58<br>\n<b>An:</b> Johannes Fleck <johannes.fleck@schilling-holz.de><br>\n<b>Betreff:</b> RE: [Ticket#944079] Holzwerk Schilling // Neuinstallation ATOSS Time Control</span></p></div></div><p><span lang=\"EN-GB\"> </span></p><p>Hallo Herr Fleck,</p><div>\n<p> </p></div><div>\n<p>leider hat sich Frau Hopp bisher noch nicht bei mir gemeldet. Könnten Sie hier bitte noch einmal nachhaken?</p></div><div>\n<p> </p></div><div>\n<p>Bezüglich der Fahrer: Sie starten im Oktober einfach mal und geben mir dann Rückmeldung, falls noch Anpassungen notwendig sind, oder?</p></div><div>\n<p> </p></div><div>\n<p>Viele Grüße</p><div>\n<div>\n<p><b>Michael Böhmländer</b></p></div><div>\n<p><span>Senior Software Consultant<br> Geplante Abwesenheiten:</span></p></div><div>\n<p> </p></div><div>\n<p><b>AIKAVA GmbH</b></p></div><div>\n<p>Galgenfeld 7 | 93413 Cham</p></div><div>\n<p> </p></div><div>\n<p>T: +49 9971 88999 40</p></div><div>\n<p><a href=\"mailto:consulting.atoss@aikava.de\" title=\"mailto:consuLine truncated
|
|
||||||
"internal": false,
|
|
||||||
"preferences": {
|
|
||||||
"subtype": "reply",
|
|
||||||
"email_address_id": 7,
|
|
||||||
"delivery_retry": 1,
|
|
||||||
"delivery_channel_id": 24,
|
|
||||||
"delivery_status_message": null,
|
|
||||||
"delivery_status": "success",
|
|
||||||
"delivery_status_date": "2026-09-25T07:54:14.523Z"
|
|
||||||
},
|
|
||||||
"updated_by_id": 1764,
|
|
||||||
"created_by_id": 1764,
|
|
||||||
"origin_by_id": null,
|
|
||||||
"created_at": "2026-09-25T07:54:11.948Z",
|
|
||||||
"updated_at": "2026-09-25T07:54:14.524Z",
|
|
||||||
"detected_language": null,
|
|
||||||
"ai_stored_result_ids": [],
|
|
||||||
"attachments": [],
|
|
||||||
"body_rendering_error": false
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": 66350,
|
|
||||||
"ticket_id": 4086,
|
|
||||||
"type_id": 1,
|
|
||||||
"sender_id": 1,
|
|
||||||
"from": "\"Michael Böhmländer via Zammad Helpdesk Support\" <consulting@aikava.de>",
|
|
||||||
"to": "birgit.hopp@schilling-holz.de",
|
|
||||||
"cc": "",
|
|
||||||
"subject": "Schnittstelle",
|
|
||||||
"reply_to": null,
|
|
||||||
"message_id": "<20260925081606.4086.cdbe02f0-4053-4ac9-a255-21cfdc926460@aikava.zammad.com>",
|
|
||||||
"message_id_md5": "2bc1e94239dd8a7315ba47fa61668419",
|
|
||||||
"in_reply_to": "",
|
|
||||||
"content_type": "text/html",
|
|
||||||
"body": "Hallo Frau Hopp,<div><br></div><div>ich bin aktuell mit Herrn Fleck in Kontakt bezüglich der Abschlussarbeiten für TimeControl. Er meinte, Sie würden sich bzgl. der DATEV-Übergabe bei mir melden. Ich würde gerne wissen, wie es mit der Übergabe aussieht? Funktioniert das schon, wird es bereits genutzt oder sind hier noch Anpassungen notwendig?</div><div><br></div><div>Viele Grüße<br><br><div data-signature=\"true\" data-signature-id=\"3\"><div><b>Michael Böhmländer</b></div><div>Senior Software Consultant<br>Geplante Abwesenheiten:<br>\n</div><div><br></div><div><b>AIKAVA GmbH</b></div><div>Galgenfeld 7 | 93413 Cham</div><div><br></div><div>T: +49 9971 88999 40</div><div><a href=\"mailto:consulting.atoss@aikava.de\" title=\"mailto:consulting.atoss@aikava.de\">consulting@aikava.de</a></div><div>\n<a href=\"https://aikava.de/\" rel=\"nofollow noreferrer noopener\" target=\"_blank\">aikava.de</a><br>\n</div><div><br></div><div><p>Amtsgericht Regensburg HRB 19554<br></p></div><div>Geschäftsführung Marco Christener</div><div><b><br></b></div><div><i>This e-mail may contain confidential and/or privileged information. If you are not the intended recipient or have received this e-mail in error please notify the sender immediately and delete this e-mail.</i></div></div></div>",
|
|
||||||
"internal": false,
|
|
||||||
"preferences": {
|
|
||||||
"email_address_id": 7,
|
|
||||||
"delivery_retry": 1,
|
|
||||||
"delivery_channel_id": 24,
|
|
||||||
"delivery_status_message": null,
|
|
||||||
"delivery_status": "success",
|
|
||||||
"delivery_status_date": "2026-09-25T08:16:09.314Z"
|
|
||||||
},
|
|
||||||
"updated_by_id": 1764,
|
|
||||||
"created_by_id": 1764,
|
|
||||||
"origin_by_id": null,
|
|
||||||
"created_at": "2026-09-25T08:16:06.517Z",
|
|
||||||
"updated_at": "2026-09-25T08:16:09.315Z",
|
|
||||||
"detected_language": null,
|
|
||||||
"ai_stored_result_ids": [],
|
|
||||||
"attachments": [],
|
|
||||||
"body_rendering_error": false
|
|
||||||
}
|
|
||||||
]
|
|
||||||
+164
@@ -0,0 +1,164 @@
|
|||||||
|
[
|
||||||
|
{
|
||||||
|
"id": 85,
|
||||||
|
"name": "customer_owner",
|
||||||
|
"display": "Kunde zugeordnet zu",
|
||||||
|
"data_type": "select",
|
||||||
|
"data_option": {
|
||||||
|
"options": {
|
||||||
|
"AIKAVA": "AIKAVA",
|
||||||
|
"ATOSS": "ATOSS",
|
||||||
|
"Compdata": "Compdata",
|
||||||
|
"OSC": "OSC",
|
||||||
|
"SD Worx": "SD Worx",
|
||||||
|
"Pro Active": "Pro Active"
|
||||||
|
},
|
||||||
|
"default": "ATOSS",
|
||||||
|
"linktemplate": "",
|
||||||
|
"translate": false,
|
||||||
|
"null": true,
|
||||||
|
"relation": "",
|
||||||
|
"nulloption": true,
|
||||||
|
"maxlength": 255,
|
||||||
|
"historical_options": {
|
||||||
|
"ATOSS": "ATOSS",
|
||||||
|
"AIKAVA": "AIKAVA",
|
||||||
|
"Compdata": "Compdata",
|
||||||
|
"OSC": "OSC",
|
||||||
|
"SD Worx": "SD Worx",
|
||||||
|
"Pro Active": "Pro Active"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"data_option_new": {
|
||||||
|
|
||||||
|
},
|
||||||
|
"editable": true,
|
||||||
|
"active": true,
|
||||||
|
"screens": {
|
||||||
|
"view": {
|
||||||
|
"ticket.customer": {
|
||||||
|
"shown": true
|
||||||
|
},
|
||||||
|
"ticket.agent": {
|
||||||
|
"shown": true
|
||||||
|
},
|
||||||
|
"admin.organization": {
|
||||||
|
"shown": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"create": {
|
||||||
|
"ticket.agent": {
|
||||||
|
"shown": true,
|
||||||
|
"required": false
|
||||||
|
},
|
||||||
|
"admin.organization": {
|
||||||
|
"shown": true,
|
||||||
|
"required": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"edit": {
|
||||||
|
"ticket.agent": {
|
||||||
|
"shown": true,
|
||||||
|
"required": false
|
||||||
|
},
|
||||||
|
"admin.organization": {
|
||||||
|
"shown": true,
|
||||||
|
"required": false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"to_create": false,
|
||||||
|
"to_migrate": false,
|
||||||
|
"to_delete": false,
|
||||||
|
"to_config": false,
|
||||||
|
"position": 1813,
|
||||||
|
"created_by_id": 4,
|
||||||
|
"updated_by_id": 4,
|
||||||
|
"created_at": "2026-04-22T14:46:46.993Z",
|
||||||
|
"updated_at": "2026-08-19T12:35:58.304Z",
|
||||||
|
"internal": false,
|
||||||
|
"object": "Organization",
|
||||||
|
"deletable": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 86,
|
||||||
|
"name": "hotline_contract",
|
||||||
|
"display": "Hotlinevertrag bei",
|
||||||
|
"data_type": "select",
|
||||||
|
"data_option": {
|
||||||
|
"options": {
|
||||||
|
"AIKAVA": "AIKAVA",
|
||||||
|
"ATOSS": "ATOSS",
|
||||||
|
"Compdata": "Compdata",
|
||||||
|
"OSC": "OSC",
|
||||||
|
"SD Worx": "SD Worx",
|
||||||
|
"(kein Hotlinevertrag)": "(kein Hotlinevertrag)"
|
||||||
|
},
|
||||||
|
"default": "ATOSS",
|
||||||
|
"linktemplate": "",
|
||||||
|
"translate": false,
|
||||||
|
"null": true,
|
||||||
|
"relation": "",
|
||||||
|
"nulloption": true,
|
||||||
|
"maxlength": 255,
|
||||||
|
"historical_options": {
|
||||||
|
"ATOSS": "ATOSS",
|
||||||
|
"AIKAVA": "AIKAVA",
|
||||||
|
"Compdata": "Compdata",
|
||||||
|
"OSC": "OSC",
|
||||||
|
"SD Worx": "SD Worx",
|
||||||
|
"(kein Hotlinevertrag)": "(kein Hotlinevertrag)"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"data_option_new": {
|
||||||
|
|
||||||
|
},
|
||||||
|
"editable": true,
|
||||||
|
"active": true,
|
||||||
|
"screens": {
|
||||||
|
"view": {
|
||||||
|
"ticket.customer": {
|
||||||
|
"shown": true
|
||||||
|
},
|
||||||
|
"ticket.agent": {
|
||||||
|
"shown": true
|
||||||
|
},
|
||||||
|
"admin.organization": {
|
||||||
|
"shown": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"create": {
|
||||||
|
"ticket.agent": {
|
||||||
|
"shown": true,
|
||||||
|
"required": false
|
||||||
|
},
|
||||||
|
"admin.organization": {
|
||||||
|
"shown": true,
|
||||||
|
"required": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"edit": {
|
||||||
|
"ticket.agent": {
|
||||||
|
"shown": true,
|
||||||
|
"required": false
|
||||||
|
},
|
||||||
|
"admin.organization": {
|
||||||
|
"shown": true,
|
||||||
|
"required": false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"to_create": false,
|
||||||
|
"to_migrate": false,
|
||||||
|
"to_delete": false,
|
||||||
|
"to_config": false,
|
||||||
|
"position": 1814,
|
||||||
|
"created_by_id": 4,
|
||||||
|
"updated_by_id": 4,
|
||||||
|
"created_at": "2026-04-22T14:48:44.782Z",
|
||||||
|
"updated_at": "2026-04-22T15:11:55.575Z",
|
||||||
|
"internal": false,
|
||||||
|
"object": "Organization",
|
||||||
|
"deletable": true
|
||||||
|
}
|
||||||
|
]
|
||||||
@@ -0,0 +1,277 @@
|
|||||||
|
import pg from "pg";
|
||||||
|
|
||||||
|
import { decryptSecret } from "../backend/src/secrets.js";
|
||||||
|
|
||||||
|
const apply = process.argv.includes("--apply");
|
||||||
|
|
||||||
|
if (!process.env.DATABASE_URL) {
|
||||||
|
throw new Error("DATABASE_URL fehlt");
|
||||||
|
}
|
||||||
|
|
||||||
|
type JsonPosition = {
|
||||||
|
apiProjectId?: string | number | null;
|
||||||
|
minutes?: number;
|
||||||
|
billableMinutes?: number;
|
||||||
|
entryCount?: number;
|
||||||
|
days?: Array<{ day?: string; minutes?: number; billableMinutes?: number; entryCount?: number }>;
|
||||||
|
};
|
||||||
|
|
||||||
|
type RemoteProject = {
|
||||||
|
apiProjectId: number;
|
||||||
|
mainApiProjectId: number;
|
||||||
|
projectId: string;
|
||||||
|
name: string;
|
||||||
|
customerApiId: number | null;
|
||||||
|
customerName: string | null;
|
||||||
|
url: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const pool = new pg.Pool({ connectionString: process.env.DATABASE_URL });
|
||||||
|
|
||||||
|
function positiveInteger(value: unknown) {
|
||||||
|
const parsed = Number(value);
|
||||||
|
return Number.isInteger(parsed) && parsed > 0 ? parsed : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function text(value: unknown) {
|
||||||
|
return typeof value === "string" && value.trim() ? value.trim() : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const settingsResult = await pool.query<{ key: string; value: string }>(
|
||||||
|
"SELECT key, value FROM app_settings WHERE key = ANY($1::text[]);",
|
||||||
|
[["teamspace.base_url", "teamspace.admin_credentials_user_id"]]
|
||||||
|
);
|
||||||
|
const settings = new Map(settingsResult.rows.map((row) => [row.key, row.value]));
|
||||||
|
const baseUrl = settings.get("teamspace.base_url")?.replace(/\/$/, "");
|
||||||
|
const credentialsUserId = settings.get("teamspace.admin_credentials_user_id");
|
||||||
|
|
||||||
|
if (!baseUrl || !credentialsUserId) {
|
||||||
|
throw new Error("Teamspace-Adminzugang ist nicht vollständig konfiguriert");
|
||||||
|
}
|
||||||
|
|
||||||
|
const credentialsResult = await pool.query<{ teamspace_device_id: string | null; teamspace_device_password: string | null }>(
|
||||||
|
"SELECT teamspace_device_id, teamspace_device_password FROM users WHERE id = $1;",
|
||||||
|
[credentialsUserId]
|
||||||
|
);
|
||||||
|
const deviceId = decryptSecret(credentialsResult.rows[0]?.teamspace_device_id);
|
||||||
|
const devicePassword = decryptSecret(credentialsResult.rows[0]?.teamspace_device_password);
|
||||||
|
|
||||||
|
if (!deviceId || !devicePassword) {
|
||||||
|
throw new Error("Teamspace-Adminzugang enthält keine Geräte-Credentials");
|
||||||
|
}
|
||||||
|
|
||||||
|
const authorization = `Basic ${Buffer.from(`${deviceId}:${devicePassword}`).toString("base64")}`;
|
||||||
|
const fetchJson = async (path: string) => {
|
||||||
|
const response = await fetch(`${baseUrl}${path}`, { headers: { Authorization: authorization, Accept: "application/json" } });
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Teamspace ${path} antwortet mit HTTP ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.json() as Promise<Record<string, unknown>>;
|
||||||
|
};
|
||||||
|
|
||||||
|
const [entryResult, resourceResult, exclusionResult, userActualResult, entryActualResult] = await Promise.all([
|
||||||
|
pool.query<{ id: string; api_id: string }>("SELECT id::text, teamspace_api_project_id::text AS api_id FROM project_teamspace_entries WHERE teamspace_api_project_id IS NOT NULL;"),
|
||||||
|
pool.query<{ id: string; api_id: string }>("SELECT id::text, teamspace_api_project_id::text AS api_id FROM teamspace_project_resources WHERE teamspace_api_project_id IS NOT NULL;"),
|
||||||
|
pool.query<{ teamspace_project: string; api_id: string | null; api_ids: string[] }>(`
|
||||||
|
SELECT teamspace_project,
|
||||||
|
teamspace_api_project_id::text AS api_id,
|
||||||
|
ARRAY(SELECT value::text FROM unnest(teamspace_api_project_ids) AS value) AS api_ids
|
||||||
|
FROM teamspace_project_exclusions;
|
||||||
|
`),
|
||||||
|
pool.query<{ user_id: string; month: string; positions: JsonPosition[] }>("SELECT user_id::text, month::text, positions FROM teamspace_monthly_user_actuals;"),
|
||||||
|
pool.query<{ user_id: string; project_teamspace_entry_id: string; month: string; positions: JsonPosition[] }>(
|
||||||
|
"SELECT user_id::text, project_teamspace_entry_id::text, month::text, positions FROM project_teamspace_monthly_actuals;"
|
||||||
|
)
|
||||||
|
]);
|
||||||
|
|
||||||
|
const apiIds = new Set<number>();
|
||||||
|
const addApiId = (value: unknown) => {
|
||||||
|
const apiId = positiveInteger(value);
|
||||||
|
if (apiId) apiIds.add(apiId);
|
||||||
|
};
|
||||||
|
|
||||||
|
entryResult.rows.forEach((row) => addApiId(row.api_id));
|
||||||
|
resourceResult.rows.forEach((row) => addApiId(row.api_id));
|
||||||
|
exclusionResult.rows.forEach((row) => {
|
||||||
|
addApiId(row.api_id);
|
||||||
|
row.api_ids.forEach(addApiId);
|
||||||
|
});
|
||||||
|
[...userActualResult.rows, ...entryActualResult.rows].forEach((row) => row.positions.forEach((position) => addApiId(position.apiProjectId)));
|
||||||
|
|
||||||
|
const rawProjects = new Map<number, Record<string, unknown>>();
|
||||||
|
const ids = [...apiIds].sort((left, right) => left - right);
|
||||||
|
|
||||||
|
for (let offset = 0; offset < ids.length; offset += 8) {
|
||||||
|
const projects = await Promise.all(ids.slice(offset, offset + 8).map(async (apiId) => [apiId, await fetchJson(`/api/project/${apiId}`)] as const));
|
||||||
|
projects.forEach(([apiId, project]) => rawProjects.set(apiId, project));
|
||||||
|
}
|
||||||
|
|
||||||
|
const mainIds = new Set<number>();
|
||||||
|
for (const [apiId, project] of rawProjects) {
|
||||||
|
mainIds.add(positiveInteger((project.mainProject as Record<string, unknown> | undefined)?.value) ?? apiId);
|
||||||
|
}
|
||||||
|
|
||||||
|
const missingMainIds = [...mainIds].filter((apiId) => !rawProjects.has(apiId));
|
||||||
|
for (let offset = 0; offset < missingMainIds.length; offset += 8) {
|
||||||
|
const projects = await Promise.all(missingMainIds.slice(offset, offset + 8).map(async (apiId) => [apiId, await fetchJson(`/api/project/${apiId}`)] as const));
|
||||||
|
projects.forEach(([apiId, project]) => rawProjects.set(apiId, project));
|
||||||
|
}
|
||||||
|
|
||||||
|
const customerIds = new Set<number>();
|
||||||
|
for (const apiId of mainIds) {
|
||||||
|
const customerApiId = positiveInteger((rawProjects.get(apiId)?.customer as Record<string, unknown> | undefined)?.value);
|
||||||
|
if (customerApiId) customerIds.add(customerApiId);
|
||||||
|
}
|
||||||
|
const customerNames = new Map<number, string>();
|
||||||
|
|
||||||
|
for (let offset = 0; offset < customerIds.size; offset += 8) {
|
||||||
|
const chunk = [...customerIds].slice(offset, offset + 8);
|
||||||
|
const customers = await Promise.all(chunk.map(async (apiId) => [apiId, await fetchJson(`/api/organization/${apiId}`)] as const));
|
||||||
|
customers.forEach(([apiId, customer]) => {
|
||||||
|
const name = text(customer.name) ?? text(customer.companyName) ?? text(customer.displayName);
|
||||||
|
if (name) customerNames.set(apiId, name);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const projectMap = new Map<number, RemoteProject>();
|
||||||
|
for (const sourceApiId of apiIds) {
|
||||||
|
const source = rawProjects.get(sourceApiId);
|
||||||
|
if (!source) throw new Error(`Teamspace API-ID ${sourceApiId} konnte nicht aufgelöst werden`);
|
||||||
|
const mainApiProjectId = positiveInteger((source.mainProject as Record<string, unknown> | undefined)?.value) ?? sourceApiId;
|
||||||
|
const project = rawProjects.get(mainApiProjectId);
|
||||||
|
if (!project) throw new Error(`Teamspace Hauptprojekt ${mainApiProjectId} konnte nicht aufgelöst werden`);
|
||||||
|
const customerApiId = positiveInteger((project.customer as Record<string, unknown> | undefined)?.value);
|
||||||
|
const projectId = text(project.projectId);
|
||||||
|
if (!projectId) throw new Error(`Teamspace Hauptprojekt ${mainApiProjectId} hat keine Projektnummer`);
|
||||||
|
|
||||||
|
projectMap.set(sourceApiId, {
|
||||||
|
apiProjectId: mainApiProjectId,
|
||||||
|
mainApiProjectId,
|
||||||
|
projectId,
|
||||||
|
name: text(project.name) ?? projectId,
|
||||||
|
customerApiId,
|
||||||
|
customerName: customerApiId ? customerNames.get(customerApiId) ?? null : null,
|
||||||
|
url: text(project._url)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizePositions = (positions: JsonPosition[]) => {
|
||||||
|
const grouped = new Map<number, {
|
||||||
|
project: RemoteProject;
|
||||||
|
minutes: number;
|
||||||
|
billableMinutes: number;
|
||||||
|
entryCount: number;
|
||||||
|
days: Map<string, { minutes: number; billableMinutes: number; entryCount: number }>;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
for (const position of positions) {
|
||||||
|
const sourceApiId = positiveInteger(position.apiProjectId);
|
||||||
|
if (!sourceApiId) continue;
|
||||||
|
const project = projectMap.get(sourceApiId);
|
||||||
|
if (!project) throw new Error(`Keine Hauptprojekt-Zuordnung für API-ID ${sourceApiId}`);
|
||||||
|
const current = grouped.get(project.mainApiProjectId) ?? { project, minutes: 0, billableMinutes: 0, entryCount: 0, days: new Map() };
|
||||||
|
current.minutes += Number(position.minutes ?? 0);
|
||||||
|
current.billableMinutes += Number(position.billableMinutes ?? 0);
|
||||||
|
current.entryCount += Number(position.entryCount ?? 0);
|
||||||
|
|
||||||
|
for (const sourceDay of position.days ?? []) {
|
||||||
|
const dayLabel = text(sourceDay.day);
|
||||||
|
if (!dayLabel) continue;
|
||||||
|
const day = current.days.get(dayLabel) ?? { minutes: 0, billableMinutes: 0, entryCount: 0 };
|
||||||
|
day.minutes += Number(sourceDay.minutes ?? 0);
|
||||||
|
day.billableMinutes += Number(sourceDay.billableMinutes ?? 0);
|
||||||
|
day.entryCount += Number(sourceDay.entryCount ?? 0);
|
||||||
|
current.days.set(dayLabel, day);
|
||||||
|
}
|
||||||
|
|
||||||
|
grouped.set(project.mainApiProjectId, current);
|
||||||
|
}
|
||||||
|
|
||||||
|
return [...grouped.values()].sort((left, right) => left.project.mainApiProjectId - right.project.mainApiProjectId).map((value) => ({
|
||||||
|
apiProjectId: String(value.project.mainApiProjectId),
|
||||||
|
projectId: value.project.projectId,
|
||||||
|
mainProjectId: value.project.projectId,
|
||||||
|
label: value.project.name,
|
||||||
|
customerName: value.project.customerName,
|
||||||
|
isSubentry: false,
|
||||||
|
billingType: null,
|
||||||
|
minutes: value.minutes,
|
||||||
|
billableMinutes: value.billableMinutes,
|
||||||
|
entryCount: value.entryCount,
|
||||||
|
days: [...value.days.entries()].sort(([left], [right]) => left.localeCompare(right)).map(([day, totals]) => ({ day, ...totals }))
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
const normalizedUserActuals = userActualResult.rows.map((row) => ({ ...row, positions: normalizePositions(row.positions) }));
|
||||||
|
const normalizedEntryActuals = entryActualResult.rows.map((row) => ({ ...row, positions: normalizePositions(row.positions) }));
|
||||||
|
const subentryCountResult = await pool.query<{ count: number }>("SELECT count(*)::int AS count FROM project_teamspace_subentries;");
|
||||||
|
const changedSourceIds = [...projectMap.entries()].filter(([sourceApiId, project]) => sourceApiId !== project.mainApiProjectId).length;
|
||||||
|
|
||||||
|
console.log(JSON.stringify({
|
||||||
|
mode: apply ? "apply" : "dry-run",
|
||||||
|
apiIdsChecked: apiIds.size,
|
||||||
|
subprojectIdsNormalized: changedSourceIds,
|
||||||
|
monthlyUserActuals: normalizedUserActuals.length,
|
||||||
|
monthlyProjectActuals: normalizedEntryActuals.length,
|
||||||
|
legacySubentriesToDelete: subentryCountResult.rows[0]?.count ?? 0
|
||||||
|
}, null, 2));
|
||||||
|
|
||||||
|
if (!apply) return;
|
||||||
|
|
||||||
|
const client = await pool.connect();
|
||||||
|
try {
|
||||||
|
await client.query("BEGIN");
|
||||||
|
|
||||||
|
for (const row of entryResult.rows) {
|
||||||
|
const project = projectMap.get(Number(row.api_id));
|
||||||
|
if (!project) continue;
|
||||||
|
await client.query("UPDATE project_teamspace_entries SET teamspace_api_project_id=$2, teamspace_api_project_url=$3, updated_at=now() WHERE id=$1;", [row.id, project.mainApiProjectId, project.url]);
|
||||||
|
}
|
||||||
|
for (const row of resourceResult.rows) {
|
||||||
|
const project = projectMap.get(Number(row.api_id));
|
||||||
|
if (!project) continue;
|
||||||
|
await client.query("UPDATE teamspace_project_resources SET teamspace_api_project_id=$2, teamspace_api_project_url=$3, updated_at=now() WHERE id=$1;", [row.id, project.mainApiProjectId, project.url]);
|
||||||
|
}
|
||||||
|
for (const row of exclusionResult.rows) {
|
||||||
|
const sourceApiId = positiveInteger(row.api_id) ?? row.api_ids.map(positiveInteger).find((value): value is number => value !== null);
|
||||||
|
const project = sourceApiId ? projectMap.get(sourceApiId) : null;
|
||||||
|
await client.query(`
|
||||||
|
UPDATE teamspace_project_exclusions
|
||||||
|
SET teamspace_api_project_id=$2,
|
||||||
|
teamspace_api_project_ids=CASE WHEN $2::bigint IS NULL THEN '{}'::bigint[] ELSE ARRAY[$2::bigint] END,
|
||||||
|
teamspace_name=COALESCE($3, teamspace_name),
|
||||||
|
customer_name=COALESCE($4, customer_name),
|
||||||
|
updated_at=now()
|
||||||
|
WHERE teamspace_project=$1;
|
||||||
|
`, [row.teamspace_project, project?.mainApiProjectId ?? null, project?.name ?? null, project?.customerName ?? null]);
|
||||||
|
}
|
||||||
|
for (const row of normalizedUserActuals) {
|
||||||
|
const minutes = row.positions.reduce((sum, position) => sum + position.minutes, 0);
|
||||||
|
const billableMinutes = row.positions.reduce((sum, position) => sum + position.billableMinutes, 0);
|
||||||
|
const entryCount = row.positions.reduce((sum, position) => sum + position.entryCount, 0);
|
||||||
|
await client.query(`UPDATE teamspace_monthly_user_actuals SET positions=$3::jsonb, teamspace_minutes=$4, teamspace_billable_minutes=$5, teamspace_time_entry_count=$6, updated_at=now() WHERE user_id=$1 AND month=$2::date;`, [row.user_id, row.month, JSON.stringify(row.positions), minutes, billableMinutes, entryCount]);
|
||||||
|
}
|
||||||
|
for (const row of normalizedEntryActuals) {
|
||||||
|
const minutes = row.positions.reduce((sum, position) => sum + position.minutes, 0);
|
||||||
|
const billableMinutes = row.positions.reduce((sum, position) => sum + position.billableMinutes, 0);
|
||||||
|
const entryCount = row.positions.reduce((sum, position) => sum + position.entryCount, 0);
|
||||||
|
await client.query(`UPDATE project_teamspace_monthly_actuals SET positions=$4::jsonb, teamspace_minutes=$5, teamspace_billable_minutes=$6, teamspace_time_entry_count=$7, updated_at=now() WHERE user_id=$1 AND project_teamspace_entry_id=$2 AND month=$3::date;`, [row.user_id, row.project_teamspace_entry_id, row.month, JSON.stringify(row.positions), minutes, billableMinutes, entryCount]);
|
||||||
|
}
|
||||||
|
await client.query("DELETE FROM project_teamspace_subentries;");
|
||||||
|
await client.query("COMMIT");
|
||||||
|
} catch (error) {
|
||||||
|
await client.query("ROLLBACK");
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
client.release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await main();
|
||||||
|
} finally {
|
||||||
|
await pool.end();
|
||||||
|
}
|
||||||
Reference in new issue
Block a user