Compare commits
2
Commits
9107296900
...
fa6db04370
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fa6db04370 | ||
|
|
9be6c6e36a |
No files matched your search
+14
-2
@@ -18,6 +18,10 @@ export type AuthUser = {
|
|||||||
zammad_user_id: string | null;
|
zammad_user_id: string | null;
|
||||||
has_zammad_api_token: boolean;
|
has_zammad_api_token: boolean;
|
||||||
zammad_email_session_webhook_enabled: 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 {
|
declare global {
|
||||||
@@ -79,7 +83,11 @@ function publicUser(user: AuthUser) {
|
|||||||
active: user.active,
|
active: user.active,
|
||||||
zammad_user_id: user.zammad_user_id ?? null,
|
zammad_user_id: user.zammad_user_id ?? null,
|
||||||
has_zammad_api_token: Boolean(user.has_zammad_api_token),
|
has_zammad_api_token: Boolean(user.has_zammad_api_token),
|
||||||
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.active,
|
||||||
u.zammad_user_id::text AS zammad_user_id,
|
u.zammad_user_id::text AS zammad_user_id,
|
||||||
(u.zammad_api_token IS NOT NULL) AS has_zammad_api_token,
|
(u.zammad_api_token IS NOT NULL) AS has_zammad_api_token,
|
||||||
u.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
|
FROM auth_sessions s
|
||||||
JOIN users u ON u.id = s.user_id
|
JOIN users u ON u.id = s.user_id
|
||||||
WHERE s.token_hash = $1
|
WHERE s.token_hash = $1
|
||||||
|
|||||||
+447
-20
@@ -226,6 +226,20 @@ function parseOptionalTeamspaceEntryId(value: unknown) {
|
|||||||
return parsePositiveInteger(value, "projectTeamspaceEntryId");
|
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) {
|
function optionalTicketSessionMonth(value: unknown) {
|
||||||
if (value === undefined || value === null || value === "" || value === "all") {
|
if (value === undefined || value === null || value === "" || value === "all") {
|
||||||
return null;
|
return null;
|
||||||
@@ -239,11 +253,11 @@ function optionalTicketSessionStatus(value: unknown) {
|
|||||||
return "all" as const;
|
return "all" as const;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (value === "open" || value === "billed" || value === "non_billable") {
|
if (value === "open" || value === "billable" || value === "billed" || value === "non_billable") {
|
||||||
return value;
|
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) {
|
function optionalTicketSessionSource(value: unknown) {
|
||||||
@@ -560,6 +574,204 @@ async function fetchZammadJson(baseUrl: string, apiToken: string, path: string)
|
|||||||
return response.json() as Promise<unknown>;
|
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>[] {
|
function zammadTicketCandidates(payload: unknown): Record<string, unknown>[] {
|
||||||
if (Array.isArray(payload)) {
|
if (Array.isArray(payload)) {
|
||||||
return payload.filter(isPlainObject);
|
return payload.filter(isPlainObject);
|
||||||
@@ -799,6 +1011,8 @@ type SessionCreationInput = {
|
|||||||
requestedWorkType: "support" | "consulting" | null;
|
requestedWorkType: "support" | "consulting" | null;
|
||||||
useZammadLookup: boolean;
|
useZammadLookup: boolean;
|
||||||
requestedProjectTeamspaceEntryId: number | null;
|
requestedProjectTeamspaceEntryId: number | null;
|
||||||
|
billingStatus: "billable" | "billed" | "non_billable" | null;
|
||||||
|
createZammadArticle: boolean;
|
||||||
startedAt: Date;
|
startedAt: Date;
|
||||||
endedAt: Date;
|
endedAt: Date;
|
||||||
durationSeconds: number;
|
durationSeconds: number;
|
||||||
@@ -818,6 +1032,8 @@ async function createSessionForUser(input: SessionCreationInput) {
|
|||||||
requestedWorkType,
|
requestedWorkType,
|
||||||
useZammadLookup,
|
useZammadLookup,
|
||||||
requestedProjectTeamspaceEntryId,
|
requestedProjectTeamspaceEntryId,
|
||||||
|
billingStatus,
|
||||||
|
createZammadArticle,
|
||||||
startedAt,
|
startedAt,
|
||||||
endedAt,
|
endedAt,
|
||||||
durationSeconds,
|
durationSeconds,
|
||||||
@@ -947,7 +1163,7 @@ async function createSessionForUser(input: SessionCreationInput) {
|
|||||||
throw badRequest("organizationId must reference an existing organization");
|
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)
|
INSERT INTO tickets (ticket_number, organization_id, customer_name, work_type)
|
||||||
VALUES ($1, $2, $3, $4)
|
VALUES ($1, $2, $3, $4)
|
||||||
@@ -962,7 +1178,7 @@ async function createSessionForUser(input: SessionCreationInput) {
|
|||||||
) THEN 'consulting'
|
) THEN 'consulting'
|
||||||
ELSE COALESCE(tickets.work_type, EXCLUDED.work_type)
|
ELSE COALESCE(tickets.work_type, EXCLUDED.work_type)
|
||||||
END
|
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]
|
[ticketNumber, organization.id, organization.name, existingTicket?.project_id ? "consulting" : requestedWorkType, userId]
|
||||||
);
|
);
|
||||||
@@ -1011,9 +1227,11 @@ async function createSessionForUser(input: SessionCreationInput) {
|
|||||||
ended_at,
|
ended_at,
|
||||||
duration_seconds,
|
duration_seconds,
|
||||||
rounded_minutes,
|
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 *;
|
RETURNING *;
|
||||||
`,
|
`,
|
||||||
[
|
[
|
||||||
@@ -1027,9 +1245,60 @@ async function createSessionForUser(input: SessionCreationInput) {
|
|||||||
endedAt.toISOString(),
|
endedAt.toISOString(),
|
||||||
durationSeconds,
|
durationSeconds,
|
||||||
roundedMinutes,
|
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);
|
await reopenPeriodsForSession(client, ticket.id, startedAt, userId);
|
||||||
if (workType === "consulting") {
|
if (workType === "consulting") {
|
||||||
@@ -1044,13 +1313,13 @@ async function createSessionForUser(input: SessionCreationInput) {
|
|||||||
SET session_id = $1
|
SET session_id = $1
|
||||||
WHERE id = $2;
|
WHERE id = $2;
|
||||||
`,
|
`,
|
||||||
[sessionResult.rows[0].id, eventId]
|
[session.id, eventId]
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
ticket,
|
ticket,
|
||||||
session: sessionResult.rows[0]
|
session
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -1404,6 +1673,8 @@ async function createZammadEmailSessionFromWebhook(payload: unknown, deliveryId:
|
|||||||
requestedWorkType: null,
|
requestedWorkType: null,
|
||||||
useZammadLookup: true,
|
useZammadLookup: true,
|
||||||
requestedProjectTeamspaceEntryId: null,
|
requestedProjectTeamspaceEntryId: null,
|
||||||
|
billingStatus: null,
|
||||||
|
createZammadArticle: false,
|
||||||
startedAt,
|
startedAt,
|
||||||
endedAt,
|
endedAt,
|
||||||
durationSeconds,
|
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,
|
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,
|
COALESCE(SUM(s.rounded_minutes) FILTER (WHERE s.billing_status = 'billed'), 0)::int AS total_minutes,
|
||||||
ticket_crm.crm_billed_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 = '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,
|
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 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 = 'billed')::int AS billed_count,
|
||||||
COUNT(*) FILTER (WHERE s.billing_status = 'non_billable')::int AS non_billable_count,
|
COUNT(*) FILTER (WHERE s.billing_status = 'non_billable')::int AS non_billable_count,
|
||||||
false AS requires_closure,
|
false AS requires_closure,
|
||||||
@@ -3634,6 +3907,7 @@ async function getPeriodOverview(config: PeriodConfig, period: ParsedPeriod, use
|
|||||||
sessions: totalSessions,
|
sessions: totalSessions,
|
||||||
minutes: totalMinutes,
|
minutes: totalMinutes,
|
||||||
crmBilledMinutes: crmBillingResult.rows[0]?.crm_billed_minutes ?? 0,
|
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),
|
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),
|
openMinutes: tickets.reduce((sum: number, ticket: any) => sum + ticket.open_minutes, 0),
|
||||||
openSessions: openSessions.length
|
openSessions: openSessions.length
|
||||||
@@ -3714,9 +3988,11 @@ async function getStatisticsOverview(period: ParsedPeriod, userId: string) {
|
|||||||
COUNT(*)::int AS session_count,
|
COUNT(*)::int AS session_count,
|
||||||
COALESCE(SUM(sb.rounded_minutes) FILTER (WHERE sb.billing_status = 'billed'), 0)::int AS total_minutes,
|
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 = '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 = 'non_billable')::int AS non_billable_minutes,
|
||||||
SUM(sb.rounded_minutes) FILTER (WHERE sb.billing_status IS NULL)::int AS open_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 = '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 = 'non_billable')::int AS non_billable_sessions,
|
||||||
COUNT(*) FILTER (WHERE sb.billing_status IS NULL)::int AS open_count,
|
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,
|
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,
|
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 minutes,
|
||||||
COALESCE(SUM(rounded_minutes) FILTER (WHERE billing_status = 'billed'), 0)::int AS billed_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 = 'non_billable'), 0)::int AS non_billable_minutes,
|
||||||
COALESCE(SUM(rounded_minutes) FILTER (WHERE billing_status IS NULL), 0)::int AS open_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 = '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 = 'non_billable')::int AS non_billable_sessions,
|
||||||
COUNT(*) FILTER (WHERE billing_status IS NULL)::int AS open_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,
|
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,
|
COALESCE(day_sessions.session_count, 0)::int AS sessions,
|
||||||
td.tracked_minutes::int AS total_minutes,
|
td.tracked_minutes::int AS total_minutes,
|
||||||
COALESCE(day_sessions.billed_minutes, 0)::int AS billed_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.non_billable_minutes, 0)::int AS non_billable_minutes,
|
||||||
COALESCE(day_sessions.open_minutes, 0)::int AS open_minutes,
|
COALESCE(day_sessions.open_minutes, 0)::int AS open_minutes,
|
||||||
COALESCE(day_sessions.billed_sessions, 0)::int AS billed_sessions,
|
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.non_billable_sessions, 0)::int AS non_billable_sessions,
|
||||||
COALESCE(day_sessions.open_sessions, 0)::int AS open_sessions,
|
COALESCE(day_sessions.open_sessions, 0)::int AS open_sessions,
|
||||||
COALESCE(SUM(tdb.crm_billed_minutes), 0)::int AS crm_billed_minutes
|
COALESCE(SUM(tdb.crm_billed_minutes), 0)::int AS crm_billed_minutes
|
||||||
@@ -3796,16 +4076,18 @@ async function getStatisticsOverview(period: ParsedPeriod, userId: string) {
|
|||||||
day,
|
day,
|
||||||
SUM(session_count)::int AS session_count,
|
SUM(session_count)::int AS session_count,
|
||||||
SUM(COALESCE(billed_minutes, 0))::int AS billed_minutes,
|
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(non_billable_minutes, 0))::int AS non_billable_minutes,
|
||||||
SUM(COALESCE(open_minutes, 0))::int AS open_minutes,
|
SUM(COALESCE(open_minutes, 0))::int AS open_minutes,
|
||||||
SUM(billed_sessions)::int AS billed_sessions,
|
SUM(billed_sessions)::int AS billed_sessions,
|
||||||
|
SUM(billable_sessions)::int AS billable_sessions,
|
||||||
SUM(non_billable_sessions)::int AS non_billable_sessions,
|
SUM(non_billable_sessions)::int AS non_billable_sessions,
|
||||||
SUM(open_count)::int AS open_sessions
|
SUM(open_count)::int AS open_sessions
|
||||||
FROM session_group
|
FROM session_group
|
||||||
GROUP BY day
|
GROUP BY day
|
||||||
) day_sessions ON day_sessions.day = td.day
|
) day_sessions ON day_sessions.day = td.day
|
||||||
LEFT JOIN ticket_day_billing tdb ON tdb.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;
|
ORDER BY td.day ASC;
|
||||||
`,
|
`,
|
||||||
[period.startIso, period.endIso, userId]
|
[period.startIso, period.endIso, userId]
|
||||||
@@ -3820,9 +4102,11 @@ async function getStatisticsOverview(period: ParsedPeriod, userId: string) {
|
|||||||
SUM(session_count)::int AS sessions,
|
SUM(session_count)::int AS sessions,
|
||||||
COALESCE(SUM(total_minutes), 0)::int AS total_minutes,
|
COALESCE(SUM(total_minutes), 0)::int AS total_minutes,
|
||||||
COALESCE(SUM(COALESCE(billed_minutes, 0)), 0)::int AS billed_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(non_billable_minutes, 0)), 0)::int AS non_billable_minutes,
|
||||||
COALESCE(SUM(COALESCE(open_minutes, 0)), 0)::int AS open_minutes,
|
COALESCE(SUM(COALESCE(open_minutes, 0)), 0)::int AS open_minutes,
|
||||||
COALESCE(SUM(billed_sessions), 0)::int AS billed_sessions,
|
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(non_billable_sessions), 0)::int AS non_billable_sessions,
|
||||||
COALESCE(SUM(open_count), 0)::int AS open_sessions,
|
COALESCE(SUM(open_count), 0)::int AS open_sessions,
|
||||||
COALESCE(SUM(crm_billed_minutes), 0)::int AS crm_billed_minutes
|
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,
|
SUM(session_count)::int AS sessions,
|
||||||
COALESCE(SUM(total_minutes), 0)::int AS total_minutes,
|
COALESCE(SUM(total_minutes), 0)::int AS total_minutes,
|
||||||
COALESCE(SUM(COALESCE(billed_minutes, 0)), 0)::int AS billed_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(non_billable_minutes, 0)), 0)::int AS non_billable_minutes,
|
||||||
COALESCE(SUM(COALESCE(open_minutes, 0)), 0)::int AS open_minutes,
|
COALESCE(SUM(COALESCE(open_minutes, 0)), 0)::int AS open_minutes,
|
||||||
COALESCE(SUM(billed_sessions), 0)::int AS billed_sessions,
|
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(non_billable_sessions), 0)::int AS non_billable_sessions,
|
||||||
COALESCE(SUM(open_count), 0)::int AS open_sessions,
|
COALESCE(SUM(open_count), 0)::int AS open_sessions,
|
||||||
COALESCE(SUM(crm_billed_minutes), 0)::int AS crm_billed_minutes
|
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,
|
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 total_minutes,
|
||||||
COALESCE(SUM(rounded_minutes) FILTER (WHERE billing_status = 'billed'), 0)::int AS billed_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 = 'non_billable'), 0)::int AS non_billable_minutes,
|
||||||
COALESCE(SUM(rounded_minutes) FILTER (WHERE billing_status IS NULL), 0)::int AS open_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 = '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 = 'non_billable')::int AS non_billable_sessions,
|
||||||
COUNT(*) FILTER (WHERE billing_status IS NULL)::int AS open_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
|
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,
|
sessions: 0,
|
||||||
minutes: 0,
|
minutes: 0,
|
||||||
billed_minutes: 0,
|
billed_minutes: 0,
|
||||||
|
billable_minutes: 0,
|
||||||
non_billable_minutes: 0,
|
non_billable_minutes: 0,
|
||||||
open_minutes: 0,
|
open_minutes: 0,
|
||||||
billed_sessions: 0,
|
billed_sessions: 0,
|
||||||
|
billable_sessions: 0,
|
||||||
non_billable_sessions: 0,
|
non_billable_sessions: 0,
|
||||||
open_sessions: 0,
|
open_sessions: 0,
|
||||||
recurring_minutes: 0,
|
recurring_minutes: 0,
|
||||||
@@ -4020,9 +4310,11 @@ async function getStatisticsOverview(period: ParsedPeriod, userId: string) {
|
|||||||
sessions: totals.sessions,
|
sessions: totals.sessions,
|
||||||
minutes: totals.minutes,
|
minutes: totals.minutes,
|
||||||
billedMinutes: totals.billed_minutes,
|
billedMinutes: totals.billed_minutes,
|
||||||
|
billableMinutes: totals.billable_minutes,
|
||||||
nonBillableMinutes: totals.non_billable_minutes,
|
nonBillableMinutes: totals.non_billable_minutes,
|
||||||
openMinutes: totals.open_minutes,
|
openMinutes: totals.open_minutes,
|
||||||
billedSessions: totals.billed_sessions,
|
billedSessions: totals.billed_sessions,
|
||||||
|
billableSessions: totals.billable_sessions,
|
||||||
nonBillableSessions: totals.non_billable_sessions,
|
nonBillableSessions: totals.non_billable_sessions,
|
||||||
openSessions: totals.open_sessions,
|
openSessions: totals.open_sessions,
|
||||||
recurringMinutes: totals.recurring_minutes,
|
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) {
|
if (filters.month) {
|
||||||
await ensureRecurringSessionsForUserPeriod(userId, filters.month.startIso, filters.month.endIso);
|
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;
|
zammad_user_id: string | null;
|
||||||
has_zammad_api_token: boolean;
|
has_zammad_api_token: boolean;
|
||||||
zammad_email_session_webhook_enabled: 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)
|
INSERT INTO users (username, display_name, password_hash, role, active)
|
||||||
VALUES ($1, $2, $3, 'admin', true)
|
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)]
|
[username, displayName, await hashPassword(password)]
|
||||||
);
|
);
|
||||||
@@ -4606,6 +4902,10 @@ app.post("/api/auth/login", async (req, res) => {
|
|||||||
zammad_user_id: string | null;
|
zammad_user_id: string | null;
|
||||||
has_zammad_api_token: boolean;
|
has_zammad_api_token: boolean;
|
||||||
zammad_email_session_webhook_enabled: 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
|
SELECT
|
||||||
@@ -4617,7 +4917,11 @@ app.post("/api/auth/login", async (req, res) => {
|
|||||||
password_hash,
|
password_hash,
|
||||||
zammad_user_id::text AS zammad_user_id,
|
zammad_user_id::text AS zammad_user_id,
|
||||||
(zammad_api_token IS NOT NULL) AS has_zammad_api_token,
|
(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
|
FROM users
|
||||||
WHERE lower(username) = lower($1);
|
WHERE lower(username) = lower($1);
|
||||||
`,
|
`,
|
||||||
@@ -4663,7 +4967,11 @@ app.post("/api/auth/login", async (req, res) => {
|
|||||||
active: user.active,
|
active: user.active,
|
||||||
zammad_user_id: user.zammad_user_id,
|
zammad_user_id: user.zammad_user_id,
|
||||||
has_zammad_api_token: user.has_zammad_api_token,
|
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"
|
const zammadEmailSessionWebhookEnabled = user.role === "user"
|
||||||
? optionalBoolean(req.body.zammadEmailSessionWebhookEnabled, user.zammad_email_session_webhook_enabled)
|
? optionalBoolean(req.body.zammadEmailSessionWebhookEnabled, user.zammad_email_session_webhook_enabled)
|
||||||
: false;
|
: 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) {
|
if (newPassword && newPassword.length < 6) {
|
||||||
throw badRequest("newPassword must be at least 6 characters");
|
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_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_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,
|
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()
|
updated_at = now()
|
||||||
WHERE id = $4
|
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,
|
username,
|
||||||
@@ -4910,7 +5234,11 @@ app.patch("/api/auth/me", async (req, res) => {
|
|||||||
zammadApiToken ? encryptSecret(zammadApiToken) : null,
|
zammadApiToken ? encryptSecret(zammadApiToken) : null,
|
||||||
zammadUserId,
|
zammadUserId,
|
||||||
user.role === "user",
|
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) => {
|
app.get("/api/admin/users", requireAdmin, async (_req, res) => {
|
||||||
const result = await query(
|
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
|
FROM users
|
||||||
ORDER BY username ASC;
|
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)
|
INSERT INTO users (username, display_name, password_hash, role, active)
|
||||||
VALUES ($1, $2, $3, $4, $5)
|
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]
|
[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),
|
password_hash = COALESCE($4, password_hash),
|
||||||
updated_at = now()
|
updated_at = now()
|
||||||
WHERE id = $5
|
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]
|
[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.start_time,
|
||||||
row.end_time,
|
row.end_time,
|
||||||
row.rounded_minutes,
|
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.teamspace_minutes ?? "",
|
||||||
row.source === "fix" ? "Fixe Abrechnung" : "Manuell",
|
row.source === "fix" ? "Fixe Abrechnung" : "Manuell",
|
||||||
row.activity
|
row.activity
|
||||||
@@ -5993,6 +6327,8 @@ app.post("/api/sessions", requireUser, async (req, res) => {
|
|||||||
const requestedWorkType = optionalWorkType(req.body.workType);
|
const requestedWorkType = optionalWorkType(req.body.workType);
|
||||||
const useZammadLookup = optionalBoolean(req.body.zammadLookup, false);
|
const useZammadLookup = optionalBoolean(req.body.zammadLookup, false);
|
||||||
const requestedProjectTeamspaceEntryId = parseOptionalTeamspaceEntryId(req.body.projectTeamspaceEntryId);
|
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 startedAt = parseIsoDate(req.body.startedAt, "startedAt");
|
||||||
const endedAt = parseIsoDate(req.body.endedAt, "endedAt");
|
const endedAt = parseIsoDate(req.body.endedAt, "endedAt");
|
||||||
const durationSeconds = parsePositiveInteger(req.body.durationSeconds, "durationSeconds");
|
const durationSeconds = parsePositiveInteger(req.body.durationSeconds, "durationSeconds");
|
||||||
@@ -6005,6 +6341,8 @@ app.post("/api/sessions", requireUser, async (req, res) => {
|
|||||||
requestedWorkType,
|
requestedWorkType,
|
||||||
useZammadLookup,
|
useZammadLookup,
|
||||||
requestedProjectTeamspaceEntryId,
|
requestedProjectTeamspaceEntryId,
|
||||||
|
billingStatus,
|
||||||
|
createZammadArticle,
|
||||||
startedAt,
|
startedAt,
|
||||||
endedAt,
|
endedAt,
|
||||||
durationSeconds
|
durationSeconds
|
||||||
@@ -6013,6 +6351,94 @@ app.post("/api/sessions", requireUser, async (req, res) => {
|
|||||||
res.status(201).json(created);
|
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) => {
|
app.get("/api/periods/:periodType/:period/overview", requireUser, async (req, res) => {
|
||||||
const { config, period } = parsePeriod(String(req.params.periodType), String(req.params.period));
|
const { config, period } = parsePeriod(String(req.params.periodType), String(req.params.period));
|
||||||
res.json(await getPeriodOverview(config, period, currentUser(req).id));
|
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,
|
COUNT(s.id)::int AS session_count,
|
||||||
COALESCE(SUM(s.rounded_minutes), 0)::int AS total_minutes,
|
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 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 = 'billed')::int AS billed_count,
|
||||||
COUNT(*) FILTER (WHERE s.billing_status = 'non_billable')::int AS non_billable_count,
|
COUNT(*) FILTER (WHERE s.billing_status = 'non_billable')::int AS non_billable_count,
|
||||||
tm.closed_at IS NOT NULL AS closed,
|
tm.closed_at IS NOT NULL AS closed,
|
||||||
|
|||||||
@@ -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_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_user_id BIGINT;");
|
||||||
await query("ALTER TABLE users ADD COLUMN IF NOT EXISTS zammad_email_session_webhook_enabled BOOLEAN NOT NULL DEFAULT false;");
|
await query("ALTER TABLE users ADD COLUMN IF NOT EXISTS zammad_email_session_webhook_enabled BOOLEAN NOT NULL DEFAULT false;");
|
||||||
|
await query("ALTER TABLE users ADD COLUMN IF NOT EXISTS session_billing_prompt_enabled BOOLEAN NOT NULL DEFAULT true;");
|
||||||
|
await query("ALTER TABLE users ADD COLUMN IF NOT EXISTS 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(`
|
await query(`
|
||||||
CREATE TABLE IF NOT EXISTS auth_sessions (
|
CREATE TABLE IF NOT EXISTS auth_sessions (
|
||||||
@@ -207,7 +216,7 @@ export async function migrate() {
|
|||||||
billing_updated_at TIMESTAMPTZ,
|
billing_updated_at TIMESTAMPTZ,
|
||||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
CONSTRAINT work_type_valid CHECK (work_type IN ('support', 'consulting')),
|
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 duration_non_negative CHECK (duration_seconds >= 0),
|
||||||
CONSTRAINT rounded_minutes_positive CHECK (rounded_minutes >= 1),
|
CONSTRAINT rounded_minutes_positive CHECK (rounded_minutes >= 1),
|
||||||
CONSTRAINT ended_after_started CHECK (ended_at >= started_at)
|
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_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 recurring_occurrence_key TEXT;");
|
||||||
await query("ALTER TABLE sessions ADD COLUMN IF NOT EXISTS project_teamspace_entry_id BIGINT;");
|
await query("ALTER TABLE sessions ADD COLUMN IF NOT EXISTS project_teamspace_entry_id BIGINT;");
|
||||||
|
await query(`
|
||||||
|
ALTER TABLE sessions
|
||||||
|
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(`
|
await query(`
|
||||||
CREATE TABLE IF NOT EXISTS zammad_email_session_events (
|
CREATE TABLE IF NOT EXISTS zammad_email_session_events (
|
||||||
id BIGSERIAL PRIMARY KEY,
|
id BIGSERIAL PRIMARY KEY,
|
||||||
@@ -242,6 +256,37 @@ export async function migrate() {
|
|||||||
ON zammad_email_session_events(delivery_id)
|
ON zammad_email_session_events(delivery_id)
|
||||||
WHERE delivery_id IS NOT NULL;
|
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) {
|
if (adminId) {
|
||||||
await query("UPDATE sessions SET user_id = $1 WHERE user_id IS NULL;", [adminId]);
|
await query("UPDATE sessions SET user_id = $1 WHERE user_id IS NULL;", [adminId]);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -110,8 +110,8 @@ export function parseBillingStatus(value: unknown) {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (value !== "billed" && value !== "non_billable") {
|
if (value !== "billable" && value !== "billed" && value !== "non_billable") {
|
||||||
throw badRequest("billingStatus must be billed, non_billable or null");
|
throw badRequest("billingStatus must be billable, billed, non_billable or null");
|
||||||
}
|
}
|
||||||
|
|
||||||
return value;
|
return value;
|
||||||
|
|||||||
+38
-12
@@ -71,19 +71,27 @@ Die Seite `Timer` ist fuer laufende Arbeit an Tickets gedacht.
|
|||||||
1. Ticketnummer im Format `Ticket#XXXXXX` eingeben.
|
1. Ticketnummer im Format `Ticket#XXXXXX` eingeben.
|
||||||
2. `Timer starten` klicken.
|
2. `Timer starten` klicken.
|
||||||
3. Der Timer startet.
|
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.
|
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.
|
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:
|
Beim Speichern nach dem Beenden gilt:
|
||||||
|
|
||||||
- Existiert das Ticket bereits, werden Organisation und Art vom Ticket uebernommen.
|
- 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 und Art ausgefuellt werden.
|
||||||
- Existiert das Ticket noch nicht, muessen Organisation, Taetigkeit und Art ausgefuellt werden.
|
|
||||||
|
|
||||||
Die gemessene Zeit wird auf Minuten gerundet. Pausen werden von der Gesamtdauer abgezogen.
|
Die gemessene Zeit wird auf Minuten gerundet. Pausen werden von der Gesamtdauer abgezogen.
|
||||||
|
|
||||||
@@ -101,10 +109,13 @@ Beim Nachtragen werden angegeben:
|
|||||||
- Datum
|
- Datum
|
||||||
- Von-Uhrzeit
|
- Von-Uhrzeit
|
||||||
- Bis-Uhrzeit
|
- Bis-Uhrzeit
|
||||||
|
- Bewertung: `---`, `Abrechenbar` oder `Nicht abrechenbar`
|
||||||
- Taetigkeit
|
- Taetigkeit
|
||||||
|
|
||||||
Das Taetigkeitsfeld ist mehrzeilig. Zeilenumbrueche bleiben in der Auswertung erhalten.
|
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.
|
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.
|
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
|
- Tickets: Anzahl der Tickets im Zeitraum
|
||||||
- Sessions: Anzahl der Sessions im Zeitraum
|
- Sessions: Anzahl der Sessions im Zeitraum
|
||||||
- Abgerechnet: Summe der Sessions mit Bewertung `Abgerechnet` und, sofern vorhanden, die dazugehoerige Teamspace-Zeit als Vergleichswert
|
- 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`
|
- Nicht abrechenbar: Summe der Sessions mit Bewertung `Nicht abrechenbar`
|
||||||
- Offen: Anzahl unbewerteter Sessions
|
- Offen: Anzahl unbewerteter Sessions
|
||||||
|
|
||||||
@@ -306,6 +318,7 @@ Die Sessions sind pro Tag gruppiert. In jeder Tagesgruppe stehen:
|
|||||||
|
|
||||||
- Tagesdatum
|
- Tagesdatum
|
||||||
- `Tag abgerechnet`: Summe der als `Abgerechnet` bewerteten Session-Zeit dieses Tages
|
- `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
|
- `Nicht`: Summe der als `Nicht abrechenbar` bewerteten Session-Zeit dieses Tages
|
||||||
- `Offen`: Summe der noch unbewerteten 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
|
- `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:
|
Jede Session kann bewertet werden als:
|
||||||
|
|
||||||
- Abgerechnet
|
- Abgerechnet
|
||||||
|
- Abrechenbar
|
||||||
- Nicht abrechenbar
|
- Nicht abrechenbar
|
||||||
|
|
||||||
Ein erneuter Klick auf den bereits gewaehlten Zustand nimmt die Bewertung zurueck. Dadurch wird die Session wieder offen.
|
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.
|
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
|
### 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:
|
`Teamspace` zeigt die Summe aller Teamspace-Tageswerte im Monat. Die Differenz wird immer so berechnet:
|
||||||
|
|
||||||
@@ -449,13 +463,14 @@ Das bedeutet:
|
|||||||
Farben:
|
Farben:
|
||||||
|
|
||||||
- Abgerechnet: blau
|
- Abgerechnet: blau
|
||||||
|
- Abrechenbar: grau
|
||||||
- Teamspace: fuchsia
|
- Teamspace: fuchsia
|
||||||
- Positive oder ausgeglichene Differenz: gruen
|
- Positive oder ausgeglichene Differenz: gruen
|
||||||
- Negative Differenz: rot
|
- Negative Differenz: rot
|
||||||
- Nicht abrechenbar: indigo
|
- Nicht abrechenbar: indigo
|
||||||
- Offen: gelb/orange
|
- 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.
|
`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.
|
- Balken zeigen abgerechnete Session-Zeit.
|
||||||
- Die Linie zeigt Teamspace-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.
|
- Der Teamspace-Abgleich zeigt die Gesamtdifferenz.
|
||||||
- Manuell und Fix trennen manuell erfasste abrechenbare Sessions von automatisch erzeugten abrechenbaren Sessions.
|
- Manuell und Fix trennen manuell erfasste abrechenbare Sessions von automatisch erzeugten abrechenbaren Sessions.
|
||||||
|
|
||||||
@@ -477,7 +492,7 @@ Sichtbar sind:
|
|||||||
|
|
||||||
- Organisation
|
- Organisation
|
||||||
- Sessions
|
- Sessions
|
||||||
- Zeiten: Abgerechnet, nicht abrechenbar und Teamspace
|
- Zeiten: Abgerechnet, abrechenbar, nicht abrechenbar und Teamspace
|
||||||
- Differenz
|
- Differenz
|
||||||
- Anteil an der abgerechneten Monatszeit
|
- Anteil an der abgerechneten Monatszeit
|
||||||
|
|
||||||
@@ -495,7 +510,7 @@ Sie zeigt je Typ:
|
|||||||
- Nicht abrechenbare Session-Zeit
|
- Nicht abrechenbare Session-Zeit
|
||||||
- Teamspace-Zeit
|
- Teamspace-Zeit
|
||||||
- Differenz
|
- Differenz
|
||||||
- Anzahl abgerechnet, nicht abrechenbar und offen
|
- Anzahl abgerechnet, abrechenbar, nicht abrechenbar und offen
|
||||||
|
|
||||||
### Ticket-Hotspots
|
### Ticket-Hotspots
|
||||||
|
|
||||||
@@ -511,6 +526,9 @@ Unter `Profil` kann jeder Benutzer seine eigenen Daten pflegen:
|
|||||||
- Voller Name
|
- Voller Name
|
||||||
- Passwort
|
- Passwort
|
||||||
- persoenlicher Zammad API-Token
|
- 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.
|
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.
|
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
|
## Adminbereich
|
||||||
|
|
||||||
Der feste Admin ist nur fuer die Systemverwaltung vorgesehen. Er kann:
|
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.
|
3. Am Monatsende `Tickets` oeffnen.
|
||||||
4. Monat auswaehlen.
|
4. Monat auswaehlen.
|
||||||
5. Offene Sessions pruefen.
|
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.
|
7. Pro Tag und Ticket die wirklich in Teamspace eingetragene Zeit im Feld `Teamspace` pflegen.
|
||||||
8. `Monatsabschluss` oeffnen.
|
8. `Monatsabschluss` oeffnen.
|
||||||
9. Offene Bewertungen abarbeiten.
|
9. Offene Bewertungen abarbeiten.
|
||||||
|
|||||||
@@ -1073,6 +1073,8 @@ export function App() {
|
|||||||
startedAt: now,
|
startedAt: now,
|
||||||
pausedTotalMs: 0,
|
pausedTotalMs: 0,
|
||||||
pausedAt: null,
|
pausedAt: null,
|
||||||
|
activity: "",
|
||||||
|
billingStatus: null,
|
||||||
phase: "running"
|
phase: "running"
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -1472,6 +1474,7 @@ export function App() {
|
|||||||
selectedTimerId={selectedTimerId}
|
selectedTimerId={selectedTimerId}
|
||||||
setSelectedTimerId={setSelectedTimerId}
|
setSelectedTimerId={setSelectedTimerId}
|
||||||
tick={tick}
|
tick={tick}
|
||||||
|
currentUser={currentUser}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
{!isAdmin && route.page === "dashboard" ? <DashboardPage onNavigate={navigate} /> : null}
|
{!isAdmin && route.page === "dashboard" ? <DashboardPage onNavigate={navigate} /> : null}
|
||||||
@@ -1492,6 +1495,7 @@ export function App() {
|
|||||||
selectedTimerId={selectedTimerId}
|
selectedTimerId={selectedTimerId}
|
||||||
setSelectedTimerId={setSelectedTimerId}
|
setSelectedTimerId={setSelectedTimerId}
|
||||||
tick={tick}
|
tick={tick}
|
||||||
|
currentUser={currentUser}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
{!isAdmin && route.page === "ticket" ? (
|
{!isAdmin && route.page === "ticket" ? (
|
||||||
|
|||||||
+24
-2
@@ -16,7 +16,8 @@ import type {
|
|||||||
TicketSearchResult,
|
TicketSearchResult,
|
||||||
TodoPriority,
|
TodoPriority,
|
||||||
TodoStatus,
|
TodoStatus,
|
||||||
WorkType
|
WorkType,
|
||||||
|
ZammadSessionArticleQueueItem
|
||||||
} from "./types";
|
} from "./types";
|
||||||
|
|
||||||
type CreateSessionPayload = {
|
type CreateSessionPayload = {
|
||||||
@@ -29,6 +30,8 @@ type CreateSessionPayload = {
|
|||||||
durationSeconds: number;
|
durationSeconds: number;
|
||||||
zammadLookup?: boolean;
|
zammadLookup?: boolean;
|
||||||
projectTeamspaceEntryId?: string | null;
|
projectTeamspaceEntryId?: string | null;
|
||||||
|
billingStatus?: BillingStatus;
|
||||||
|
createZammadArticle?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
type DeleteSessionResult = {
|
type DeleteSessionResult = {
|
||||||
@@ -198,6 +201,10 @@ export function updateCurrentUser(payload: {
|
|||||||
newPassword?: string;
|
newPassword?: string;
|
||||||
zammadApiToken?: string;
|
zammadApiToken?: string;
|
||||||
zammadEmailSessionWebhookEnabled?: boolean;
|
zammadEmailSessionWebhookEnabled?: boolean;
|
||||||
|
sessionBillingPromptEnabled?: boolean;
|
||||||
|
zammadSessionNoteDefaultEnabled?: boolean;
|
||||||
|
zammadTimeUnitRoundingEnabled?: boolean;
|
||||||
|
zammadTimeUnitRoundingMinutes?: number;
|
||||||
}) {
|
}) {
|
||||||
return request<{ user: AuthUser }>("/api/auth/me", {
|
return request<{ user: AuthUser }>("/api/auth/me", {
|
||||||
method: "PATCH",
|
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") {
|
export function lookupTicket(ticketNumber: string, source: "local" | "zammad" = "local") {
|
||||||
const params = new URLSearchParams({ ticketNumber });
|
const params = new URLSearchParams({ ticketNumber });
|
||||||
|
|
||||||
@@ -771,7 +793,7 @@ export function getTicketDetails(
|
|||||||
ticketId: string,
|
ticketId: string,
|
||||||
filters: {
|
filters: {
|
||||||
month?: string;
|
month?: string;
|
||||||
status?: "all" | "open" | "billed" | "non_billable";
|
status?: "all" | "open" | "billable" | "billed" | "non_billable";
|
||||||
source?: "all" | "manual" | "recurring";
|
source?: "all" | "manual" | "recurring";
|
||||||
} = {}
|
} = {}
|
||||||
) {
|
) {
|
||||||
|
|||||||
@@ -34,19 +34,20 @@ export const helpSections: HelpSection[] = [
|
|||||||
title: "Monatsworkflow",
|
title: "Monatsworkflow",
|
||||||
description: "Der empfohlene Ablauf vom Start einer Arbeitssession bis zum sauberen Monatsabschluss.",
|
description: "Der empfohlene Ablauf vom Start einer Arbeitssession bis zum sauberen Monatsabschluss.",
|
||||||
quickItems: [
|
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.",
|
"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."
|
"Am Monatsende: offene Bewertungen bearbeiten, Teamspace-Tageswerte pflegen, Kontrolle durchführen und Monat schließen."
|
||||||
],
|
],
|
||||||
details: [
|
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.",
|
"Der normale Einstieg ist das Ticketfeld im Kopfbereich oder die Seite Session starten. Dort gibst du die Ticketnummer ein und startest den Timer mit Timer starten.",
|
||||||
"Während der Arbeit läuft genau ein Timer aktiv. Wechselst du zu einem anderen Timer, pausiert TicketTracker die übrigen Timer automatisch.",
|
"Während der 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.",
|
"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.",
|
||||||
"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.",
|
"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.",
|
"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.",
|
"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.",
|
"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.",
|
"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.",
|
"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.",
|
"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.",
|
"Ticketfeld im Kopfbereich oder Session starten öffnen.",
|
||||||
"Ticketnummer eingeben und Timer starten.",
|
"Ticketnummer eingeben und Timer starten.",
|
||||||
"Arbeit erledigen; bei Bedarf zwischen vorbereiteten Timern wechseln.",
|
"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.",
|
"Vergessene Zeiten über Session nachtragen ergänzen.",
|
||||||
"Tickets öffnen, Monat wählen und Einträge prüfen.",
|
"Tickets öffnen, Monat wählen und Einträge prüfen.",
|
||||||
"Ticket öffnen, Sessions pro Tag bewerten und Teamspace-Tageswerte speichern.",
|
"Ticket öffnen, Sessions pro Tag bewerten und Teamspace-Tageswerte speichern.",
|
||||||
@@ -71,8 +72,8 @@ export const helpSections: HelpSection[] = [
|
|||||||
"Ticketnummer eingeben",
|
"Ticketnummer eingeben",
|
||||||
"Timer starten",
|
"Timer starten",
|
||||||
"Arbeit am Ticket erledigen",
|
"Arbeit am Ticket erledigen",
|
||||||
|
"Tätigkeit im Timer eintragen",
|
||||||
"Timer beenden",
|
"Timer beenden",
|
||||||
"Tätigkeit eintragen",
|
|
||||||
"Session wird dem Ticket und Benutzer gespeichert",
|
"Session wird dem Ticket und Benutzer gespeichert",
|
||||||
"Optional: vergessene Session nachtragen",
|
"Optional: vergessene Session nachtragen",
|
||||||
"Tickets öffnen und Ticket prüfen",
|
"Tickets öffnen und Ticket prüfen",
|
||||||
@@ -111,17 +112,21 @@ export const helpSections: HelpSection[] = [
|
|||||||
quickItems: [
|
quickItems: [
|
||||||
"Ticketnummer eingeben und Timer starten.",
|
"Ticketnummer eingeben und Timer starten.",
|
||||||
"Aktivieren eines Timers pausiert alle anderen Timer automatisch.",
|
"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: [
|
details: [
|
||||||
"Neue Timer werden mit einer Ticketnummer im Format Ticket#XXXXXX gestartet.",
|
"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.",
|
"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.",
|
"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.",
|
"Wenn du einen Timer auswählst, zeigt die Timerseite dessen Details inklusive gemessener Zeit und Tätigkeitsfeld.",
|
||||||
"Existiert das Ticket bereits, übernimmt TicketTracker Organisation und Art. Dann muss beim Beenden nur noch die Tätigkeit eingetragen werden.",
|
"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 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.",
|
"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.",
|
"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."
|
"Der Schnellstart im Kopfbereich funktioniert auf allen Unterseiten und nutzt dieselbe Timerlogik."
|
||||||
]
|
]
|
||||||
@@ -131,7 +136,7 @@ export const helpSections: HelpSection[] = [
|
|||||||
title: "Session Nachtragen",
|
title: "Session Nachtragen",
|
||||||
description: "Nachtragen ist für vergessene Zeiten gedacht, wenn kein Timer gelaufen ist.",
|
description: "Nachtragen ist für vergessene Zeiten gedacht, wenn kein Timer gelaufen ist.",
|
||||||
quickItems: [
|
quickItems: [
|
||||||
"Datum, Von, Bis und Tätigkeit eintragen.",
|
"Datum, Von, Bis, Bewertung und Tätigkeit eintragen.",
|
||||||
"Benutzer tragen eigene Sessions nach.",
|
"Benutzer tragen eigene Sessions nach.",
|
||||||
"Tätigkeiten sind mehrzeilig."
|
"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.",
|
"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 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.",
|
"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.",
|
"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."
|
"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: [
|
details: [
|
||||||
"Oben steht die Ticketnummer mit Copy-Icon, damit sie schnell in andere Systeme übernommen werden kann.",
|
"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.",
|
"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.",
|
"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.",
|
"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.",
|
"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",
|
id: "sessions-bewerten",
|
||||||
title: "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: [
|
quickItems: [
|
||||||
"Jede Session braucht genau eine Bewertung.",
|
"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."
|
"Offene Bewertungen blockieren den Monatsabschluss."
|
||||||
],
|
],
|
||||||
details: [
|
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.",
|
"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.",
|
"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.",
|
"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.",
|
"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."
|
"Die offenen Bewertungen findest du gebündelt im Monatsabschluss."
|
||||||
]
|
]
|
||||||
@@ -378,12 +384,12 @@ export const helpSections: HelpSection[] = [
|
|||||||
],
|
],
|
||||||
details: [
|
details: [
|
||||||
"Die Statistikseite betrachtet immer einen Monat.",
|
"Die Statistikseite betrachtet immer einen Monat.",
|
||||||
"Abgerechnet zeigt nur Sessions mit der Bewertung Abgerechnet. Offene Zeiten sind dort nicht enthalten.",
|
"Abgerechnet zeigt nur Sessions mit der Bewertung Abgerechnet. Abrechenbare, nicht abrechenbare und offene Zeiten sind dort nicht enthalten.",
|
||||||
"Alle Statistik- und Vergleichswerte nutzen nur abgerechnete Zeiten.",
|
"Teamspace-Vergleiche nutzen nur abgerechnete Zeiten.",
|
||||||
"Teamspace zeigt, wie viel Zeit für dieselben abrechenbaren Ticket-Tage im CRM eingetragen wurde.",
|
"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 positive Differenz bedeutet: In Teamspace steht mehr Zeit als abgerechnet wurde.",
|
||||||
"Eine negative Differenz bedeutet: In Teamspace steht weniger 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.",
|
"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."
|
"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",
|
id: "statistik-getrackt",
|
||||||
title: "Statistik: Abgerechnet",
|
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: [
|
quickItems: [
|
||||||
"Summe aller Sessions mit Bewertung Abgerechnet.",
|
"Summe aller Sessions mit Bewertung Abgerechnet.",
|
||||||
"Zeigt zusätzlich die abgerechnete Sessionanzahl.",
|
"Zeigt zusätzlich die abgerechnete Sessionanzahl.",
|
||||||
@@ -399,7 +405,7 @@ export const helpSections: HelpSection[] = [
|
|||||||
],
|
],
|
||||||
details: [
|
details: [
|
||||||
"Abgerechnet umfasst nur Sessions des angemeldeten Benutzers, die aktiv als Abgerechnet markiert wurden.",
|
"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.",
|
"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.",
|
"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."
|
"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.",
|
"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 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.",
|
"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."
|
"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.",
|
"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 blauen Balken stehen für abgerechnete Session-Zeit.",
|
||||||
"Die fuchsiafarbene Linie steht für Teamspace-Zeit, sofern für den Tag Werte gepflegt wurden.",
|
"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.",
|
"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."
|
"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.",
|
"Support und Consulting werden getrennt summiert.",
|
||||||
"Die Zeitwerte zeigen abgerechnete Sessions und Teamspace für den jeweiligen Typ.",
|
"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 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 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.",
|
"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.",
|
"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.",
|
"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."
|
"Admins können Benutzer ebenfalls verwalten, aber jeder Benutzer kann die eigenen Daten jederzeit selbst pflegen."
|
||||||
]
|
]
|
||||||
|
|||||||
+17
-5
@@ -1,3 +1,5 @@
|
|||||||
|
import type { BillingStatus } from "./types";
|
||||||
|
|
||||||
export type TimerPhase = "running" | "paused";
|
export type TimerPhase = "running" | "paused";
|
||||||
|
|
||||||
export type TimerEntry = {
|
export type TimerEntry = {
|
||||||
@@ -8,6 +10,8 @@ export type TimerEntry = {
|
|||||||
isProject?: boolean;
|
isProject?: boolean;
|
||||||
projectName?: string | null;
|
projectName?: string | null;
|
||||||
ticketLookupDone?: boolean;
|
ticketLookupDone?: boolean;
|
||||||
|
activity: string;
|
||||||
|
billingStatus: BillingStatus;
|
||||||
startedAt: number;
|
startedAt: number;
|
||||||
pausedTotalMs: number;
|
pausedTotalMs: number;
|
||||||
pausedAt: number | null;
|
pausedAt: number | null;
|
||||||
@@ -59,23 +63,29 @@ export function normalizeTimers(timers: TimerEntry[]) {
|
|||||||
return timers
|
return timers
|
||||||
.filter((timer) => ticketPattern.test(timer.ticketNumber))
|
.filter((timer) => ticketPattern.test(timer.ticketNumber))
|
||||||
.map((timer) => {
|
.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") {
|
if (timer.phase !== "running") {
|
||||||
return {
|
return {
|
||||||
...timer,
|
...normalizedTimer,
|
||||||
phase: "paused" as const,
|
phase: "paused" as const,
|
||||||
pausedAt: timer.pausedAt ?? now,
|
pausedAt: normalizedTimer.pausedAt ?? now,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!runningSeen) {
|
if (!runningSeen) {
|
||||||
runningSeen = true;
|
runningSeen = true;
|
||||||
return {
|
return {
|
||||||
...timer,
|
...normalizedTimer,
|
||||||
pausedAt: null,
|
pausedAt: null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
return pauseEntry(timer, now);
|
return pauseEntry(normalizedTimer, now);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -106,13 +116,15 @@ export function readStoredTimers(userId: string): TimerEntry[] {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
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);
|
localStorage.removeItem(legacyStorageKey);
|
||||||
|
|
||||||
return normalizeTimers([
|
return normalizeTimers([
|
||||||
{
|
{
|
||||||
...parsed,
|
...parsed,
|
||||||
id: createClientId("timer"),
|
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 {
|
} catch {
|
||||||
|
|||||||
+33
-2
@@ -1,5 +1,5 @@
|
|||||||
export type WorkType = "support" | "consulting";
|
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 PeriodType = "month" | "day";
|
||||||
export type TicketDetailPeriodType = PeriodType | "all";
|
export type TicketDetailPeriodType = PeriodType | "all";
|
||||||
export type UserRole = "admin" | "user";
|
export type UserRole = "admin" | "user";
|
||||||
@@ -13,6 +13,10 @@ export type AuthUser = {
|
|||||||
zammad_user_id: string | null;
|
zammad_user_id: string | null;
|
||||||
has_zammad_api_token: boolean;
|
has_zammad_api_token: boolean;
|
||||||
zammad_email_session_webhook_enabled: 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";
|
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;
|
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 = {
|
export type Organization = {
|
||||||
id: string;
|
id: string;
|
||||||
zammad_id: string;
|
zammad_id: string;
|
||||||
@@ -231,10 +251,12 @@ export type TicketSummary = {
|
|||||||
session_count: number;
|
session_count: number;
|
||||||
total_minutes: number;
|
total_minutes: number;
|
||||||
crm_billed_minutes: number;
|
crm_billed_minutes: number;
|
||||||
|
billable_minutes: number;
|
||||||
non_billable_minutes: number;
|
non_billable_minutes: number;
|
||||||
open_minutes: number;
|
open_minutes: number;
|
||||||
open_count: number;
|
open_count: number;
|
||||||
ticket_open_count: number;
|
ticket_open_count: number;
|
||||||
|
billable_count: number;
|
||||||
billed_count: number;
|
billed_count: number;
|
||||||
non_billable_count: number;
|
non_billable_count: number;
|
||||||
recurring_session_count: number;
|
recurring_session_count: number;
|
||||||
@@ -277,6 +299,7 @@ export type PeriodOverview = {
|
|||||||
sessions: number;
|
sessions: number;
|
||||||
minutes: number;
|
minutes: number;
|
||||||
crmBilledMinutes: number;
|
crmBilledMinutes: number;
|
||||||
|
billableMinutes: number;
|
||||||
nonBillableMinutes: number;
|
nonBillableMinutes: number;
|
||||||
openMinutes: number;
|
openMinutes: number;
|
||||||
openSessions: number;
|
openSessions: number;
|
||||||
@@ -324,7 +347,7 @@ export type TicketPeriod = {
|
|||||||
closedAt: string | null;
|
closedAt: string | null;
|
||||||
filters?: {
|
filters?: {
|
||||||
month: string;
|
month: string;
|
||||||
status: "all" | "open" | "billed" | "non_billable";
|
status: "all" | "open" | "billable" | "billed" | "non_billable";
|
||||||
source: "all" | "manual" | "recurring";
|
source: "all" | "manual" | "recurring";
|
||||||
};
|
};
|
||||||
availableMonths?: TicketAvailableMonth[];
|
availableMonths?: TicketAvailableMonth[];
|
||||||
@@ -361,9 +384,11 @@ export type StatisticsDailyBucket = {
|
|||||||
sessions: number;
|
sessions: number;
|
||||||
total_minutes: number;
|
total_minutes: number;
|
||||||
billed_minutes: number;
|
billed_minutes: number;
|
||||||
|
billable_minutes: number;
|
||||||
non_billable_minutes: number;
|
non_billable_minutes: number;
|
||||||
open_minutes: number;
|
open_minutes: number;
|
||||||
billed_sessions: number;
|
billed_sessions: number;
|
||||||
|
billable_sessions: number;
|
||||||
non_billable_sessions: number;
|
non_billable_sessions: number;
|
||||||
open_sessions: number;
|
open_sessions: number;
|
||||||
crm_billed_minutes: number;
|
crm_billed_minutes: number;
|
||||||
@@ -377,9 +402,11 @@ export type StatisticsGroup = {
|
|||||||
sessions: number;
|
sessions: number;
|
||||||
total_minutes: number;
|
total_minutes: number;
|
||||||
billed_minutes: number;
|
billed_minutes: number;
|
||||||
|
billable_minutes: number;
|
||||||
non_billable_minutes: number;
|
non_billable_minutes: number;
|
||||||
open_minutes: number;
|
open_minutes: number;
|
||||||
billed_sessions: number;
|
billed_sessions: number;
|
||||||
|
billable_sessions: number;
|
||||||
non_billable_sessions: number;
|
non_billable_sessions: number;
|
||||||
open_sessions: number;
|
open_sessions: number;
|
||||||
crm_billed_minutes: number;
|
crm_billed_minutes: number;
|
||||||
@@ -395,9 +422,11 @@ export type StatisticsTicket = {
|
|||||||
sessions: number;
|
sessions: number;
|
||||||
total_minutes: number;
|
total_minutes: number;
|
||||||
billed_minutes: number;
|
billed_minutes: number;
|
||||||
|
billable_minutes: number;
|
||||||
non_billable_minutes: number;
|
non_billable_minutes: number;
|
||||||
open_minutes: number;
|
open_minutes: number;
|
||||||
billed_sessions: number;
|
billed_sessions: number;
|
||||||
|
billable_sessions: number;
|
||||||
non_billable_sessions: number;
|
non_billable_sessions: number;
|
||||||
open_sessions: number;
|
open_sessions: number;
|
||||||
crm_billed_minutes: number;
|
crm_billed_minutes: number;
|
||||||
@@ -448,9 +477,11 @@ export type StatisticsOverview = {
|
|||||||
sessions: number;
|
sessions: number;
|
||||||
minutes: number;
|
minutes: number;
|
||||||
billedMinutes: number;
|
billedMinutes: number;
|
||||||
|
billableMinutes: number;
|
||||||
nonBillableMinutes: number;
|
nonBillableMinutes: number;
|
||||||
openMinutes: number;
|
openMinutes: number;
|
||||||
billedSessions: number;
|
billedSessions: number;
|
||||||
|
billableSessions: number;
|
||||||
nonBillableSessions: number;
|
nonBillableSessions: number;
|
||||||
openSessions: number;
|
openSessions: number;
|
||||||
recurringMinutes: number;
|
recurringMinutes: number;
|
||||||
|
|||||||
@@ -443,6 +443,7 @@ export function AnalysisPage({ onNavigate }: AnalysisPageProps) {
|
|||||||
{ label: "Tickets", value: totals?.tickets ?? 0, icon: Ticket },
|
{ label: "Tickets", value: totals?.tickets ?? 0, icon: Ticket },
|
||||||
{ label: "Sessions", value: totals?.sessions ?? 0, icon: Users },
|
{ label: "Sessions", value: totals?.sessions ?? 0, icon: Users },
|
||||||
{ label: "Abgerechnet", value: formatMinutes(totals?.minutes ?? 0), detail: `Teamspace ${formatMinutes(totals?.crmBilledMinutes ?? 0)}`, icon: Clock3 },
|
{ label: "Abgerechnet", value: formatMinutes(totals?.minutes ?? 0), detail: `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: "Nicht abrechenbar", value: formatMinutes(totals?.nonBillableMinutes ?? 0), icon: UserCheck },
|
||||||
{ label: "Offen", value: totals?.openSessions ?? 0, detail: formatMinutes(totals?.openMinutes ?? 0), icon: UserCheck }
|
{ label: "Offen", value: totals?.openSessions ?? 0, detail: formatMinutes(totals?.openMinutes ?? 0), icon: UserCheck }
|
||||||
];
|
];
|
||||||
@@ -786,8 +787,8 @@ export function AnalysisPage({ onNavigate }: AnalysisPageProps) {
|
|||||||
<p className="font-medium">{formatMinutes(ticket.total_minutes)}</p>
|
<p className="font-medium">{formatMinutes(ticket.total_minutes)}</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="rounded-md bg-muted/50 p-2">
|
<div className="rounded-md bg-muted/50 p-2">
|
||||||
<p className="text-muted-foreground">Sessions</p>
|
<p className="text-muted-foreground">Abrechenbar</p>
|
||||||
<p className="font-medium">{ticket.billed_count}</p>
|
<p className="font-medium">{ticket.billable_count}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@@ -1536,7 +1536,7 @@ function WidgetContent(props: WidgetContentProps) {
|
|||||||
return (
|
return (
|
||||||
<MetricGrid
|
<MetricGrid
|
||||||
items={[
|
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 }
|
{ label: "Offene Bewertungen", value: stats.dayTotals?.openSessions ?? 0, icon: ClipboardCheck }
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
@@ -1547,7 +1547,7 @@ function WidgetContent(props: WidgetContentProps) {
|
|||||||
return (
|
return (
|
||||||
<MetricGrid
|
<MetricGrid
|
||||||
items={[
|
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 }
|
{ label: "Offene Bewertungen", value: stats.monthTotals?.openSessions ?? 0, icon: ClipboardCheck }
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
@@ -1560,7 +1560,7 @@ function WidgetContent(props: WidgetContentProps) {
|
|||||||
<MetricGrid
|
<MetricGrid
|
||||||
items={[
|
items={[
|
||||||
{ label: "TS-Zeit Monat", value: formatMinutes(stats.monthTotals?.crmBilledMinutes ?? 0), icon: LayoutDashboard },
|
{ 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 todayTickets = stableSort(stats.todayTickets, (left, right) => compareText(left.ticket_number, right.ticket_number));
|
||||||
const todayOpenRatings = stableSort(stats.todayOpenRatings, (left, right) => compareOptionalDate(left.started_at, right.started_at, "asc") || compareText(left.ticket_number, right.ticket_number));
|
const todayOpenRatings = stableSort(stats.todayOpenRatings, (left, right) => compareOptionalDate(left.started_at, right.started_at, "asc") || compareText(left.ticket_number, right.ticket_number));
|
||||||
const teamspaceReviewTickets = todayTickets.filter((ticket) => ticket.open_count === 0 && teamspaceDeltaMinutes(ticket) > 0);
|
const teamspaceReviewTickets = 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(
|
const budgetAttention = stableSort(
|
||||||
projects.filter((project) => {
|
projects.filter((project) => {
|
||||||
const percent = projectConsumptionPercent(project);
|
const percent = projectConsumptionPercent(project);
|
||||||
@@ -1778,7 +1779,7 @@ function DayControlWidget({ stats, projects, todos, onNavigate }: WidgetContentP
|
|||||||
tone: "warning" as const,
|
tone: "warning" as const,
|
||||||
title: session.ticket_number,
|
title: session.ticket_number,
|
||||||
detail: `${formatMinutes(session.rounded_minutes)} offen · ${session.organization_name ?? "Keine Organisation"}`,
|
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`)
|
onClick: () => onNavigate(`/tickets/${session.ticket_id}?month=${currentMonth()}&status=open`)
|
||||||
})),
|
})),
|
||||||
...teamspaceReviewTickets.slice(0, 4).map((ticket) => ({
|
...teamspaceReviewTickets.slice(0, 4).map((ticket) => ({
|
||||||
@@ -1826,11 +1827,11 @@ function DayControlWidget({ stats, projects, todos, onNavigate }: WidgetContentP
|
|||||||
<DayControlMiniSection
|
<DayControlMiniSection
|
||||||
title="Nacharbeit"
|
title="Nacharbeit"
|
||||||
icon={CalendarCheck}
|
icon={CalendarCheck}
|
||||||
empty="Heute noch keine Sessions."
|
empty={todayTickets.length === 0 ? "Heute noch keine Sessions." : "Keine Nacharbeit offen."}
|
||||||
items={todayTickets.slice(0, 4).map((ticket) => ({
|
items={afterworkTickets.slice(0, 4).map((ticket) => ({
|
||||||
key: `today-ticket-${ticket.id}`,
|
key: `today-ticket-${ticket.id}`,
|
||||||
title: ticket.title || ticket.ticket_number,
|
title: ticket.title || ticket.ticket_number,
|
||||||
detail: `${ticket.ticket_number} · ${formatMinutes(ticket.total_minutes + ticket.non_billable_minutes + ticket.open_minutes)} · ${ticket.session_count} Session(s)`,
|
detail: `${ticket.ticket_number} · ${formatMinutes(ticket.total_minutes + ticket.billable_minutes + ticket.non_billable_minutes + ticket.open_minutes)} · ${ticket.session_count} Session(s)`,
|
||||||
note: dayControlTodaySessionReason(ticket),
|
note: dayControlTodaySessionReason(ticket),
|
||||||
onClick: () => onNavigate(`/tickets/${ticket.id}?month=${currentMonth()}`)
|
onClick: () => onNavigate(`/tickets/${ticket.id}?month=${currentMonth()}`)
|
||||||
}))}
|
}))}
|
||||||
@@ -1895,6 +1896,10 @@ function dayControlTodaySessionReason(ticket: PeriodOverview["tickets"][number])
|
|||||||
return `Teamspace ok: ${formatMinutes(crmMinutes)} erfasst.`;
|
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) {
|
if (ticket.non_billable_count > 0) {
|
||||||
return `Nicht abrechenbar gesetzt: Zammad-Dokumentation kontrollieren.`;
|
return `Nicht abrechenbar gesetzt: Zammad-Dokumentation kontrollieren.`;
|
||||||
}
|
}
|
||||||
@@ -2503,7 +2508,7 @@ function StatisticWidget({ widget, editing, stats, onNavigate, onUpdateConfig }:
|
|||||||
{settings}
|
{settings}
|
||||||
<MetricGrid
|
<MetricGrid
|
||||||
items={[
|
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: "Offen", value: statistics.totals.openSessions, icon: ClipboardCheck },
|
||||||
{ label: "Ø Session", value: formatMinutes(statistics.totals.averageSessionMinutes), icon: Timer },
|
{ label: "Ø Session", value: formatMinutes(statistics.totals.averageSessionMinutes), icon: Timer },
|
||||||
{ label: "Aktive Tage", value: statistics.totals.activeDays, icon: LayoutDashboard }
|
{ label: "Aktive Tage", value: statistics.totals.activeDays, icon: LayoutDashboard }
|
||||||
@@ -2519,7 +2524,8 @@ function StatisticWidget({ widget, editing, stats, onNavigate, onUpdateConfig }:
|
|||||||
{settings}
|
{settings}
|
||||||
<BarList
|
<BarList
|
||||||
items={[
|
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: "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)` }
|
{ 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,
|
key: day.day,
|
||||||
title: formatDate(`${day.day}T00:00:00`),
|
title: formatDate(`${day.day}T00:00:00`),
|
||||||
value: view === "teamspaceDaily" ? day.crm_billed_minutes : day.total_minutes,
|
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 (
|
return (
|
||||||
|
|||||||
@@ -1,13 +1,14 @@
|
|||||||
import { FormEvent, useEffect, useState } from "react";
|
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 { toast } from "sonner";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Switch } from "@/components/ui/switch";
|
import { Switch } from "@/components/ui/switch";
|
||||||
import { HelpLink } from "@/components/HelpLink";
|
import { HelpLink } from "@/components/HelpLink";
|
||||||
import { updateCurrentUser } from "../api";
|
import { getZammadSessionArticleQueue, retryZammadSessionArticleQueue, updateCurrentUser } from "../api";
|
||||||
import type { AuthUser } from "../types";
|
import { formatDateTime, formatMinutes } from "../format";
|
||||||
|
import type { AuthUser, ZammadSessionArticleQueueItem } from "../types";
|
||||||
|
|
||||||
type ProfilePageProps = {
|
type ProfilePageProps = {
|
||||||
currentUser: AuthUser;
|
currentUser: AuthUser;
|
||||||
@@ -23,6 +24,13 @@ export function ProfilePage({ currentUser, onUserUpdated }: ProfilePageProps) {
|
|||||||
const [zammadApiToken, setZammadApiToken] = useState("");
|
const [zammadApiToken, setZammadApiToken] = useState("");
|
||||||
const [zammadTokenDirty, setZammadTokenDirty] = useState(false);
|
const [zammadTokenDirty, setZammadTokenDirty] = useState(false);
|
||||||
const [zammadEmailSessionWebhookEnabled, setZammadEmailSessionWebhookEnabled] = useState(currentUser.zammad_email_session_webhook_enabled);
|
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);
|
const [saving, setSaving] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -31,8 +39,61 @@ export function ProfilePage({ currentUser, onUserUpdated }: ProfilePageProps) {
|
|||||||
setZammadApiToken("");
|
setZammadApiToken("");
|
||||||
setZammadTokenDirty(false);
|
setZammadTokenDirty(false);
|
||||||
setZammadEmailSessionWebhookEnabled(currentUser.zammad_email_session_webhook_enabled);
|
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]);
|
}, [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) {
|
async function submit(event: FormEvent) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
|
|
||||||
@@ -58,7 +119,15 @@ export function ProfilePage({ currentUser, onUserUpdated }: ProfilePageProps) {
|
|||||||
currentPassword: wantsPasswordChange ? currentPassword : undefined,
|
currentPassword: wantsPasswordChange ? currentPassword : undefined,
|
||||||
newPassword: wantsPasswordChange ? newPassword : undefined,
|
newPassword: wantsPasswordChange ? newPassword : undefined,
|
||||||
...(currentUser.role === "user" && zammadTokenDirty ? { zammadApiToken } : {}),
|
...(currentUser.role === "user" && zammadTokenDirty ? { zammadApiToken } : {}),
|
||||||
...(currentUser.role === "user" ? { zammadEmailSessionWebhookEnabled } : {})
|
...(currentUser.role === "user"
|
||||||
|
? {
|
||||||
|
zammadEmailSessionWebhookEnabled,
|
||||||
|
sessionBillingPromptEnabled,
|
||||||
|
zammadSessionNoteDefaultEnabled,
|
||||||
|
zammadTimeUnitRoundingEnabled,
|
||||||
|
zammadTimeUnitRoundingMinutes
|
||||||
|
}
|
||||||
|
: {})
|
||||||
};
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -226,6 +295,119 @@ export function ProfilePage({ currentUser, onUserUpdated }: ProfilePageProps) {
|
|||||||
onCheckedChange={setZammadEmailSessionWebhookEnabled}
|
onCheckedChange={setZammadEmailSessionWebhookEnabled}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="mt-3 flex items-center justify-between gap-3 rounded-md border bg-background p-3">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<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>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
RotateCcw,
|
RotateCcw,
|
||||||
Ticket,
|
Ticket,
|
||||||
TrendingUp,
|
TrendingUp,
|
||||||
|
UserCheck,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import type { LucideIcon } from "lucide-react";
|
import type { LucideIcon } from "lucide-react";
|
||||||
import { useEffect, useMemo, useRef, useState } from "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 trackedDotClass = "bg-sky-500 dark:bg-sky-400";
|
||||||
const teamspaceDotClass = "bg-fuchsia-500 dark:bg-fuchsia-400";
|
const teamspaceDotClass = "bg-fuchsia-500 dark:bg-fuchsia-400";
|
||||||
const openTextClass = "text-amber-700 dark:text-amber-300";
|
const openTextClass = "text-amber-700 dark:text-amber-300";
|
||||||
|
const nonBillableTextClass = "text-indigo-700 dark:text-indigo-300";
|
||||||
const organizationPageSize = 8;
|
const organizationPageSize = 8;
|
||||||
|
|
||||||
function workTypeLabel(workType: WorkType | undefined) {
|
function workTypeLabel(workType: WorkType | undefined) {
|
||||||
@@ -115,6 +117,14 @@ function ColorLegend() {
|
|||||||
<span className={`h-0.5 w-4 ${teamspaceDotClass}`} />
|
<span className={`h-0.5 w-4 ${teamspaceDotClass}`} />
|
||||||
<span className={teamspaceTextClass}>Teamspace</span>
|
<span className={teamspaceTextClass}>Teamspace</span>
|
||||||
</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="inline-flex items-center gap-1.5">
|
||||||
<span className="size-2 rounded-full bg-emerald-500 dark:bg-emerald-400" />
|
<span className="size-2 rounded-full bg-emerald-500 dark:bg-emerald-400" />
|
||||||
<span className="text-emerald-700 dark:text-emerald-300">Differenz plus oder ausgeglichen</span>
|
<span className="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">
|
<TableCell className="max-w-[320px] whitespace-normal">
|
||||||
<div className="font-medium">{organization.organization_name ?? "Keine Organisation"}</div>
|
<div className="font-medium">{organization.organization_name ?? "Keine Organisation"}</div>
|
||||||
<div className="text-xs text-muted-foreground">
|
<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>
|
</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>{organization.billed_sessions}</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={trackedTextClass}>Abgerechnet {formatMinutes(ticket.total_minutes)}</span>
|
||||||
<span className={teamspaceTextClass}>Teamspace {formatMinutes(ticket.crm_billed_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.billed_sessions} abgerechnet</span>
|
||||||
|
<span className="text-muted-foreground">{ticket.billable_sessions} abrechenbar</span>
|
||||||
<span>{ticket.active_days} Tag(e)</span>
|
<span>{ticket.active_days} Tag(e)</span>
|
||||||
<span className={deltaTextClass(ticket.crm_delta_minutes)}>{formatTeamspaceDelta(ticket.crm_delta_minutes)}</span>
|
<span className={deltaTextClass(ticket.crm_delta_minutes)}>{formatTeamspaceDelta(ticket.crm_delta_minutes)}</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -413,8 +424,13 @@ export function StatisticsPage({ onNavigate }: StatisticsPageProps) {
|
|||||||
const totals = stats?.totals;
|
const totals = stats?.totals;
|
||||||
const billedSessions = totals?.billedSessions ?? 0;
|
const billedSessions = totals?.billedSessions ?? 0;
|
||||||
const billedMinutes = totals?.billedMinutes ?? 0;
|
const billedMinutes = totals?.billedMinutes ?? 0;
|
||||||
|
const billableSessions = totals?.billableSessions ?? 0;
|
||||||
|
const billableMinutes = totals?.billableMinutes ?? 0;
|
||||||
|
const nonBillableSessions = totals?.nonBillableSessions ?? 0;
|
||||||
|
const nonBillableMinutes = totals?.nonBillableMinutes ?? 0;
|
||||||
const openSessions = totals?.openSessions ?? 0;
|
const openSessions = totals?.openSessions ?? 0;
|
||||||
const openMinutes = totals?.openMinutes ?? 0;
|
const openMinutes = totals?.openMinutes ?? 0;
|
||||||
|
const evaluatedSessions = billedSessions + billableSessions + nonBillableSessions;
|
||||||
const crmCoverage = percentage(totals?.crmBilledMinutes ?? 0, totals?.minutes ?? 0);
|
const crmCoverage = percentage(totals?.crmBilledMinutes ?? 0, totals?.minutes ?? 0);
|
||||||
const maxOrganizationMinutes = Math.max(...(stats?.organizations ?? []).map((organization) => organization.total_minutes), 1);
|
const maxOrganizationMinutes = Math.max(...(stats?.organizations ?? []).map((organization) => organization.total_minutes), 1);
|
||||||
const topOrganization = stats?.organizations[0] ?? null;
|
const topOrganization = stats?.organizations[0] ?? null;
|
||||||
@@ -460,7 +476,7 @@ export function StatisticsPage({ onNavigate }: StatisticsPageProps) {
|
|||||||
</div>
|
</div>
|
||||||
</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
|
<MetricCard
|
||||||
label="Abgerechnet"
|
label="Abgerechnet"
|
||||||
value={formatMinutes(totals?.minutes ?? 0)}
|
value={formatMinutes(totals?.minutes ?? 0)}
|
||||||
@@ -470,6 +486,14 @@ export function StatisticsPage({ onNavigate }: StatisticsPageProps) {
|
|||||||
helpAnchor="statistik-getrackt"
|
helpAnchor="statistik-getrackt"
|
||||||
helpLabel="Hilfe zur Kennzahl Abgerechnet"
|
helpLabel="Hilfe zur Kennzahl Abgerechnet"
|
||||||
/>
|
/>
|
||||||
|
<MetricCard
|
||||||
|
label="Abrechenbar"
|
||||||
|
value={formatMinutes(billableMinutes)}
|
||||||
|
detail={`${billableSessions} Session(s)`}
|
||||||
|
icon={UserCheck}
|
||||||
|
helpAnchor="bewertungen"
|
||||||
|
helpLabel="Hilfe zu Bewertungen"
|
||||||
|
/>
|
||||||
<MetricCard
|
<MetricCard
|
||||||
label="Teamspace"
|
label="Teamspace"
|
||||||
value={formatMinutes(totals?.crmBilledMinutes ?? 0)}
|
value={formatMinutes(totals?.crmBilledMinutes ?? 0)}
|
||||||
@@ -510,17 +534,19 @@ export function StatisticsPage({ onNavigate }: StatisticsPageProps) {
|
|||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<DailyEffortChart month={month} buckets={stats?.dailySeries ?? []} />
|
<DailyEffortChart month={month} buckets={stats?.dailySeries ?? []} />
|
||||||
<ColorLegend />
|
<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>
|
||||||
|
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="space-y-2 rounded-md border bg-muted/20 p-3">
|
<div className="space-y-2 rounded-md border bg-muted/20 p-3">
|
||||||
<div className="flex items-center justify-between gap-3 text-sm">
|
<div className="flex items-center justify-between gap-3 text-sm">
|
||||||
<span className="text-muted-foreground">Bewertungsstand</span>
|
<span className="text-muted-foreground">Bewertungsstand</span>
|
||||||
<span className="font-medium">{billedSessions} abrechenbar</span>
|
<span className="font-medium">{evaluatedSessions} bewertet</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid gap-2 text-xs sm:grid-cols-2 xl:grid-cols-1 2xl:grid-cols-2">
|
<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={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 className={openTextClass}>Offen: {openSessions} Session(s), {formatMinutes(openMinutes)}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -615,7 +641,7 @@ export function StatisticsPage({ onNavigate }: StatisticsPageProps) {
|
|||||||
<div className="flex items-start justify-between gap-2">
|
<div className="flex items-start justify-between gap-2">
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<p className="truncate font-medium">{organization.organization_name ?? "Keine Organisation"}</p>
|
<p className="truncate font-medium">{organization.organization_name ?? "Keine Organisation"}</p>
|
||||||
<p className="text-xs text-muted-foreground">{organization.tickets} Ticket(s) · {organization.billed_sessions} abgerechnet</p>
|
<p className="text-xs text-muted-foreground">{organization.tickets} Ticket(s) · {organization.billed_sessions} abgerechnet · {organization.billable_sessions} abrechenbar</p>
|
||||||
</div>
|
</div>
|
||||||
<DeltaBadge minutes={organization.crm_delta_minutes} />
|
<DeltaBadge minutes={organization.crm_delta_minutes} />
|
||||||
</div>
|
</div>
|
||||||
@@ -684,7 +710,7 @@ export function StatisticsPage({ onNavigate }: StatisticsPageProps) {
|
|||||||
<div className="flex items-center justify-between gap-3">
|
<div className="flex items-center justify-between gap-3">
|
||||||
<div>
|
<div>
|
||||||
<p className="font-semibold">{workTypeLabel(group.work_type)}</p>
|
<p className="font-semibold">{workTypeLabel(group.work_type)}</p>
|
||||||
<p className="text-xs text-muted-foreground">{group.tickets} Ticket(s), {group.billed_sessions} abgerechnet</p>
|
<p className="text-xs text-muted-foreground">{group.tickets} Ticket(s), {group.billed_sessions} abgerechnet, {group.billable_sessions} abrechenbar</p>
|
||||||
</div>
|
</div>
|
||||||
<DeltaBadge minutes={group.crm_delta_minutes} />
|
<DeltaBadge minutes={group.crm_delta_minutes} />
|
||||||
</div>
|
</div>
|
||||||
@@ -693,8 +719,9 @@ export function StatisticsPage({ onNavigate }: StatisticsPageProps) {
|
|||||||
<span className={trackedTextClass}>Abgerechnet {formatMinutes(group.total_minutes)}</span>
|
<span className={trackedTextClass}>Abgerechnet {formatMinutes(group.total_minutes)}</span>
|
||||||
<span className={teamspaceTextClass}>Teamspace {formatMinutes(group.crm_billed_minutes)}</span>
|
<span className={teamspaceTextClass}>Teamspace {formatMinutes(group.crm_billed_minutes)}</span>
|
||||||
</div>
|
</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>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>
|
<span>Offen {group.open_sessions} / {formatMinutes(group.open_minutes)}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -59,6 +59,7 @@ type TicketSessionFormState = {
|
|||||||
organizationName: string | null;
|
organizationName: string | null;
|
||||||
projectTeamspaceEntryId: string;
|
projectTeamspaceEntryId: string;
|
||||||
activity: string;
|
activity: string;
|
||||||
|
billingStatus: BillingStatus;
|
||||||
workType: WorkType;
|
workType: WorkType;
|
||||||
day: string;
|
day: string;
|
||||||
startTime: string;
|
startTime: string;
|
||||||
@@ -90,6 +91,7 @@ function emptyTicketSessionForm(): TicketSessionFormState {
|
|||||||
organizationName: null,
|
organizationName: null,
|
||||||
projectTeamspaceEntryId: "",
|
projectTeamspaceEntryId: "",
|
||||||
activity: "",
|
activity: "",
|
||||||
|
billingStatus: null,
|
||||||
workType: "support",
|
workType: "support",
|
||||||
day: currentDay(),
|
day: currentDay(),
|
||||||
startTime: "09:00",
|
startTime: "09:00",
|
||||||
@@ -174,7 +176,17 @@ function BillingButtons({
|
|||||||
const status = session.billing_status;
|
const status = session.billing_status;
|
||||||
|
|
||||||
return (
|
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
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
size="sm"
|
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) {
|
function sessionDayKey(value: string) {
|
||||||
const date = new Date(value);
|
const date = new Date(value);
|
||||||
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}-${String(date.getDate()).padStart(2, "0")}`;
|
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 [ticketSession, setTicketSession] = useState<TicketSessionFormState>(() => emptyTicketSessionForm());
|
||||||
const [savingTicketSession, setSavingTicketSession] = useState(false);
|
const [savingTicketSession, setSavingTicketSession] = useState(false);
|
||||||
const [globalMonthFilter, setGlobalMonthFilter] = useState(initialGlobalMonthFilter);
|
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 [globalSourceFilter, setGlobalSourceFilter] = useState<"all" | "manual" | "recurring">("all");
|
||||||
const [editingProject, setEditingProject] = useState(false);
|
const [editingProject, setEditingProject] = useState(false);
|
||||||
const [savingProject, setSavingProject] = useState(false);
|
const [savingProject, setSavingProject] = useState(false);
|
||||||
@@ -595,19 +630,21 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onO
|
|||||||
const sessionGroups = useMemo(() => {
|
const sessionGroups = useMemo(() => {
|
||||||
const groups = new Map<
|
const groups = new Map<
|
||||||
string,
|
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 ?? []) {
|
for (const session of data?.sessions ?? []) {
|
||||||
const key = sessionDayKey(session.started_at);
|
const key = sessionDayKey(session.started_at);
|
||||||
const group = groups.get(key);
|
const group = groups.get(key);
|
||||||
const billedMinutes = session.billing_status === "billed" ? session.rounded_minutes : 0;
|
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 nonBillableMinutes = session.billing_status === "non_billable" ? session.rounded_minutes : 0;
|
||||||
const openMinutes = session.billing_status === null ? session.rounded_minutes : 0;
|
const openMinutes = session.billing_status === null ? session.rounded_minutes : 0;
|
||||||
|
|
||||||
if (group) {
|
if (group) {
|
||||||
group.sessions.push(session);
|
group.sessions.push(session);
|
||||||
group.totalMinutes += billedMinutes;
|
group.totalMinutes += billedMinutes;
|
||||||
|
group.billableMinutes += billableMinutes;
|
||||||
group.nonBillableMinutes += nonBillableMinutes;
|
group.nonBillableMinutes += nonBillableMinutes;
|
||||||
group.openMinutes += openMinutes;
|
group.openMinutes += openMinutes;
|
||||||
continue;
|
continue;
|
||||||
@@ -617,6 +654,7 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onO
|
|||||||
key,
|
key,
|
||||||
label: formatDate(session.started_at),
|
label: formatDate(session.started_at),
|
||||||
totalMinutes: billedMinutes,
|
totalMinutes: billedMinutes,
|
||||||
|
billableMinutes,
|
||||||
nonBillableMinutes,
|
nonBillableMinutes,
|
||||||
openMinutes,
|
openMinutes,
|
||||||
sessions: [session]
|
sessions: [session]
|
||||||
@@ -984,7 +1022,8 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onO
|
|||||||
startedAt: startedAt.toISOString(),
|
startedAt: startedAt.toISOString(),
|
||||||
endedAt: endedAt.toISOString(),
|
endedAt: endedAt.toISOString(),
|
||||||
durationSeconds,
|
durationSeconds,
|
||||||
projectTeamspaceEntryId: effectiveWorkType === "consulting" && requiresProjectTeamspaceEntry ? ticketSession.projectTeamspaceEntryId : null
|
projectTeamspaceEntryId: effectiveWorkType === "consulting" && requiresProjectTeamspaceEntry ? ticketSession.projectTeamspaceEntryId : null,
|
||||||
|
billingStatus: ticketSession.billingStatus
|
||||||
});
|
});
|
||||||
toast.success("Session nachgetragen", {
|
toast.success("Session nachgetragen", {
|
||||||
description: "Der Eintrag wurde diesem Ticket zugeordnet. Betroffene Abschlüsse wurden wieder geöffnet."
|
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,
|
organizationName: ticket.organization_name ?? null,
|
||||||
projectTeamspaceEntryId: "",
|
projectTeamspaceEntryId: "",
|
||||||
workType: ticket.work_type ?? "support",
|
workType: ticket.work_type ?? "support",
|
||||||
day: ticketSession.day
|
day: ticketSession.day,
|
||||||
|
billingStatus: null
|
||||||
});
|
});
|
||||||
await load();
|
await load();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -1277,7 +1317,7 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onO
|
|||||||
>
|
>
|
||||||
<option value="all">Alle Bewertungen</option>
|
<option value="all">Alle Bewertungen</option>
|
||||||
<option value="open">Offen</option>
|
<option value="open">Offen</option>
|
||||||
<option value="billed">Abgerechnet</option>
|
<option value="billable">Abrechenbar</option>
|
||||||
<option value="non_billable">Nicht abrechenbar</option>
|
<option value="non_billable">Nicht abrechenbar</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
@@ -1657,7 +1697,7 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onO
|
|||||||
<p className="truncate font-medium">{group.label}</p>
|
<p className="truncate font-medium">{group.label}</p>
|
||||||
<p className="text-xs text-muted-foreground">{group.sessions.length} Session(s)</p>
|
<p className="text-xs text-muted-foreground">{group.sessions.length} Session(s)</p>
|
||||||
</div>
|
</div>
|
||||||
<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)}>
|
<Button type="button" size="sm" variant="secondary" className="h-7 w-full justify-center" onClick={() => openTeamspacePreparation(group)}>
|
||||||
<ClipboardList className="size-3.5" />
|
<ClipboardList className="size-3.5" />
|
||||||
TS Aufbereitung
|
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">
|
<Badge variant="outline" className="flex h-7 w-full justify-center whitespace-nowrap">
|
||||||
Abgerechnet {formatMinutes(group.totalMinutes)}
|
Abgerechnet {formatMinutes(group.totalMinutes)}
|
||||||
</Badge>
|
</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">
|
<Badge variant={group.openMinutes > 0 ? "warning" : "success"} className="flex h-7 w-full justify-center whitespace-nowrap">
|
||||||
Offen {formatMinutes(group.openMinutes)}
|
Offen {formatMinutes(group.openMinutes)}
|
||||||
</Badge>
|
</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>
|
<label htmlFor={`crm-billing-${group.key}`}>Teamspace</label>
|
||||||
<Input
|
<Input
|
||||||
id={`crm-billing-${group.key}`}
|
id={`crm-billing-${group.key}`}
|
||||||
@@ -1872,7 +1915,7 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onO
|
|||||||
</div>
|
</div>
|
||||||
) : null}
|
) : 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">
|
<div className="space-y-2">
|
||||||
<label className="text-sm font-medium" htmlFor="ticket-session-day">
|
<label className="text-sm font-medium" htmlFor="ticket-session-day">
|
||||||
Datum
|
Datum
|
||||||
@@ -1909,6 +1952,16 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onO
|
|||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
</div>
|
</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">
|
<div className="space-y-2">
|
||||||
<label className="text-sm font-medium" htmlFor="ticket-session-activity">
|
<label className="text-sm font-medium" htmlFor="ticket-session-activity">
|
||||||
Tätigkeit
|
Tätigkeit
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { toast } from "sonner";
|
|||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
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 { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Textarea } from "@/components/ui/textarea";
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
@@ -14,7 +15,7 @@ import { createSession, getRecurringBillings, lookupTicket } from "../api";
|
|||||||
import { formatTimer } from "../format";
|
import { formatTimer } from "../format";
|
||||||
import { isRecurringBillingTicketNumber, ticketHasRecurringBilling } from "../recurring-billings";
|
import { isRecurringBillingTicketNumber, ticketHasRecurringBilling } from "../recurring-billings";
|
||||||
import { activeElapsedMs, createClientId, pauseEntry, resumeEntry, ticketPattern, type TimerEntry } from "../timers";
|
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 = {
|
type ManualSessionFormState = {
|
||||||
ticketNumber: string;
|
ticketNumber: string;
|
||||||
@@ -22,6 +23,7 @@ type ManualSessionFormState = {
|
|||||||
organizationName: string | null;
|
organizationName: string | null;
|
||||||
projectTeamspaceEntryId: string;
|
projectTeamspaceEntryId: string;
|
||||||
activity: string;
|
activity: string;
|
||||||
|
billingStatus: BillingStatus;
|
||||||
workType: WorkType;
|
workType: WorkType;
|
||||||
day: string;
|
day: string;
|
||||||
startTime: string;
|
startTime: string;
|
||||||
@@ -40,6 +42,7 @@ function emptyManualSession(): ManualSessionFormState {
|
|||||||
organizationName: null,
|
organizationName: null,
|
||||||
projectTeamspaceEntryId: "",
|
projectTeamspaceEntryId: "",
|
||||||
activity: "",
|
activity: "",
|
||||||
|
billingStatus: null,
|
||||||
workType: "support",
|
workType: "support",
|
||||||
day: currentDay(),
|
day: currentDay(),
|
||||||
startTime: "09:00",
|
startTime: "09:00",
|
||||||
@@ -51,6 +54,10 @@ type FinishedSession = {
|
|||||||
timerId: string;
|
timerId: string;
|
||||||
ticketNumber: string;
|
ticketNumber: string;
|
||||||
ticket: TicketMeta | null;
|
ticket: TicketMeta | null;
|
||||||
|
activity: string;
|
||||||
|
billingStatus: BillingStatus;
|
||||||
|
promptBillingStatus: boolean;
|
||||||
|
createZammadArticle: boolean;
|
||||||
startedAt: string;
|
startedAt: string;
|
||||||
endedAt: string;
|
endedAt: string;
|
||||||
durationSeconds: number;
|
durationSeconds: number;
|
||||||
@@ -77,16 +84,42 @@ type TimerPageProps = {
|
|||||||
selectedTimerId: string | null;
|
selectedTimerId: string | null;
|
||||||
setSelectedTimerId: Dispatch<SetStateAction<string | null>>;
|
setSelectedTimerId: Dispatch<SetStateAction<string | null>>;
|
||||||
tick: number;
|
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 [ticketNumber, setTicketNumber] = useState("");
|
||||||
const [finished, setFinished] = useState<FinishedSession | null>(null);
|
const [finished, setFinished] = useState<FinishedSession | null>(null);
|
||||||
const [finishingId, setFinishingId] = useState<string | null>(null);
|
const [finishingId, setFinishingId] = useState<string | null>(null);
|
||||||
const [organizationId, setOrganizationId] = useState("");
|
const [organizationId, setOrganizationId] = useState("");
|
||||||
const [organizationName, setOrganizationName] = useState<string | null>(null);
|
const [organizationName, setOrganizationName] = useState<string | null>(null);
|
||||||
const [projectTeamspaceEntryId, setProjectTeamspaceEntryId] = useState("");
|
const [projectTeamspaceEntryId, setProjectTeamspaceEntryId] = useState("");
|
||||||
const [activity, setActivity] = useState("");
|
|
||||||
const [workType, setWorkType] = useState<WorkType>("support");
|
const [workType, setWorkType] = useState<WorkType>("support");
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
const [manualSession, setManualSession] = useState<ManualSessionFormState>(() => emptyManualSession());
|
const [manualSession, setManualSession] = useState<ManualSessionFormState>(() => emptyManualSession());
|
||||||
@@ -250,6 +283,8 @@ export function TimerPage({ timers, setTimers, selectedTimerId, setSelectedTimer
|
|||||||
startedAt: now,
|
startedAt: now,
|
||||||
pausedTotalMs: 0,
|
pausedTotalMs: 0,
|
||||||
pausedAt: null,
|
pausedAt: null,
|
||||||
|
activity: "",
|
||||||
|
billingStatus: null,
|
||||||
phase: "running"
|
phase: "running"
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -356,6 +391,14 @@ export function TimerPage({ timers, setTimers, selectedTimerId, setSelectedTimer
|
|||||||
setTimers((current) => current.filter((timer) => timer.id !== timerId));
|
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) {
|
async function finishTimer(timerId: string) {
|
||||||
const timer = timers.find((entry) => entry.id === timerId);
|
const timer = timers.find((entry) => entry.id === timerId);
|
||||||
|
|
||||||
@@ -363,6 +406,16 @@ export function TimerPage({ timers, setTimers, selectedTimerId, setSelectedTimer
|
|||||||
return;
|
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 now = Date.now();
|
||||||
const stoppedTimer = pauseEntry(timer, now);
|
const stoppedTimer = pauseEntry(timer, now);
|
||||||
const durationSeconds = Math.floor(activeElapsedMs(stoppedTimer, now) / 1000);
|
const durationSeconds = Math.floor(activeElapsedMs(stoppedTimer, now) / 1000);
|
||||||
@@ -388,11 +441,14 @@ export function TimerPage({ timers, setTimers, selectedTimerId, setSelectedTimer
|
|||||||
setOrganizationName(ticket?.organization_name ?? null);
|
setOrganizationName(ticket?.organization_name ?? null);
|
||||||
setProjectTeamspaceEntryId("");
|
setProjectTeamspaceEntryId("");
|
||||||
setWorkType(ticket?.work_type ?? "support");
|
setWorkType(ticket?.work_type ?? "support");
|
||||||
setActivity("");
|
|
||||||
setFinished({
|
setFinished({
|
||||||
timerId: timer.id,
|
timerId: timer.id,
|
||||||
ticketNumber: timer.ticketNumber,
|
ticketNumber: timer.ticketNumber,
|
||||||
ticket,
|
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(),
|
startedAt: new Date(stoppedTimer.startedAt).toISOString(),
|
||||||
endedAt: new Date(now).toISOString(),
|
endedAt: new Date(now).toISOString(),
|
||||||
durationSeconds,
|
durationSeconds,
|
||||||
@@ -414,6 +470,13 @@ export function TimerPage({ timers, setTimers, selectedTimerId, setSelectedTimer
|
|||||||
return;
|
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)) {
|
if (ticketHasRecurringBilling(finished.ticket, recurringBillings) || isRecurringBillingTicketNumber(finished.ticketNumber, recurringBillings)) {
|
||||||
toast.error("Fixe Abrechnung", {
|
toast.error("Fixe Abrechnung", {
|
||||||
description: "Für fixe Abrechnungen dürfen keine manuellen Sessions gespeichert werden. Bitte die Definition bearbeiten."
|
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,
|
endedAt: finished.endedAt,
|
||||||
durationSeconds: finished.durationSeconds,
|
durationSeconds: finished.durationSeconds,
|
||||||
organizationId: effectiveOrganizationId,
|
organizationId: effectiveOrganizationId,
|
||||||
activity,
|
activity: finished.activity,
|
||||||
workType: effectiveWorkType,
|
workType: effectiveWorkType,
|
||||||
zammadLookup: Boolean(finished.ticket?.zammad_ticket_id),
|
zammadLookup: Boolean(finished.ticket?.zammad_ticket_id),
|
||||||
projectTeamspaceEntryId: finishedRequiresTeamspaceEntry ? projectTeamspaceEntryId : null
|
projectTeamspaceEntryId: finishedRequiresTeamspaceEntry ? projectTeamspaceEntryId : null,
|
||||||
|
billingStatus: finished.billingStatus,
|
||||||
|
createZammadArticle: finished.createZammadArticle
|
||||||
});
|
});
|
||||||
|
|
||||||
toast.success("Session gespeichert", {
|
toast.success("Session gespeichert", {
|
||||||
@@ -464,7 +529,6 @@ export function TimerPage({ timers, setTimers, selectedTimerId, setSelectedTimer
|
|||||||
setOrganizationId("");
|
setOrganizationId("");
|
||||||
setOrganizationName(null);
|
setOrganizationName(null);
|
||||||
setProjectTeamspaceEntryId("");
|
setProjectTeamspaceEntryId("");
|
||||||
setActivity("");
|
|
||||||
setWorkType("support");
|
setWorkType("support");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
toast.error("Speichern fehlgeschlagen", {
|
toast.error("Speichern fehlgeschlagen", {
|
||||||
@@ -531,7 +595,8 @@ export function TimerPage({ timers, setTimers, selectedTimerId, setSelectedTimer
|
|||||||
endedAt: endedAt.toISOString(),
|
endedAt: endedAt.toISOString(),
|
||||||
durationSeconds,
|
durationSeconds,
|
||||||
zammadLookup: Boolean(manualLookupTicket?.zammad_ticket_id),
|
zammadLookup: Boolean(manualLookupTicket?.zammad_ticket_id),
|
||||||
projectTeamspaceEntryId: requiresTeamspaceEntry(manualLookupTicket) ? manualSession.projectTeamspaceEntryId : null
|
projectTeamspaceEntryId: requiresTeamspaceEntry(manualLookupTicket) ? manualSession.projectTeamspaceEntryId : null,
|
||||||
|
billingStatus: manualSession.billingStatus
|
||||||
});
|
});
|
||||||
toast.success("Session nachgetragen", {
|
toast.success("Session nachgetragen", {
|
||||||
description: manualLookupTicket?.is_project ? "Der Eintrag wurde dem Projekt zugeordnet." : "Der Eintrag ist in deiner Ticketliste offen."
|
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>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-4 p-4">
|
<CardContent className="space-y-4 p-4">
|
||||||
<div className="rounded-lg border bg-background p-4 sm:p-5">
|
<div className="grid gap-4 lg:grid-cols-[minmax(220px,320px)_1fr]">
|
||||||
<p className="mb-2 text-sm font-medium text-muted-foreground">Gemessene Arbeitszeit</p>
|
<div className="rounded-lg border bg-background p-4 sm:p-5">
|
||||||
<p className="font-mono text-4xl font-bold leading-none tracking-normal sm:text-6xl">
|
<p className="mb-2 text-sm font-medium text-muted-foreground">Gemessene Arbeitszeit</p>
|
||||||
{formatTimer(elapsedSeconds)}
|
<p className="font-mono text-4xl font-bold leading-none tracking-normal sm:text-6xl">
|
||||||
</p>
|
{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>
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-2 sm:flex sm:flex-wrap">
|
<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}
|
) : null}
|
||||||
</div>
|
</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">
|
<div className="space-y-2">
|
||||||
<label className="text-sm font-medium" htmlFor="manual-day">Datum</label>
|
<label className="text-sm font-medium" htmlFor="manual-day">Datum</label>
|
||||||
<Input
|
<Input
|
||||||
@@ -792,6 +890,14 @@ export function TimerPage({ timers, setTimers, selectedTimerId, setSelectedTimer
|
|||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
</div>
|
</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">
|
<div className="space-y-2">
|
||||||
<label className="text-sm font-medium" htmlFor="manual-activity">Tätigkeit</label>
|
<label className="text-sm font-medium" htmlFor="manual-activity">Tätigkeit</label>
|
||||||
<Textarea
|
<Textarea
|
||||||
@@ -858,11 +964,53 @@ export function TimerPage({ timers, setTimers, selectedTimerId, setSelectedTimer
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label className="text-sm font-medium" htmlFor="activity">
|
<label className="text-sm font-medium" htmlFor="finished-activity">
|
||||||
Tätigkeit
|
Tätigkeit
|
||||||
</label>
|
</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>
|
</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 : (
|
{finished?.ticket?.organization_id && finished.ticket.work_type ? null : (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label className="text-sm font-medium" htmlFor="work-type">
|
<label className="text-sm font-medium" htmlFor="work-type">
|
||||||
|
|||||||
Reference in new issue
Block a user