Compare commits

...
2 Commits
Author SHA1 Message Date
michael fa6db04370 Update 2026-09-23 11:09:10 +00:00
michael 9be6c6e36a Update 2026-09-23 08:47:34 +00:00
16 changed files with 1125 additions and 119 deletions

No files matched your search

+14 -2
View File
@@ -18,6 +18,10 @@ export type AuthUser = {
zammad_user_id: string | null;
has_zammad_api_token: boolean;
zammad_email_session_webhook_enabled: boolean;
session_billing_prompt_enabled: boolean;
zammad_session_note_default_enabled: boolean;
zammad_time_unit_rounding_enabled: boolean;
zammad_time_unit_rounding_minutes: number;
};
declare global {
@@ -79,7 +83,11 @@ function publicUser(user: AuthUser) {
active: user.active,
zammad_user_id: user.zammad_user_id ?? null,
has_zammad_api_token: Boolean(user.has_zammad_api_token),
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),
zammad_session_note_default_enabled: Boolean(user.zammad_session_note_default_enabled),
zammad_time_unit_rounding_enabled: Boolean(user.zammad_time_unit_rounding_enabled),
zammad_time_unit_rounding_minutes: Number(user.zammad_time_unit_rounding_minutes ?? 15)
};
}
@@ -137,7 +145,11 @@ export async function requireAuth(req: Request, res: Response, next: NextFunctio
u.active,
u.zammad_user_id::text AS zammad_user_id,
(u.zammad_api_token IS NOT NULL) AS has_zammad_api_token,
u.zammad_email_session_webhook_enabled
u.zammad_email_session_webhook_enabled,
u.session_billing_prompt_enabled,
u.zammad_session_note_default_enabled,
u.zammad_time_unit_rounding_enabled,
u.zammad_time_unit_rounding_minutes
FROM auth_sessions s
JOIN users u ON u.id = s.user_id
WHERE s.token_hash = $1
+447 -20
View File
@@ -226,6 +226,20 @@ function parseOptionalTeamspaceEntryId(value: unknown) {
return parsePositiveInteger(value, "projectTeamspaceEntryId");
}
function parseZammadTimeUnitRoundingMinutes(value: unknown, fallback = 15) {
if (value === undefined || value === null || value === "") {
return fallback;
}
const minutes = Number(value);
if (![5, 10, 15, 30].includes(minutes)) {
throw badRequest("zammadTimeUnitRoundingMinutes must be 5, 10, 15 or 30");
}
return minutes;
}
function optionalTicketSessionMonth(value: unknown) {
if (value === undefined || value === null || value === "" || value === "all") {
return null;
@@ -239,11 +253,11 @@ function optionalTicketSessionStatus(value: unknown) {
return "all" as const;
}
if (value === "open" || value === "billed" || value === "non_billable") {
if (value === "open" || value === "billable" || value === "billed" || value === "non_billable") {
return value;
}
throw badRequest("status must be all, open, billed or non_billable");
throw badRequest("status must be all, open, billable, billed or non_billable");
}
function optionalTicketSessionSource(value: unknown) {
@@ -560,6 +574,204 @@ async function fetchZammadJson(baseUrl: string, apiToken: string, path: string)
return response.json() as Promise<unknown>;
}
async function postZammadJson(baseUrl: string, apiToken: string, path: string, payload: Record<string, unknown>) {
const response = await fetch(`${baseUrl}${path}`, {
method: "POST",
headers: {
Authorization: `Token token=${apiToken}`,
Accept: "application/json",
"Content-Type": "application/json"
},
body: JSON.stringify(payload)
});
if (!response.ok) {
const body = await response.text().catch(() => "");
throw Object.assign(new Error(`Zammad Article konnte nicht erstellt werden (${response.status})${body ? `: ${body.slice(0, 250)}` : ""}`), { status: 502 });
}
return response.json() as Promise<unknown>;
}
function zammadTimeUnit(durationSeconds: number, roundingEnabled: boolean, roundingMinutes: number) {
const minutes = durationSeconds / 60;
const roundedMinutes = roundingEnabled ? Math.ceil(minutes / roundingMinutes) * roundingMinutes : minutes;
return Number((roundedMinutes / 60).toFixed(2));
}
function errorMessage(error: unknown) {
return error instanceof Error ? error.message : String(error);
}
async function zammadTimeUnitForUser(userId: string, durationSeconds: number) {
const userResult = await query<{
zammad_time_unit_rounding_enabled: boolean;
zammad_time_unit_rounding_minutes: number;
}>(
`
SELECT zammad_time_unit_rounding_enabled, zammad_time_unit_rounding_minutes
FROM users
WHERE id = $1;
`,
[userId]
);
const userSettings = userResult.rows[0] ?? {
zammad_time_unit_rounding_enabled: false,
zammad_time_unit_rounding_minutes: 15
};
return zammadTimeUnit(
durationSeconds,
Boolean(userSettings.zammad_time_unit_rounding_enabled),
Number(userSettings.zammad_time_unit_rounding_minutes ?? 15)
);
}
type ZammadSessionArticlePayload = {
ticket_id: number;
type: "note";
internal: true;
subject: string;
body: string;
content_type: "text/plain";
time_unit: number;
};
function zammadSessionArticlePayload(input: {
zammadTicketId: string | number;
activity: string;
timeUnit: number;
}): ZammadSessionArticlePayload {
const ticketId = Number(input.zammadTicketId);
if (!Number.isInteger(ticketId) || ticketId < 1) {
throw badRequest("Ticket hat keine gültige Zammad-Ticket-ID");
}
return {
ticket_id: ticketId,
type: "note",
internal: true,
subject: "TicketTracker Session",
body: input.activity,
content_type: "text/plain",
time_unit: input.timeUnit
};
}
async function postZammadSessionArticleForUser(userId: string, payload: ZammadSessionArticlePayload) {
const credentials = await getZammadCredentialsForUser(userId);
return postZammadJson(credentials.baseUrl, credentials.apiToken, "/api/v1/ticket_articles", payload);
}
async function enqueueZammadSessionArticle(client: pg.PoolClient, input: {
userId: string;
sessionId: string | number;
ticketId: string | number;
ticketNumber: string;
zammadTicketId: string | number | null;
activity: string;
timeUnit: number;
error: string;
}) {
await client.query(
`
INSERT INTO zammad_session_article_queue (
user_id,
session_id,
ticket_id,
ticket_number,
zammad_ticket_id,
activity,
time_unit,
last_error
)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
ON CONFLICT (session_id) WHERE status = 'pending'
DO UPDATE SET
zammad_ticket_id = COALESCE(EXCLUDED.zammad_ticket_id, zammad_session_article_queue.zammad_ticket_id),
activity = EXCLUDED.activity,
time_unit = EXCLUDED.time_unit,
last_error = EXCLUDED.last_error,
updated_at = now();
`,
[
input.userId,
input.sessionId,
input.ticketId,
input.ticketNumber,
input.zammadTicketId,
input.activity,
input.timeUnit,
input.error.slice(0, 1000)
]
);
}
function zammadArticleId(payload: unknown) {
return isPlainObject(payload) ? numberOrNull(payload.id) : null;
}
type ZammadSessionArticleQueueRow = {
id: string;
user_id: string;
session_id: string;
ticket_id: string;
ticket_number: string;
zammad_ticket_id: string | null;
activity: string;
time_unit: string | number;
};
async function retryZammadSessionArticleQueueRow(row: ZammadSessionArticleQueueRow) {
let zammadTicketId = row.zammad_ticket_id;
if (!zammadTicketId) {
const zammadTicket = await fetchZammadTicketByNumber(row.user_id, row.ticket_number);
if (!zammadTicket) {
throw new Error("Ticket wurde in Zammad nicht gefunden");
}
await withTransaction(async (client) => {
await importZammadTicketForUser(client, zammadTicket, row.user_id);
});
zammadTicketId = String(zammadTicket.zammadTicketId);
await query(
`
UPDATE zammad_session_article_queue
SET zammad_ticket_id = $1, updated_at = now()
WHERE id = $2;
`,
[zammadTicketId, row.id]
);
}
const payload = zammadSessionArticlePayload({
zammadTicketId,
activity: row.activity,
timeUnit: Number(row.time_unit)
});
const zammadResponse = await postZammadSessionArticleForUser(row.user_id, payload);
const articleId = zammadArticleId(zammadResponse);
await query(
`
UPDATE zammad_session_article_queue
SET status = 'sent',
sent_at = now(),
zammad_article_id = $1,
last_error = NULL,
last_attempt_at = now(),
updated_at = now()
WHERE id = $2;
`,
[articleId, row.id]
);
return articleId;
}
function zammadTicketCandidates(payload: unknown): Record<string, unknown>[] {
if (Array.isArray(payload)) {
return payload.filter(isPlainObject);
@@ -799,6 +1011,8 @@ type SessionCreationInput = {
requestedWorkType: "support" | "consulting" | null;
useZammadLookup: boolean;
requestedProjectTeamspaceEntryId: number | null;
billingStatus: "billable" | "billed" | "non_billable" | null;
createZammadArticle: boolean;
startedAt: Date;
endedAt: Date;
durationSeconds: number;
@@ -818,6 +1032,8 @@ async function createSessionForUser(input: SessionCreationInput) {
requestedWorkType,
useZammadLookup,
requestedProjectTeamspaceEntryId,
billingStatus,
createZammadArticle,
startedAt,
endedAt,
durationSeconds,
@@ -947,7 +1163,7 @@ async function createSessionForUser(input: SessionCreationInput) {
throw badRequest("organizationId must reference an existing organization");
}
const ticketResult = await client.query<{ id: string; ticket_number: string; organization_id: string | null; customer_name: string | null; work_type: string | null }>(
const ticketResult = await client.query<{ id: string; ticket_number: string; organization_id: string | null; customer_name: string | null; work_type: string | null; zammad_ticket_id: string | null }>(
`
INSERT INTO tickets (ticket_number, organization_id, customer_name, work_type)
VALUES ($1, $2, $3, $4)
@@ -962,7 +1178,7 @@ async function createSessionForUser(input: SessionCreationInput) {
) THEN 'consulting'
ELSE COALESCE(tickets.work_type, EXCLUDED.work_type)
END
RETURNING id, ticket_number, organization_id, customer_name, work_type;
RETURNING id, ticket_number, organization_id, customer_name, work_type, zammad_ticket_id::text AS zammad_ticket_id;
`,
[ticketNumber, organization.id, organization.name, existingTicket?.project_id ? "consulting" : requestedWorkType, userId]
);
@@ -1011,9 +1227,11 @@ async function createSessionForUser(input: SessionCreationInput) {
ended_at,
duration_seconds,
rounded_minutes,
project_teamspace_entry_id
project_teamspace_entry_id,
billing_status,
billing_updated_at
)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, CASE WHEN $12::text IS NULL THEN NULL ELSE now() END)
RETURNING *;
`,
[
@@ -1027,9 +1245,60 @@ async function createSessionForUser(input: SessionCreationInput) {
endedAt.toISOString(),
durationSeconds,
roundedMinutes,
projectTeamspaceEntryId
projectTeamspaceEntryId,
billingStatus
]
);
const session = sessionResult.rows[0];
if (createZammadArticle) {
const timeUnit = await zammadTimeUnitForUser(userId, durationSeconds);
let zammadArticleTicketId = ticket.zammad_ticket_id;
let zammadArticleError: string | null = null;
if (!zammadArticleTicketId && useZammadLookup) {
try {
const articleZammadTicket = await fetchZammadTicketByNumber(userId, ticketNumber);
if (articleZammadTicket) {
await importZammadTicketForUser(client, articleZammadTicket, userId);
zammadArticleTicketId = String(articleZammadTicket.zammadTicketId);
} else {
zammadArticleError = "Ticket wurde in Zammad nicht gefunden";
}
} catch (error) {
zammadArticleError = errorMessage(error);
}
}
if (!zammadArticleError && zammadArticleTicketId) {
try {
const payload = zammadSessionArticlePayload({
zammadTicketId: zammadArticleTicketId,
activity,
timeUnit
});
await postZammadSessionArticleForUser(userId, payload);
} catch (error) {
zammadArticleError = errorMessage(error);
}
} else if (!zammadArticleError) {
zammadArticleError = "Ticket hat keine Zammad-Ticket-ID";
}
if (zammadArticleError) {
await enqueueZammadSessionArticle(client, {
userId,
sessionId: session.id,
ticketId: ticket.id,
ticketNumber,
zammadTicketId: zammadArticleTicketId,
activity,
timeUnit,
error: zammadArticleError
});
}
}
await reopenPeriodsForSession(client, ticket.id, startedAt, userId);
if (workType === "consulting") {
@@ -1044,13 +1313,13 @@ async function createSessionForUser(input: SessionCreationInput) {
SET session_id = $1
WHERE id = $2;
`,
[sessionResult.rows[0].id, eventId]
[session.id, eventId]
);
}
return {
ticket,
session: sessionResult.rows[0]
session
};
});
}
@@ -1404,6 +1673,8 @@ async function createZammadEmailSessionFromWebhook(payload: unknown, deliveryId:
requestedWorkType: null,
useZammadLookup: true,
requestedProjectTeamspaceEntryId: null,
billingStatus: null,
createZammadArticle: false,
startedAt,
endedAt,
durationSeconds,
@@ -3517,9 +3788,11 @@ async function getPeriodOverview(config: PeriodConfig, period: ParsedPeriod, use
COUNT(*) FILTER (WHERE s.recurring_billing_id IS NULL)::int AS manual_session_count,
COALESCE(SUM(s.rounded_minutes) FILTER (WHERE s.billing_status = 'billed'), 0)::int AS total_minutes,
ticket_crm.crm_billed_minutes,
COALESCE(SUM(s.rounded_minutes) FILTER (WHERE s.billing_status = 'billable'), 0)::int AS billable_minutes,
COALESCE(SUM(s.rounded_minutes) FILTER (WHERE s.billing_status = 'non_billable'), 0)::int AS non_billable_minutes,
COALESCE(SUM(s.rounded_minutes) FILTER (WHERE s.billing_status IS NULL), 0)::int AS open_minutes,
COUNT(*) FILTER (WHERE s.billing_status IS NULL)::int AS open_count,
COUNT(*) FILTER (WHERE s.billing_status = 'billable')::int AS billable_count,
COUNT(*) FILTER (WHERE s.billing_status = 'billed')::int AS billed_count,
COUNT(*) FILTER (WHERE s.billing_status = 'non_billable')::int AS non_billable_count,
false AS requires_closure,
@@ -3634,6 +3907,7 @@ async function getPeriodOverview(config: PeriodConfig, period: ParsedPeriod, use
sessions: totalSessions,
minutes: totalMinutes,
crmBilledMinutes: crmBillingResult.rows[0]?.crm_billed_minutes ?? 0,
billableMinutes: tickets.reduce((sum: number, ticket: any) => sum + ticket.billable_minutes, 0),
nonBillableMinutes: tickets.reduce((sum: number, ticket: any) => sum + ticket.non_billable_minutes, 0),
openMinutes: tickets.reduce((sum: number, ticket: any) => sum + ticket.open_minutes, 0),
openSessions: openSessions.length
@@ -3714,9 +3988,11 @@ async function getStatisticsOverview(period: ParsedPeriod, userId: string) {
COUNT(*)::int AS session_count,
COALESCE(SUM(sb.rounded_minutes) FILTER (WHERE sb.billing_status = 'billed'), 0)::int AS total_minutes,
SUM(sb.rounded_minutes) FILTER (WHERE sb.billing_status = 'billed')::int AS billed_minutes,
SUM(sb.rounded_minutes) FILTER (WHERE sb.billing_status = 'billable')::int AS billable_minutes,
SUM(sb.rounded_minutes) FILTER (WHERE sb.billing_status = 'non_billable')::int AS non_billable_minutes,
SUM(sb.rounded_minutes) FILTER (WHERE sb.billing_status IS NULL)::int AS open_minutes,
COUNT(*) FILTER (WHERE sb.billing_status = 'billed')::int AS billed_sessions,
COUNT(*) FILTER (WHERE sb.billing_status = 'billable')::int AS billable_sessions,
COUNT(*) FILTER (WHERE sb.billing_status = 'non_billable')::int AS non_billable_sessions,
COUNT(*) FILTER (WHERE sb.billing_status IS NULL)::int AS open_count,
COUNT(*) FILTER (WHERE sb.recurring_billing_id IS NOT NULL)::int AS recurring_session_count,
@@ -3756,9 +4032,11 @@ async function getStatisticsOverview(period: ParsedPeriod, userId: string) {
COUNT(*)::int AS sessions,
COALESCE(SUM(rounded_minutes) FILTER (WHERE billing_status = 'billed'), 0)::int AS minutes,
COALESCE(SUM(rounded_minutes) FILTER (WHERE billing_status = 'billed'), 0)::int AS billed_minutes,
COALESCE(SUM(rounded_minutes) FILTER (WHERE billing_status = 'billable'), 0)::int AS billable_minutes,
COALESCE(SUM(rounded_minutes) FILTER (WHERE billing_status = 'non_billable'), 0)::int AS non_billable_minutes,
COALESCE(SUM(rounded_minutes) FILTER (WHERE billing_status IS NULL), 0)::int AS open_minutes,
COUNT(*) FILTER (WHERE billing_status = 'billed')::int AS billed_sessions,
COUNT(*) FILTER (WHERE billing_status = 'billable')::int AS billable_sessions,
COUNT(*) FILTER (WHERE billing_status = 'non_billable')::int AS non_billable_sessions,
COUNT(*) FILTER (WHERE billing_status IS NULL)::int AS open_sessions,
COALESCE(SUM(rounded_minutes) FILTER (WHERE recurring_billing_id IS NOT NULL AND billing_status = 'billed'), 0)::int AS recurring_minutes,
@@ -3780,9 +4058,11 @@ async function getStatisticsOverview(period: ParsedPeriod, userId: string) {
COALESCE(day_sessions.session_count, 0)::int AS sessions,
td.tracked_minutes::int AS total_minutes,
COALESCE(day_sessions.billed_minutes, 0)::int AS billed_minutes,
COALESCE(day_sessions.billable_minutes, 0)::int AS billable_minutes,
COALESCE(day_sessions.non_billable_minutes, 0)::int AS non_billable_minutes,
COALESCE(day_sessions.open_minutes, 0)::int AS open_minutes,
COALESCE(day_sessions.billed_sessions, 0)::int AS billed_sessions,
COALESCE(day_sessions.billable_sessions, 0)::int AS billable_sessions,
COALESCE(day_sessions.non_billable_sessions, 0)::int AS non_billable_sessions,
COALESCE(day_sessions.open_sessions, 0)::int AS open_sessions,
COALESCE(SUM(tdb.crm_billed_minutes), 0)::int AS crm_billed_minutes
@@ -3796,16 +4076,18 @@ async function getStatisticsOverview(period: ParsedPeriod, userId: string) {
day,
SUM(session_count)::int AS session_count,
SUM(COALESCE(billed_minutes, 0))::int AS billed_minutes,
SUM(COALESCE(billable_minutes, 0))::int AS billable_minutes,
SUM(COALESCE(non_billable_minutes, 0))::int AS non_billable_minutes,
SUM(COALESCE(open_minutes, 0))::int AS open_minutes,
SUM(billed_sessions)::int AS billed_sessions,
SUM(billable_sessions)::int AS billable_sessions,
SUM(non_billable_sessions)::int AS non_billable_sessions,
SUM(open_count)::int AS open_sessions
FROM session_group
GROUP BY day
) day_sessions ON day_sessions.day = td.day
LEFT JOIN ticket_day_billing tdb ON tdb.day = td.day
GROUP BY td.day, td.tracked_minutes, day_sessions.session_count, day_sessions.billed_minutes, day_sessions.non_billable_minutes, day_sessions.open_minutes, day_sessions.billed_sessions, day_sessions.non_billable_sessions, day_sessions.open_sessions
GROUP BY td.day, td.tracked_minutes, day_sessions.session_count, day_sessions.billed_minutes, day_sessions.billable_minutes, day_sessions.non_billable_minutes, day_sessions.open_minutes, day_sessions.billed_sessions, day_sessions.billable_sessions, day_sessions.non_billable_sessions, day_sessions.open_sessions
ORDER BY td.day ASC;
`,
[period.startIso, period.endIso, userId]
@@ -3820,9 +4102,11 @@ async function getStatisticsOverview(period: ParsedPeriod, userId: string) {
SUM(session_count)::int AS sessions,
COALESCE(SUM(total_minutes), 0)::int AS total_minutes,
COALESCE(SUM(COALESCE(billed_minutes, 0)), 0)::int AS billed_minutes,
COALESCE(SUM(COALESCE(billable_minutes, 0)), 0)::int AS billable_minutes,
COALESCE(SUM(COALESCE(non_billable_minutes, 0)), 0)::int AS non_billable_minutes,
COALESCE(SUM(COALESCE(open_minutes, 0)), 0)::int AS open_minutes,
COALESCE(SUM(billed_sessions), 0)::int AS billed_sessions,
COALESCE(SUM(billable_sessions), 0)::int AS billable_sessions,
COALESCE(SUM(non_billable_sessions), 0)::int AS non_billable_sessions,
COALESCE(SUM(open_count), 0)::int AS open_sessions,
COALESCE(SUM(crm_billed_minutes), 0)::int AS crm_billed_minutes
@@ -3841,9 +4125,11 @@ async function getStatisticsOverview(period: ParsedPeriod, userId: string) {
SUM(session_count)::int AS sessions,
COALESCE(SUM(total_minutes), 0)::int AS total_minutes,
COALESCE(SUM(COALESCE(billed_minutes, 0)), 0)::int AS billed_minutes,
COALESCE(SUM(COALESCE(billable_minutes, 0)), 0)::int AS billable_minutes,
COALESCE(SUM(COALESCE(non_billable_minutes, 0)), 0)::int AS non_billable_minutes,
COALESCE(SUM(COALESCE(open_minutes, 0)), 0)::int AS open_minutes,
COALESCE(SUM(billed_sessions), 0)::int AS billed_sessions,
COALESCE(SUM(billable_sessions), 0)::int AS billable_sessions,
COALESCE(SUM(non_billable_sessions), 0)::int AS non_billable_sessions,
COALESCE(SUM(open_count), 0)::int AS open_sessions,
COALESCE(SUM(crm_billed_minutes), 0)::int AS crm_billed_minutes
@@ -3865,9 +4151,11 @@ async function getStatisticsOverview(period: ParsedPeriod, userId: string) {
COUNT(*)::int AS sessions,
COALESCE(SUM(rounded_minutes) FILTER (WHERE billing_status = 'billed'), 0)::int AS total_minutes,
COALESCE(SUM(rounded_minutes) FILTER (WHERE billing_status = 'billed'), 0)::int AS billed_minutes,
COALESCE(SUM(rounded_minutes) FILTER (WHERE billing_status = 'billable'), 0)::int AS billable_minutes,
COALESCE(SUM(rounded_minutes) FILTER (WHERE billing_status = 'non_billable'), 0)::int AS non_billable_minutes,
COALESCE(SUM(rounded_minutes) FILTER (WHERE billing_status IS NULL), 0)::int AS open_minutes,
COUNT(*) FILTER (WHERE billing_status = 'billed')::int AS billed_sessions,
COUNT(*) FILTER (WHERE billing_status = 'billable')::int AS billable_sessions,
COUNT(*) FILTER (WHERE billing_status = 'non_billable')::int AS non_billable_sessions,
COUNT(*) FILTER (WHERE billing_status IS NULL)::int AS open_sessions,
COALESCE((SELECT SUM(crm_billed_minutes)::int FROM ticket_day_billing tdb WHERE tdb.ticket_id = session_base.ticket_id), 0)::int AS crm_billed_minutes
@@ -3996,9 +4284,11 @@ async function getStatisticsOverview(period: ParsedPeriod, userId: string) {
sessions: 0,
minutes: 0,
billed_minutes: 0,
billable_minutes: 0,
non_billable_minutes: 0,
open_minutes: 0,
billed_sessions: 0,
billable_sessions: 0,
non_billable_sessions: 0,
open_sessions: 0,
recurring_minutes: 0,
@@ -4020,9 +4310,11 @@ async function getStatisticsOverview(period: ParsedPeriod, userId: string) {
sessions: totals.sessions,
minutes: totals.minutes,
billedMinutes: totals.billed_minutes,
billableMinutes: totals.billable_minutes,
nonBillableMinutes: totals.non_billable_minutes,
openMinutes: totals.open_minutes,
billedSessions: totals.billed_sessions,
billableSessions: totals.billable_sessions,
nonBillableSessions: totals.non_billable_sessions,
openSessions: totals.open_sessions,
recurringMinutes: totals.recurring_minutes,
@@ -4196,7 +4488,7 @@ async function getPeriodTicket(config: PeriodConfig, period: ParsedPeriod, ticke
};
}
async function getTicketDetails(ticketId: number, userId: string, filters: { month: ParsedPeriod | null; status: "all" | "open" | "billed" | "non_billable"; source: "all" | "manual" | "recurring" }) {
async function getTicketDetails(ticketId: number, userId: string, filters: { month: ParsedPeriod | null; status: "all" | "open" | "billable" | "billed" | "non_billable"; source: "all" | "manual" | "recurring" }) {
if (filters.month) {
await ensureRecurringSessionsForUserPeriod(userId, filters.month.startIso, filters.month.endIso);
}
@@ -4545,11 +4837,15 @@ app.post("/api/setup/admin", async (req, res) => {
zammad_user_id: string | null;
has_zammad_api_token: boolean;
zammad_email_session_webhook_enabled: boolean;
session_billing_prompt_enabled: boolean;
zammad_session_note_default_enabled: boolean;
zammad_time_unit_rounding_enabled: boolean;
zammad_time_unit_rounding_minutes: number;
}>(
`
INSERT INTO users (username, display_name, password_hash, role, active)
VALUES ($1, $2, $3, 'admin', true)
RETURNING id, username, display_name, role, active, zammad_user_id::text AS zammad_user_id, (zammad_api_token IS NOT NULL) AS has_zammad_api_token, zammad_email_session_webhook_enabled;
RETURNING id, username, display_name, role, active, zammad_user_id::text AS zammad_user_id, (zammad_api_token IS NOT NULL) AS has_zammad_api_token, zammad_email_session_webhook_enabled, session_billing_prompt_enabled, zammad_session_note_default_enabled, zammad_time_unit_rounding_enabled, zammad_time_unit_rounding_minutes;
`,
[username, displayName, await hashPassword(password)]
);
@@ -4606,6 +4902,10 @@ app.post("/api/auth/login", async (req, res) => {
zammad_user_id: string | null;
has_zammad_api_token: boolean;
zammad_email_session_webhook_enabled: boolean;
session_billing_prompt_enabled: boolean;
zammad_session_note_default_enabled: boolean;
zammad_time_unit_rounding_enabled: boolean;
zammad_time_unit_rounding_minutes: number;
}>(
`
SELECT
@@ -4617,7 +4917,11 @@ app.post("/api/auth/login", async (req, res) => {
password_hash,
zammad_user_id::text AS zammad_user_id,
(zammad_api_token IS NOT NULL) AS has_zammad_api_token,
zammad_email_session_webhook_enabled
zammad_email_session_webhook_enabled,
session_billing_prompt_enabled,
zammad_session_note_default_enabled,
zammad_time_unit_rounding_enabled,
zammad_time_unit_rounding_minutes
FROM users
WHERE lower(username) = lower($1);
`,
@@ -4663,7 +4967,11 @@ app.post("/api/auth/login", async (req, res) => {
active: user.active,
zammad_user_id: user.zammad_user_id,
has_zammad_api_token: user.has_zammad_api_token,
zammad_email_session_webhook_enabled: user.zammad_email_session_webhook_enabled
zammad_email_session_webhook_enabled: user.zammad_email_session_webhook_enabled,
session_billing_prompt_enabled: user.session_billing_prompt_enabled,
zammad_session_note_default_enabled: user.zammad_session_note_default_enabled,
zammad_time_unit_rounding_enabled: user.zammad_time_unit_rounding_enabled,
zammad_time_unit_rounding_minutes: user.zammad_time_unit_rounding_minutes
}
});
});
@@ -4869,6 +5177,18 @@ app.patch("/api/auth/me", async (req, res) => {
const zammadEmailSessionWebhookEnabled = user.role === "user"
? optionalBoolean(req.body.zammadEmailSessionWebhookEnabled, user.zammad_email_session_webhook_enabled)
: false;
const sessionBillingPromptEnabled = user.role === "user"
? optionalBoolean(req.body.sessionBillingPromptEnabled, user.session_billing_prompt_enabled)
: false;
const zammadSessionNoteDefaultEnabled = user.role === "user"
? optionalBoolean(req.body.zammadSessionNoteDefaultEnabled, user.zammad_session_note_default_enabled)
: false;
const zammadTimeUnitRoundingEnabled = user.role === "user"
? optionalBoolean(req.body.zammadTimeUnitRoundingEnabled, user.zammad_time_unit_rounding_enabled)
: false;
const zammadTimeUnitRoundingMinutes = user.role === "user"
? parseZammadTimeUnitRoundingMinutes(req.body.zammadTimeUnitRoundingMinutes, user.zammad_time_unit_rounding_minutes)
: 15;
if (newPassword && newPassword.length < 6) {
throw badRequest("newPassword must be at least 6 characters");
@@ -4897,9 +5217,13 @@ app.patch("/api/auth/me", async (req, res) => {
zammad_api_token = CASE WHEN $5::boolean THEN $6 ELSE zammad_api_token END,
zammad_user_id = CASE WHEN $5::boolean THEN $7 ELSE zammad_user_id END,
zammad_email_session_webhook_enabled = CASE WHEN $8::boolean THEN $9 ELSE zammad_email_session_webhook_enabled END,
session_billing_prompt_enabled = CASE WHEN $8::boolean THEN $10 ELSE session_billing_prompt_enabled END,
zammad_session_note_default_enabled = CASE WHEN $8::boolean THEN $11 ELSE zammad_session_note_default_enabled END,
zammad_time_unit_rounding_enabled = CASE WHEN $8::boolean THEN $12 ELSE zammad_time_unit_rounding_enabled END,
zammad_time_unit_rounding_minutes = CASE WHEN $8::boolean THEN $13 ELSE zammad_time_unit_rounding_minutes END,
updated_at = now()
WHERE id = $4
RETURNING id, username, display_name, role, active, zammad_user_id::text AS zammad_user_id, (zammad_api_token IS NOT NULL) AS has_zammad_api_token, zammad_email_session_webhook_enabled;
RETURNING id, username, display_name, role, active, zammad_user_id::text AS zammad_user_id, (zammad_api_token IS NOT NULL) AS has_zammad_api_token, zammad_email_session_webhook_enabled, session_billing_prompt_enabled, zammad_session_note_default_enabled, zammad_time_unit_rounding_enabled, zammad_time_unit_rounding_minutes;
`,
[
username,
@@ -4910,7 +5234,11 @@ app.patch("/api/auth/me", async (req, res) => {
zammadApiToken ? encryptSecret(zammadApiToken) : null,
zammadUserId,
user.role === "user",
zammadEmailSessionWebhookEnabled
zammadEmailSessionWebhookEnabled,
sessionBillingPromptEnabled,
zammadSessionNoteDefaultEnabled,
zammadTimeUnitRoundingEnabled,
zammadTimeUnitRoundingMinutes
]
);
@@ -4938,7 +5266,7 @@ app.patch("/api/auth/me", async (req, res) => {
app.get("/api/admin/users", requireAdmin, async (_req, res) => {
const result = await query(
`
SELECT id, username, display_name, role, active, zammad_user_id::text AS zammad_user_id, (zammad_api_token IS NOT NULL) AS has_zammad_api_token, zammad_email_session_webhook_enabled, created_at, updated_at
SELECT id, username, display_name, role, active, zammad_user_id::text AS zammad_user_id, (zammad_api_token IS NOT NULL) AS has_zammad_api_token, zammad_email_session_webhook_enabled, session_billing_prompt_enabled, zammad_session_note_default_enabled, zammad_time_unit_rounding_enabled, zammad_time_unit_rounding_minutes, created_at, updated_at
FROM users
ORDER BY username ASC;
`
@@ -4962,7 +5290,7 @@ app.post("/api/admin/users", requireAdmin, async (req, res) => {
`
INSERT INTO users (username, display_name, password_hash, role, active)
VALUES ($1, $2, $3, $4, $5)
RETURNING id, username, display_name, role, active, zammad_user_id::text AS zammad_user_id, (zammad_api_token IS NOT NULL) AS has_zammad_api_token, zammad_email_session_webhook_enabled, created_at, updated_at;
RETURNING id, username, display_name, role, active, zammad_user_id::text AS zammad_user_id, (zammad_api_token IS NOT NULL) AS has_zammad_api_token, zammad_email_session_webhook_enabled, session_billing_prompt_enabled, zammad_session_note_default_enabled, zammad_time_unit_rounding_enabled, zammad_time_unit_rounding_minutes, created_at, updated_at;
`,
[username, displayName, await hashPassword(password), "user", active]
);
@@ -5023,7 +5351,7 @@ app.patch("/api/admin/users/:userId", requireAdmin, async (req, res) => {
password_hash = COALESCE($4, password_hash),
updated_at = now()
WHERE id = $5
RETURNING id, username, display_name, role, active, zammad_user_id::text AS zammad_user_id, (zammad_api_token IS NOT NULL) AS has_zammad_api_token, zammad_email_session_webhook_enabled, created_at, updated_at;
RETURNING id, username, display_name, role, active, zammad_user_id::text AS zammad_user_id, (zammad_api_token IS NOT NULL) AS has_zammad_api_token, zammad_email_session_webhook_enabled, session_billing_prompt_enabled, zammad_session_note_default_enabled, zammad_time_unit_rounding_enabled, zammad_time_unit_rounding_minutes, created_at, updated_at;
`,
[username, displayName, active, password ? await hashPassword(password) : null, userId]
);
@@ -5446,7 +5774,13 @@ app.get("/api/export/months/:month", requireUser, async (req, res) => {
row.start_time,
row.end_time,
row.rounded_minutes,
row.billing_status === "billed" ? "Abgerechnet" : row.billing_status === "non_billable" ? "Nicht abrechenbar" : "Offen",
row.billing_status === "billed"
? "Abgerechnet"
: row.billing_status === "billable"
? "Abrechenbar"
: row.billing_status === "non_billable"
? "Nicht abrechenbar"
: "Offen",
row.teamspace_minutes ?? "",
row.source === "fix" ? "Fixe Abrechnung" : "Manuell",
row.activity
@@ -5993,6 +6327,8 @@ app.post("/api/sessions", requireUser, async (req, res) => {
const requestedWorkType = optionalWorkType(req.body.workType);
const useZammadLookup = optionalBoolean(req.body.zammadLookup, false);
const requestedProjectTeamspaceEntryId = parseOptionalTeamspaceEntryId(req.body.projectTeamspaceEntryId);
const billingStatus = req.body.billingStatus === undefined ? null : parseBillingStatus(req.body.billingStatus);
const createZammadArticle = optionalBoolean(req.body.createZammadArticle, false);
const startedAt = parseIsoDate(req.body.startedAt, "startedAt");
const endedAt = parseIsoDate(req.body.endedAt, "endedAt");
const durationSeconds = parsePositiveInteger(req.body.durationSeconds, "durationSeconds");
@@ -6005,6 +6341,8 @@ app.post("/api/sessions", requireUser, async (req, res) => {
requestedWorkType,
useZammadLookup,
requestedProjectTeamspaceEntryId,
billingStatus,
createZammadArticle,
startedAt,
endedAt,
durationSeconds
@@ -6013,6 +6351,94 @@ app.post("/api/sessions", requireUser, async (req, res) => {
res.status(201).json(created);
});
async function zammadSessionArticleQueueResponse(userId: string) {
const result = await query(
`
SELECT
q.id::text AS id,
q.session_id::text AS session_id,
q.ticket_id::text AS ticket_id,
q.ticket_number,
q.zammad_ticket_id::text AS zammad_ticket_id,
q.activity,
q.time_unit::float AS time_unit,
q.attempt_count,
q.last_error,
q.last_attempt_at,
q.created_at,
s.started_at,
s.rounded_minutes
FROM zammad_session_article_queue q
JOIN sessions s ON s.id = q.session_id
WHERE q.user_id = $1
AND q.status = 'pending'
ORDER BY q.created_at ASC;
`,
[userId]
);
return result.rows;
}
app.get("/api/zammad/session-article-queue", requireUser, async (req, res) => {
const userId = currentUser(req).id;
res.json({ queue: await zammadSessionArticleQueueResponse(userId) });
});
app.post("/api/zammad/session-article-queue/retry", requireUser, async (req, res) => {
const userId = currentUser(req).id;
const pendingResult = await query<ZammadSessionArticleQueueRow>(
`
SELECT
id::text AS id,
user_id::text AS user_id,
session_id::text AS session_id,
ticket_id::text AS ticket_id,
ticket_number,
zammad_ticket_id::text AS zammad_ticket_id,
activity,
time_unit::text AS time_unit
FROM zammad_session_article_queue
WHERE user_id = $1
AND status = 'pending'
ORDER BY created_at ASC
LIMIT 100;
`,
[userId]
);
const results: Array<{ id: string; status: "sent" | "failed"; error?: string; zammadArticleId?: number | null }> = [];
for (const row of pendingResult.rows) {
try {
const articleId = await retryZammadSessionArticleQueueRow(row);
results.push({ id: row.id, status: "sent", zammadArticleId: articleId });
} catch (error) {
const message = errorMessage(error);
await query(
`
UPDATE zammad_session_article_queue
SET attempt_count = attempt_count + 1,
last_error = $1,
last_attempt_at = now(),
updated_at = now()
WHERE id = $2
AND status = 'pending';
`,
[message.slice(0, 1000), row.id]
);
results.push({ id: row.id, status: "failed", error: message });
}
}
res.json({
sent: results.filter((result) => result.status === "sent").length,
failed: results.filter((result) => result.status === "failed").length,
results,
queue: await zammadSessionArticleQueueResponse(userId)
});
});
app.get("/api/periods/:periodType/:period/overview", requireUser, async (req, res) => {
const { config, period } = parsePeriod(String(req.params.periodType), String(req.params.period));
res.json(await getPeriodOverview(config, period, currentUser(req).id));
@@ -8213,6 +8639,7 @@ app.get("/api/months/:month/overview", requireUser, async (req, res) => {
COUNT(s.id)::int AS session_count,
COALESCE(SUM(s.rounded_minutes), 0)::int AS total_minutes,
COUNT(*) FILTER (WHERE s.billing_status IS NULL)::int AS open_count,
COUNT(*) FILTER (WHERE s.billing_status = 'billable')::int AS billable_count,
COUNT(*) FILTER (WHERE s.billing_status = 'billed')::int AS billed_count,
COUNT(*) FILTER (WHERE s.billing_status = 'non_billable')::int AS non_billable_count,
tm.closed_at IS NOT NULL AS closed,
+46 -1
View File
@@ -87,6 +87,15 @@ export async function migrate() {
await query("ALTER TABLE users ADD COLUMN IF NOT EXISTS zammad_api_token TEXT;");
await query("ALTER TABLE users ADD COLUMN IF NOT EXISTS zammad_user_id BIGINT;");
await query("ALTER TABLE users ADD COLUMN IF NOT EXISTS zammad_email_session_webhook_enabled BOOLEAN NOT NULL DEFAULT false;");
await query("ALTER TABLE users ADD COLUMN IF NOT EXISTS session_billing_prompt_enabled BOOLEAN NOT NULL DEFAULT true;");
await query("ALTER TABLE users ADD COLUMN IF NOT EXISTS zammad_session_note_default_enabled BOOLEAN NOT NULL DEFAULT false;");
await query("ALTER TABLE users ADD COLUMN IF NOT EXISTS zammad_time_unit_rounding_enabled BOOLEAN NOT NULL DEFAULT false;");
await query("ALTER TABLE users ADD COLUMN IF NOT EXISTS zammad_time_unit_rounding_minutes INTEGER NOT NULL DEFAULT 15;");
await query(`
ALTER TABLE users
DROP CONSTRAINT IF EXISTS users_zammad_time_unit_rounding_minutes_valid,
ADD CONSTRAINT users_zammad_time_unit_rounding_minutes_valid CHECK (zammad_time_unit_rounding_minutes IN (5, 10, 15, 30));
`);
await query(`
CREATE TABLE IF NOT EXISTS auth_sessions (
@@ -207,7 +216,7 @@ export async function migrate() {
billing_updated_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
CONSTRAINT work_type_valid CHECK (work_type IN ('support', 'consulting')),
CONSTRAINT billing_status_valid CHECK (billing_status IS NULL OR billing_status IN ('billed', 'non_billable')),
CONSTRAINT billing_status_valid CHECK (billing_status IS NULL OR billing_status IN ('billable', 'billed', 'non_billable')),
CONSTRAINT duration_non_negative CHECK (duration_seconds >= 0),
CONSTRAINT rounded_minutes_positive CHECK (rounded_minutes >= 1),
CONSTRAINT ended_after_started CHECK (ended_at >= started_at)
@@ -221,6 +230,11 @@ export async function migrate() {
await query("ALTER TABLE sessions ADD COLUMN IF NOT EXISTS recurring_occurrence_date DATE;");
await query("ALTER TABLE sessions ADD COLUMN IF NOT EXISTS recurring_occurrence_key TEXT;");
await query("ALTER TABLE sessions ADD COLUMN IF NOT EXISTS project_teamspace_entry_id BIGINT;");
await query(`
ALTER TABLE sessions
DROP CONSTRAINT IF EXISTS billing_status_valid,
ADD CONSTRAINT billing_status_valid CHECK (billing_status IS NULL OR billing_status IN ('billable', 'billed', 'non_billable'));
`);
await query(`
CREATE TABLE IF NOT EXISTS zammad_email_session_events (
id BIGSERIAL PRIMARY KEY,
@@ -242,6 +256,37 @@ export async function migrate() {
ON zammad_email_session_events(delivery_id)
WHERE delivery_id IS NOT NULL;
`);
await query(`
CREATE TABLE IF NOT EXISTS zammad_session_article_queue (
id BIGSERIAL PRIMARY KEY,
user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
session_id BIGINT NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,
ticket_id BIGINT NOT NULL REFERENCES tickets(id) ON DELETE CASCADE,
ticket_number TEXT NOT NULL,
zammad_ticket_id BIGINT,
activity TEXT NOT NULL,
time_unit NUMERIC(10, 2) NOT NULL,
status TEXT NOT NULL DEFAULT 'pending',
attempt_count INTEGER NOT NULL DEFAULT 0,
last_error TEXT,
last_attempt_at TIMESTAMPTZ,
sent_at TIMESTAMPTZ,
zammad_article_id BIGINT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
CONSTRAINT zammad_session_article_queue_status_valid CHECK (status IN ('pending', 'sent'))
);
`);
await query(`
CREATE UNIQUE INDEX IF NOT EXISTS idx_zammad_session_article_queue_pending_session
ON zammad_session_article_queue(session_id)
WHERE status = 'pending';
`);
await query(`
CREATE INDEX IF NOT EXISTS idx_zammad_session_article_queue_user_pending
ON zammad_session_article_queue(user_id, created_at)
WHERE status = 'pending';
`);
if (adminId) {
await query("UPDATE sessions SET user_id = $1 WHERE user_id IS NULL;", [adminId]);
}
+2 -2
View File
@@ -110,8 +110,8 @@ export function parseBillingStatus(value: unknown) {
return null;
}
if (value !== "billed" && value !== "non_billable") {
throw badRequest("billingStatus must be billed, non_billable or null");
if (value !== "billable" && value !== "billed" && value !== "non_billable") {
throw badRequest("billingStatus must be billable, billed, non_billable or null");
}
return value;
+38 -12
View File
@@ -71,19 +71,27 @@ Die Seite `Timer` ist fuer laufende Arbeit an Tickets gedacht.
1. Ticketnummer im Format `Ticket#XXXXXX` eingeben.
2. `Timer starten` klicken.
3. Der Timer startet.
4. Bei Bedarf kann der Timer pausiert, aktiviert, beendet oder entfernt werden.
4. Taetigkeit und optional Bewertung in den Details des ausgewaehlten Timers erfassen.
5. Bei Bedarf kann der Timer pausiert, aktiviert, beendet oder entfernt werden.
Es koennen mehrere Timer vorbereitet sein, aber immer nur ein Timer laeuft aktiv. Wird ein anderer Timer aktiviert, werden alle anderen Timer automatisch pausiert.
Alle laufenden und pausierten Timer stehen in der Seitenleiste. Dadurch bleiben sie auf allen Unterseiten sichtbar.
Beim Klick auf `Beenden` stoppt der Timer sofort. Das spaetere Ausfuellen des Modals verlaengert die gemessene Zeit nicht.
Die Taetigkeit und Bewertung werden im ausgewaehlten Timer erfasst. Pausierte Timer behalten diese Notizen. Wird ein Timer entfernt, werden auch die noch nicht gespeicherten Details verworfen.
Beim Klick auf `Beenden` stoppt der Timer sofort. Die vorher im Timer erfasste Taetigkeit wird beim Speichern an die Session gehaengt.
Die Bewertung hat drei Optionen: `---`, `Abrechenbar` und `Nicht abrechenbar`. `---` ist der Standard und laesst die Session offen. Wenn im Profil die Bewertungsabfrage aktiv ist, fragt TicketTracker beim Beenden eines Timers mit `---` noch einmal nach.
Im Abschlussdialog kann die Taetigkeit als interner Zammad-Article an das Ticket uebertragen werden. Der Article wird mit `internal: true` angelegt und enthaelt die Taetigkeit sowie den `time_unit`-Wert aus der gemessenen Zeit abzueglich Pausen.
Wenn Zammad beim Speichern nicht erreichbar ist, wird die Session trotzdem in TicketTracker gespeichert. Der Zammad-Article wird dann in einer Parktabelle vorgemerkt und kann spaeter im Profil erneut gesendet werden.
Beim Speichern nach dem Beenden gilt:
- Existiert das Ticket bereits, werden Organisation und Art vom Ticket uebernommen.
- Dann muss nur noch die Taetigkeit eingetragen werden.
- Existiert das Ticket noch nicht, muessen Organisation, Taetigkeit und Art ausgefuellt werden.
- Existiert das Ticket noch nicht, muessen Organisation und Art ausgefuellt werden.
Die gemessene Zeit wird auf Minuten gerundet. Pausen werden von der Gesamtdauer abgezogen.
@@ -101,10 +109,13 @@ Beim Nachtragen werden angegeben:
- Datum
- Von-Uhrzeit
- Bis-Uhrzeit
- Bewertung: `---`, `Abrechenbar` oder `Nicht abrechenbar`
- Taetigkeit
Das Taetigkeitsfeld ist mehrzeilig. Zeilenumbrueche bleiben in der Auswertung erhalten.
Mit Bewertung `---` bleibt eine nachgetragene Session offen. `Abrechenbar` und `Nicht abrechenbar` werden direkt an der neuen Session gespeichert.
Das Ende muss nach dem Beginn liegen. Die Dauer wird automatisch berechnet und auf Minuten gerundet.
In der Ticketdetailansicht gibt es ebenfalls `Session nachtragen`. Dort wird die neue Session direkt dem geoeffneten Ticket zugeordnet.
@@ -145,6 +156,7 @@ Die oberen Karten zeigen:
- Tickets: Anzahl der Tickets im Zeitraum
- Sessions: Anzahl der Sessions im Zeitraum
- Abgerechnet: Summe der Sessions mit Bewertung `Abgerechnet` und, sofern vorhanden, die dazugehoerige Teamspace-Zeit als Vergleichswert
- Abrechenbar: Summe der Sessions mit Bewertung `Abrechenbar`, noch ohne Teamspace-Wirkung
- Nicht abrechenbar: Summe der Sessions mit Bewertung `Nicht abrechenbar`
- Offen: Anzahl unbewerteter Sessions
@@ -306,6 +318,7 @@ Die Sessions sind pro Tag gruppiert. In jeder Tagesgruppe stehen:
- Tagesdatum
- `Tag abgerechnet`: Summe der als `Abgerechnet` bewerteten Session-Zeit dieses Tages
- `Abrechenbar`: Summe der als `Abrechenbar` bewerteten Session-Zeit dieses Tages
- `Nicht`: Summe der als `Nicht abrechenbar` bewerteten Session-Zeit dieses Tages
- `Offen`: Summe der noch unbewerteten Session-Zeit dieses Tages
- `Teamspace`: Stunden, die fuer dieses Ticket an diesem Tag wirklich im CRM eingetragen wurden
@@ -330,11 +343,12 @@ Jede einzelne Session ist innerhalb der Tagesgruppe aufklappbar. In der geschlos
Jede Session kann bewertet werden als:
- Abgerechnet
- Abrechenbar
- Nicht abrechenbar
Ein erneuter Klick auf den bereits gewaehlten Zustand nimmt die Bewertung zurueck. Dadurch wird die Session wieder offen.
Nicht abrechenbare Sessions gelten als bewertet. Sie blockieren den Monatsabschluss nicht.
Abrechenbare und nicht abrechenbare Sessions gelten als bewertet. Sie blockieren den Monatsabschluss nicht. `Abrechenbar` fliesst nicht in Teamspace-Statistiken ein; erst `Abgerechnet` zaehlt dort.
Es gibt keinen Ticketabschluss. Tickets dienen nur als Gruppierung fuer Sessions. Abgeschlossen wird ausschliesslich der Monat.
@@ -430,9 +444,9 @@ Die Seite `Statistiken` betrachtet immer einen Monat. Sie dient als Ueberblick f
### Kennzahlen
`Abgerechnet` zeigt die Summe aller Sessions mit Bewertung `Abgerechnet`. Nicht abrechenbare und offene Sessions sind darin nicht enthalten. Dieser Wert ist die Basis fuer den Vergleich mit Teamspace.
`Abgerechnet` zeigt die Summe aller Sessions mit Bewertung `Abgerechnet`. Abrechenbare, nicht abrechenbare und offene Sessions sind darin nicht enthalten. Dieser Wert ist die Basis fuer den Vergleich mit Teamspace.
Alle Statistik- und Vergleichswerte verwenden nur abgerechnete Zeiten. Nicht ausgewaehlte/offene und nicht abrechenbare Sessions werden nicht in Relationen, Graphen, Teamspace-Differenzen, Hotspots, Manuell/Fix-Werte oder Durchschnittswerte eingerechnet.
Teamspace-Statistiken und -Vergleiche verwenden nur abgerechnete Zeiten. Nicht ausgewaehlte/offene, abrechenbare und nicht abrechenbare Sessions werden nicht in Teamspace-Differenzen, Hotspots, Manuell/Fix-Werte oder Durchschnittswerte eingerechnet.
`Teamspace` zeigt die Summe aller Teamspace-Tageswerte im Monat. Die Differenz wird immer so berechnet:
@@ -449,13 +463,14 @@ Das bedeutet:
Farben:
- Abgerechnet: blau
- Abrechenbar: grau
- Teamspace: fuchsia
- Positive oder ausgeglichene Differenz: gruen
- Negative Differenz: rot
- Nicht abrechenbar: indigo
- Offen: gelb/orange
`Nicht abrechenbar` zeigt Sessions, die bewusst nicht fakturiert werden. In den Statistiken wird dazu kein Stundenwert ausgegeben; diese Sessions werden nicht mit Teamspace verglichen.
`Abrechenbar` und `Nicht abrechenbar` zeigen bewertete Sessions, die nicht als abgerechnet gelten. Diese Sessions werden nicht mit Teamspace verglichen.
`Offen` zeigt Sessions ohne Bewertung. Diese blockieren den Monatsabschluss.
@@ -465,7 +480,7 @@ Diese Card kombiniert den Tagesverlauf und die Bewertungsqualitaet.
- Balken zeigen abgerechnete Session-Zeit.
- Die Linie zeigt Teamspace-Zeit.
- Der Bewertungsstand trennt abgerechnete, nicht abrechenbare und offene Sessions.
- Der Bewertungsstand trennt abgerechnete, abrechenbare, nicht abrechenbare und offene Sessions.
- Der Teamspace-Abgleich zeigt die Gesamtdifferenz.
- Manuell und Fix trennen manuell erfasste abrechenbare Sessions von automatisch erzeugten abrechenbaren Sessions.
@@ -477,7 +492,7 @@ Sichtbar sind:
- Organisation
- Sessions
- Zeiten: Abgerechnet, nicht abrechenbar und Teamspace
- Zeiten: Abgerechnet, abrechenbar, nicht abrechenbar und Teamspace
- Differenz
- Anteil an der abgerechneten Monatszeit
@@ -495,7 +510,7 @@ Sie zeigt je Typ:
- Nicht abrechenbare Session-Zeit
- Teamspace-Zeit
- Differenz
- Anzahl abgerechnet, nicht abrechenbar und offen
- Anzahl abgerechnet, abrechenbar, nicht abrechenbar und offen
### Ticket-Hotspots
@@ -511,6 +526,9 @@ Unter `Profil` kann jeder Benutzer seine eigenen Daten pflegen:
- Voller Name
- Passwort
- persoenlicher Zammad API-Token
- Bewertungsabfrage beim Timerabschluss
- interne Zammad-Notiz beim Timerabschluss
- Zammad-Zeitaufrundung fuer `time_unit`
Passwortaenderungen erfordern das aktuelle Passwort. Bleiben die Passwortfelder leer, werden nur Benutzername und voller Name gespeichert.
@@ -520,6 +538,14 @@ Die Pruefung findet nur statt, wenn der Token im Profil neu eingetragen oder ent
Der persoenliche Zammad Token ist unabhaengig vom Admin-Token fuer den Organisations-Sync. Der gleiche Token kann technisch in beiden Bereichen eingetragen sein, wird aber getrennt gespeichert und getrennt verwendet. Im Admin-Profil gibt es keinen persoenlichen Zammad-Token.
Die Bewertungsabfrage beim Timerabschluss kann im Profil ein- oder ausgeschaltet werden. Sie erscheint nur, wenn der Timer beim Beenden noch auf Bewertung `---` steht.
Die interne Zammad-Notiz beim Timerabschluss steuert, ob die Checkbox im Abschlussdialog standardmaessig aktiv ist. Ist sie aktiv, legt TicketTracker beim Speichern einen internen Zammad-Article zum Ticket an.
Fuer `time_unit` kann optional eine Aufrundung aktiviert werden. Dann wird die tatsaechlich getrackte Zeit immer auf 5, 10, 15 oder 30 Minuten aufgerundet und als Stundenwert an Zammad gesendet. Ohne Rundung wird der konkrete Stundenwert mit zwei Nachkommastellen gesendet.
Unter `Geparkte Zammad-Notizen` zeigt das Profil interne Notizen, die wegen eines Zammad-Fehlers noch nicht uebertragen wurden. Mit `Senden` werden die offenen Eintraege erneut nach Zammad gepusht.
## Adminbereich
Der feste Admin ist nur fuer die Systemverwaltung vorgesehen. Er kann:
@@ -562,7 +588,7 @@ Neue Organisationen werden hinzugefuegt, geaenderte Namen aktualisiert und nicht
3. Am Monatsende `Tickets` oeffnen.
4. Monat auswaehlen.
5. Offene Sessions pruefen.
6. Pro Ticket jede Session als `Abgerechnet` oder `Nicht abrechenbar` markieren.
6. Pro Ticket jede Session als `Abrechenbar`, `Abgerechnet` oder `Nicht abrechenbar` markieren.
7. Pro Tag und Ticket die wirklich in Teamspace eingetragene Zeit im Feld `Teamspace` pflegen.
8. `Monatsabschluss` oeffnen.
9. Offene Bewertungen abarbeiten.
+4
View File
@@ -1073,6 +1073,8 @@ export function App() {
startedAt: now,
pausedTotalMs: 0,
pausedAt: null,
activity: "",
billingStatus: null,
phase: "running"
};
@@ -1472,6 +1474,7 @@ export function App() {
selectedTimerId={selectedTimerId}
setSelectedTimerId={setSelectedTimerId}
tick={tick}
currentUser={currentUser}
/>
) : null}
{!isAdmin && route.page === "dashboard" ? <DashboardPage onNavigate={navigate} /> : null}
@@ -1492,6 +1495,7 @@ export function App() {
selectedTimerId={selectedTimerId}
setSelectedTimerId={setSelectedTimerId}
tick={tick}
currentUser={currentUser}
/>
) : null}
{!isAdmin && route.page === "ticket" ? (
+24 -2
View File
@@ -16,7 +16,8 @@ import type {
TicketSearchResult,
TodoPriority,
TodoStatus,
WorkType
WorkType,
ZammadSessionArticleQueueItem
} from "./types";
type CreateSessionPayload = {
@@ -29,6 +30,8 @@ type CreateSessionPayload = {
durationSeconds: number;
zammadLookup?: boolean;
projectTeamspaceEntryId?: string | null;
billingStatus?: BillingStatus;
createZammadArticle?: boolean;
};
type DeleteSessionResult = {
@@ -198,6 +201,10 @@ export function updateCurrentUser(payload: {
newPassword?: string;
zammadApiToken?: string;
zammadEmailSessionWebhookEnabled?: boolean;
sessionBillingPromptEnabled?: boolean;
zammadSessionNoteDefaultEnabled?: boolean;
zammadTimeUnitRoundingEnabled?: boolean;
zammadTimeUnitRoundingMinutes?: number;
}) {
return request<{ user: AuthUser }>("/api/auth/me", {
method: "PATCH",
@@ -681,6 +688,21 @@ export function createSession(payload: CreateSessionPayload) {
});
}
export function getZammadSessionArticleQueue() {
return request<{ queue: ZammadSessionArticleQueueItem[] }>("/api/zammad/session-article-queue");
}
export function retryZammadSessionArticleQueue() {
return request<{
sent: number;
failed: number;
queue: ZammadSessionArticleQueueItem[];
results: Array<{ id: string; status: "sent" | "failed"; error?: string; zammadArticleId?: number | null }>;
}>("/api/zammad/session-article-queue/retry", {
method: "POST"
});
}
export function lookupTicket(ticketNumber: string, source: "local" | "zammad" = "local") {
const params = new URLSearchParams({ ticketNumber });
@@ -771,7 +793,7 @@ export function getTicketDetails(
ticketId: string,
filters: {
month?: string;
status?: "all" | "open" | "billed" | "non_billable";
status?: "all" | "open" | "billable" | "billed" | "non_billable";
source?: "all" | "manual" | "recurring";
} = {}
) {
+35 -25
View File
@@ -34,19 +34,20 @@ export const helpSections: HelpSection[] = [
title: "Monatsworkflow",
description: "Der empfohlene Ablauf vom Start einer Arbeitssession bis zum sauberen Monatsabschluss.",
quickItems: [
"Während des Monats: Ticketnummer eingeben, Timer starten, Session beenden und Tätigkeit dokumentieren.",
"Während des Monats: Ticketnummer eingeben, Timer starten, Tätigkeit dokumentieren und Session beenden.",
"Vergessene Zeiten: Session manuell auf dem Ticket oder als neues Ticket nachtragen.",
"Am Monatsende: offene Bewertungen bearbeiten, Teamspace-Tageswerte pflegen, Kontrolle durchführen und Monat schließen."
],
details: [
"Der normale Einstieg ist das Ticketfeld im Kopfbereich oder die Seite Session starten. Dort gibst du die Ticketnummer ein und startest den Timer mit Timer starten.",
"Während der Arbeit läuft genau ein Timer aktiv. Wechselst du zu einem anderen Timer, pausiert TicketTracker die übrigen Timer automatisch.",
"Wenn die Arbeit erledigt ist, beendest du den Timer. Die Zeit stoppt sofort beim Klick auf Beenden; das anschließende Ausfüllen der Tätigkeit zählt nicht mehr zur Sessionzeit.",
"Im Beenden-Dialog trägst du die Tätigkeit ein. Wenn das Ticket bereits bekannt ist, übernimmt TicketTracker Organisation und Art automatisch. Bei neuen Tickets wählst du Organisation und Art zusätzlich aus.",
"Während der Timer läuft oder pausiert ist, trägst du die Tätigkeit direkt in den Timer-Details ein. Pausierte Timer behalten diese Notizen.",
"Wenn die Arbeit erledigt ist, beendest du den Timer. Die Zeit stoppt sofort beim Klick auf Beenden; die bereits erfasste Tätigkeit wird mit der Session gespeichert.",
"Wenn das Ticket bereits bekannt ist, übernimmt TicketTracker Organisation und Art automatisch. Bei neuen Tickets wählst du Organisation und Art beim Speichern zusätzlich aus.",
"Hat ein Consulting-Projekt mehrere Teamspace-Einträge, wählst du beim Speichern der Session den passenden Eintrag aus. Bei genau einem Teamspace-Eintrag ordnet TicketTracker automatisch zu.",
"Vergessene Sessions trägst du auf der Seite Session starten im Bereich Session nachtragen nach. Wenn das Ticket bereits in der Ticketliste geöffnet ist, nutzt du in der Ticketdetailansicht ebenfalls Session nachtragen; dann ist die Zuordnung zum Ticket bereits vorbelegt.",
"Unter Tickets wählst du Monat oder Tag, öffnest ein Ticket über Öffnen und siehst dort alle Sessions nach Tag gruppiert.",
"Im Ticket bewertest du jede Session mit Abgerechnet oder Nicht abrechenbar. Ein erneuter Klick auf den aktiven Status nimmt die Bewertung zurück.",
"Im Ticket bewertest du jede Session mit Abrechenbar, Abgerechnet oder Nicht abrechenbar. Ein erneuter Klick auf den aktiven Status nimmt die Bewertung zurück.",
"Im Ticket steht pro Tag das Feld Teamspace. Dort trägst du die Stunden ein, die du für genau dieses Ticket an genau diesem Tag wirklich im CRM beziehungsweise Teamspace erfasst hast. Nach einer Änderung erscheint der Speichern-Button.",
"Die Teamspace-Zeit ist ein Tageswert pro Ticket und Benutzer. Monats- und Tagesansicht zeigen denselben Wert; er muss also nur einmal gepflegt werden.",
"Am Monatsende öffnest du Monatsabschluss. Dort findest du offene Bewertungen gruppiert nach Ticket, Tage ohne Teamspace-Wert und die Schaltfläche Monat abschließen.",
@@ -59,7 +60,7 @@ export const helpSections: HelpSection[] = [
"Ticketfeld im Kopfbereich oder Session starten öffnen.",
"Ticketnummer eingeben und Timer starten.",
"Arbeit erledigen; bei Bedarf zwischen vorbereiteten Timern wechseln.",
"Timer beenden und Tätigkeit speichern.",
"Tätigkeit im Timer erfassen und Timer beenden.",
"Vergessene Zeiten über Session nachtragen ergänzen.",
"Tickets öffnen, Monat wählen und Einträge prüfen.",
"Ticket öffnen, Sessions pro Tag bewerten und Teamspace-Tageswerte speichern.",
@@ -71,8 +72,8 @@ export const helpSections: HelpSection[] = [
"Ticketnummer eingeben",
"Timer starten",
"Arbeit am Ticket erledigen",
"Tätigkeit im Timer eintragen",
"Timer beenden",
"Tätigkeit eintragen",
"Session wird dem Ticket und Benutzer gespeichert",
"Optional: vergessene Session nachtragen",
"Tickets öffnen und Ticket prüfen",
@@ -111,17 +112,21 @@ export const helpSections: HelpSection[] = [
quickItems: [
"Ticketnummer eingeben und Timer starten.",
"Aktivieren eines Timers pausiert alle anderen Timer automatisch.",
"Beim Beenden stoppt die Zeit sofort; Details werden danach gespeichert."
"Die Tätigkeit wird im ausgewählten Timer erfasst und beim Beenden gespeichert."
],
details: [
"Neue Timer werden mit einer Ticketnummer im Format Ticket#XXXXXX gestartet.",
"Alle laufenden und pausierten Timer bleiben in der Seitenleiste sichtbar, damit du auf jeder Unterseite sehen kannst, was vorbereitet oder aktiv ist.",
"Wenn ein anderer Timer aktiviert wird, pausiert TicketTracker die übrigen Timer automatisch. Dadurch kann nie versehentlich auf zwei Tickets gleichzeitig Zeit laufen.",
"Beim Klick auf Beenden wird die Stoppuhr sofort angehalten. Das Speichern der Tätigkeit findet danach statt und verlängert die gemessene Zeit nicht.",
"Existiert das Ticket bereits, übernimmt TicketTracker Organisation und Art. Dann muss beim Beenden nur noch die Tätigkeit eingetragen werden.",
"Wenn du einen Timer auswählst, zeigt die Timerseite dessen Details inklusive gemessener Zeit und Tätigkeitsfeld.",
"Pausierte Timer behalten ihre Tätigkeit. Entfernst du einen Timer, wird auch die noch nicht gespeicherte Tätigkeit verworfen.",
"Beim Klick auf Beenden wird die Stoppuhr sofort angehalten. Die bereits im Timer erfasste Tätigkeit wird beim Speichern an die Session gehängt.",
"Im Abschlussdialog kann die Tätigkeit als interne Zammad-Notiz übertragen werden. Der Article wird intern erstellt und bekommt die getrackte Zeit als time_unit.",
"Wenn Zammad beim Speichern nicht erreichbar ist, bleibt die Session trotzdem gespeichert. Die interne Notiz wird geparkt und kann später im Profil erneut gesendet werden.",
"Existiert das Ticket bereits, übernimmt TicketTracker Organisation und Art.",
"Bei Projekt-Tickets mit mehreren Teamspace-Einträgen zeigt der Beenden-Dialog eine Auswahl. Der Standard-Eintrag ist dort mit Standard gekennzeichnet.",
"Bei Projekt-Tickets mit genau einem Teamspace-Eintrag wird die Session automatisch diesem Eintrag zugeordnet.",
"Existiert das Ticket noch nicht, müssen Organisation, Tätigkeit und Art angegeben werden.",
"Existiert das Ticket noch nicht, müssen Organisation und Art angegeben werden.",
"Pausen werden von der Gesamtdauer abgezogen. Die gespeicherte Zeit wird auf volle Minuten gerundet.",
"Der Schnellstart im Kopfbereich funktioniert auf allen Unterseiten und nutzt dieselbe Timerlogik."
]
@@ -131,7 +136,7 @@ export const helpSections: HelpSection[] = [
title: "Session Nachtragen",
description: "Nachtragen ist für vergessene Zeiten gedacht, wenn kein Timer gelaufen ist.",
quickItems: [
"Datum, Von, Bis und Tätigkeit eintragen.",
"Datum, Von, Bis, Bewertung und Tätigkeit eintragen.",
"Benutzer tragen eigene Sessions nach.",
"Tätigkeiten sind mehrzeilig."
],
@@ -140,7 +145,7 @@ export const helpSections: HelpSection[] = [
"Das Ende muss nach dem Beginn liegen. Übernacht-Sessions sind aktuell nicht vorgesehen und sollten als getrennte Einträge gepflegt werden.",
"Die Organisation muss aus der synchronisierten Organisationsliste gewählt werden. Freitext ist absichtlich gesperrt.",
"Die Tätigkeit ist eine Textarea. Zeilenumbrüche bleiben erhalten und werden in der Ticketdetailansicht wieder ausgegeben.",
"Nachgetragene Sessions starten immer offen. Sie müssen später als Abgerechnet oder Nicht abrechenbar bewertet werden.",
"Mit Bewertung --- bleibt die Session offen. Abrechenbar oder Nicht abrechenbar werden direkt an der neuen Session gespeichert.",
"In der Ticketdetailansicht kann eine Session direkt zum geöffneten Ticket nachgetragen werden. Ticketnummer und Stammdaten sind dort schon vorbelegt beziehungsweise aus dem Ticket abgeleitet.",
"Bei Consulting-Projekten mit mehreren Teamspace-Einträgen ist der passende Teamspace-Eintrag Pflicht. Bei nur einem Eintrag wird er automatisch gesetzt."
]
@@ -214,7 +219,7 @@ export const helpSections: HelpSection[] = [
details: [
"Oben steht die Ticketnummer mit Copy-Icon, damit sie schnell in andere Systeme übernommen werden kann.",
"Die Session-Einträge sind nach Kalendertagen gruppiert. Pro Tagesgruppe steht die Summe der abgerechneten Zeit.",
"Nicht abrechenbare und offene Zeiten werden neben der abgerechneten Tageszeit separat angezeigt.",
"Abrechenbare, nicht abrechenbare und offene Zeiten werden neben der abgerechneten Tageszeit separat angezeigt.",
"Bei Projekt-Tickets mit mehreren Teamspace-Einträgen zeigt jede zugeordnete Session den gewählten Teamspace-Eintrag. Nicht zugeordnete Sessions werden hervorgehoben.",
"Das Teamspace-Feld wird als Stundenwert gepflegt, zum Beispiel 1, 1,5 oder 2.25.",
"Während der Eingabe wird der Teamspace-Wert nicht sofort gespeichert. Sobald eine Änderung vorliegt, erscheint ein Speichern-Button.",
@@ -230,17 +235,18 @@ export const helpSections: HelpSection[] = [
{
id: "sessions-bewerten",
title: "Sessions Bewerten",
description: "Bewertungen bestimmen, ob eine Session abgerechnet wurde oder bewusst nicht abrechenbar ist.",
description: "Bewertungen bestimmen, ob eine Session offen, abrechenbar, abgerechnet oder bewusst nicht abrechenbar ist.",
quickItems: [
"Jede Session braucht genau eine Bewertung.",
"Abgerechnet und Nicht abrechenbar können durch erneuten Klick zurückgenommen werden.",
"Abrechenbar, Abgerechnet und Nicht abrechenbar können durch erneuten Klick zurückgenommen werden.",
"Offene Bewertungen blockieren den Monatsabschluss."
],
details: [
"Abrechenbar bedeutet: Die Session ist bewertet, aber noch nicht als abgerechnet/Teamspace-relevant markiert.",
"Abgerechnet bedeutet: Die Session wurde für die Abrechnung beziehungsweise im CRM berücksichtigt.",
"Nicht abrechenbar bedeutet: Die Session gehört zum Arbeitsnachweis, wird aber nicht abgerechnet.",
"Ein erneuter Klick auf den aktiven Bewertungsbutton entfernt die Bewertung und macht die Session wieder offen.",
"Nicht abrechenbare Sessions zählen als bewertet. Sie blockieren den Monatsabschluss nicht.",
"Abrechenbare und nicht abrechenbare Sessions zählen als bewertet. Sie blockieren den Monatsabschluss nicht.",
"Wenn ein Monat abgeschlossen ist, sind Bewertungen gesperrt. Öffne den Monat zuerst wieder, wenn du etwas korrigieren musst.",
"Die offenen Bewertungen findest du gebündelt im Monatsabschluss."
]
@@ -378,12 +384,12 @@ export const helpSections: HelpSection[] = [
],
details: [
"Die Statistikseite betrachtet immer einen Monat.",
"Abgerechnet zeigt nur Sessions mit der Bewertung Abgerechnet. Offene Zeiten sind dort nicht enthalten.",
"Alle Statistik- und Vergleichswerte nutzen nur abgerechnete Zeiten.",
"Teamspace zeigt, wie viel Zeit für dieselben abrechenbaren Ticket-Tage im CRM eingetragen wurde.",
"Abgerechnet zeigt nur Sessions mit der Bewertung Abgerechnet. Abrechenbare, nicht abrechenbare und offene Zeiten sind dort nicht enthalten.",
"Teamspace-Vergleiche nutzen nur abgerechnete Zeiten.",
"Teamspace zeigt, wie viel Zeit für dieselben abgerechneten Ticket-Tage im CRM eingetragen wurde.",
"Eine positive Differenz bedeutet: In Teamspace steht mehr Zeit als abgerechnet wurde.",
"Eine negative Differenz bedeutet: In Teamspace steht weniger Zeit als abgerechnet wurde.",
"Offene Sessions werden nicht in Relationen, Graphen, Teamspace-Differenzen, Hotspots, Manuell/Fix-Werte oder Durchschnittswerte eingerechnet.",
"Abrechenbare, nicht abrechenbare und offene Sessions werden nicht in Teamspace-Differenzen, Hotspots, Manuell/Fix-Werte oder Durchschnittswerte eingerechnet.",
"Der Monatsverlauf zeigt abgerechnete Session-Zeit als Balken und Teamspace als Linie.",
"Organisationen, Typen und Ticket-Hotspots helfen dabei, Auffälligkeiten im Monat schnell zu finden."
]
@@ -391,7 +397,7 @@ export const helpSections: HelpSection[] = [
{
id: "statistik-getrackt",
title: "Statistik: Abgerechnet",
description: "Diese Kennzahl zeigt die abrechenbare Session-Zeit im gewählten Monat.",
description: "Diese Kennzahl zeigt die abgerechnete Session-Zeit im gewählten Monat.",
quickItems: [
"Summe aller Sessions mit Bewertung Abgerechnet.",
"Zeigt zusätzlich die abgerechnete Sessionanzahl.",
@@ -399,7 +405,7 @@ export const helpSections: HelpSection[] = [
],
details: [
"Abgerechnet umfasst nur Sessions des angemeldeten Benutzers, die aktiv als Abgerechnet markiert wurden.",
"Offene Sessions werden aus diesem Wert herausgehalten.",
"Abrechenbare, nicht abrechenbare und offene Sessions werden aus diesem Wert herausgehalten.",
"Auch automatisch erzeugte Sessions aus fixen Abrechnungen zählen hier mit, sobald sie als Abgerechnet markiert wurden.",
"Es werden keine offenen Zeiten in diesen Wert eingerechnet.",
"Dieser Wert ist die Ausgangsbasis für Teamspace-Differenzen: Teamspace minus abgerechnete Session-Zeit ergibt die Differenz."
@@ -419,7 +425,7 @@ export const helpSections: HelpSection[] = [
"Die Statistik summiert alle Teamspace-Werte des Monats für den angemeldeten Benutzer.",
"Eine positive Differenz bedeutet, dass in Teamspace mehr Zeit steht als abgerechnet wurde.",
"Eine negative Differenz bedeutet, dass in Teamspace weniger Zeit steht als abgerechnet wurde.",
"Offene Sessions fließen nicht in diese Differenz ein.",
"Abrechenbare, nicht abrechenbare und offene Sessions fließen nicht in diese Differenz ein.",
"Fehlende Teamspace-Werte können im Monatsabschluss geprüft und bei Bedarf zur Kenntnis genommen werden."
]
},
@@ -453,7 +459,7 @@ export const helpSections: HelpSection[] = [
"Der Monatsverlauf zeigt jeden Tag des Monats. Tage ohne Sessions bleiben leer, damit die Monatsstruktur sichtbar bleibt.",
"Die blauen Balken stehen für abgerechnete Session-Zeit.",
"Die fuchsiafarbene Linie steht für Teamspace-Zeit, sofern für den Tag Werte gepflegt wurden.",
"Der Bewertungsstand weist Abgerechnet und Offen getrennt aus.",
"Der Bewertungsstand weist Abgerechnet, Abrechenbar, Nicht abrechenbar und Offen getrennt aus.",
"Der Teamspace-Abgleich zeigt die Gesamtdifferenz zwischen CRM-Werten und abgerechneter Session-Zeit.",
"Manuell und Fix trennen selbst erfasste abrechenbare Sessions von automatisch erzeugten abrechenbaren Sessions aus fixen Abrechnungen."
]
@@ -490,7 +496,7 @@ export const helpSections: HelpSection[] = [
"Support und Consulting werden getrennt summiert.",
"Die Zeitwerte zeigen abgerechnete Sessions und Teamspace für den jeweiligen Typ.",
"Die Differenz folgt derselben Logik wie überall: Teamspace minus abgerechnete Session-Zeit.",
"Die Zeile Abr. und Offen zeigt, wie viele Sessions dieses Typs bereits abgerechnet oder noch offen sind."
"Die Zeile Abr., Abrechenbar, Nicht abrechenbar und Offen zeigt, wie viele Sessions dieses Typs bereits abgerechnet, als abrechenbar markiert, bewusst nicht abrechenbar oder noch offen sind."
]
},
{
@@ -530,6 +536,10 @@ export const helpSections: HelpSection[] = [
"Die gespeicherte Zammad User-ID wird danach read-only im Profil angezeigt.",
"Die Token-Prüfung läuft nur, wenn der Token im Profil geändert oder entfernt wird. Normale Profiländerungen prüfen Zammad nicht erneut.",
"Der persönliche Zammad Token ist vom Admin-Token für den Organisations-Sync getrennt. Er wird für ticketbezogene Zammad-Abgleiche, Consulting-Importe und die Zuordnung zum Zammad-Owner verwendet.",
"Die Bewertungsabfrage beim Timerabschluss kann im Profil ein- oder ausgeschaltet werden. Sie erscheint nur, wenn der Timer noch auf --- steht.",
"Die interne Zammad-Notiz beim Timerabschluss steuert, ob die Checkbox im Abschlussdialog standardmäßig aktiv ist.",
"Für die an Zammad gesendete time_unit kann optional eine Aufrundung auf 5, 10, 15 oder 30 Minuten aktiviert werden. Ohne Rundung wird die konkrete Zeit mit zwei Nachkommastellen gesendet.",
"Geparkte Zammad-Notizen listet interne Notizen, die wegen eines Zammad-Fehlers noch nicht übertragen wurden. Über Senden werden sie gesammelt nach Zammad gepusht.",
"Im Admin-Profil wird kein persönlicher Zammad-Token angezeigt.",
"Admins können Benutzer ebenfalls verwalten, aber jeder Benutzer kann die eigenen Daten jederzeit selbst pflegen."
]
+17 -5
View File
@@ -1,3 +1,5 @@
import type { BillingStatus } from "./types";
export type TimerPhase = "running" | "paused";
export type TimerEntry = {
@@ -8,6 +10,8 @@ export type TimerEntry = {
isProject?: boolean;
projectName?: string | null;
ticketLookupDone?: boolean;
activity: string;
billingStatus: BillingStatus;
startedAt: number;
pausedTotalMs: number;
pausedAt: number | null;
@@ -59,23 +63,29 @@ export function normalizeTimers(timers: TimerEntry[]) {
return timers
.filter((timer) => ticketPattern.test(timer.ticketNumber))
.map((timer) => {
const normalizedTimer = {
...timer,
activity: typeof timer.activity === "string" ? timer.activity : "",
billingStatus: timer.billingStatus === "billed" ? "billable" : timer.billingStatus === "billable" || timer.billingStatus === "non_billable" ? timer.billingStatus : null,
};
if (timer.phase !== "running") {
return {
...timer,
...normalizedTimer,
phase: "paused" as const,
pausedAt: timer.pausedAt ?? now,
pausedAt: normalizedTimer.pausedAt ?? now,
};
}
if (!runningSeen) {
runningSeen = true;
return {
...timer,
...normalizedTimer,
pausedAt: null,
};
}
return pauseEntry(timer, now);
return pauseEntry(normalizedTimer, now);
});
}
@@ -106,13 +116,15 @@ export function readStoredTimers(userId: string): TimerEntry[] {
}
try {
const parsed = JSON.parse(legacy) as Omit<TimerEntry, "id">;
const parsed = JSON.parse(legacy) as Omit<TimerEntry, "id" | "activity" | "billingStatus"> & { activity?: string; billingStatus?: BillingStatus };
localStorage.removeItem(legacyStorageKey);
return normalizeTimers([
{
...parsed,
id: createClientId("timer"),
activity: typeof parsed.activity === "string" ? parsed.activity : "",
billingStatus: parsed.billingStatus === "billed" ? "billable" : parsed.billingStatus === "billable" || parsed.billingStatus === "non_billable" ? parsed.billingStatus : null,
},
]);
} catch {
+33 -2
View File
@@ -1,5 +1,5 @@
export type WorkType = "support" | "consulting";
export type BillingStatus = "billed" | "non_billable" | null;
export type BillingStatus = "billable" | "billed" | "non_billable" | null;
export type PeriodType = "month" | "day";
export type TicketDetailPeriodType = PeriodType | "all";
export type UserRole = "admin" | "user";
@@ -13,6 +13,10 @@ export type AuthUser = {
zammad_user_id: string | null;
has_zammad_api_token: boolean;
zammad_email_session_webhook_enabled: boolean;
session_billing_prompt_enabled: boolean;
zammad_session_note_default_enabled: boolean;
zammad_time_unit_rounding_enabled: boolean;
zammad_time_unit_rounding_minutes: number;
};
export type DashboardWidgetType = "dayControl" | "today" | "month" | "openRatings" | "projectBudgets" | "todos" | "teamspace" | "favorites" | "divider" | "statistic" | "notes";
@@ -63,6 +67,22 @@ export type AdminUser = AuthUser & {
updated_at: string;
};
export type ZammadSessionArticleQueueItem = {
id: string;
session_id: string;
ticket_id: string;
ticket_number: string;
zammad_ticket_id: string | null;
activity: string;
time_unit: number;
attempt_count: number;
last_error: string | null;
last_attempt_at: string | null;
created_at: string;
started_at: string;
rounded_minutes: number;
};
export type Organization = {
id: string;
zammad_id: string;
@@ -231,10 +251,12 @@ export type TicketSummary = {
session_count: number;
total_minutes: number;
crm_billed_minutes: number;
billable_minutes: number;
non_billable_minutes: number;
open_minutes: number;
open_count: number;
ticket_open_count: number;
billable_count: number;
billed_count: number;
non_billable_count: number;
recurring_session_count: number;
@@ -277,6 +299,7 @@ export type PeriodOverview = {
sessions: number;
minutes: number;
crmBilledMinutes: number;
billableMinutes: number;
nonBillableMinutes: number;
openMinutes: number;
openSessions: number;
@@ -324,7 +347,7 @@ export type TicketPeriod = {
closedAt: string | null;
filters?: {
month: string;
status: "all" | "open" | "billed" | "non_billable";
status: "all" | "open" | "billable" | "billed" | "non_billable";
source: "all" | "manual" | "recurring";
};
availableMonths?: TicketAvailableMonth[];
@@ -361,9 +384,11 @@ export type StatisticsDailyBucket = {
sessions: number;
total_minutes: number;
billed_minutes: number;
billable_minutes: number;
non_billable_minutes: number;
open_minutes: number;
billed_sessions: number;
billable_sessions: number;
non_billable_sessions: number;
open_sessions: number;
crm_billed_minutes: number;
@@ -377,9 +402,11 @@ export type StatisticsGroup = {
sessions: number;
total_minutes: number;
billed_minutes: number;
billable_minutes: number;
non_billable_minutes: number;
open_minutes: number;
billed_sessions: number;
billable_sessions: number;
non_billable_sessions: number;
open_sessions: number;
crm_billed_minutes: number;
@@ -395,9 +422,11 @@ export type StatisticsTicket = {
sessions: number;
total_minutes: number;
billed_minutes: number;
billable_minutes: number;
non_billable_minutes: number;
open_minutes: number;
billed_sessions: number;
billable_sessions: number;
non_billable_sessions: number;
open_sessions: number;
crm_billed_minutes: number;
@@ -448,9 +477,11 @@ export type StatisticsOverview = {
sessions: number;
minutes: number;
billedMinutes: number;
billableMinutes: number;
nonBillableMinutes: number;
openMinutes: number;
billedSessions: number;
billableSessions: number;
nonBillableSessions: number;
openSessions: number;
recurringMinutes: number;
+3 -2
View File
@@ -443,6 +443,7 @@ export function AnalysisPage({ onNavigate }: AnalysisPageProps) {
{ label: "Tickets", value: totals?.tickets ?? 0, icon: Ticket },
{ label: "Sessions", value: totals?.sessions ?? 0, icon: Users },
{ label: "Abgerechnet", value: formatMinutes(totals?.minutes ?? 0), detail: `Teamspace ${formatMinutes(totals?.crmBilledMinutes ?? 0)}`, icon: Clock3 },
{ label: "Abrechenbar", value: formatMinutes(totals?.billableMinutes ?? 0), icon: UserCheck },
{ label: "Nicht abrechenbar", value: formatMinutes(totals?.nonBillableMinutes ?? 0), icon: UserCheck },
{ label: "Offen", value: totals?.openSessions ?? 0, detail: formatMinutes(totals?.openMinutes ?? 0), icon: UserCheck }
];
@@ -786,8 +787,8 @@ export function AnalysisPage({ onNavigate }: AnalysisPageProps) {
<p className="font-medium">{formatMinutes(ticket.total_minutes)}</p>
</div>
<div className="rounded-md bg-muted/50 p-2">
<p className="text-muted-foreground">Sessions</p>
<p className="font-medium">{ticket.billed_count}</p>
<p className="text-muted-foreground">Abrechenbar</p>
<p className="font-medium">{ticket.billable_count}</p>
</div>
</div>
<Button
+16 -10
View File
@@ -1536,7 +1536,7 @@ function WidgetContent(props: WidgetContentProps) {
return (
<MetricGrid
items={[
{ label: "Bewertet heute", value: formatMinutes(stats.dayTotals?.minutes ?? 0), icon: Timer },
{ label: "Abgerechnet heute", value: formatMinutes(stats.dayTotals?.minutes ?? 0), icon: Timer },
{ label: "Offene Bewertungen", value: stats.dayTotals?.openSessions ?? 0, icon: ClipboardCheck }
]}
/>
@@ -1547,7 +1547,7 @@ function WidgetContent(props: WidgetContentProps) {
return (
<MetricGrid
items={[
{ label: "Bewertet Monat", value: formatMinutes(stats.monthTotals?.minutes ?? 0), icon: CheckCircle2 },
{ label: "Abgerechnet Monat", value: formatMinutes(stats.monthTotals?.minutes ?? 0), icon: CheckCircle2 },
{ label: "Offene Bewertungen", value: stats.monthTotals?.openSessions ?? 0, icon: ClipboardCheck }
]}
/>
@@ -1560,7 +1560,7 @@ function WidgetContent(props: WidgetContentProps) {
<MetricGrid
items={[
{ label: "TS-Zeit Monat", value: formatMinutes(stats.monthTotals?.crmBilledMinutes ?? 0), icon: LayoutDashboard },
{ label: "Bewertet - TS", value: `${delta >= 0 ? "+" : "-"}${formatMinutes(Math.abs(delta))}`, icon: ArrowUp, tone: delta === 0 ? "normal" : delta > 0 ? "warning" : "good" }
{ label: "Abgerechnet - TS", value: `${delta >= 0 ? "+" : "-"}${formatMinutes(Math.abs(delta))}`, icon: ArrowUp, tone: delta === 0 ? "normal" : delta > 0 ? "warning" : "good" }
]}
/>
);
@@ -1697,6 +1697,7 @@ function DayControlWidget({ stats, projects, todos, onNavigate }: WidgetContentP
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 teamspaceReviewTickets = 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 budgetAttention = stableSort(
projects.filter((project) => {
const percent = projectConsumptionPercent(project);
@@ -1778,7 +1779,7 @@ function DayControlWidget({ stats, projects, todos, onNavigate }: WidgetContentP
tone: "warning" as const,
title: session.ticket_number,
detail: `${formatMinutes(session.rounded_minutes)} offen · ${session.organization_name ?? "Keine Organisation"}`,
note: "Bewertung setzen: Abgerechnet oder Nicht abrechenbar.",
note: "Bewertung setzen: Abrechenbar oder Nicht abrechenbar.",
onClick: () => onNavigate(`/tickets/${session.ticket_id}?month=${currentMonth()}&status=open`)
})),
...teamspaceReviewTickets.slice(0, 4).map((ticket) => ({
@@ -1826,11 +1827,11 @@ function DayControlWidget({ stats, projects, todos, onNavigate }: WidgetContentP
<DayControlMiniSection
title="Nacharbeit"
icon={CalendarCheck}
empty="Heute noch keine Sessions."
items={todayTickets.slice(0, 4).map((ticket) => ({
empty={todayTickets.length === 0 ? "Heute noch keine Sessions." : "Keine Nacharbeit offen."}
items={afterworkTickets.slice(0, 4).map((ticket) => ({
key: `today-ticket-${ticket.id}`,
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.billable_minutes + ticket.non_billable_minutes + ticket.open_minutes)} · ${ticket.session_count} Session(s)`,
note: dayControlTodaySessionReason(ticket),
onClick: () => onNavigate(`/tickets/${ticket.id}?month=${currentMonth()}`)
}))}
@@ -1895,6 +1896,10 @@ function dayControlTodaySessionReason(ticket: PeriodOverview["tickets"][number])
return `Teamspace ok: ${formatMinutes(crmMinutes)} erfasst.`;
}
if (ticket.billable_count > 0) {
return `Abrechenbar gesetzt: später abrechnen oder Status finalisieren.`;
}
if (ticket.non_billable_count > 0) {
return `Nicht abrechenbar gesetzt: Zammad-Dokumentation kontrollieren.`;
}
@@ -2503,7 +2508,7 @@ function StatisticWidget({ widget, editing, stats, onNavigate, onUpdateConfig }:
{settings}
<MetricGrid
items={[
{ label: "Bewertet", value: formatMinutes(statistics.totals.minutes), icon: CheckCircle2 },
{ label: "Abgerechnet", value: formatMinutes(statistics.totals.minutes), icon: CheckCircle2 },
{ label: "Offen", value: statistics.totals.openSessions, icon: ClipboardCheck },
{ label: "Ø Session", value: formatMinutes(statistics.totals.averageSessionMinutes), icon: Timer },
{ label: "Aktive Tage", value: statistics.totals.activeDays, icon: LayoutDashboard }
@@ -2519,7 +2524,8 @@ function StatisticWidget({ widget, editing, stats, onNavigate, onUpdateConfig }:
{settings}
<BarList
items={[
{ key: "billed", title: "Bewertet", value: statistics.totals.billedMinutes, detail: `${statistics.totals.billedSessions} Session(s)` },
{ key: "billed", title: "Abgerechnet", value: statistics.totals.billedMinutes, detail: `${statistics.totals.billedSessions} Session(s)` },
{ key: "billable", title: "Abrechenbar", value: statistics.totals.billableMinutes, detail: `${statistics.totals.billableSessions} Session(s)` },
{ key: "non", title: "Nicht abrechenbar", value: statistics.totals.nonBillableMinutes, detail: `${statistics.totals.nonBillableSessions} Session(s)` },
{ key: "open", title: "Offen", value: statistics.totals.openMinutes, detail: `${statistics.totals.openSessions} Session(s)` }
]}
@@ -2615,7 +2621,7 @@ function StatisticWidget({ widget, editing, stats, onNavigate, onUpdateConfig }:
key: day.day,
title: formatDate(`${day.day}T00:00:00`),
value: view === "teamspaceDaily" ? day.crm_billed_minutes : day.total_minutes,
detail: view === "teamspaceDaily" ? `Bewertet ${formatMinutes(day.total_minutes)}` : `${day.sessions} Session(s)`
detail: view === "teamspaceDaily" ? `Abgerechnet ${formatMinutes(day.total_minutes)}` : `${day.sessions} Session(s)`
}));
return (
+186 -4
View File
@@ -1,13 +1,14 @@
import { FormEvent, useEffect, useState } from "react";
import { KeyRound, MailPlus, Save, Unplug, UserCircle, X } from "lucide-react";
import { Clock3, ClipboardCheck, FileText, KeyRound, MailPlus, RefreshCw, Save, Send, Unplug, UserCircle, X } from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Switch } from "@/components/ui/switch";
import { HelpLink } from "@/components/HelpLink";
import { updateCurrentUser } from "../api";
import type { AuthUser } from "../types";
import { getZammadSessionArticleQueue, retryZammadSessionArticleQueue, updateCurrentUser } from "../api";
import { formatDateTime, formatMinutes } from "../format";
import type { AuthUser, ZammadSessionArticleQueueItem } from "../types";
type ProfilePageProps = {
currentUser: AuthUser;
@@ -23,6 +24,13 @@ export function ProfilePage({ currentUser, onUserUpdated }: ProfilePageProps) {
const [zammadApiToken, setZammadApiToken] = useState("");
const [zammadTokenDirty, setZammadTokenDirty] = useState(false);
const [zammadEmailSessionWebhookEnabled, setZammadEmailSessionWebhookEnabled] = useState(currentUser.zammad_email_session_webhook_enabled);
const [sessionBillingPromptEnabled, setSessionBillingPromptEnabled] = useState(currentUser.session_billing_prompt_enabled);
const [zammadSessionNoteDefaultEnabled, setZammadSessionNoteDefaultEnabled] = useState(currentUser.zammad_session_note_default_enabled);
const [zammadTimeUnitRoundingEnabled, setZammadTimeUnitRoundingEnabled] = useState(currentUser.zammad_time_unit_rounding_enabled);
const [zammadTimeUnitRoundingMinutes, setZammadTimeUnitRoundingMinutes] = useState(currentUser.zammad_time_unit_rounding_minutes);
const [zammadQueue, setZammadQueue] = useState<ZammadSessionArticleQueueItem[]>([]);
const [loadingZammadQueue, setLoadingZammadQueue] = useState(false);
const [retryingZammadQueue, setRetryingZammadQueue] = useState(false);
const [saving, setSaving] = useState(false);
useEffect(() => {
@@ -31,8 +39,61 @@ export function ProfilePage({ currentUser, onUserUpdated }: ProfilePageProps) {
setZammadApiToken("");
setZammadTokenDirty(false);
setZammadEmailSessionWebhookEnabled(currentUser.zammad_email_session_webhook_enabled);
setSessionBillingPromptEnabled(currentUser.session_billing_prompt_enabled);
setZammadSessionNoteDefaultEnabled(currentUser.zammad_session_note_default_enabled);
setZammadTimeUnitRoundingEnabled(currentUser.zammad_time_unit_rounding_enabled);
setZammadTimeUnitRoundingMinutes(currentUser.zammad_time_unit_rounding_minutes);
}, [currentUser]);
async function loadZammadQueue() {
if (currentUser.role !== "user") {
setZammadQueue([]);
return;
}
setLoadingZammadQueue(true);
try {
const result = await getZammadSessionArticleQueue();
setZammadQueue(result.queue);
} catch (error) {
toast.error("Zammad-Parktabelle konnte nicht geladen werden", {
description: error instanceof Error ? error.message : "Unbekannter Fehler"
});
} finally {
setLoadingZammadQueue(false);
}
}
useEffect(() => {
void loadZammadQueue();
}, [currentUser.role]);
async function retryZammadQueue() {
setRetryingZammadQueue(true);
try {
const result = await retryZammadSessionArticleQueue();
setZammadQueue(result.queue);
if (result.failed > 0) {
toast.warning("Zammad-Notizen teilweise gesendet", {
description: `${result.sent} gesendet, ${result.failed} weiter geparkt.`
});
} else {
toast.success("Zammad-Notizen gesendet", {
description: `${result.sent} Eintrag/Einträge erfolgreich übertragen.`
});
}
} catch (error) {
toast.error("Zammad-Notizen konnten nicht gesendet werden", {
description: error instanceof Error ? error.message : "Unbekannter Fehler"
});
} finally {
setRetryingZammadQueue(false);
}
}
async function submit(event: FormEvent) {
event.preventDefault();
@@ -58,7 +119,15 @@ export function ProfilePage({ currentUser, onUserUpdated }: ProfilePageProps) {
currentPassword: wantsPasswordChange ? currentPassword : undefined,
newPassword: wantsPasswordChange ? newPassword : undefined,
...(currentUser.role === "user" && zammadTokenDirty ? { zammadApiToken } : {}),
...(currentUser.role === "user" ? { zammadEmailSessionWebhookEnabled } : {})
...(currentUser.role === "user"
? {
zammadEmailSessionWebhookEnabled,
sessionBillingPromptEnabled,
zammadSessionNoteDefaultEnabled,
zammadTimeUnitRoundingEnabled,
zammadTimeUnitRoundingMinutes
}
: {})
};
try {
@@ -226,6 +295,119 @@ export function ProfilePage({ currentUser, onUserUpdated }: ProfilePageProps) {
onCheckedChange={setZammadEmailSessionWebhookEnabled}
/>
</div>
<div className="mt-3 flex items-center justify-between gap-3 rounded-md border bg-background p-3">
<div className="flex items-center gap-2">
<ClipboardCheck className="size-4 text-muted-foreground" />
<div>
<label className="text-sm font-medium" htmlFor="profile-session-billing-prompt">
Bewertungsabfrage beim Timerabschluss
</label>
<p className="text-xs text-muted-foreground">Bei Bewertung --- vor dem Speichern noch einmal nachfragen.</p>
</div>
</div>
<Switch
id="profile-session-billing-prompt"
checked={sessionBillingPromptEnabled}
onCheckedChange={setSessionBillingPromptEnabled}
/>
</div>
<div className="mt-3 flex items-center justify-between gap-3 rounded-md border bg-background p-3">
<div className="flex items-center gap-2">
<FileText className="size-4 text-muted-foreground" />
<div>
<label className="text-sm font-medium" htmlFor="profile-zammad-session-note">
Interne Zammad-Notiz beim Timerabschluss
</label>
<p className="text-xs text-muted-foreground">Checkbox im Speichern-Dialog standardmäßig aktivieren.</p>
</div>
</div>
<Switch
id="profile-zammad-session-note"
checked={zammadSessionNoteDefaultEnabled}
onCheckedChange={(checked) => {
setZammadSessionNoteDefaultEnabled(checked);
if (!checked) {
setZammadTimeUnitRoundingEnabled(false);
}
}}
/>
</div>
{zammadSessionNoteDefaultEnabled ? (
<div className="mt-3 rounded-md border bg-background p-3">
<div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-2">
<Clock3 className="size-4 text-muted-foreground" />
<div>
<label className="text-sm font-medium" htmlFor="profile-zammad-time-rounding">
Zammad-Zeit aufrunden
</label>
<p className="text-xs text-muted-foreground">Ohne Rundung wird die konkrete Zeit als Stundenwert mit 2 Nachkommastellen gesendet.</p>
</div>
</div>
<Switch
id="profile-zammad-time-rounding"
checked={zammadTimeUnitRoundingEnabled}
onCheckedChange={setZammadTimeUnitRoundingEnabled}
/>
</div>
{zammadTimeUnitRoundingEnabled ? (
<div className="mt-3 grid gap-2 sm:w-56">
<label className="text-xs font-medium text-muted-foreground" htmlFor="profile-zammad-rounding-minutes">
Rundungsintervall
</label>
<select
id="profile-zammad-rounding-minutes"
className="h-9 rounded-md border border-input bg-background px-3 text-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
value={zammadTimeUnitRoundingMinutes}
onChange={(event) => setZammadTimeUnitRoundingMinutes(Number(event.currentTarget.value))}
>
<option value={5}>5 Minuten</option>
<option value={10}>10 Minuten</option>
<option value={15}>15 Minuten</option>
<option value={30}>30 Minuten</option>
</select>
</div>
) : null}
</div>
) : null}
<div className="mt-3 rounded-md border bg-background p-3">
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div className="flex items-start gap-2">
<Send className="mt-0.5 size-4 text-muted-foreground" />
<div>
<p className="text-sm font-medium">Geparkte Zammad-Notizen</p>
<p className="text-xs text-muted-foreground">Nicht übertragene interne Notizen nachträglich senden.</p>
</div>
</div>
<div className="flex gap-2">
<Button type="button" size="sm" variant="secondary" disabled={loadingZammadQueue} onClick={() => void loadZammadQueue()}>
<RefreshCw className="size-3.5" />
Prüfen
</Button>
<Button type="button" size="sm" disabled={zammadQueue.length === 0 || retryingZammadQueue} onClick={() => void retryZammadQueue()}>
<Send className="size-3.5" />
{retryingZammadQueue ? "Sendet..." : "Senden"}
</Button>
</div>
</div>
<div className="mt-3 space-y-2">
{loadingZammadQueue ? <p className="text-xs text-muted-foreground">Lädt...</p> : null}
{!loadingZammadQueue && zammadQueue.length === 0 ? <p className="text-xs text-muted-foreground">Keine geparkten Zammad-Notizen.</p> : null}
{zammadQueue.slice(0, 5).map((item) => (
<div key={item.id} className="rounded-md border bg-muted/30 p-2 text-xs">
<div className="flex flex-wrap items-center gap-x-2 gap-y-1">
<span className="font-medium">{item.ticket_number}</span>
<span className="text-muted-foreground">{formatDateTime(item.started_at)}</span>
<span className="text-muted-foreground">{formatMinutes(item.rounded_minutes)}</span>
<span className="text-muted-foreground">time_unit {item.time_unit}</span>
</div>
{item.last_error ? <p className="mt-1 line-clamp-2 text-destructive">{item.last_error}</p> : null}
</div>
))}
{zammadQueue.length > 5 ? <p className="text-xs text-muted-foreground">Weitere {zammadQueue.length - 5} Einträge werden beim Senden ebenfalls berücksichtigt.</p> : null}
</div>
</div>
</div>
) : null}
+34 -7
View File
@@ -7,6 +7,7 @@ import {
RotateCcw,
Ticket,
TrendingUp,
UserCheck,
} from "lucide-react";
import type { LucideIcon } from "lucide-react";
import { useEffect, useMemo, useRef, useState } from "react";
@@ -36,6 +37,7 @@ const teamspaceStrokeClass = "text-fuchsia-600 dark:text-fuchsia-300";
const trackedDotClass = "bg-sky-500 dark:bg-sky-400";
const teamspaceDotClass = "bg-fuchsia-500 dark:bg-fuchsia-400";
const openTextClass = "text-amber-700 dark:text-amber-300";
const nonBillableTextClass = "text-indigo-700 dark:text-indigo-300";
const organizationPageSize = 8;
function workTypeLabel(workType: WorkType | undefined) {
@@ -115,6 +117,14 @@ function ColorLegend() {
<span className={`h-0.5 w-4 ${teamspaceDotClass}`} />
<span className={teamspaceTextClass}>Teamspace</span>
</span>
<span className="inline-flex items-center gap-1.5">
<span className="size-2 rounded-sm bg-slate-500 dark:bg-slate-400" />
<span>Abrechenbar</span>
</span>
<span className="inline-flex items-center gap-1.5">
<span className="size-2 rounded-sm bg-indigo-500 dark:bg-indigo-400" />
<span className={nonBillableTextClass}>Nicht abrechenbar</span>
</span>
<span className="inline-flex items-center gap-1.5">
<span className="size-2 rounded-full bg-emerald-500 dark:bg-emerald-400" />
<span className="text-emerald-700 dark:text-emerald-300">Differenz plus oder ausgeglichen</span>
@@ -307,7 +317,7 @@ function OrganizationRow({ organization, maxMinutes, totalMinutes }: { organizat
<TableCell className="max-w-[320px] whitespace-normal">
<div className="font-medium">{organization.organization_name ?? "Keine Organisation"}</div>
<div className="text-xs text-muted-foreground">
{organization.tickets} Ticket(s) · {organization.billed_sessions} abgerechnet
{organization.tickets} Ticket(s) · {organization.billed_sessions} abgerechnet · {organization.billable_sessions} abrechenbar
</div>
</TableCell>
<TableCell>{organization.billed_sessions}</TableCell>
@@ -338,6 +348,7 @@ function TicketHotspot({ ticket, month, onNavigate }: { ticket: StatisticsTicket
<span className={trackedTextClass}>Abgerechnet {formatMinutes(ticket.total_minutes)}</span>
<span className={teamspaceTextClass}>Teamspace {formatMinutes(ticket.crm_billed_minutes)}</span>
<span className="text-muted-foreground">{ticket.billed_sessions} abgerechnet</span>
<span className="text-muted-foreground">{ticket.billable_sessions} abrechenbar</span>
<span>{ticket.active_days} Tag(e)</span>
<span className={deltaTextClass(ticket.crm_delta_minutes)}>{formatTeamspaceDelta(ticket.crm_delta_minutes)}</span>
</div>
@@ -413,8 +424,13 @@ export function StatisticsPage({ onNavigate }: StatisticsPageProps) {
const totals = stats?.totals;
const billedSessions = totals?.billedSessions ?? 0;
const billedMinutes = totals?.billedMinutes ?? 0;
const billableSessions = totals?.billableSessions ?? 0;
const billableMinutes = totals?.billableMinutes ?? 0;
const nonBillableSessions = totals?.nonBillableSessions ?? 0;
const nonBillableMinutes = totals?.nonBillableMinutes ?? 0;
const openSessions = totals?.openSessions ?? 0;
const openMinutes = totals?.openMinutes ?? 0;
const evaluatedSessions = billedSessions + billableSessions + nonBillableSessions;
const crmCoverage = percentage(totals?.crmBilledMinutes ?? 0, totals?.minutes ?? 0);
const maxOrganizationMinutes = Math.max(...(stats?.organizations ?? []).map((organization) => organization.total_minutes), 1);
const topOrganization = stats?.organizations[0] ?? null;
@@ -460,7 +476,7 @@ export function StatisticsPage({ onNavigate }: StatisticsPageProps) {
</div>
</div>
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-3">
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
<MetricCard
label="Abgerechnet"
value={formatMinutes(totals?.minutes ?? 0)}
@@ -470,6 +486,14 @@ export function StatisticsPage({ onNavigate }: StatisticsPageProps) {
helpAnchor="statistik-getrackt"
helpLabel="Hilfe zur Kennzahl Abgerechnet"
/>
<MetricCard
label="Abrechenbar"
value={formatMinutes(billableMinutes)}
detail={`${billableSessions} Session(s)`}
icon={UserCheck}
helpAnchor="bewertungen"
helpLabel="Hilfe zu Bewertungen"
/>
<MetricCard
label="Teamspace"
value={formatMinutes(totals?.crmBilledMinutes ?? 0)}
@@ -510,17 +534,19 @@ export function StatisticsPage({ onNavigate }: StatisticsPageProps) {
<div className="space-y-3">
<DailyEffortChart month={month} buckets={stats?.dailySeries ?? []} />
<ColorLegend />
{strongestDay ? <p className="text-xs text-muted-foreground">Stärkster abrechenbarer Tag: {formatDate(`${strongestDay.day}T00:00:00`)} mit {formatMinutes(strongestDay.total_minutes)}</p> : null}
{strongestDay ? <p className="text-xs text-muted-foreground">Stärkster abgerechneter Tag: {formatDate(`${strongestDay.day}T00:00:00`)} mit {formatMinutes(strongestDay.total_minutes)}</p> : null}
</div>
<div className="space-y-4">
<div className="space-y-2 rounded-md border bg-muted/20 p-3">
<div className="flex items-center justify-between gap-3 text-sm">
<span className="text-muted-foreground">Bewertungsstand</span>
<span className="font-medium">{billedSessions} abrechenbar</span>
<span className="font-medium">{evaluatedSessions} bewertet</span>
</div>
<div className="grid gap-2 text-xs sm:grid-cols-2 xl:grid-cols-1 2xl:grid-cols-2">
<div className={trackedTextClass}>Abgerechnet: {billedSessions} Session(s), {formatMinutes(billedMinutes)}</div>
<div className="text-muted-foreground">Abrechenbar: {billableSessions} Session(s), {formatMinutes(billableMinutes)}</div>
<div className={nonBillableTextClass}>Nicht abrechenbar: {nonBillableSessions} Session(s), {formatMinutes(nonBillableMinutes)}</div>
<div className={openTextClass}>Offen: {openSessions} Session(s), {formatMinutes(openMinutes)}</div>
</div>
</div>
@@ -615,7 +641,7 @@ export function StatisticsPage({ onNavigate }: StatisticsPageProps) {
<div className="flex items-start justify-between gap-2">
<div className="min-w-0">
<p className="truncate font-medium">{organization.organization_name ?? "Keine Organisation"}</p>
<p className="text-xs text-muted-foreground">{organization.tickets} Ticket(s) · {organization.billed_sessions} abgerechnet</p>
<p className="text-xs text-muted-foreground">{organization.tickets} Ticket(s) · {organization.billed_sessions} abgerechnet · {organization.billable_sessions} abrechenbar</p>
</div>
<DeltaBadge minutes={organization.crm_delta_minutes} />
</div>
@@ -684,7 +710,7 @@ export function StatisticsPage({ onNavigate }: StatisticsPageProps) {
<div className="flex items-center justify-between gap-3">
<div>
<p className="font-semibold">{workTypeLabel(group.work_type)}</p>
<p className="text-xs text-muted-foreground">{group.tickets} Ticket(s), {group.billed_sessions} abgerechnet</p>
<p className="text-xs text-muted-foreground">{group.tickets} Ticket(s), {group.billed_sessions} abgerechnet, {group.billable_sessions} abrechenbar</p>
</div>
<DeltaBadge minutes={group.crm_delta_minutes} />
</div>
@@ -693,8 +719,9 @@ export function StatisticsPage({ onNavigate }: StatisticsPageProps) {
<span className={trackedTextClass}>Abgerechnet {formatMinutes(group.total_minutes)}</span>
<span className={teamspaceTextClass}>Teamspace {formatMinutes(group.crm_billed_minutes)}</span>
</div>
<div className="grid grid-cols-2 gap-2 text-xs text-muted-foreground">
<div className="grid gap-2 text-xs text-muted-foreground sm:grid-cols-3">
<span>Abr. {group.billed_sessions} / {formatMinutes(group.billed_minutes)}</span>
<span>Abrechb. {group.billable_sessions} / {formatMinutes(group.billable_minutes)}</span>
<span>Offen {group.open_sessions} / {formatMinutes(group.open_minutes)}</span>
</div>
</div>
+62 -9
View File
@@ -59,6 +59,7 @@ type TicketSessionFormState = {
organizationName: string | null;
projectTeamspaceEntryId: string;
activity: string;
billingStatus: BillingStatus;
workType: WorkType;
day: string;
startTime: string;
@@ -90,6 +91,7 @@ function emptyTicketSessionForm(): TicketSessionFormState {
organizationName: null,
projectTeamspaceEntryId: "",
activity: "",
billingStatus: null,
workType: "support",
day: currentDay(),
startTime: "09:00",
@@ -174,7 +176,17 @@ function BillingButtons({
const status = session.billing_status;
return (
<div className="grid grid-cols-2 gap-1.5 sm:inline-grid">
<div className="grid grid-cols-3 gap-1.5 sm:inline-grid">
<Button
type="button"
size="sm"
variant={status === "billable" ? "default" : "secondary"}
disabled={loading || (disabled && status !== "billable")}
onClick={() => onChange(status === "billable" ? null : "billable")}
className={cn("h-8", status === "billable" ? "" : "bg-secondary/70")}
>
Abrechenbar
</Button>
<Button
type="button"
size="sm"
@@ -199,6 +211,29 @@ function BillingButtons({
);
}
function BillingCaptureSelect({
id,
value,
onChange
}: {
id: string;
value: BillingStatus;
onChange: (status: BillingStatus) => void;
}) {
return (
<select
id={id}
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={value ?? ""}
onChange={(event) => onChange(event.currentTarget.value === "" ? null : event.currentTarget.value as Exclude<BillingStatus, null>)}
>
<option value="">---</option>
<option value="billable">Abrechenbar</option>
<option value="non_billable">Nicht abrechenbar</option>
</select>
);
}
function sessionDayKey(value: string) {
const date = new Date(value);
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}-${String(date.getDate()).padStart(2, "0")}`;
@@ -474,7 +509,7 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onO
const [ticketSession, setTicketSession] = useState<TicketSessionFormState>(() => emptyTicketSessionForm());
const [savingTicketSession, setSavingTicketSession] = useState(false);
const [globalMonthFilter, setGlobalMonthFilter] = useState(initialGlobalMonthFilter);
const [globalStatusFilter, setGlobalStatusFilter] = useState<"all" | "open" | "billed" | "non_billable">("all");
const [globalStatusFilter, setGlobalStatusFilter] = useState<"all" | "open" | "billable" | "billed" | "non_billable">("all");
const [globalSourceFilter, setGlobalSourceFilter] = useState<"all" | "manual" | "recurring">("all");
const [editingProject, setEditingProject] = useState(false);
const [savingProject, setSavingProject] = useState(false);
@@ -595,19 +630,21 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onO
const sessionGroups = useMemo(() => {
const groups = new Map<
string,
{ key: string; label: string; totalMinutes: number; nonBillableMinutes: number; openMinutes: number; sessions: SessionEntry[] }
{ key: string; label: string; totalMinutes: number; billableMinutes: number; nonBillableMinutes: number; openMinutes: number; sessions: SessionEntry[] }
>();
for (const session of data?.sessions ?? []) {
const key = sessionDayKey(session.started_at);
const group = groups.get(key);
const billedMinutes = session.billing_status === "billed" ? session.rounded_minutes : 0;
const billableMinutes = session.billing_status === "billable" ? session.rounded_minutes : 0;
const nonBillableMinutes = session.billing_status === "non_billable" ? session.rounded_minutes : 0;
const openMinutes = session.billing_status === null ? session.rounded_minutes : 0;
if (group) {
group.sessions.push(session);
group.totalMinutes += billedMinutes;
group.billableMinutes += billableMinutes;
group.nonBillableMinutes += nonBillableMinutes;
group.openMinutes += openMinutes;
continue;
@@ -617,6 +654,7 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onO
key,
label: formatDate(session.started_at),
totalMinutes: billedMinutes,
billableMinutes,
nonBillableMinutes,
openMinutes,
sessions: [session]
@@ -984,7 +1022,8 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onO
startedAt: startedAt.toISOString(),
endedAt: endedAt.toISOString(),
durationSeconds,
projectTeamspaceEntryId: effectiveWorkType === "consulting" && requiresProjectTeamspaceEntry ? ticketSession.projectTeamspaceEntryId : null
projectTeamspaceEntryId: effectiveWorkType === "consulting" && requiresProjectTeamspaceEntry ? ticketSession.projectTeamspaceEntryId : null,
billingStatus: ticketSession.billingStatus
});
toast.success("Session nachgetragen", {
description: "Der Eintrag wurde diesem Ticket zugeordnet. Betroffene Abschlüsse wurden wieder geöffnet."
@@ -995,7 +1034,8 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onO
organizationName: ticket.organization_name ?? null,
projectTeamspaceEntryId: "",
workType: ticket.work_type ?? "support",
day: ticketSession.day
day: ticketSession.day,
billingStatus: null
});
await load();
} catch (error) {
@@ -1277,7 +1317,7 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onO
>
<option value="all">Alle Bewertungen</option>
<option value="open">Offen</option>
<option value="billed">Abgerechnet</option>
<option value="billable">Abrechenbar</option>
<option value="non_billable">Nicht abrechenbar</option>
</select>
</div>
@@ -1657,7 +1697,7 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onO
<p className="truncate font-medium">{group.label}</p>
<p className="text-xs text-muted-foreground">{group.sessions.length} Session(s)</p>
</div>
<div className="grid w-full gap-2 sm:grid-cols-[13rem_10rem_8rem] xl:w-auto xl:grid-cols-[13rem_10rem_8rem_auto] xl:items-center xl:justify-end">
<div className="grid w-full gap-2 sm:grid-cols-[13rem_10rem_10rem_8rem] xl:w-auto xl:grid-cols-[13rem_10rem_10rem_8rem_auto] xl:items-center xl:justify-end">
<Button type="button" size="sm" variant="secondary" className="h-7 w-full justify-center" onClick={() => openTeamspacePreparation(group)}>
<ClipboardList className="size-3.5" />
TS Aufbereitung
@@ -1665,10 +1705,13 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onO
<Badge variant="outline" className="flex h-7 w-full justify-center whitespace-nowrap">
Abgerechnet {formatMinutes(group.totalMinutes)}
</Badge>
<Badge variant="outline" className="flex h-7 w-full justify-center whitespace-nowrap">
Abrechenbar {formatMinutes(group.billableMinutes)}
</Badge>
<Badge variant={group.openMinutes > 0 ? "warning" : "success"} className="flex h-7 w-full justify-center whitespace-nowrap">
Offen {formatMinutes(group.openMinutes)}
</Badge>
<div className="flex items-center gap-2 text-xs text-muted-foreground sm:col-span-3 xl:col-span-1">
<div className="flex items-center gap-2 text-xs text-muted-foreground sm:col-span-4 xl:col-span-1">
<label htmlFor={`crm-billing-${group.key}`}>Teamspace</label>
<Input
id={`crm-billing-${group.key}`}
@@ -1872,7 +1915,7 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onO
</div>
) : null}
<div className="grid gap-3 lg:grid-cols-[150px_120px_120px_minmax(220px,1fr)_auto] lg:items-end">
<div className="grid gap-3 lg:grid-cols-[150px_120px_120px_160px_minmax(220px,1fr)_auto] lg:items-end">
<div className="space-y-2">
<label className="text-sm font-medium" htmlFor="ticket-session-day">
Datum
@@ -1909,6 +1952,16 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onO
required
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium" htmlFor="ticket-session-billing-status">
Bewertung
</label>
<BillingCaptureSelect
id="ticket-session-billing-status"
value={ticketSession.billingStatus}
onChange={(billingStatus) => setTicketSession({ ...ticketSession, billingStatus })}
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium" htmlFor="ticket-session-activity">
Tätigkeit
+164 -16
View File
@@ -5,6 +5,7 @@ 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 { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
@@ -14,7 +15,7 @@ import { createSession, getRecurringBillings, lookupTicket } from "../api";
import { formatTimer } from "../format";
import { isRecurringBillingTicketNumber, ticketHasRecurringBilling } from "../recurring-billings";
import { activeElapsedMs, createClientId, pauseEntry, resumeEntry, ticketPattern, type TimerEntry } from "../timers";
import type { RecurringBilling, TicketMeta, WorkType } from "../types";
import type { AuthUser, BillingStatus, RecurringBilling, TicketMeta, WorkType } from "../types";
type ManualSessionFormState = {
ticketNumber: string;
@@ -22,6 +23,7 @@ type ManualSessionFormState = {
organizationName: string | null;
projectTeamspaceEntryId: string;
activity: string;
billingStatus: BillingStatus;
workType: WorkType;
day: string;
startTime: string;
@@ -40,6 +42,7 @@ function emptyManualSession(): ManualSessionFormState {
organizationName: null,
projectTeamspaceEntryId: "",
activity: "",
billingStatus: null,
workType: "support",
day: currentDay(),
startTime: "09:00",
@@ -51,6 +54,10 @@ type FinishedSession = {
timerId: string;
ticketNumber: string;
ticket: TicketMeta | null;
activity: string;
billingStatus: BillingStatus;
promptBillingStatus: boolean;
createZammadArticle: boolean;
startedAt: string;
endedAt: string;
durationSeconds: number;
@@ -77,16 +84,42 @@ type TimerPageProps = {
selectedTimerId: string | null;
setSelectedTimerId: Dispatch<SetStateAction<string | null>>;
tick: number;
currentUser: AuthUser;
};
export function TimerPage({ timers, setTimers, selectedTimerId, setSelectedTimerId, tick }: TimerPageProps) {
function BillingStatusSelect({
id,
value,
onChange,
disabled = false
}: {
id: string;
value: BillingStatus;
onChange: (status: BillingStatus) => void;
disabled?: boolean;
}) {
return (
<select
id={id}
className="h-10 w-full rounded-md border border-input bg-background px-3 text-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
value={value ?? ""}
disabled={disabled}
onChange={(event) => onChange(event.currentTarget.value === "" ? null : event.currentTarget.value as Exclude<BillingStatus, null>)}
>
<option value="">---</option>
<option value="billable">Abrechenbar</option>
<option value="non_billable">Nicht abrechenbar</option>
</select>
);
}
export function TimerPage({ timers, setTimers, selectedTimerId, setSelectedTimerId, tick, currentUser }: TimerPageProps) {
const [ticketNumber, setTicketNumber] = useState("");
const [finished, setFinished] = useState<FinishedSession | null>(null);
const [finishingId, setFinishingId] = useState<string | null>(null);
const [organizationId, setOrganizationId] = useState("");
const [organizationName, setOrganizationName] = useState<string | null>(null);
const [projectTeamspaceEntryId, setProjectTeamspaceEntryId] = useState("");
const [activity, setActivity] = useState("");
const [workType, setWorkType] = useState<WorkType>("support");
const [saving, setSaving] = useState(false);
const [manualSession, setManualSession] = useState<ManualSessionFormState>(() => emptyManualSession());
@@ -250,6 +283,8 @@ export function TimerPage({ timers, setTimers, selectedTimerId, setSelectedTimer
startedAt: now,
pausedTotalMs: 0,
pausedAt: null,
activity: "",
billingStatus: null,
phase: "running"
};
@@ -356,6 +391,14 @@ export function TimerPage({ timers, setTimers, selectedTimerId, setSelectedTimer
setTimers((current) => current.filter((timer) => timer.id !== timerId));
}
function updateTimerActivity(timerId: string, nextActivity: string) {
setTimers((current) => current.map((timer) => (timer.id === timerId ? { ...timer, activity: nextActivity } : timer)));
}
function updateTimerBillingStatus(timerId: string, billingStatus: BillingStatus) {
setTimers((current) => current.map((timer) => (timer.id === timerId ? { ...timer, billingStatus } : timer)));
}
async function finishTimer(timerId: string) {
const timer = timers.find((entry) => entry.id === timerId);
@@ -363,6 +406,16 @@ export function TimerPage({ timers, setTimers, selectedTimerId, setSelectedTimer
return;
}
const timerActivity = timer.activity;
if (!timerActivity.trim()) {
setSelectedTimerId(timer.id);
toast.error("Tätigkeit erfassen", {
description: "Bitte dokumentiere die Tätigkeit vor dem Beenden der Session."
});
return;
}
const now = Date.now();
const stoppedTimer = pauseEntry(timer, now);
const durationSeconds = Math.floor(activeElapsedMs(stoppedTimer, now) / 1000);
@@ -388,11 +441,14 @@ export function TimerPage({ timers, setTimers, selectedTimerId, setSelectedTimer
setOrganizationName(ticket?.organization_name ?? null);
setProjectTeamspaceEntryId("");
setWorkType(ticket?.work_type ?? "support");
setActivity("");
setFinished({
timerId: timer.id,
ticketNumber: timer.ticketNumber,
ticket,
activity: timerActivity,
billingStatus: timer.billingStatus,
promptBillingStatus: currentUser.session_billing_prompt_enabled && timer.billingStatus === null,
createZammadArticle: currentUser.zammad_session_note_default_enabled,
startedAt: new Date(stoppedTimer.startedAt).toISOString(),
endedAt: new Date(now).toISOString(),
durationSeconds,
@@ -414,6 +470,13 @@ export function TimerPage({ timers, setTimers, selectedTimerId, setSelectedTimer
return;
}
if (!finished.activity.trim()) {
toast.error("Tätigkeit erfassen", {
description: "Bitte dokumentiere die Tätigkeit vor dem Speichern der Session."
});
return;
}
if (ticketHasRecurringBilling(finished.ticket, recurringBillings) || isRecurringBillingTicketNumber(finished.ticketNumber, recurringBillings)) {
toast.error("Fixe Abrechnung", {
description: "Für fixe Abrechnungen dürfen keine manuellen Sessions gespeichert werden. Bitte die Definition bearbeiten."
@@ -450,10 +513,12 @@ export function TimerPage({ timers, setTimers, selectedTimerId, setSelectedTimer
endedAt: finished.endedAt,
durationSeconds: finished.durationSeconds,
organizationId: effectiveOrganizationId,
activity,
activity: finished.activity,
workType: effectiveWorkType,
zammadLookup: Boolean(finished.ticket?.zammad_ticket_id),
projectTeamspaceEntryId: finishedRequiresTeamspaceEntry ? projectTeamspaceEntryId : null
projectTeamspaceEntryId: finishedRequiresTeamspaceEntry ? projectTeamspaceEntryId : null,
billingStatus: finished.billingStatus,
createZammadArticle: finished.createZammadArticle
});
toast.success("Session gespeichert", {
@@ -464,7 +529,6 @@ export function TimerPage({ timers, setTimers, selectedTimerId, setSelectedTimer
setOrganizationId("");
setOrganizationName(null);
setProjectTeamspaceEntryId("");
setActivity("");
setWorkType("support");
} catch (error) {
toast.error("Speichern fehlgeschlagen", {
@@ -531,7 +595,8 @@ export function TimerPage({ timers, setTimers, selectedTimerId, setSelectedTimer
endedAt: endedAt.toISOString(),
durationSeconds,
zammadLookup: Boolean(manualLookupTicket?.zammad_ticket_id),
projectTeamspaceEntryId: requiresTeamspaceEntry(manualLookupTicket) ? manualSession.projectTeamspaceEntryId : null
projectTeamspaceEntryId: requiresTeamspaceEntry(manualLookupTicket) ? manualSession.projectTeamspaceEntryId : null,
billingStatus: manualSession.billingStatus
});
toast.success("Session nachgetragen", {
description: manualLookupTicket?.is_project ? "Der Eintrag wurde dem Projekt zugeordnet." : "Der Eintrag ist in deiner Ticketliste offen."
@@ -625,11 +690,44 @@ export function TimerPage({ timers, setTimers, selectedTimerId, setSelectedTimer
</div>
</CardHeader>
<CardContent className="space-y-4 p-4">
<div className="rounded-lg border bg-background p-4 sm:p-5">
<p className="mb-2 text-sm font-medium text-muted-foreground">Gemessene Arbeitszeit</p>
<p className="font-mono text-4xl font-bold leading-none tracking-normal sm:text-6xl">
{formatTimer(elapsedSeconds)}
</p>
<div className="grid gap-4 lg:grid-cols-[minmax(220px,320px)_1fr]">
<div className="rounded-lg border bg-background p-4 sm:p-5">
<p className="mb-2 text-sm font-medium text-muted-foreground">Gemessene Arbeitszeit</p>
<p className="font-mono text-4xl font-bold leading-none tracking-normal sm:text-6xl">
{formatTimer(elapsedSeconds)}
</p>
</div>
<div className="grid gap-3 md:grid-cols-[1fr_180px]">
<div className="space-y-2">
<label className="text-sm font-medium" htmlFor="timer-activity">
Tätigkeit
</label>
<Textarea
id="timer-activity"
className="min-h-32"
value={selectedTimer?.activity ?? ""}
disabled={!selectedTimer}
placeholder={selectedTimer ? "Beschreibe, woran du in dieser Session arbeitest." : "Timer auswählen oder starten."}
onChange={(event) => selectedTimer && updateTimerActivity(selectedTimer.id, event.currentTarget.value)}
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium" htmlFor="timer-billing-status">
Bewertung
</label>
<BillingStatusSelect
id="timer-billing-status"
value={selectedTimer?.billingStatus ?? null}
disabled={!selectedTimer}
onChange={(billingStatus) => selectedTimer && updateTimerBillingStatus(selectedTimer.id, billingStatus)}
/>
</div>
{selectedTimer ? (
<p className="text-xs text-muted-foreground md:col-span-2">
Tätigkeit und Bewertung bleiben bei pausierten Timern erhalten und werden beim Beenden mit der Session gespeichert.
</p>
) : null}
</div>
</div>
<div className="grid grid-cols-2 gap-2 sm:flex sm:flex-wrap">
@@ -761,7 +859,7 @@ export function TimerPage({ timers, setTimers, selectedTimerId, setSelectedTimer
) : null}
</div>
<div className="grid gap-3 lg:grid-cols-[160px_120px_120px_minmax(220px,1fr)_auto] lg:items-end">
<div className="grid gap-3 lg:grid-cols-[160px_120px_120px_160px_minmax(220px,1fr)_auto] lg:items-end">
<div className="space-y-2">
<label className="text-sm font-medium" htmlFor="manual-day">Datum</label>
<Input
@@ -792,6 +890,14 @@ export function TimerPage({ timers, setTimers, selectedTimerId, setSelectedTimer
required
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium" htmlFor="manual-billing-status">Bewertung</label>
<BillingStatusSelect
id="manual-billing-status"
value={manualSession.billingStatus}
onChange={(billingStatus) => setManualSession({ ...manualSession, billingStatus })}
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium" htmlFor="manual-activity">Tätigkeit</label>
<Textarea
@@ -858,11 +964,53 @@ export function TimerPage({ timers, setTimers, selectedTimerId, setSelectedTimer
</div>
)}
<div className="space-y-2">
<label className="text-sm font-medium" htmlFor="activity">
<label className="text-sm font-medium" htmlFor="finished-activity">
Tätigkeit
</label>
<Textarea id="activity" value={activity} onChange={(event) => setActivity(event.currentTarget.value)} autoFocus required />
<Textarea
id="finished-activity"
className="min-h-24"
value={finished?.activity ?? ""}
onChange={(event) => {
const nextActivity = event.currentTarget.value;
setFinished((current) => (current ? { ...current, activity: nextActivity } : current));
}}
required
/>
</div>
{finished ? (
<div className="flex items-start gap-3 rounded-md border bg-muted/30 p-3">
<Checkbox
id="finished-zammad-article"
checked={finished.createZammadArticle}
onCheckedChange={(checked) => setFinished((current) => (current ? { ...current, createZammadArticle: checked === true } : current))}
/>
<div className="space-y-1">
<label className="text-sm font-medium" htmlFor="finished-zammad-article">
Tätigkeit als interne Zammad-Notiz übertragen
</label>
<p className="text-xs text-muted-foreground">
Erstellt einen internen Zammad-Article mit Tätigkeit und getrackter Zeit.
</p>
{!finished.ticket?.zammad_ticket_id ? (
<p className="text-xs text-muted-foreground">Falls die Zammad-ID noch fehlt, wird sie beim Speichern oder späteren Nachsenden erneut gesucht.</p>
) : null}
</div>
</div>
) : null}
{finished?.promptBillingStatus ? (
<div className="space-y-2">
<label className="text-sm font-medium" htmlFor="finished-billing-status">Bewertung</label>
<BillingStatusSelect
id="finished-billing-status"
value={finished.billingStatus}
onChange={(billingStatus) => setFinished((current) => (current ? { ...current, billingStatus } : current))}
/>
<p className="text-xs text-muted-foreground">
<span className="font-mono">---</span> lässt die Session offen und kann später in der Ticketdetailansicht bewertet werden.
</p>
</div>
) : null}
{finished?.ticket?.organization_id && finished.ticket.work_type ? null : (
<div className="space-y-2">
<label className="text-sm font-medium" htmlFor="work-type">