This commit is contained in:
michael committed 2026-09-23 13:34:38 +00:00
1 parent 0c14a3acbd
commit 2105a0c5f7
11 files changed
+398 -42

No files matched your search

+5 -2
View File
@@ -22,6 +22,7 @@ export type AuthUser = {
zammad_session_note_default_enabled: boolean; zammad_session_note_default_enabled: boolean;
zammad_time_unit_rounding_enabled: boolean; zammad_time_unit_rounding_enabled: boolean;
zammad_time_unit_rounding_minutes: number; zammad_time_unit_rounding_minutes: number;
zammad_article_type_id: number;
}; };
declare global { declare global {
@@ -87,7 +88,8 @@ function publicUser(user: AuthUser) {
session_billing_prompt_enabled: Boolean(user.session_billing_prompt_enabled), session_billing_prompt_enabled: Boolean(user.session_billing_prompt_enabled),
zammad_session_note_default_enabled: Boolean(user.zammad_session_note_default_enabled), zammad_session_note_default_enabled: Boolean(user.zammad_session_note_default_enabled),
zammad_time_unit_rounding_enabled: Boolean(user.zammad_time_unit_rounding_enabled), zammad_time_unit_rounding_enabled: Boolean(user.zammad_time_unit_rounding_enabled),
zammad_time_unit_rounding_minutes: Number(user.zammad_time_unit_rounding_minutes ?? 15) zammad_time_unit_rounding_minutes: Number(user.zammad_time_unit_rounding_minutes ?? 15),
zammad_article_type_id: Number(user.zammad_article_type_id ?? 6)
}; };
} }
@@ -149,7 +151,8 @@ export async function requireAuth(req: Request, res: Response, next: NextFunctio
u.session_billing_prompt_enabled, u.session_billing_prompt_enabled,
u.zammad_session_note_default_enabled, u.zammad_session_note_default_enabled,
u.zammad_time_unit_rounding_enabled, u.zammad_time_unit_rounding_enabled,
u.zammad_time_unit_rounding_minutes u.zammad_time_unit_rounding_minutes,
u.zammad_article_type_id
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
+158 -15
View File
@@ -240,6 +240,20 @@ function parseZammadTimeUnitRoundingMinutes(value: unknown, fallback = 15) {
return minutes; return minutes;
} }
function parseZammadArticleTypeId(value: unknown, fallback = 6) {
if (value === undefined || value === null || value === "") {
return fallback ?? 6;
}
const typeId = Number(value);
if (![1, 2, 3, 4, 5, 6].includes(typeId)) {
throw badRequest("zammadArticleTypeId must be 1, 2, 3, 4, 5 or 6");
}
return typeId;
}
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;
@@ -635,12 +649,14 @@ type ZammadSessionArticlePayload = {
body: string; body: string;
content_type: "text/plain"; content_type: "text/plain";
time_unit: number; time_unit: number;
accounted_time_type_id: number;
}; };
function zammadSessionArticlePayload(input: { function zammadSessionArticlePayload(input: {
zammadTicketId: string | number; zammadTicketId: string | number;
activity: string; activity: string;
timeUnit: number; timeUnit: number;
articleTypeId: number;
}): ZammadSessionArticlePayload { }): ZammadSessionArticlePayload {
const ticketId = Number(input.zammadTicketId); const ticketId = Number(input.zammadTicketId);
@@ -655,7 +671,8 @@ function zammadSessionArticlePayload(input: {
subject: "TicketTracker Session", subject: "TicketTracker Session",
body: input.activity, body: input.activity,
content_type: "text/plain", content_type: "text/plain",
time_unit: input.timeUnit time_unit: input.timeUnit,
accounted_time_type_id: input.articleTypeId
}; };
} }
@@ -672,6 +689,7 @@ async function enqueueZammadSessionArticle(client: pg.PoolClient, input: {
zammadTicketId: string | number | null; zammadTicketId: string | number | null;
activity: string; activity: string;
timeUnit: number; timeUnit: number;
articleTypeId: number;
error: string; error: string;
}) { }) {
await client.query( await client.query(
@@ -684,14 +702,16 @@ async function enqueueZammadSessionArticle(client: pg.PoolClient, input: {
zammad_ticket_id, zammad_ticket_id,
activity, activity,
time_unit, time_unit,
zammad_article_type_id,
last_error last_error
) )
VALUES ($1, $2, $3, $4, $5, $6, $7, $8) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
ON CONFLICT (session_id) WHERE status = 'pending' ON CONFLICT (session_id) WHERE status = 'pending'
DO UPDATE SET DO UPDATE SET
zammad_ticket_id = COALESCE(EXCLUDED.zammad_ticket_id, zammad_session_article_queue.zammad_ticket_id), zammad_ticket_id = COALESCE(EXCLUDED.zammad_ticket_id, zammad_session_article_queue.zammad_ticket_id),
activity = EXCLUDED.activity, activity = EXCLUDED.activity,
time_unit = EXCLUDED.time_unit, time_unit = EXCLUDED.time_unit,
zammad_article_type_id = EXCLUDED.zammad_article_type_id,
last_error = EXCLUDED.last_error, last_error = EXCLUDED.last_error,
updated_at = now(); updated_at = now();
`, `,
@@ -703,11 +723,55 @@ async function enqueueZammadSessionArticle(client: pg.PoolClient, input: {
input.zammadTicketId, input.zammadTicketId,
input.activity, input.activity,
input.timeUnit, input.timeUnit,
input.articleTypeId,
input.error.slice(0, 1000) input.error.slice(0, 1000)
] ]
); );
} }
async function recordZammadSessionArticleSent(client: pg.PoolClient, input: {
userId: string;
sessionId: string | number;
ticketId: string | number;
ticketNumber: string;
zammadTicketId: string | number;
activity: string;
timeUnit: number;
articleTypeId: number;
articleId: number | null;
}) {
await client.query(
`
INSERT INTO zammad_session_article_queue (
user_id,
session_id,
ticket_id,
ticket_number,
zammad_ticket_id,
activity,
time_unit,
zammad_article_type_id,
status,
sent_at,
zammad_article_id,
last_error
)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, 'sent', now(), $9, NULL);
`,
[
input.userId,
input.sessionId,
input.ticketId,
input.ticketNumber,
input.zammadTicketId,
input.activity,
input.timeUnit,
input.articleTypeId,
input.articleId
]
);
}
function zammadArticleId(payload: unknown) { function zammadArticleId(payload: unknown) {
return isPlainObject(payload) ? numberOrNull(payload.id) : null; return isPlainObject(payload) ? numberOrNull(payload.id) : null;
} }
@@ -721,6 +785,7 @@ type ZammadSessionArticleQueueRow = {
zammad_ticket_id: string | null; zammad_ticket_id: string | null;
activity: string; activity: string;
time_unit: string | number; time_unit: string | number;
zammad_article_type_id: number | null;
}; };
async function retryZammadSessionArticleQueueRow(row: ZammadSessionArticleQueueRow) { async function retryZammadSessionArticleQueueRow(row: ZammadSessionArticleQueueRow) {
@@ -750,7 +815,8 @@ async function retryZammadSessionArticleQueueRow(row: ZammadSessionArticleQueueR
const payload = zammadSessionArticlePayload({ const payload = zammadSessionArticlePayload({
zammadTicketId, zammadTicketId,
activity: row.activity, activity: row.activity,
timeUnit: Number(row.time_unit) timeUnit: Number(row.time_unit),
articleTypeId: Number(row.zammad_article_type_id ?? 6)
}); });
const zammadResponse = await postZammadSessionArticleForUser(row.user_id, payload); const zammadResponse = await postZammadSessionArticleForUser(row.user_id, payload);
const articleId = zammadArticleId(zammadResponse); const articleId = zammadArticleId(zammadResponse);
@@ -1013,6 +1079,7 @@ type SessionCreationInput = {
requestedProjectTeamspaceEntryId: number | null; requestedProjectTeamspaceEntryId: number | null;
billingStatus: "billable" | "billed" | "non_billable" | null; billingStatus: "billable" | "billed" | "non_billable" | null;
createZammadArticle: boolean; createZammadArticle: boolean;
zammadArticleTypeId: number;
startedAt: Date; startedAt: Date;
endedAt: Date; endedAt: Date;
durationSeconds: number; durationSeconds: number;
@@ -1034,6 +1101,7 @@ async function createSessionForUser(input: SessionCreationInput) {
requestedProjectTeamspaceEntryId, requestedProjectTeamspaceEntryId,
billingStatus, billingStatus,
createZammadArticle, createZammadArticle,
zammadArticleTypeId,
startedAt, startedAt,
endedAt, endedAt,
durationSeconds, durationSeconds,
@@ -1251,6 +1319,18 @@ async function createSessionForUser(input: SessionCreationInput) {
); );
const session = sessionResult.rows[0]; const session = sessionResult.rows[0];
const zammadArticle: {
requested: boolean;
status: "not_requested" | "sent" | "queued";
error: string | null;
articleId: number | null;
} = {
requested: createZammadArticle,
status: createZammadArticle ? "queued" : "not_requested",
error: null,
articleId: null
};
if (createZammadArticle) { if (createZammadArticle) {
const timeUnit = await zammadTimeUnitForUser(userId, durationSeconds); const timeUnit = await zammadTimeUnitForUser(userId, durationSeconds);
let zammadArticleTicketId = ticket.zammad_ticket_id; let zammadArticleTicketId = ticket.zammad_ticket_id;
@@ -1276,9 +1356,23 @@ async function createSessionForUser(input: SessionCreationInput) {
const payload = zammadSessionArticlePayload({ const payload = zammadSessionArticlePayload({
zammadTicketId: zammadArticleTicketId, zammadTicketId: zammadArticleTicketId,
activity, activity,
timeUnit timeUnit,
articleTypeId: zammadArticleTypeId
});
const zammadResponse = await postZammadSessionArticleForUser(userId, payload);
zammadArticle.status = "sent";
zammadArticle.articleId = zammadArticleId(zammadResponse);
await recordZammadSessionArticleSent(client, {
userId,
sessionId: session.id,
ticketId: ticket.id,
ticketNumber,
zammadTicketId: zammadArticleTicketId,
activity,
timeUnit,
articleTypeId: zammadArticleTypeId,
articleId: zammadArticle.articleId
}); });
await postZammadSessionArticleForUser(userId, payload);
} catch (error) { } catch (error) {
zammadArticleError = errorMessage(error); zammadArticleError = errorMessage(error);
} }
@@ -1287,6 +1381,8 @@ async function createSessionForUser(input: SessionCreationInput) {
} }
if (zammadArticleError) { if (zammadArticleError) {
zammadArticle.status = "queued";
zammadArticle.error = zammadArticleError;
await enqueueZammadSessionArticle(client, { await enqueueZammadSessionArticle(client, {
userId, userId,
sessionId: session.id, sessionId: session.id,
@@ -1295,6 +1391,7 @@ async function createSessionForUser(input: SessionCreationInput) {
zammadTicketId: zammadArticleTicketId, zammadTicketId: zammadArticleTicketId,
activity, activity,
timeUnit, timeUnit,
articleTypeId: zammadArticleTypeId,
error: zammadArticleError error: zammadArticleError
}); });
} }
@@ -1319,7 +1416,8 @@ async function createSessionForUser(input: SessionCreationInput) {
return { return {
ticket, ticket,
session session,
zammadArticle
}; };
}); });
} }
@@ -1675,6 +1773,7 @@ async function createZammadEmailSessionFromWebhook(payload: unknown, deliveryId:
requestedProjectTeamspaceEntryId: null, requestedProjectTeamspaceEntryId: null,
billingStatus: null, billingStatus: null,
createZammadArticle: false, createZammadArticle: false,
zammadArticleTypeId: 6,
startedAt, startedAt,
endedAt, endedAt,
durationSeconds, durationSeconds,
@@ -4433,6 +4532,27 @@ async function getPeriodTicket(config: PeriodConfig, period: ParsedPeriod, ticke
recurring_billing_id, recurring_billing_id,
recurring_billing_slot_id, recurring_billing_slot_id,
recurring_occurrence_date, recurring_occurrence_date,
(
SELECT status
FROM zammad_session_article_queue
WHERE session_id = sessions.id
ORDER BY updated_at DESC
LIMIT 1
) AS zammad_article_status,
(
SELECT zammad_article_id::text
FROM zammad_session_article_queue
WHERE session_id = sessions.id
ORDER BY updated_at DESC
LIMIT 1
) AS zammad_article_id,
(
SELECT zammad_article_type_id
FROM zammad_session_article_queue
WHERE session_id = sessions.id
ORDER BY updated_at DESC
LIMIT 1
) AS zammad_article_type_id,
project_teamspace_entry_id::text AS project_teamspace_entry_id, project_teamspace_entry_id::text AS project_teamspace_entry_id,
( (
SELECT teamspace_project SELECT teamspace_project
@@ -4591,10 +4711,20 @@ async function getTicketDetails(ticketId: number, userId: string, filters: { mon
s.recurring_billing_id, s.recurring_billing_id,
s.recurring_billing_slot_id, s.recurring_billing_slot_id,
s.recurring_occurrence_date, s.recurring_occurrence_date,
za.status AS zammad_article_status,
za.zammad_article_id::text AS zammad_article_id,
za.zammad_article_type_id,
s.project_teamspace_entry_id::text AS project_teamspace_entry_id, s.project_teamspace_entry_id::text AS project_teamspace_entry_id,
pte.teamspace_project AS project_teamspace_project pte.teamspace_project AS project_teamspace_project
FROM sessions s FROM sessions s
LEFT JOIN project_teamspace_entries pte ON pte.id = s.project_teamspace_entry_id LEFT JOIN project_teamspace_entries pte ON pte.id = s.project_teamspace_entry_id
LEFT JOIN LATERAL (
SELECT status, zammad_article_id, zammad_article_type_id
FROM zammad_session_article_queue
WHERE session_id = s.id
ORDER BY updated_at DESC
LIMIT 1
) za ON true
WHERE ${sessionWhere} WHERE ${sessionWhere}
ORDER BY s.started_at ASC; ORDER BY s.started_at ASC;
`, `,
@@ -4841,11 +4971,12 @@ app.post("/api/setup/admin", async (req, res) => {
zammad_session_note_default_enabled: boolean; zammad_session_note_default_enabled: boolean;
zammad_time_unit_rounding_enabled: boolean; zammad_time_unit_rounding_enabled: boolean;
zammad_time_unit_rounding_minutes: number; zammad_time_unit_rounding_minutes: number;
zammad_article_type_id: number | null;
}>( }>(
` `
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, session_billing_prompt_enabled, zammad_session_note_default_enabled, zammad_time_unit_rounding_enabled, zammad_time_unit_rounding_minutes; 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, zammad_article_type_id;
`, `,
[username, displayName, await hashPassword(password)] [username, displayName, await hashPassword(password)]
); );
@@ -4906,6 +5037,7 @@ app.post("/api/auth/login", async (req, res) => {
zammad_session_note_default_enabled: boolean; zammad_session_note_default_enabled: boolean;
zammad_time_unit_rounding_enabled: boolean; zammad_time_unit_rounding_enabled: boolean;
zammad_time_unit_rounding_minutes: number; zammad_time_unit_rounding_minutes: number;
zammad_article_type_id: number | null;
}>( }>(
` `
SELECT SELECT
@@ -4921,7 +5053,8 @@ app.post("/api/auth/login", async (req, res) => {
session_billing_prompt_enabled, session_billing_prompt_enabled,
zammad_session_note_default_enabled, zammad_session_note_default_enabled,
zammad_time_unit_rounding_enabled, zammad_time_unit_rounding_enabled,
zammad_time_unit_rounding_minutes zammad_time_unit_rounding_minutes,
zammad_article_type_id
FROM users FROM users
WHERE lower(username) = lower($1); WHERE lower(username) = lower($1);
`, `,
@@ -4971,7 +5104,8 @@ app.post("/api/auth/login", async (req, res) => {
session_billing_prompt_enabled: user.session_billing_prompt_enabled, session_billing_prompt_enabled: user.session_billing_prompt_enabled,
zammad_session_note_default_enabled: user.zammad_session_note_default_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_enabled: user.zammad_time_unit_rounding_enabled,
zammad_time_unit_rounding_minutes: user.zammad_time_unit_rounding_minutes zammad_time_unit_rounding_minutes: user.zammad_time_unit_rounding_minutes,
zammad_article_type_id: user.zammad_article_type_id
} }
}); });
}); });
@@ -5189,6 +5323,9 @@ app.patch("/api/auth/me", async (req, res) => {
const zammadTimeUnitRoundingMinutes = user.role === "user" const zammadTimeUnitRoundingMinutes = user.role === "user"
? parseZammadTimeUnitRoundingMinutes(req.body.zammadTimeUnitRoundingMinutes, user.zammad_time_unit_rounding_minutes) ? parseZammadTimeUnitRoundingMinutes(req.body.zammadTimeUnitRoundingMinutes, user.zammad_time_unit_rounding_minutes)
: 15; : 15;
const zammadArticleTypeId = user.role === "user"
? parseZammadArticleTypeId(req.body.zammadArticleTypeId, user.zammad_article_type_id)
: 6;
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");
@@ -5221,9 +5358,10 @@ app.patch("/api/auth/me", async (req, res) => {
zammad_session_note_default_enabled = CASE WHEN $8::boolean THEN $11 ELSE zammad_session_note_default_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_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, zammad_time_unit_rounding_minutes = CASE WHEN $8::boolean THEN $13 ELSE zammad_time_unit_rounding_minutes END,
zammad_article_type_id = CASE WHEN $8::boolean THEN $14 ELSE zammad_article_type_id 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, session_billing_prompt_enabled, zammad_session_note_default_enabled, zammad_time_unit_rounding_enabled, zammad_time_unit_rounding_minutes; 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, zammad_article_type_id;
`, `,
[ [
username, username,
@@ -5238,7 +5376,8 @@ app.patch("/api/auth/me", async (req, res) => {
sessionBillingPromptEnabled, sessionBillingPromptEnabled,
zammadSessionNoteDefaultEnabled, zammadSessionNoteDefaultEnabled,
zammadTimeUnitRoundingEnabled, zammadTimeUnitRoundingEnabled,
zammadTimeUnitRoundingMinutes zammadTimeUnitRoundingMinutes,
zammadArticleTypeId
] ]
); );
@@ -5266,7 +5405,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, session_billing_prompt_enabled, zammad_session_note_default_enabled, zammad_time_unit_rounding_enabled, zammad_time_unit_rounding_minutes, 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, zammad_article_type_id, created_at, updated_at
FROM users FROM users
ORDER BY username ASC; ORDER BY username ASC;
` `
@@ -5290,7 +5429,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, session_billing_prompt_enabled, zammad_session_note_default_enabled, zammad_time_unit_rounding_enabled, zammad_time_unit_rounding_minutes, 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, zammad_article_type_id, created_at, updated_at;
`, `,
[username, displayName, await hashPassword(password), "user", active] [username, displayName, await hashPassword(password), "user", active]
); );
@@ -5351,7 +5490,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, session_billing_prompt_enabled, zammad_session_note_default_enabled, zammad_time_unit_rounding_enabled, zammad_time_unit_rounding_minutes, 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, zammad_article_type_id, created_at, updated_at;
`, `,
[username, displayName, active, password ? await hashPassword(password) : null, userId] [username, displayName, active, password ? await hashPassword(password) : null, userId]
); );
@@ -6329,6 +6468,7 @@ app.post("/api/sessions", requireUser, async (req, res) => {
const requestedProjectTeamspaceEntryId = parseOptionalTeamspaceEntryId(req.body.projectTeamspaceEntryId); const requestedProjectTeamspaceEntryId = parseOptionalTeamspaceEntryId(req.body.projectTeamspaceEntryId);
const billingStatus = req.body.billingStatus === undefined ? null : parseBillingStatus(req.body.billingStatus); const billingStatus = req.body.billingStatus === undefined ? null : parseBillingStatus(req.body.billingStatus);
const createZammadArticle = optionalBoolean(req.body.createZammadArticle, false); const createZammadArticle = optionalBoolean(req.body.createZammadArticle, false);
const zammadArticleTypeId = parseZammadArticleTypeId(req.body.zammadArticleTypeId, user.zammad_article_type_id);
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");
@@ -6343,6 +6483,7 @@ app.post("/api/sessions", requireUser, async (req, res) => {
requestedProjectTeamspaceEntryId, requestedProjectTeamspaceEntryId,
billingStatus, billingStatus,
createZammadArticle, createZammadArticle,
zammadArticleTypeId,
startedAt, startedAt,
endedAt, endedAt,
durationSeconds durationSeconds
@@ -6362,6 +6503,7 @@ async function zammadSessionArticleQueueResponse(userId: string) {
q.zammad_ticket_id::text AS zammad_ticket_id, q.zammad_ticket_id::text AS zammad_ticket_id,
q.activity, q.activity,
q.time_unit::float AS time_unit, q.time_unit::float AS time_unit,
q.zammad_article_type_id,
q.attempt_count, q.attempt_count,
q.last_error, q.last_error,
q.last_attempt_at, q.last_attempt_at,
@@ -6398,7 +6540,8 @@ app.post("/api/zammad/session-article-queue/retry", requireUser, async (req, res
ticket_number, ticket_number,
zammad_ticket_id::text AS zammad_ticket_id, zammad_ticket_id::text AS zammad_ticket_id,
activity, activity,
time_unit::text AS time_unit time_unit::text AS time_unit,
zammad_article_type_id
FROM zammad_session_article_queue FROM zammad_session_article_queue
WHERE user_id = $1 WHERE user_id = $1
AND status = 'pending' AND status = 'pending'
+15
View File
@@ -91,11 +91,18 @@ export async function migrate() {
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_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_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 ADD COLUMN IF NOT EXISTS zammad_time_unit_rounding_minutes INTEGER NOT NULL DEFAULT 15;");
await query("ALTER TABLE users ADD COLUMN IF NOT EXISTS zammad_article_type_id INTEGER DEFAULT 6;");
await query("ALTER TABLE users ALTER COLUMN zammad_article_type_id DROP NOT NULL;");
await query(` await query(`
ALTER TABLE users ALTER TABLE users
DROP CONSTRAINT IF EXISTS users_zammad_time_unit_rounding_minutes_valid, 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)); ADD CONSTRAINT users_zammad_time_unit_rounding_minutes_valid CHECK (zammad_time_unit_rounding_minutes IN (5, 10, 15, 30));
`); `);
await query(`
ALTER TABLE users
DROP CONSTRAINT IF EXISTS users_zammad_article_type_id_valid,
ADD CONSTRAINT users_zammad_article_type_id_valid CHECK (zammad_article_type_id IS NULL OR zammad_article_type_id IN (1, 2, 3, 4, 5, 6));
`);
await query(` await query(`
CREATE TABLE IF NOT EXISTS auth_sessions ( CREATE TABLE IF NOT EXISTS auth_sessions (
@@ -266,6 +273,7 @@ export async function migrate() {
zammad_ticket_id BIGINT, zammad_ticket_id BIGINT,
activity TEXT NOT NULL, activity TEXT NOT NULL,
time_unit NUMERIC(10, 2) NOT NULL, time_unit NUMERIC(10, 2) NOT NULL,
zammad_article_type_id INTEGER DEFAULT 6,
status TEXT NOT NULL DEFAULT 'pending', status TEXT NOT NULL DEFAULT 'pending',
attempt_count INTEGER NOT NULL DEFAULT 0, attempt_count INTEGER NOT NULL DEFAULT 0,
last_error TEXT, last_error TEXT,
@@ -277,6 +285,13 @@ export async function migrate() {
CONSTRAINT zammad_session_article_queue_status_valid CHECK (status IN ('pending', 'sent')) CONSTRAINT zammad_session_article_queue_status_valid CHECK (status IN ('pending', 'sent'))
); );
`); `);
await query("ALTER TABLE zammad_session_article_queue ADD COLUMN IF NOT EXISTS zammad_article_type_id INTEGER DEFAULT 6;");
await query("ALTER TABLE zammad_session_article_queue ALTER COLUMN zammad_article_type_id DROP NOT NULL;");
await query(`
ALTER TABLE zammad_session_article_queue
DROP CONSTRAINT IF EXISTS zammad_session_article_queue_type_id_valid,
ADD CONSTRAINT zammad_session_article_queue_type_id_valid CHECK (zammad_article_type_id IS NULL OR zammad_article_type_id IN (1, 2, 3, 4, 5, 6));
`);
await query(` await query(`
CREATE UNIQUE INDEX IF NOT EXISTS idx_zammad_session_article_queue_pending_session CREATE UNIQUE INDEX IF NOT EXISTS idx_zammad_session_article_queue_pending_session
ON zammad_session_article_queue(session_id) ON zammad_session_article_queue(session_id)
+6 -3
View File
@@ -84,7 +84,7 @@ Beim Klick auf `Beenden` stoppt der Timer sofort. Die vorher im Timer erfasste T
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. 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. Im Abschlussdialog kann die Taetigkeit als interner Zammad-Article an das Ticket uebertragen werden. Der Article wird mit `internal: true` angelegt und enthaelt die Taetigkeit sowie den `time_unit`-Wert aus der gemessenen Zeit abzueglich Pausen. Wenn die interne Zammad-Notiz aktiv ist, kann dort auch der Zammad-Zeitbuchungstyp gewaehlt werden.
Wenn Zammad beim Speichern nicht erreichbar ist, wird die Session trotzdem in TicketTracker gespeichert. Der Zammad-Article wird dann in einer Parktabelle vorgemerkt und kann spaeter im Profil erneut gesendet werden. 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.
@@ -117,7 +117,7 @@ Das Taetigkeitsfeld ist mehrzeilig. Zeilenumbrueche bleiben in der Auswertung er
Mit Bewertung `---` bleibt eine nachgetragene Session offen. `Abrechenbar` und `Nicht abrechenbar` werden direkt an der neuen Session gespeichert. Mit Bewertung `---` bleibt eine nachgetragene Session offen. `Abrechenbar` und `Nicht abrechenbar` werden direkt an der neuen Session gespeichert.
Wenn die interne Zammad-Notiz aktiviert ist, wird die Taetigkeit auch beim Nachtragen als interner Zammad-Article uebertragen. Bei Zammad-Fehlern wird der Eintrag wie beim Timerabschluss geparkt und kann spaeter im Profil gesendet werden. Wenn die interne Zammad-Notiz aktiviert ist, wird die Taetigkeit auch beim Nachtragen als interner Zammad-Article uebertragen. Der Zammad-Typ kann dabei angepasst werden. Bei Zammad-Fehlern wird der Eintrag wie beim Timerabschluss geparkt und kann spaeter im Profil gesendet werden.
Das Ende muss nach dem Beginn liegen. Die Dauer wird automatisch berechnet und auf Minuten gerundet. Das Ende muss nach dem Beginn liegen. Die Dauer wird automatisch berechnet und auf Minuten gerundet.
@@ -534,6 +534,7 @@ Unter `Profil` kann jeder Benutzer seine eigenen Daten pflegen:
- persoenlicher Zammad API-Token - persoenlicher Zammad API-Token
- Bewertungsabfrage beim Timerabschluss - Bewertungsabfrage beim Timerabschluss
- interne Zammad-Notiz beim Timerabschluss - interne Zammad-Notiz beim Timerabschluss
- Standard-Typ fuer interne Zammad-Notizen
- Zammad-Zeitaufrundung fuer `time_unit` - 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.
@@ -546,7 +547,9 @@ Der persoenliche Zammad Token ist unabhaengig vom Admin-Token fuer den Organisat
Die Bewertungsabfrage beim Timerabschluss kann im Profil ein- oder ausgeschaltet werden. Sie erscheint nur, wenn der Timer beim Beenden noch auf Bewertung `---` steht. 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. Die interne Zammad-Notiz beim Timerabschluss steuert, ob die Checkbox im Abschlussdialog standardmaessig aktiv ist. Ist sie aktiv, legt TicketTracker beim Speichern einen internen Zammad-Article zum Ticket an. Nur dann werden auch der Standard-Typ und die Zeitaufrundung im Profil angezeigt.
Der Standard-Typ belegt den Zammad-Zeitbuchungstyp fuer neue interne Notizen vor. Beim Timerabschluss und beim Nachtragen kann der Typ vor dem Speichern noch geaendert werden.
Fuer `time_unit` kann optional eine Aufrundung aktiviert werden. Dann wird die tatsaechlich getrackte Zeit immer auf 5, 10, 15 oder 30 Minuten aufgerundet und als Stundenwert an Zammad gesendet. Ohne Rundung wird der konkrete Stundenwert mit zwei Nachkommastellen gesendet. 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.
+12 -1
View File
@@ -32,6 +32,7 @@ type CreateSessionPayload = {
projectTeamspaceEntryId?: string | null; projectTeamspaceEntryId?: string | null;
billingStatus?: BillingStatus; billingStatus?: BillingStatus;
createZammadArticle?: boolean; createZammadArticle?: boolean;
zammadArticleTypeId?: number;
}; };
type DeleteSessionResult = { type DeleteSessionResult = {
@@ -46,6 +47,15 @@ type DeleteSessionResult = {
}; };
}; };
export type CreateSessionResult = {
zammadArticle?: {
requested: boolean;
status: "not_requested" | "sent" | "queued";
error: string | null;
articleId: number | null;
};
};
export type ZammadSyncExistingFields = { export type ZammadSyncExistingFields = {
title: boolean; title: boolean;
organization: boolean; organization: boolean;
@@ -205,6 +215,7 @@ export function updateCurrentUser(payload: {
zammadSessionNoteDefaultEnabled?: boolean; zammadSessionNoteDefaultEnabled?: boolean;
zammadTimeUnitRoundingEnabled?: boolean; zammadTimeUnitRoundingEnabled?: boolean;
zammadTimeUnitRoundingMinutes?: number; zammadTimeUnitRoundingMinutes?: number;
zammadArticleTypeId?: number;
}) { }) {
return request<{ user: AuthUser }>("/api/auth/me", { return request<{ user: AuthUser }>("/api/auth/me", {
method: "PATCH", method: "PATCH",
@@ -682,7 +693,7 @@ export function deleteTodo(todoId: string) {
} }
export function createSession(payload: CreateSessionPayload) { export function createSession(payload: CreateSessionPayload) {
return request("/api/sessions", { return request<CreateSessionResult>("/api/sessions", {
method: "POST", method: "POST",
body: JSON.stringify(payload) body: JSON.stringify(payload)
}); });
+4 -3
View File
@@ -121,7 +121,7 @@ export const helpSections: HelpSection[] = [
"Wenn du einen Timer auswählst, zeigt die Timerseite dessen Details inklusive gemessener Zeit und Tätigkeitsfeld.", "Wenn du einen Timer auswählst, zeigt die Timerseite dessen Details inklusive gemessener Zeit und Tätigkeitsfeld.",
"Pausierte Timer behalten ihre Tätigkeit. Entfernst du einen Timer, wird auch die noch nicht gespeicherte Tätigkeit verworfen.", "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.", "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.", "Im Abschlussdialog kann die Tätigkeit als interne Zammad-Notiz übertragen werden. Der Article wird intern erstellt und bekommt die getrackte Zeit als time_unit sowie den gewählten Zammad-Typ.",
"Wenn Zammad beim Speichern nicht erreichbar ist, bleibt die Session trotzdem gespeichert. Die interne Notiz wird geparkt und kann später im Profil erneut gesendet werden.", "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.", "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.",
@@ -146,7 +146,7 @@ export const helpSections: HelpSection[] = [
"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.",
"Mit Bewertung --- bleibt die Session offen. Abrechenbar oder Nicht abrechenbar werden direkt an der neuen Session gespeichert.", "Mit Bewertung --- bleibt die Session offen. Abrechenbar oder Nicht abrechenbar werden direkt an der neuen Session gespeichert.",
"Die interne Zammad-Notiz kann auch beim Nachtragen gesetzt werden. Bei Zammad-Fehlern wird sie geparkt und kann später im Profil gesendet werden.", "Die interne Zammad-Notiz kann auch beim Nachtragen gesetzt werden. Ist sie aktiv, kann der Zammad-Typ angepasst werden. Bei Zammad-Fehlern wird sie geparkt und kann später im Profil gesendet werden.",
"In der Ticketdetailansicht kann eine Session direkt zum geöffneten Ticket nachgetragen werden. Ticketnummer und Stammdaten sind dort schon vorbelegt beziehungsweise aus dem Ticket abgeleitet.", "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."
] ]
@@ -539,7 +539,8 @@ export const helpSections: HelpSection[] = [
"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 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.", "Die interne Zammad-Notiz beim Timerabschluss steuert, ob die Checkbox im Abschlussdialog standardmäßig aktiv ist. Nur dann werden Standard-Typ und Zeitaufrundung angezeigt.",
"Der Standard-Typ belegt die Zammad-type_id für interne Notizen vor und kann beim Timerabschluss oder Nachtragen noch geändert werden.",
"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.", "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.", "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.",
+5
View File
@@ -17,6 +17,7 @@ export type AuthUser = {
zammad_session_note_default_enabled: boolean; zammad_session_note_default_enabled: boolean;
zammad_time_unit_rounding_enabled: boolean; zammad_time_unit_rounding_enabled: boolean;
zammad_time_unit_rounding_minutes: number; zammad_time_unit_rounding_minutes: number;
zammad_article_type_id: 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";
@@ -75,6 +76,7 @@ export type ZammadSessionArticleQueueItem = {
zammad_ticket_id: string | null; zammad_ticket_id: string | null;
activity: string; activity: string;
time_unit: number; time_unit: number;
zammad_article_type_id: number | null;
attempt_count: number; attempt_count: number;
last_error: string | null; last_error: string | null;
last_attempt_at: string | null; last_attempt_at: string | null;
@@ -338,6 +340,9 @@ export type SessionEntry = {
recurring_billing_id: string | null; recurring_billing_id: string | null;
recurring_billing_slot_id: string | null; recurring_billing_slot_id: string | null;
recurring_occurrence_date: string | null; recurring_occurrence_date: string | null;
zammad_article_status?: "pending" | "sent" | null;
zammad_article_id?: string | null;
zammad_article_type_id?: number | null;
}; };
export type TicketPeriod = { export type TicketPeriod = {
+29 -2
View File
@@ -9,6 +9,7 @@ import { HelpLink } from "@/components/HelpLink";
import { getZammadSessionArticleQueue, retryZammadSessionArticleQueue, updateCurrentUser } from "../api"; import { getZammadSessionArticleQueue, retryZammadSessionArticleQueue, updateCurrentUser } from "../api";
import { formatDateTime, formatMinutes } from "../format"; import { formatDateTime, formatMinutes } from "../format";
import type { AuthUser, ZammadSessionArticleQueueItem } from "../types"; import type { AuthUser, ZammadSessionArticleQueueItem } from "../types";
import { zammadArticleTypeFromValue, zammadArticleTypeLabel, zammadArticleTypeOptions, zammadArticleTypeValue, type ZammadArticleTypeId } from "../zammad-article-types";
type ProfilePageProps = { type ProfilePageProps = {
currentUser: AuthUser; currentUser: AuthUser;
@@ -28,6 +29,7 @@ export function ProfilePage({ currentUser, onUserUpdated }: ProfilePageProps) {
const [zammadSessionNoteDefaultEnabled, setZammadSessionNoteDefaultEnabled] = useState(currentUser.zammad_session_note_default_enabled); const [zammadSessionNoteDefaultEnabled, setZammadSessionNoteDefaultEnabled] = useState(currentUser.zammad_session_note_default_enabled);
const [zammadTimeUnitRoundingEnabled, setZammadTimeUnitRoundingEnabled] = useState(currentUser.zammad_time_unit_rounding_enabled); const [zammadTimeUnitRoundingEnabled, setZammadTimeUnitRoundingEnabled] = useState(currentUser.zammad_time_unit_rounding_enabled);
const [zammadTimeUnitRoundingMinutes, setZammadTimeUnitRoundingMinutes] = useState(currentUser.zammad_time_unit_rounding_minutes); const [zammadTimeUnitRoundingMinutes, setZammadTimeUnitRoundingMinutes] = useState(currentUser.zammad_time_unit_rounding_minutes);
const [zammadArticleTypeId, setZammadArticleTypeId] = useState<ZammadArticleTypeId>(currentUser.zammad_article_type_id ?? 6);
const [zammadQueue, setZammadQueue] = useState<ZammadSessionArticleQueueItem[]>([]); const [zammadQueue, setZammadQueue] = useState<ZammadSessionArticleQueueItem[]>([]);
const [loadingZammadQueue, setLoadingZammadQueue] = useState(false); const [loadingZammadQueue, setLoadingZammadQueue] = useState(false);
const [retryingZammadQueue, setRetryingZammadQueue] = useState(false); const [retryingZammadQueue, setRetryingZammadQueue] = useState(false);
@@ -43,6 +45,7 @@ export function ProfilePage({ currentUser, onUserUpdated }: ProfilePageProps) {
setZammadSessionNoteDefaultEnabled(currentUser.zammad_session_note_default_enabled); setZammadSessionNoteDefaultEnabled(currentUser.zammad_session_note_default_enabled);
setZammadTimeUnitRoundingEnabled(currentUser.zammad_time_unit_rounding_enabled); setZammadTimeUnitRoundingEnabled(currentUser.zammad_time_unit_rounding_enabled);
setZammadTimeUnitRoundingMinutes(currentUser.zammad_time_unit_rounding_minutes); setZammadTimeUnitRoundingMinutes(currentUser.zammad_time_unit_rounding_minutes);
setZammadArticleTypeId(currentUser.zammad_article_type_id ?? 6);
}, [currentUser]); }, [currentUser]);
async function loadZammadQueue() { async function loadZammadQueue() {
@@ -125,7 +128,8 @@ export function ProfilePage({ currentUser, onUserUpdated }: ProfilePageProps) {
sessionBillingPromptEnabled, sessionBillingPromptEnabled,
zammadSessionNoteDefaultEnabled, zammadSessionNoteDefaultEnabled,
zammadTimeUnitRoundingEnabled, zammadTimeUnitRoundingEnabled,
zammadTimeUnitRoundingMinutes zammadTimeUnitRoundingMinutes,
zammadArticleTypeId
} }
: {}) : {})
}; };
@@ -334,7 +338,28 @@ export function ProfilePage({ currentUser, onUserUpdated }: ProfilePageProps) {
/> />
</div> </div>
{zammadSessionNoteDefaultEnabled ? ( {zammadSessionNoteDefaultEnabled ? (
<div className="mt-3 rounded-md border bg-background p-3"> <div className="mt-3 space-y-3">
<div className="rounded-md border bg-background p-3">
<div className="grid gap-2 sm:max-w-lg">
<label className="text-sm font-medium" htmlFor="profile-zammad-article-type">
Standard-Typ
</label>
<select
id="profile-zammad-article-type"
className="h-9 rounded-md border border-input bg-background px-3 text-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
value={zammadArticleTypeValue(zammadArticleTypeId)}
onChange={(event) => setZammadArticleTypeId(zammadArticleTypeFromValue(event.currentTarget.value))}
>
{zammadArticleTypeOptions.map((option) => (
<option key={option.value || "empty"} value={option.value}>
{option.label}
</option>
))}
</select>
<p className="text-xs text-muted-foreground">Wird beim Erstellen interner Zammad-Notizen vorbelegt.</p>
</div>
</div>
<div className="rounded-md border bg-background p-3">
<div className="flex items-center justify-between gap-3"> <div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Clock3 className="size-4 text-muted-foreground" /> <Clock3 className="size-4 text-muted-foreground" />
@@ -370,6 +395,7 @@ export function ProfilePage({ currentUser, onUserUpdated }: ProfilePageProps) {
</div> </div>
) : null} ) : null}
</div> </div>
</div>
) : null} ) : null}
<div className="mt-3 rounded-md border bg-background p-3"> <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 flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
@@ -401,6 +427,7 @@ export function ProfilePage({ currentUser, onUserUpdated }: ProfilePageProps) {
<span className="text-muted-foreground">{formatDateTime(item.started_at)}</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">{formatMinutes(item.rounded_minutes)}</span>
<span className="text-muted-foreground">time_unit {item.time_unit}</span> <span className="text-muted-foreground">time_unit {item.time_unit}</span>
<span className="text-muted-foreground">{zammadArticleTypeLabel(item.zammad_article_type_id)}</span>
</div> </div>
{item.last_error ? <p className="mt-1 line-clamp-2 text-destructive">{item.last_error}</p> : null} {item.last_error ? <p className="mt-1 line-clamp-2 text-destructive">{item.last_error}</p> : null}
</div> </div>
+60 -7
View File
@@ -1,4 +1,4 @@
import { ArrowLeft, CheckCircle2, ChevronDown, ChevronUp, ClipboardList, Copy, EyeOff, FolderKanban, Pencil, Play, Plus, RotateCcw, Save, Star, Trash2 } from "lucide-react"; import { ArrowLeft, CheckCircle2, ChevronDown, ChevronUp, ClipboardList, Copy, EyeOff, FolderKanban, Pencil, Play, Plus, RotateCcw, Save, Send, Star, Trash2 } from "lucide-react";
import { FormEvent, useEffect, useMemo, useState } from "react"; import { FormEvent, useEffect, useMemo, useState } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import { Alert } from "@/components/ui/alert"; import { Alert } from "@/components/ui/alert";
@@ -30,10 +30,12 @@ import {
updateProject, updateProject,
updateProjectStatus updateProjectStatus
} from "../api"; } from "../api";
import type { CreateSessionResult } from "../api";
import { currentDay, currentMonth, formatDate, formatDateTime, formatMinutes, formatTimeRange } from "../format"; import { currentDay, currentMonth, formatDate, formatDateTime, formatMinutes, formatTimeRange } from "../format";
import { ticketHasRecurringBilling } from "../recurring-billings"; import { ticketHasRecurringBilling } from "../recurring-billings";
import type { AuthUser, BillingStatus, PeriodType, ProjectSummary, ProjectTeamspaceEntry, SessionEntry, TicketDetailPeriodType, TicketPeriod, WorkType } from "../types"; import type { AuthUser, BillingStatus, PeriodType, ProjectSummary, ProjectTeamspaceEntry, SessionEntry, TicketDetailPeriodType, TicketPeriod, WorkType } from "../types";
import { ZammadPriorityBadge, ZammadStateBadge } from "../zammad-meta"; import { ZammadPriorityBadge, ZammadStateBadge } from "../zammad-meta";
import { zammadArticleTypeFromValue, zammadArticleTypeLabel, zammadArticleTypeOptions, zammadArticleTypeValue, type ZammadArticleTypeId } from "../zammad-article-types";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
type TicketDetailPageProps = { type TicketDetailPageProps = {
@@ -63,6 +65,7 @@ type TicketSessionFormState = {
activity: string; activity: string;
billingStatus: BillingStatus; billingStatus: BillingStatus;
createZammadArticle: boolean; createZammadArticle: boolean;
zammadArticleTypeId: ZammadArticleTypeId;
workType: WorkType; workType: WorkType;
day: string; day: string;
startTime: string; startTime: string;
@@ -96,6 +99,7 @@ function emptyTicketSessionForm(): TicketSessionFormState {
activity: "", activity: "",
billingStatus: null, billingStatus: null,
createZammadArticle: false, createZammadArticle: false,
zammadArticleTypeId: 6,
workType: "support", workType: "support",
day: currentDay(), day: currentDay(),
startTime: "09:00", startTime: "09:00",
@@ -103,6 +107,19 @@ function emptyTicketSessionForm(): TicketSessionFormState {
}; };
} }
function notifyZammadArticleResult(result: CreateSessionResult) {
if (result.zammadArticle?.status === "sent") {
toast.success("Zammad-Notiz übertragen");
return;
}
if (result.zammadArticle?.status === "queued") {
toast.warning("Zammad-Notiz geparkt", {
description: result.zammadArticle.error ?? "Die Session wurde gespeichert. Die Notiz kann im Profil später erneut gesendet werden."
});
}
}
function projectEntryId() { function projectEntryId() {
if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") { if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") {
return crypto.randomUUID(); return crypto.randomUUID();
@@ -599,16 +616,21 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onS
useEffect(() => { useEffect(() => {
setTicketSession((current) => { setTicketSession((current) => {
if (current.activity || current.billingStatus || current.createZammadArticle === currentUser.zammad_session_note_default_enabled) { if (
current.activity ||
current.billingStatus ||
(current.createZammadArticle === currentUser.zammad_session_note_default_enabled && current.zammadArticleTypeId === currentUser.zammad_article_type_id)
) {
return current; return current;
} }
return { return {
...current, ...current,
createZammadArticle: currentUser.zammad_session_note_default_enabled createZammadArticle: currentUser.zammad_session_note_default_enabled,
zammadArticleTypeId: currentUser.zammad_article_type_id
}; };
}); });
}, [currentUser.zammad_session_note_default_enabled]); }, [currentUser.zammad_article_type_id, currentUser.zammad_session_note_default_enabled]);
useEffect(() => { useEffect(() => {
setProjectDescriptionExpanded(false); setProjectDescriptionExpanded(false);
@@ -1031,7 +1053,7 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onS
setSavingTicketSession(true); setSavingTicketSession(true);
try { try {
await createSession({ const result = await createSession({
ticketNumber: ticket.ticket_number, ticketNumber: ticket.ticket_number,
organizationId: effectiveOrganizationId, organizationId: effectiveOrganizationId,
activity: ticketSession.activity, activity: ticketSession.activity,
@@ -1041,11 +1063,13 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onS
durationSeconds, durationSeconds,
projectTeamspaceEntryId: effectiveWorkType === "consulting" && requiresProjectTeamspaceEntry ? ticketSession.projectTeamspaceEntryId : null, projectTeamspaceEntryId: effectiveWorkType === "consulting" && requiresProjectTeamspaceEntry ? ticketSession.projectTeamspaceEntryId : null,
billingStatus: ticketSession.billingStatus, billingStatus: ticketSession.billingStatus,
createZammadArticle: ticketSession.createZammadArticle createZammadArticle: ticketSession.createZammadArticle,
zammadArticleTypeId: ticketSession.zammadArticleTypeId
}); });
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."
}); });
notifyZammadArticleResult(result);
setTicketSession({ setTicketSession({
...emptyTicketSessionForm(), ...emptyTicketSessionForm(),
organizationId: ticket.organization_id ?? "", organizationId: ticket.organization_id ?? "",
@@ -1054,7 +1078,8 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onS
workType: ticket.work_type ?? "support", workType: ticket.work_type ?? "support",
day: ticketSession.day, day: ticketSession.day,
billingStatus: null, billingStatus: null,
createZammadArticle: currentUser.zammad_session_note_default_enabled createZammadArticle: currentUser.zammad_session_note_default_enabled,
zammadArticleTypeId: currentUser.zammad_article_type_id
}); });
await load(); await load();
} catch (error) { } catch (error) {
@@ -1842,7 +1867,16 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onS
<p className="whitespace-pre-wrap break-words text-sm font-medium">{session.activity}</p> <p className="whitespace-pre-wrap break-words text-sm font-medium">{session.activity}</p>
<div className="mt-2 flex flex-wrap gap-x-3 gap-y-1 text-xs text-muted-foreground"> <div className="mt-2 flex flex-wrap gap-x-3 gap-y-1 text-xs text-muted-foreground">
<span>{session.organization_name ?? session.customer_name}</span> <span>{session.organization_name ?? session.customer_name}</span>
{session.zammad_article_status ? (
<span className="inline-flex items-center gap-1">
{session.zammad_article_status === "sent" ? (
<Send className="size-3.5 text-emerald-500" aria-hidden="true" />
) : null}
<span>{zammadArticleTypeLabel(session.zammad_article_type_id)}</span>
</span>
) : (
<span>{session.work_type === "support" ? "Support" : "Consulting"}</span> <span>{session.work_type === "support" ? "Support" : "Consulting"}</span>
)}
<span>Erfasst {formatDateTime(session.created_at)}</span> <span>Erfasst {formatDateTime(session.created_at)}</span>
</div> </div>
</div> </div>
@@ -2009,6 +2043,25 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onS
<span className="block text-xs text-muted-foreground">Überträgt die Tätigkeit inklusive time_unit nach Zammad oder parkt sie bei Fehlern.</span> <span className="block text-xs text-muted-foreground">Überträgt die Tätigkeit inklusive time_unit nach Zammad oder parkt sie bei Fehlern.</span>
</span> </span>
</label> </label>
{ticketSession.createZammadArticle ? (
<div className="space-y-2 lg:col-span-5">
<label className="text-sm font-medium" htmlFor="ticket-session-zammad-article-type">
Zammad-Typ
</label>
<select
id="ticket-session-zammad-article-type"
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={zammadArticleTypeValue(ticketSession.zammadArticleTypeId)}
onChange={(event) => setTicketSession({ ...ticketSession, zammadArticleTypeId: zammadArticleTypeFromValue(event.currentTarget.value) })}
>
{zammadArticleTypeOptions.map((option) => (
<option key={option.value || "empty"} value={option.value}>
{option.label}
</option>
))}
</select>
</div>
) : null}
<Button type="submit" disabled={savingTicketSession}> <Button type="submit" disabled={savingTicketSession}>
<Save className="size-4" /> <Save className="size-4" />
{savingTicketSession ? "Speichert..." : "Nachtragen"} {savingTicketSession ? "Speichert..." : "Nachtragen"}
+80 -9
View File
@@ -12,10 +12,12 @@ import { Textarea } from "@/components/ui/textarea";
import { HelpLink } from "@/components/HelpLink"; import { HelpLink } from "@/components/HelpLink";
import { OrganizationSelect } from "@/components/OrganizationSelect"; import { OrganizationSelect } from "@/components/OrganizationSelect";
import { createSession, getRecurringBillings, lookupTicket } from "../api"; import { createSession, getRecurringBillings, lookupTicket } from "../api";
import type { CreateSessionResult } from "../api";
import { formatTimer } from "../format"; import { formatTimer } from "../format";
import { isRecurringBillingTicketNumber, ticketHasRecurringBilling } from "../recurring-billings"; import { isRecurringBillingTicketNumber, ticketHasRecurringBilling } from "../recurring-billings";
import { activeElapsedMs, createClientId, pauseEntry, resumeEntry, ticketPattern, type TimerEntry } from "../timers"; import { activeElapsedMs, createClientId, pauseEntry, resumeEntry, ticketPattern, type TimerEntry } from "../timers";
import type { AuthUser, BillingStatus, RecurringBilling, TicketMeta, WorkType } from "../types"; import type { AuthUser, BillingStatus, RecurringBilling, TicketMeta, WorkType } from "../types";
import { zammadArticleTypeFromValue, zammadArticleTypeOptions, zammadArticleTypeValue, type ZammadArticleTypeId } from "../zammad-article-types";
type ManualSessionFormState = { type ManualSessionFormState = {
ticketNumber: string; ticketNumber: string;
@@ -25,6 +27,7 @@ type ManualSessionFormState = {
activity: string; activity: string;
billingStatus: BillingStatus; billingStatus: BillingStatus;
createZammadArticle: boolean; createZammadArticle: boolean;
zammadArticleTypeId: ZammadArticleTypeId;
workType: WorkType; workType: WorkType;
day: string; day: string;
startTime: string; startTime: string;
@@ -45,6 +48,7 @@ function emptyManualSession(): ManualSessionFormState {
activity: "", activity: "",
billingStatus: null, billingStatus: null,
createZammadArticle: false, createZammadArticle: false,
zammadArticleTypeId: 6,
workType: "support", workType: "support",
day: currentDay(), day: currentDay(),
startTime: "09:00", startTime: "09:00",
@@ -60,6 +64,7 @@ type FinishedSession = {
billingStatus: BillingStatus; billingStatus: BillingStatus;
promptBillingStatus: boolean; promptBillingStatus: boolean;
createZammadArticle: boolean; createZammadArticle: boolean;
zammadArticleTypeId: ZammadArticleTypeId;
startedAt: string; startedAt: string;
endedAt: string; endedAt: string;
durationSeconds: number; durationSeconds: number;
@@ -115,6 +120,19 @@ function BillingStatusSelect({
); );
} }
function notifyZammadArticleResult(result: CreateSessionResult) {
if (result.zammadArticle?.status === "sent") {
toast.success("Zammad-Notiz übertragen");
return;
}
if (result.zammadArticle?.status === "queued") {
toast.warning("Zammad-Notiz geparkt", {
description: result.zammadArticle.error ?? "Die Session wurde gespeichert. Die Notiz kann im Profil später erneut gesendet werden."
});
}
}
export function TimerPage({ timers, setTimers, selectedTimerId, setSelectedTimerId, tick, currentUser }: TimerPageProps) { 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);
@@ -225,16 +243,23 @@ export function TimerPage({ timers, setTimers, selectedTimerId, setSelectedTimer
useEffect(() => { useEffect(() => {
setManualSession((current) => { setManualSession((current) => {
if (current.ticketNumber || current.activity || current.organizationId || current.billingStatus || current.createZammadArticle === currentUser.zammad_session_note_default_enabled) { if (
current.ticketNumber ||
current.activity ||
current.organizationId ||
current.billingStatus ||
(current.createZammadArticle === currentUser.zammad_session_note_default_enabled && current.zammadArticleTypeId === currentUser.zammad_article_type_id)
) {
return current; return current;
} }
return { return {
...current, ...current,
createZammadArticle: currentUser.zammad_session_note_default_enabled createZammadArticle: currentUser.zammad_session_note_default_enabled,
zammadArticleTypeId: currentUser.zammad_article_type_id
}; };
}); });
}, [currentUser.zammad_session_note_default_enabled]); }, [currentUser.zammad_article_type_id, currentUser.zammad_session_note_default_enabled]);
async function addTimer(source: "local" | "zammad" = "local") { async function addTimer(source: "local" | "zammad" = "local") {
const normalizedTicketNumber = ticketNumber.trim(); const normalizedTicketNumber = ticketNumber.trim();
@@ -464,6 +489,7 @@ export function TimerPage({ timers, setTimers, selectedTimerId, setSelectedTimer
billingStatus: timer.billingStatus, billingStatus: timer.billingStatus,
promptBillingStatus: currentUser.session_billing_prompt_enabled && timer.billingStatus === null, promptBillingStatus: currentUser.session_billing_prompt_enabled && timer.billingStatus === null,
createZammadArticle: currentUser.zammad_session_note_default_enabled, createZammadArticle: currentUser.zammad_session_note_default_enabled,
zammadArticleTypeId: currentUser.zammad_article_type_id,
startedAt: new Date(stoppedTimer.startedAt).toISOString(), startedAt: new Date(stoppedTimer.startedAt).toISOString(),
endedAt: new Date(now).toISOString(), endedAt: new Date(now).toISOString(),
durationSeconds, durationSeconds,
@@ -522,7 +548,7 @@ export function TimerPage({ timers, setTimers, selectedTimerId, setSelectedTimer
setSaving(true); setSaving(true);
try { try {
await createSession({ const result = await createSession({
ticketNumber: finished.ticketNumber, ticketNumber: finished.ticketNumber,
startedAt: finished.startedAt, startedAt: finished.startedAt,
endedAt: finished.endedAt, endedAt: finished.endedAt,
@@ -533,12 +559,14 @@ export function TimerPage({ timers, setTimers, selectedTimerId, setSelectedTimer
zammadLookup: Boolean(finished.ticket?.zammad_ticket_id), zammadLookup: Boolean(finished.ticket?.zammad_ticket_id),
projectTeamspaceEntryId: finishedRequiresTeamspaceEntry ? projectTeamspaceEntryId : null, projectTeamspaceEntryId: finishedRequiresTeamspaceEntry ? projectTeamspaceEntryId : null,
billingStatus: finished.billingStatus, billingStatus: finished.billingStatus,
createZammadArticle: finished.createZammadArticle createZammadArticle: finished.createZammadArticle,
zammadArticleTypeId: finished.zammadArticleTypeId
}); });
toast.success("Session gespeichert", { toast.success("Session gespeichert", {
description: `${finished.ticketNumber} wurde mit ${finished.roundedMinutes} Minute(n) erfasst.` description: `${finished.ticketNumber} wurde mit ${finished.roundedMinutes} Minute(n) erfasst.`
}); });
notifyZammadArticleResult(result);
setTimers((current) => current.filter((timer) => timer.id !== finished.timerId)); setTimers((current) => current.filter((timer) => timer.id !== finished.timerId));
setFinished(null); setFinished(null);
setOrganizationId(""); setOrganizationId("");
@@ -601,7 +629,7 @@ export function TimerPage({ timers, setTimers, selectedTimerId, setSelectedTimer
setSavingManualSession(true); setSavingManualSession(true);
try { try {
await createSession({ const result = await createSession({
ticketNumber: manualSession.ticketNumber.trim(), ticketNumber: manualSession.ticketNumber.trim(),
organizationId: manualSession.organizationId, organizationId: manualSession.organizationId,
activity: manualSession.activity, activity: manualSession.activity,
@@ -612,15 +640,18 @@ export function TimerPage({ timers, setTimers, selectedTimerId, setSelectedTimer
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, billingStatus: manualSession.billingStatus,
createZammadArticle: manualSession.createZammadArticle createZammadArticle: manualSession.createZammadArticle,
zammadArticleTypeId: manualSession.zammadArticleTypeId
}); });
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."
}); });
notifyZammadArticleResult(result);
setManualSession({ setManualSession({
...emptyManualSession(), ...emptyManualSession(),
day: manualSession.day, day: manualSession.day,
createZammadArticle: currentUser.zammad_session_note_default_enabled createZammadArticle: currentUser.zammad_session_note_default_enabled,
zammadArticleTypeId: currentUser.zammad_article_type_id
}); });
} catch (error) { } catch (error) {
toast.error("Session konnte nicht nachgetragen werden", { toast.error("Session konnte nicht nachgetragen werden", {
@@ -935,6 +966,25 @@ export function TimerPage({ timers, setTimers, selectedTimerId, setSelectedTimer
<span className="block text-xs text-muted-foreground">Überträgt die Tätigkeit inklusive time_unit nach Zammad oder parkt sie bei Fehlern.</span> <span className="block text-xs text-muted-foreground">Überträgt die Tätigkeit inklusive time_unit nach Zammad oder parkt sie bei Fehlern.</span>
</span> </span>
</label> </label>
{manualSession.createZammadArticle ? (
<div className="space-y-2 lg:col-span-5">
<label className="text-sm font-medium" htmlFor="manual-zammad-article-type">
Zammad-Typ
</label>
<select
id="manual-zammad-article-type"
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={zammadArticleTypeValue(manualSession.zammadArticleTypeId)}
onChange={(event) => setManualSession({ ...manualSession, zammadArticleTypeId: zammadArticleTypeFromValue(event.currentTarget.value) })}
>
{zammadArticleTypeOptions.map((option) => (
<option key={option.value || "empty"} value={option.value}>
{option.label}
</option>
))}
</select>
</div>
) : null}
<Button type="submit" disabled={savingManualSession || manualTicketIsRecurringBilling}> <Button type="submit" disabled={savingManualSession || manualTicketIsRecurringBilling}>
<Upload className="size-4" /> <Upload className="size-4" />
{savingManualSession ? "Speichert..." : "Nachtragen"} {savingManualSession ? "Speichert..." : "Nachtragen"}
@@ -1006,7 +1056,8 @@ export function TimerPage({ timers, setTimers, selectedTimerId, setSelectedTimer
/> />
</div> </div>
{finished ? ( {finished ? (
<div className="flex items-start gap-3 rounded-md border bg-muted/30 p-3"> <div className="space-y-3 rounded-md border bg-muted/30 p-3">
<div className="flex items-start gap-3">
<Checkbox <Checkbox
id="finished-zammad-article" id="finished-zammad-article"
checked={finished.createZammadArticle} checked={finished.createZammadArticle}
@@ -1024,6 +1075,26 @@ export function TimerPage({ timers, setTimers, selectedTimerId, setSelectedTimer
) : null} ) : null}
</div> </div>
</div> </div>
{finished.createZammadArticle ? (
<div className="space-y-2">
<label className="text-sm font-medium" htmlFor="finished-zammad-article-type">
Zammad-Typ
</label>
<select
id="finished-zammad-article-type"
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={zammadArticleTypeValue(finished.zammadArticleTypeId)}
onChange={(event) => setFinished((current) => (current ? { ...current, zammadArticleTypeId: zammadArticleTypeFromValue(event.currentTarget.value) } : current))}
>
{zammadArticleTypeOptions.map((option) => (
<option key={option.value || "empty"} value={option.value}>
{option.label}
</option>
))}
</select>
</div>
) : null}
</div>
) : null} ) : null}
{finished?.promptBillingStatus ? ( {finished?.promptBillingStatus ? (
<div className="space-y-2"> <div className="space-y-2">
+24
View File
@@ -0,0 +1,24 @@
export const zammadArticleTypeOptions = [
{ value: "4", label: "Dienstleistung" },
{ value: "1", label: "Koordination, Kommunikation und Dokumentation" },
{ value: "6", label: "Nicht kategorisiert" },
{ value: "2", label: "Schulungen, Workshops und Meetings" },
{ value: "5", label: "Support" },
{ value: "3", label: "Vor- und Nacharbeiten" }
] as const;
export type ZammadArticleTypeId = number;
export function zammadArticleTypeValue(typeId: ZammadArticleTypeId | null | undefined) {
return String(typeId ?? 6);
}
export function zammadArticleTypeFromValue(value: string): ZammadArticleTypeId {
return Number(value || 6);
}
export function zammadArticleTypeLabel(typeId: ZammadArticleTypeId | null | undefined) {
const value = zammadArticleTypeValue(typeId);
return zammadArticleTypeOptions.find((option) => option.value === value)?.label ?? "Nicht kategorisiert";
}