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

No files matched your search

+5 -2
View File
@@ -22,6 +22,7 @@ export type AuthUser = {
zammad_session_note_default_enabled: boolean;
zammad_time_unit_rounding_enabled: boolean;
zammad_time_unit_rounding_minutes: number;
zammad_article_type_id: number;
};
declare global {
@@ -87,7 +88,8 @@ function publicUser(user: AuthUser) {
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)
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.zammad_session_note_default_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
JOIN users u ON u.id = s.user_id
WHERE s.token_hash = $1
+158 -15
View File
@@ -240,6 +240,20 @@ function parseZammadTimeUnitRoundingMinutes(value: unknown, fallback = 15) {
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) {
if (value === undefined || value === null || value === "" || value === "all") {
return null;
@@ -635,12 +649,14 @@ type ZammadSessionArticlePayload = {
body: string;
content_type: "text/plain";
time_unit: number;
accounted_time_type_id: number;
};
function zammadSessionArticlePayload(input: {
zammadTicketId: string | number;
activity: string;
timeUnit: number;
articleTypeId: number;
}): ZammadSessionArticlePayload {
const ticketId = Number(input.zammadTicketId);
@@ -655,7 +671,8 @@ function zammadSessionArticlePayload(input: {
subject: "TicketTracker Session",
body: input.activity,
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;
activity: string;
timeUnit: number;
articleTypeId: number;
error: string;
}) {
await client.query(
@@ -684,14 +702,16 @@ async function enqueueZammadSessionArticle(client: pg.PoolClient, input: {
zammad_ticket_id,
activity,
time_unit,
zammad_article_type_id,
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'
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,
zammad_article_type_id = EXCLUDED.zammad_article_type_id,
last_error = EXCLUDED.last_error,
updated_at = now();
`,
@@ -703,11 +723,55 @@ async function enqueueZammadSessionArticle(client: pg.PoolClient, input: {
input.zammadTicketId,
input.activity,
input.timeUnit,
input.articleTypeId,
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) {
return isPlainObject(payload) ? numberOrNull(payload.id) : null;
}
@@ -721,6 +785,7 @@ type ZammadSessionArticleQueueRow = {
zammad_ticket_id: string | null;
activity: string;
time_unit: string | number;
zammad_article_type_id: number | null;
};
async function retryZammadSessionArticleQueueRow(row: ZammadSessionArticleQueueRow) {
@@ -750,7 +815,8 @@ async function retryZammadSessionArticleQueueRow(row: ZammadSessionArticleQueueR
const payload = zammadSessionArticlePayload({
zammadTicketId,
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 articleId = zammadArticleId(zammadResponse);
@@ -1013,6 +1079,7 @@ type SessionCreationInput = {
requestedProjectTeamspaceEntryId: number | null;
billingStatus: "billable" | "billed" | "non_billable" | null;
createZammadArticle: boolean;
zammadArticleTypeId: number;
startedAt: Date;
endedAt: Date;
durationSeconds: number;
@@ -1034,6 +1101,7 @@ async function createSessionForUser(input: SessionCreationInput) {
requestedProjectTeamspaceEntryId,
billingStatus,
createZammadArticle,
zammadArticleTypeId,
startedAt,
endedAt,
durationSeconds,
@@ -1251,6 +1319,18 @@ async function createSessionForUser(input: SessionCreationInput) {
);
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) {
const timeUnit = await zammadTimeUnitForUser(userId, durationSeconds);
let zammadArticleTicketId = ticket.zammad_ticket_id;
@@ -1276,9 +1356,23 @@ async function createSessionForUser(input: SessionCreationInput) {
const payload = zammadSessionArticlePayload({
zammadTicketId: zammadArticleTicketId,
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) {
zammadArticleError = errorMessage(error);
}
@@ -1287,6 +1381,8 @@ async function createSessionForUser(input: SessionCreationInput) {
}
if (zammadArticleError) {
zammadArticle.status = "queued";
zammadArticle.error = zammadArticleError;
await enqueueZammadSessionArticle(client, {
userId,
sessionId: session.id,
@@ -1295,6 +1391,7 @@ async function createSessionForUser(input: SessionCreationInput) {
zammadTicketId: zammadArticleTicketId,
activity,
timeUnit,
articleTypeId: zammadArticleTypeId,
error: zammadArticleError
});
}
@@ -1319,7 +1416,8 @@ async function createSessionForUser(input: SessionCreationInput) {
return {
ticket,
session
session,
zammadArticle
};
});
}
@@ -1675,6 +1773,7 @@ async function createZammadEmailSessionFromWebhook(payload: unknown, deliveryId:
requestedProjectTeamspaceEntryId: null,
billingStatus: null,
createZammadArticle: false,
zammadArticleTypeId: 6,
startedAt,
endedAt,
durationSeconds,
@@ -4433,6 +4532,27 @@ async function getPeriodTicket(config: PeriodConfig, period: ParsedPeriod, ticke
recurring_billing_id,
recurring_billing_slot_id,
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,
(
SELECT teamspace_project
@@ -4591,10 +4711,20 @@ async function getTicketDetails(ticketId: number, userId: string, filters: { mon
s.recurring_billing_id,
s.recurring_billing_slot_id,
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,
pte.teamspace_project AS project_teamspace_project
FROM sessions s
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}
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_time_unit_rounding_enabled: boolean;
zammad_time_unit_rounding_minutes: number;
zammad_article_type_id: number | null;
}>(
`
INSERT INTO users (username, display_name, password_hash, role, active)
VALUES ($1, $2, $3, 'admin', true)
RETURNING id, username, display_name, role, active, zammad_user_id::text AS zammad_user_id, (zammad_api_token IS NOT NULL) AS has_zammad_api_token, zammad_email_session_webhook_enabled, 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)]
);
@@ -4906,6 +5037,7 @@ app.post("/api/auth/login", async (req, res) => {
zammad_session_note_default_enabled: boolean;
zammad_time_unit_rounding_enabled: boolean;
zammad_time_unit_rounding_minutes: number;
zammad_article_type_id: number | null;
}>(
`
SELECT
@@ -4921,7 +5053,8 @@ app.post("/api/auth/login", async (req, res) => {
session_billing_prompt_enabled,
zammad_session_note_default_enabled,
zammad_time_unit_rounding_enabled,
zammad_time_unit_rounding_minutes
zammad_time_unit_rounding_minutes,
zammad_article_type_id
FROM users
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,
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
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"
? parseZammadTimeUnitRoundingMinutes(req.body.zammadTimeUnitRoundingMinutes, user.zammad_time_unit_rounding_minutes)
: 15;
const zammadArticleTypeId = user.role === "user"
? parseZammadArticleTypeId(req.body.zammadArticleTypeId, user.zammad_article_type_id)
: 6;
if (newPassword && newPassword.length < 6) {
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_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_article_type_id = CASE WHEN $8::boolean THEN $14 ELSE zammad_article_type_id END,
updated_at = now()
WHERE id = $4
RETURNING id, username, display_name, role, active, zammad_user_id::text AS zammad_user_id, (zammad_api_token IS NOT NULL) AS has_zammad_api_token, zammad_email_session_webhook_enabled, 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,
@@ -5238,7 +5376,8 @@ app.patch("/api/auth/me", async (req, res) => {
sessionBillingPromptEnabled,
zammadSessionNoteDefaultEnabled,
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) => {
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
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)
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]
);
@@ -5351,7 +5490,7 @@ app.patch("/api/admin/users/:userId", requireAdmin, async (req, res) => {
password_hash = COALESCE($4, password_hash),
updated_at = now()
WHERE id = $5
RETURNING id, username, display_name, role, active, zammad_user_id::text AS zammad_user_id, (zammad_api_token IS NOT NULL) AS has_zammad_api_token, zammad_email_session_webhook_enabled, 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]
);
@@ -6329,6 +6468,7 @@ app.post("/api/sessions", requireUser, async (req, res) => {
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 zammadArticleTypeId = parseZammadArticleTypeId(req.body.zammadArticleTypeId, user.zammad_article_type_id);
const startedAt = parseIsoDate(req.body.startedAt, "startedAt");
const endedAt = parseIsoDate(req.body.endedAt, "endedAt");
const durationSeconds = parsePositiveInteger(req.body.durationSeconds, "durationSeconds");
@@ -6343,6 +6483,7 @@ app.post("/api/sessions", requireUser, async (req, res) => {
requestedProjectTeamspaceEntryId,
billingStatus,
createZammadArticle,
zammadArticleTypeId,
startedAt,
endedAt,
durationSeconds
@@ -6362,6 +6503,7 @@ async function zammadSessionArticleQueueResponse(userId: string) {
q.zammad_ticket_id::text AS zammad_ticket_id,
q.activity,
q.time_unit::float AS time_unit,
q.zammad_article_type_id,
q.attempt_count,
q.last_error,
q.last_attempt_at,
@@ -6398,7 +6540,8 @@ app.post("/api/zammad/session-article-queue/retry", requireUser, async (req, res
ticket_number,
zammad_ticket_id::text AS zammad_ticket_id,
activity,
time_unit::text AS time_unit
time_unit::text AS time_unit,
zammad_article_type_id
FROM zammad_session_article_queue
WHERE user_id = $1
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_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_article_type_id INTEGER DEFAULT 6;");
await query("ALTER TABLE users ALTER COLUMN zammad_article_type_id DROP NOT NULL;");
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(`
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(`
CREATE TABLE IF NOT EXISTS auth_sessions (
@@ -266,6 +273,7 @@ export async function migrate() {
zammad_ticket_id BIGINT,
activity TEXT NOT NULL,
time_unit NUMERIC(10, 2) NOT NULL,
zammad_article_type_id INTEGER DEFAULT 6,
status TEXT NOT NULL DEFAULT 'pending',
attempt_count INTEGER NOT NULL DEFAULT 0,
last_error TEXT,
@@ -277,6 +285,13 @@ export async function migrate() {
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(`
CREATE UNIQUE INDEX IF NOT EXISTS idx_zammad_session_article_queue_pending_session
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.
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.
@@ -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.
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.
@@ -534,6 +534,7 @@ Unter `Profil` kann jeder Benutzer seine eigenen Daten pflegen:
- persoenlicher Zammad API-Token
- Bewertungsabfrage beim Timerabschluss
- interne Zammad-Notiz beim Timerabschluss
- Standard-Typ fuer interne Zammad-Notizen
- Zammad-Zeitaufrundung fuer `time_unit`
Passwortaenderungen erfordern das aktuelle Passwort. 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 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.
+12 -1
View File
@@ -32,6 +32,7 @@ type CreateSessionPayload = {
projectTeamspaceEntryId?: string | null;
billingStatus?: BillingStatus;
createZammadArticle?: boolean;
zammadArticleTypeId?: number;
};
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 = {
title: boolean;
organization: boolean;
@@ -205,6 +215,7 @@ export function updateCurrentUser(payload: {
zammadSessionNoteDefaultEnabled?: boolean;
zammadTimeUnitRoundingEnabled?: boolean;
zammadTimeUnitRoundingMinutes?: number;
zammadArticleTypeId?: number;
}) {
return request<{ user: AuthUser }>("/api/auth/me", {
method: "PATCH",
@@ -682,7 +693,7 @@ export function deleteTodo(todoId: string) {
}
export function createSession(payload: CreateSessionPayload) {
return request("/api/sessions", {
return request<CreateSessionResult>("/api/sessions", {
method: "POST",
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.",
"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.",
"Im Abschlussdialog kann die Tätigkeit als interne Zammad-Notiz übertragen werden. Der Article wird intern erstellt und bekommt die getrackte Zeit als time_unit sowie den gewählten Zammad-Typ.",
"Wenn Zammad beim Speichern nicht erreichbar ist, bleibt die Session trotzdem gespeichert. Die interne Notiz wird geparkt und kann später im Profil erneut gesendet werden.",
"Existiert das Ticket bereits, übernimmt TicketTracker Organisation und Art.",
"Bei Projekt-Tickets mit mehreren Teamspace-Einträgen zeigt der Beenden-Dialog eine Auswahl. Der Standard-Eintrag ist dort mit Standard gekennzeichnet.",
@@ -146,7 +146,7 @@ export const helpSections: HelpSection[] = [
"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.",
"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.",
"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.",
"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.",
"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.",
"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.",
+5
View File
@@ -17,6 +17,7 @@ export type AuthUser = {
zammad_session_note_default_enabled: boolean;
zammad_time_unit_rounding_enabled: boolean;
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";
@@ -75,6 +76,7 @@ export type ZammadSessionArticleQueueItem = {
zammad_ticket_id: string | null;
activity: string;
time_unit: number;
zammad_article_type_id: number | null;
attempt_count: number;
last_error: string | null;
last_attempt_at: string | null;
@@ -338,6 +340,9 @@ export type SessionEntry = {
recurring_billing_id: string | null;
recurring_billing_slot_id: 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 = {
+57 -30
View File
@@ -9,6 +9,7 @@ import { HelpLink } from "@/components/HelpLink";
import { getZammadSessionArticleQueue, retryZammadSessionArticleQueue, updateCurrentUser } from "../api";
import { formatDateTime, formatMinutes } from "../format";
import type { AuthUser, ZammadSessionArticleQueueItem } from "../types";
import { zammadArticleTypeFromValue, zammadArticleTypeLabel, zammadArticleTypeOptions, zammadArticleTypeValue, type ZammadArticleTypeId } from "../zammad-article-types";
type ProfilePageProps = {
currentUser: AuthUser;
@@ -28,6 +29,7 @@ export function ProfilePage({ currentUser, onUserUpdated }: ProfilePageProps) {
const [zammadSessionNoteDefaultEnabled, setZammadSessionNoteDefaultEnabled] = useState(currentUser.zammad_session_note_default_enabled);
const [zammadTimeUnitRoundingEnabled, setZammadTimeUnitRoundingEnabled] = useState(currentUser.zammad_time_unit_rounding_enabled);
const [zammadTimeUnitRoundingMinutes, setZammadTimeUnitRoundingMinutes] = useState(currentUser.zammad_time_unit_rounding_minutes);
const [zammadArticleTypeId, setZammadArticleTypeId] = useState<ZammadArticleTypeId>(currentUser.zammad_article_type_id ?? 6);
const [zammadQueue, setZammadQueue] = useState<ZammadSessionArticleQueueItem[]>([]);
const [loadingZammadQueue, setLoadingZammadQueue] = useState(false);
const [retryingZammadQueue, setRetryingZammadQueue] = useState(false);
@@ -43,6 +45,7 @@ export function ProfilePage({ currentUser, onUserUpdated }: ProfilePageProps) {
setZammadSessionNoteDefaultEnabled(currentUser.zammad_session_note_default_enabled);
setZammadTimeUnitRoundingEnabled(currentUser.zammad_time_unit_rounding_enabled);
setZammadTimeUnitRoundingMinutes(currentUser.zammad_time_unit_rounding_minutes);
setZammadArticleTypeId(currentUser.zammad_article_type_id ?? 6);
}, [currentUser]);
async function loadZammadQueue() {
@@ -125,7 +128,8 @@ export function ProfilePage({ currentUser, onUserUpdated }: ProfilePageProps) {
sessionBillingPromptEnabled,
zammadSessionNoteDefaultEnabled,
zammadTimeUnitRoundingEnabled,
zammadTimeUnitRoundingMinutes
zammadTimeUnitRoundingMinutes,
zammadArticleTypeId
}
: {})
};
@@ -334,41 +338,63 @@ export function ProfilePage({ currentUser, onUserUpdated }: ProfilePageProps) {
/>
</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
<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-rounding-minutes"
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={zammadTimeUnitRoundingMinutes}
onChange={(event) => setZammadTimeUnitRoundingMinutes(Number(event.currentTarget.value))}
value={zammadArticleTypeValue(zammadArticleTypeId)}
onChange={(event) => setZammadArticleTypeId(zammadArticleTypeFromValue(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>
{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>
) : null}
</div>
<div className="rounded-md border bg-background p-3">
<div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-2">
<Clock3 className="size-4 text-muted-foreground" />
<div>
<label className="text-sm font-medium" htmlFor="profile-zammad-time-rounding">
Zammad-Zeit aufrunden
</label>
<p className="text-xs text-muted-foreground">Ohne Rundung wird die konkrete Zeit als Stundenwert mit 2 Nachkommastellen gesendet.</p>
</div>
</div>
<Switch
id="profile-zammad-time-rounding"
checked={zammadTimeUnitRoundingEnabled}
onCheckedChange={setZammadTimeUnitRoundingEnabled}
/>
</div>
{zammadTimeUnitRoundingEnabled ? (
<div className="mt-3 grid gap-2 sm:w-56">
<label className="text-xs font-medium text-muted-foreground" htmlFor="profile-zammad-rounding-minutes">
Rundungsintervall
</label>
<select
id="profile-zammad-rounding-minutes"
className="h-9 rounded-md border border-input bg-background px-3 text-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
value={zammadTimeUnitRoundingMinutes}
onChange={(event) => setZammadTimeUnitRoundingMinutes(Number(event.currentTarget.value))}
>
<option value={5}>5 Minuten</option>
<option value={10}>10 Minuten</option>
<option value={15}>15 Minuten</option>
<option value={30}>30 Minuten</option>
</select>
</div>
) : null}
</div>
</div>
) : null}
<div className="mt-3 rounded-md border bg-background p-3">
@@ -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">{formatMinutes(item.rounded_minutes)}</span>
<span className="text-muted-foreground">time_unit {item.time_unit}</span>
<span className="text-muted-foreground">{zammadArticleTypeLabel(item.zammad_article_type_id)}</span>
</div>
{item.last_error ? <p className="mt-1 line-clamp-2 text-destructive">{item.last_error}</p> : null}
</div>
+61 -8
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 { toast } from "sonner";
import { Alert } from "@/components/ui/alert";
@@ -30,10 +30,12 @@ import {
updateProject,
updateProjectStatus
} from "../api";
import type { CreateSessionResult } from "../api";
import { currentDay, currentMonth, formatDate, formatDateTime, formatMinutes, formatTimeRange } from "../format";
import { ticketHasRecurringBilling } from "../recurring-billings";
import type { AuthUser, BillingStatus, PeriodType, ProjectSummary, ProjectTeamspaceEntry, SessionEntry, TicketDetailPeriodType, TicketPeriod, WorkType } from "../types";
import { ZammadPriorityBadge, ZammadStateBadge } from "../zammad-meta";
import { zammadArticleTypeFromValue, zammadArticleTypeLabel, zammadArticleTypeOptions, zammadArticleTypeValue, type ZammadArticleTypeId } from "../zammad-article-types";
import { cn } from "@/lib/utils";
type TicketDetailPageProps = {
@@ -63,6 +65,7 @@ type TicketSessionFormState = {
activity: string;
billingStatus: BillingStatus;
createZammadArticle: boolean;
zammadArticleTypeId: ZammadArticleTypeId;
workType: WorkType;
day: string;
startTime: string;
@@ -96,6 +99,7 @@ function emptyTicketSessionForm(): TicketSessionFormState {
activity: "",
billingStatus: null,
createZammadArticle: false,
zammadArticleTypeId: 6,
workType: "support",
day: currentDay(),
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() {
if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") {
return crypto.randomUUID();
@@ -599,16 +616,21 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onS
useEffect(() => {
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,
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(() => {
setProjectDescriptionExpanded(false);
@@ -1031,7 +1053,7 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onS
setSavingTicketSession(true);
try {
await createSession({
const result = await createSession({
ticketNumber: ticket.ticket_number,
organizationId: effectiveOrganizationId,
activity: ticketSession.activity,
@@ -1041,11 +1063,13 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onS
durationSeconds,
projectTeamspaceEntryId: effectiveWorkType === "consulting" && requiresProjectTeamspaceEntry ? ticketSession.projectTeamspaceEntryId : null,
billingStatus: ticketSession.billingStatus,
createZammadArticle: ticketSession.createZammadArticle
createZammadArticle: ticketSession.createZammadArticle,
zammadArticleTypeId: ticketSession.zammadArticleTypeId
});
toast.success("Session nachgetragen", {
description: "Der Eintrag wurde diesem Ticket zugeordnet. Betroffene Abschlüsse wurden wieder geöffnet."
});
notifyZammadArticleResult(result);
setTicketSession({
...emptyTicketSessionForm(),
organizationId: ticket.organization_id ?? "",
@@ -1054,7 +1078,8 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onS
workType: ticket.work_type ?? "support",
day: ticketSession.day,
billingStatus: null,
createZammadArticle: currentUser.zammad_session_note_default_enabled
createZammadArticle: currentUser.zammad_session_note_default_enabled,
zammadArticleTypeId: currentUser.zammad_article_type_id
});
await load();
} 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>
<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.work_type === "support" ? "Support" : "Consulting"}</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>Erfasst {formatDateTime(session.created_at)}</span>
</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>
</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}>
<Save className="size-4" />
{savingTicketSession ? "Speichert..." : "Nachtragen"}
+95 -24
View File
@@ -12,10 +12,12 @@ import { Textarea } from "@/components/ui/textarea";
import { HelpLink } from "@/components/HelpLink";
import { OrganizationSelect } from "@/components/OrganizationSelect";
import { createSession, getRecurringBillings, lookupTicket } from "../api";
import type { CreateSessionResult } from "../api";
import { formatTimer } from "../format";
import { isRecurringBillingTicketNumber, ticketHasRecurringBilling } from "../recurring-billings";
import { activeElapsedMs, createClientId, pauseEntry, resumeEntry, ticketPattern, type TimerEntry } from "../timers";
import type { AuthUser, BillingStatus, RecurringBilling, TicketMeta, WorkType } from "../types";
import { zammadArticleTypeFromValue, zammadArticleTypeOptions, zammadArticleTypeValue, type ZammadArticleTypeId } from "../zammad-article-types";
type ManualSessionFormState = {
ticketNumber: string;
@@ -25,6 +27,7 @@ type ManualSessionFormState = {
activity: string;
billingStatus: BillingStatus;
createZammadArticle: boolean;
zammadArticleTypeId: ZammadArticleTypeId;
workType: WorkType;
day: string;
startTime: string;
@@ -45,6 +48,7 @@ function emptyManualSession(): ManualSessionFormState {
activity: "",
billingStatus: null,
createZammadArticle: false,
zammadArticleTypeId: 6,
workType: "support",
day: currentDay(),
startTime: "09:00",
@@ -60,6 +64,7 @@ type FinishedSession = {
billingStatus: BillingStatus;
promptBillingStatus: boolean;
createZammadArticle: boolean;
zammadArticleTypeId: ZammadArticleTypeId;
startedAt: string;
endedAt: string;
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) {
const [ticketNumber, setTicketNumber] = useState("");
const [finished, setFinished] = useState<FinishedSession | null>(null);
@@ -225,16 +243,23 @@ export function TimerPage({ timers, setTimers, selectedTimerId, setSelectedTimer
useEffect(() => {
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,
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") {
const normalizedTicketNumber = ticketNumber.trim();
@@ -464,6 +489,7 @@ export function TimerPage({ timers, setTimers, selectedTimerId, setSelectedTimer
billingStatus: timer.billingStatus,
promptBillingStatus: currentUser.session_billing_prompt_enabled && timer.billingStatus === null,
createZammadArticle: currentUser.zammad_session_note_default_enabled,
zammadArticleTypeId: currentUser.zammad_article_type_id,
startedAt: new Date(stoppedTimer.startedAt).toISOString(),
endedAt: new Date(now).toISOString(),
durationSeconds,
@@ -522,7 +548,7 @@ export function TimerPage({ timers, setTimers, selectedTimerId, setSelectedTimer
setSaving(true);
try {
await createSession({
const result = await createSession({
ticketNumber: finished.ticketNumber,
startedAt: finished.startedAt,
endedAt: finished.endedAt,
@@ -533,12 +559,14 @@ export function TimerPage({ timers, setTimers, selectedTimerId, setSelectedTimer
zammadLookup: Boolean(finished.ticket?.zammad_ticket_id),
projectTeamspaceEntryId: finishedRequiresTeamspaceEntry ? projectTeamspaceEntryId : null,
billingStatus: finished.billingStatus,
createZammadArticle: finished.createZammadArticle
createZammadArticle: finished.createZammadArticle,
zammadArticleTypeId: finished.zammadArticleTypeId
});
toast.success("Session gespeichert", {
description: `${finished.ticketNumber} wurde mit ${finished.roundedMinutes} Minute(n) erfasst.`
});
notifyZammadArticleResult(result);
setTimers((current) => current.filter((timer) => timer.id !== finished.timerId));
setFinished(null);
setOrganizationId("");
@@ -601,7 +629,7 @@ export function TimerPage({ timers, setTimers, selectedTimerId, setSelectedTimer
setSavingManualSession(true);
try {
await createSession({
const result = await createSession({
ticketNumber: manualSession.ticketNumber.trim(),
organizationId: manualSession.organizationId,
activity: manualSession.activity,
@@ -612,15 +640,18 @@ export function TimerPage({ timers, setTimers, selectedTimerId, setSelectedTimer
zammadLookup: Boolean(manualLookupTicket?.zammad_ticket_id),
projectTeamspaceEntryId: requiresTeamspaceEntry(manualLookupTicket) ? manualSession.projectTeamspaceEntryId : null,
billingStatus: manualSession.billingStatus,
createZammadArticle: manualSession.createZammadArticle
createZammadArticle: manualSession.createZammadArticle,
zammadArticleTypeId: manualSession.zammadArticleTypeId
});
toast.success("Session nachgetragen", {
description: manualLookupTicket?.is_project ? "Der Eintrag wurde dem Projekt zugeordnet." : "Der Eintrag ist in deiner Ticketliste offen."
});
notifyZammadArticleResult(result);
setManualSession({
...emptyManualSession(),
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) {
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>
</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}>
<Upload className="size-4" />
{savingManualSession ? "Speichert..." : "Nachtragen"}
@@ -1006,23 +1056,44 @@ export function TimerPage({ timers, setTimers, selectedTimerId, setSelectedTimer
/>
</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 className="space-y-3 rounded-md border bg-muted/30 p-3">
<div className="flex items-start gap-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>
{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}
{finished?.promptBillingStatus ? (
+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";
}