Restrict admin to system management

This commit is contained in:
2026-08-06 08:07:26 +02:00
parent 0bef65ab75
commit 19e4621aeb
13 changed files with 634 additions and 882 deletions
+9
View File
@@ -155,6 +155,15 @@ export function requireAdmin(req: Request, res: Response, next: NextFunction) {
next(); next();
} }
export function requireUser(req: Request, res: Response, next: NextFunction) {
if (req.user?.role !== "user") {
res.status(403).json({ error: "Dieser Bereich ist nur für Benutzerkonten vorgesehen" });
return;
}
next();
}
export function currentUser(req: Request) { export function currentUser(req: Request) {
if (!req.user) { if (!req.user) {
throw Object.assign(new Error("Nicht angemeldet"), { status: 401 }); throw Object.assign(new Error("Nicht angemeldet"), { status: 401 });
+264 -294
View File
@@ -5,7 +5,7 @@ import path from "node:path";
import { fileURLToPath } from "node:url"; import { fileURLToPath } from "node:url";
import { migrate } from "./migrations.js"; import { migrate } from "./migrations.js";
import { pool, query, withTransaction } from "./db.js"; import { pool, query, withTransaction } from "./db.js";
import { clearAuthSession, createAuthSession, currentUser, hashPassword, requireAdmin, requireAuth, verifyPassword, type UserRole } from "./auth.js"; import { clearAuthSession, createAuthSession, currentUser, hashPassword, requireAdmin, requireAuth, requireUser, verifyPassword } from "./auth.js";
import { import {
badRequest, badRequest,
parseDay, parseDay,
@@ -84,14 +84,6 @@ function parseOptionalBilledMinutes(value: unknown) {
return minutes; return minutes;
} }
function parseUserRole(value: unknown) {
if (value !== "admin" && value !== "user") {
throw badRequest("role must be admin or user");
}
return value;
}
function parseZammadBaseUrl(value: unknown) { function parseZammadBaseUrl(value: unknown) {
const raw = requireString(value, "baseUrl").replace(/\/+$/, ""); const raw = requireString(value, "baseUrl").replace(/\/+$/, "");
@@ -1178,7 +1170,7 @@ app.post("/api/auth/login", async (req, res) => {
id: string; id: string;
username: string; username: string;
display_name: string; display_name: string;
role: UserRole; role: "admin" | "user";
active: boolean; active: boolean;
password_hash: string; password_hash: string;
}>( }>(
@@ -1295,7 +1287,6 @@ app.post("/api/admin/users", requireAdmin, async (req, res) => {
const username = parseUsername(req.body.username); const username = parseUsername(req.body.username);
const displayName = requireString(req.body.displayName, "displayName"); const displayName = requireString(req.body.displayName, "displayName");
const password = requireString(req.body.password, "password"); const password = requireString(req.body.password, "password");
const role = parseUserRole(req.body.role ?? "user");
const active = optionalBoolean(req.body.active, true); const active = optionalBoolean(req.body.active, true);
if (password.length < 6) { if (password.length < 6) {
@@ -1309,7 +1300,7 @@ app.post("/api/admin/users", requireAdmin, async (req, res) => {
VALUES ($1, $2, $3, $4, $5) VALUES ($1, $2, $3, $4, $5)
RETURNING id, username, display_name, role, active, created_at, updated_at; RETURNING id, username, display_name, role, active, created_at, updated_at;
`, `,
[username, displayName, await hashPassword(password), role, active] [username, displayName, await hashPassword(password), "user", active]
); );
res.status(201).json({ user: result.rows[0] }); res.status(201).json({ user: result.rows[0] });
@@ -1332,7 +1323,6 @@ app.patch("/api/admin/users/:userId", requireAdmin, async (req, res) => {
const userId = parsePositiveInteger(req.params.userId, "userId"); const userId = parsePositiveInteger(req.params.userId, "userId");
const username = parseUsername(req.body.username); const username = parseUsername(req.body.username);
const displayName = requireString(req.body.displayName, "displayName"); const displayName = requireString(req.body.displayName, "displayName");
const role = parseUserRole(req.body.role);
const active = optionalBoolean(req.body.active, true); const active = optionalBoolean(req.body.active, true);
const password = optionalString(req.body.password); const password = optionalString(req.body.password);
@@ -1340,8 +1330,21 @@ app.patch("/api/admin/users/:userId", requireAdmin, async (req, res) => {
throw badRequest("password must be at least 6 characters"); throw badRequest("password must be at least 6 characters");
} }
if (currentUser(req).id === String(userId) && (!active || role !== "admin")) { const existingResult = await query<{ id: string; role: "admin" | "user" }>("SELECT id, role FROM users WHERE id = $1;", [userId]);
res.status(409).json({ error: "Du kannst deinen eigenen Admin-Zugang nicht deaktivieren oder herabstufen" }); const existingUser = existingResult.rows[0] ?? null;
if (!existingUser) {
res.status(404).json({ error: "User not found" });
return;
}
if (existingUser.role === "admin" && currentUser(req).id !== String(userId)) {
res.status(409).json({ error: "Der feste Admin kann nicht über die Benutzerliste bearbeitet werden" });
return;
}
if (existingUser.role === "admin" && !active) {
res.status(409).json({ error: "Der feste Admin kann nicht deaktiviert werden" });
return; return;
} }
@@ -1351,14 +1354,14 @@ app.patch("/api/admin/users/:userId", requireAdmin, async (req, res) => {
UPDATE users UPDATE users
SET username = $1, SET username = $1,
display_name = $2, display_name = $2,
role = $3, role = role,
active = $4, active = $3,
password_hash = COALESCE($5, password_hash), password_hash = COALESCE($4, password_hash),
updated_at = now() updated_at = now()
WHERE id = $6 WHERE id = $5
RETURNING id, username, display_name, role, active, created_at, updated_at; RETURNING id, username, display_name, role, active, created_at, updated_at;
`, `,
[username, displayName, role, active, password ? await hashPassword(password) : null, userId] [username, displayName, active, password ? await hashPassword(password) : null, userId]
); );
if (result.rowCount === 0) { if (result.rowCount === 0) {
@@ -1588,6 +1591,202 @@ app.post("/api/admin/zammad/organizations/sync", requireAdmin, async (req, res)
}); });
}); });
const databaseExportTables = [
"users",
"auth_sessions",
"app_settings",
"organizations",
"tickets",
"recurring_billings",
"recurring_billing_slots",
"recurring_billing_exceptions",
"sessions",
"ticket_month_closures",
"month_closures",
"ticket_day_closures",
"day_closures",
"ticket_day_billings",
"ticket_day_billing_acknowledgements"
] as const;
function quoteIdentifier(value: string) {
return `"${value.replace(/"/g, '""')}"`;
}
function quoteLiteral(value: unknown) {
if (value === null || value === undefined) {
return "NULL";
}
if (value instanceof Date) {
return `'${value.toISOString().replace(/'/g, "''")}'`;
}
if (Buffer.isBuffer(value)) {
return `decode('${value.toString("hex")}', 'hex')`;
}
return `'${String(value).replace(/'/g, "''")}'`;
}
async function exportTableSql(table: string) {
const columnsResult = await query<{ column_name: string }>(
`
SELECT column_name
FROM information_schema.columns
WHERE table_schema = 'public'
AND table_name = $1
ORDER BY ordinal_position ASC;
`,
[table]
);
const columns = columnsResult.rows.map((row) => row.column_name);
if (columns.length === 0) {
return [`-- Tabelle ${table} existiert nicht im aktuellen Schema.`];
}
const rowsResult = await query<Record<string, unknown>>(
`
SELECT ${columns.map(quoteIdentifier).join(", ")}
FROM ${quoteIdentifier(table)}
ORDER BY ${columns.map(quoteIdentifier).join(", ")};
`
);
const lines = [`-- ${table}: ${rowsResult.rows.length} Datensatz/Datensätze`];
for (const row of rowsResult.rows) {
lines.push(
`INSERT INTO ${quoteIdentifier(table)} (${columns.map(quoteIdentifier).join(", ")}) VALUES (${columns.map((column) => quoteLiteral(row[column])).join(", ")});`
);
}
if (columns.includes("id")) {
lines.push(
`SELECT setval(pg_get_serial_sequence(${quoteLiteral(table)}, 'id'), COALESCE((SELECT MAX("id") FROM ${quoteIdentifier(table)}), 1), COALESCE((SELECT MAX("id") FROM ${quoteIdentifier(table)}), 0) > 0);`
);
}
return lines;
}
app.get("/api/admin/export/database", requireAdmin, async (_req, res) => {
const lines = [
"-- TicketTracker Datenbankexport",
`-- Erstellt: ${new Date().toISOString()}`,
"-- Restore: frische TicketTracker-Datenbank migrieren lassen, danach diese Datei mit psql einspielen.",
"BEGIN;",
`TRUNCATE TABLE ${[...databaseExportTables].reverse().map(quoteIdentifier).join(", ")} RESTART IDENTITY CASCADE;`
];
for (const table of databaseExportTables) {
lines.push("", ...(await exportTableSql(table)));
}
lines.push("", "COMMIT;", "");
const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
res.setHeader("Content-Type", "application/sql; charset=utf-8");
res.setHeader("Content-Disposition", `attachment; filename="tickettracker-backup-${timestamp}.sql"`);
res.send(lines.join("\n"));
});
function csvCell(value: unknown) {
if (value === null || value === undefined) {
return "";
}
const text = String(value);
if (/[;"\n\r]/.test(text)) {
return `"${text.replace(/"/g, '""')}"`;
}
return text;
}
app.get("/api/export/months/:month", requireUser, async (req, res) => {
const month = parseMonth(String(req.params.month));
const user = currentUser(req);
await ensureRecurringSessionsForUserPeriod(user.id, month.startIso, month.endIso);
const result = await query<{
ticket_number: string;
organization_name: string;
work_type: string;
day: string;
start_time: string;
end_time: string;
rounded_minutes: number;
billing_status: string | null;
teamspace_minutes: number | null;
source: string;
activity: string;
}>(
`
SELECT
t.ticket_number,
COALESCE(so.name, ot.name, s.customer_name, t.customer_name, '') AS organization_name,
s.work_type,
to_char(s.started_at AT TIME ZONE 'Europe/Berlin', 'YYYY-MM-DD') AS day,
to_char(s.started_at AT TIME ZONE 'Europe/Berlin', 'HH24:MI') AS start_time,
to_char(s.ended_at AT TIME ZONE 'Europe/Berlin', 'HH24:MI') AS end_time,
s.rounded_minutes,
s.billing_status,
tdb.billed_minutes AS teamspace_minutes,
CASE WHEN s.recurring_billing_id IS NULL THEN 'manuell' ELSE 'fix' END AS source,
s.activity
FROM sessions s
JOIN tickets t ON t.id = s.ticket_id
LEFT JOIN organizations so ON so.id = s.organization_id
LEFT JOIN organizations ot ON ot.id = t.organization_id
LEFT JOIN ticket_day_billings tdb
ON tdb.ticket_id = s.ticket_id
AND tdb.user_id = s.user_id
AND tdb.day = (s.started_at AT TIME ZONE 'Europe/Berlin')::date
WHERE s.started_at >= $1::timestamptz
AND s.started_at < $2::timestamptz
AND s.user_id = $3
ORDER BY s.started_at ASC, t.ticket_number ASC, s.id ASC;
`,
[month.startIso, month.endIso, user.id]
);
const header = [
"Ticket",
"Organisation",
"Art",
"Datum",
"Von",
"Bis",
"Session-Minuten",
"Bewertung",
"Teamspace-Minuten",
"Quelle",
"Taetigkeit"
];
const rows = result.rows.map((row) => [
row.ticket_number,
row.organization_name,
row.work_type === "support" ? "Support" : "Consulting",
row.day,
row.start_time,
row.end_time,
row.rounded_minutes,
row.billing_status === "billed" ? "Abgerechnet" : row.billing_status === "non_billable" ? "Nicht abrechenbar" : "Offen",
row.teamspace_minutes ?? "",
row.source === "fix" ? "Fixe Abrechnung" : "Manuell",
row.activity
]);
const csv = [`\uFEFF${header.map(csvCell).join(";")}`, ...rows.map((row) => row.map(csvCell).join(";"))].join("\n");
res.setHeader("Content-Type", "text/csv; charset=utf-8");
res.setHeader("Content-Disposition", `attachment; filename="tickettracker-${user.username}-${month.label}.csv"`);
res.send(csv);
});
function parseRecurrenceType(value: unknown) { function parseRecurrenceType(value: unknown) {
if (value !== "weekly" && value !== "every_n_weeks") { if (value !== "weekly" && value !== "every_n_weeks") {
throw badRequest("recurrenceType must be weekly or every_n_weeks"); throw badRequest("recurrenceType must be weekly or every_n_weeks");
@@ -1722,11 +1921,11 @@ async function recurringBillingResponse(userId: string) {
return result.rows; return result.rows;
} }
app.get("/api/recurring-billings", async (req, res) => { app.get("/api/recurring-billings", requireUser, async (req, res) => {
res.json({ recurringBillings: await recurringBillingResponse(currentUser(req).id) }); res.json({ recurringBillings: await recurringBillingResponse(currentUser(req).id) });
}); });
app.post("/api/recurring-billings", async (req, res) => { app.post("/api/recurring-billings", requireUser, async (req, res) => {
const userId = currentUser(req).id; const userId = currentUser(req).id;
const payload = parseRecurringBillingPayload(req.body); const payload = parseRecurringBillingPayload(req.body);
@@ -1789,7 +1988,7 @@ app.post("/api/recurring-billings", async (req, res) => {
res.status(201).json({ recurringBillings: await recurringBillingResponse(userId) }); res.status(201).json({ recurringBillings: await recurringBillingResponse(userId) });
}); });
app.patch("/api/recurring-billings/:billingId", async (req, res) => { app.patch("/api/recurring-billings/:billingId", requireUser, async (req, res) => {
const billingId = parsePositiveInteger(req.params.billingId, "billingId"); const billingId = parsePositiveInteger(req.params.billingId, "billingId");
const userId = currentUser(req).id; const userId = currentUser(req).id;
const active = optionalBoolean(req.body.active, true); const active = optionalBoolean(req.body.active, true);
@@ -2045,7 +2244,7 @@ app.patch("/api/recurring-billings/:billingId", async (req, res) => {
res.json({ recurringBillings: await recurringBillingResponse(userId) }); res.json({ recurringBillings: await recurringBillingResponse(userId) });
}); });
app.delete("/api/recurring-billings/:billingId", async (req, res) => { app.delete("/api/recurring-billings/:billingId", requireUser, async (req, res) => {
const billingId = parsePositiveInteger(req.params.billingId, "billingId"); const billingId = parsePositiveInteger(req.params.billingId, "billingId");
const userId = currentUser(req).id; const userId = currentUser(req).id;
const result = await withTransaction(async (client) => { const result = await withTransaction(async (client) => {
@@ -2097,247 +2296,18 @@ app.delete("/api/recurring-billings/:billingId", async (req, res) => {
}); });
app.get("/api/admin/sessions", requireAdmin, async (_req, res) => { app.get("/api/admin/sessions", requireAdmin, async (_req, res) => {
const result = await query( res.status(410).json({ error: "Session-Verwaltung ist für den festen Admin deaktiviert" });
`
SELECT
s.id,
s.ticket_id,
s.user_id,
owner.username AS owner_username,
owner.display_name AS owner_display_name,
t.ticket_number,
s.organization_id,
so.name AS organization_name,
COALESCE(so.name, s.customer_name) AS customer_name,
s.activity,
s.work_type,
s.started_at,
s.ended_at,
s.duration_seconds,
s.rounded_minutes,
s.billing_status,
s.created_at,
s.recurring_billing_id,
s.recurring_billing_slot_id,
s.recurring_occurrence_date
FROM sessions s
JOIN tickets t ON t.id = s.ticket_id
JOIN users owner ON owner.id = s.user_id
LEFT JOIN organizations so ON so.id = s.organization_id
ORDER BY s.started_at DESC
LIMIT 500;
`
);
res.json({ sessions: result.rows });
}); });
app.post("/api/admin/sessions", requireAdmin, async (req, res) => { app.post("/api/admin/sessions", requireAdmin, async (_req, res) => {
const userId = parsePositiveInteger(req.body.userId, "userId"); res.status(410).json({ error: "Der feste Admin erfasst keine Sessions" });
const ticketNumber = parseTicketNumber(req.body.ticketNumber);
const organizationId = parseOrganizationId(req.body.organizationId);
const activity = requireString(req.body.activity, "activity");
const workType = parseWorkType(req.body.workType);
const startedAt = parseIsoDate(req.body.startedAt, "startedAt");
const endedAt = parseIsoDate(req.body.endedAt, "endedAt");
if (endedAt <= startedAt) {
throw badRequest("endedAt must be after startedAt");
}
const durationSeconds = Math.floor((endedAt.getTime() - startedAt.getTime()) / 1000);
const roundedMinutes = Math.max(1, Math.round(durationSeconds / 60));
const created = await withTransaction(async (client) => {
const userResult = await client.query<{ id: string }>(
`
SELECT id
FROM users
WHERE id = $1
AND active = true;
`,
[userId]
);
if (userResult.rows.length === 0) {
return null;
}
const organization = await getOrganizationById(client, organizationId);
if (!organization) {
return "organization-not-found" as const;
}
const ticketResult = await client.query<{ id: string; ticket_number: string; organization_id: string | null; customer_name: string | null; work_type: string | null }>(
`
INSERT INTO tickets (ticket_number, organization_id, customer_name, work_type)
VALUES ($1, $2, $3, $4)
ON CONFLICT (ticket_number)
DO UPDATE SET
organization_id = COALESCE(tickets.organization_id, EXCLUDED.organization_id),
customer_name = COALESCE(tickets.customer_name, EXCLUDED.customer_name),
work_type = COALESCE(tickets.work_type, EXCLUDED.work_type)
RETURNING id, ticket_number, organization_id, customer_name, work_type;
`,
[ticketNumber, organization.id, organization.name, workType]
);
const sessionResult = await client.query<{ id: string }>(
`
INSERT INTO sessions (
ticket_id,
organization_id,
customer_name,
activity,
work_type,
user_id,
started_at,
ended_at,
duration_seconds,
rounded_minutes
)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
RETURNING id;
`,
[
ticketResult.rows[0].id,
organization.id,
organization.name,
activity,
workType,
userId,
startedAt.toISOString(),
endedAt.toISOString(),
durationSeconds,
roundedMinutes
]
);
await reopenPeriodsForSession(client, ticketResult.rows[0].id, startedAt, String(userId));
return sessionResult.rows[0].id;
}); });
if (created === "organization-not-found") { app.patch("/api/admin/sessions/:sessionId/owner", requireAdmin, async (_req, res) => {
res.status(404).json({ error: "Organisation nicht gefunden oder inaktiv" }); res.status(410).json({ error: "Session-Verwaltung ist für den festen Admin deaktiviert" });
return;
}
if (!created) {
res.status(404).json({ error: "Zielbenutzer nicht gefunden oder inaktiv" });
return;
}
const result = await query(
`
SELECT
s.id,
s.ticket_id,
s.user_id,
owner.username AS owner_username,
owner.display_name AS owner_display_name,
t.ticket_number,
s.organization_id,
so.name AS organization_name,
COALESCE(so.name, s.customer_name) AS customer_name,
s.activity,
s.work_type,
s.started_at,
s.ended_at,
s.duration_seconds,
s.rounded_minutes,
s.billing_status,
s.created_at,
s.recurring_billing_id,
s.recurring_billing_slot_id,
s.recurring_occurrence_date
FROM sessions s
JOIN tickets t ON t.id = s.ticket_id
JOIN users owner ON owner.id = s.user_id
LEFT JOIN organizations so ON so.id = s.organization_id
WHERE s.id = $1;
`,
[created]
);
res.status(201).json({ session: result.rows[0] });
}); });
app.patch("/api/admin/sessions/:sessionId/owner", requireAdmin, async (req, res) => { app.post("/api/sessions", requireUser, async (req, res) => {
const sessionId = parsePositiveInteger(req.params.sessionId, "sessionId");
const nextUserId = parsePositiveInteger(req.body.userId, "userId");
const reassigned = await withTransaction(async (client) => {
const targetResult = await client.query<{ id: string }>(
`
SELECT id
FROM users
WHERE id = $1
AND active = true;
`,
[nextUserId]
);
if (targetResult.rows.length === 0) {
return { status: "target-not-found" as const };
}
const currentResult = await client.query<{
id: string;
ticket_id: string;
user_id: string;
started_at: string;
}>(
`
SELECT id, ticket_id, user_id, started_at
FROM sessions
WHERE id = $1
FOR UPDATE;
`,
[sessionId]
);
if (currentResult.rows.length === 0) {
return { status: "session-not-found" as const };
}
const session = currentResult.rows[0];
if (session.user_id === String(nextUserId)) {
return { status: "ok" as const, session };
}
await reopenPeriodsForSession(client, session.ticket_id, new Date(session.started_at), session.user_id);
await reopenPeriodsForSession(client, session.ticket_id, new Date(session.started_at), String(nextUserId));
const updatedResult = await client.query(
`
UPDATE sessions
SET user_id = $1
WHERE id = $2
RETURNING *;
`,
[nextUserId, sessionId]
);
return { status: "ok" as const, session: updatedResult.rows[0] };
});
if (reassigned.status === "target-not-found") {
res.status(404).json({ error: "Zielbenutzer nicht gefunden oder inaktiv" });
return;
}
if (reassigned.status === "session-not-found") {
res.status(404).json({ error: "Session not found" });
return;
}
res.json({ session: reassigned.session });
});
app.post("/api/sessions", async (req, res) => {
const user = currentUser(req); const user = currentUser(req);
const ticketNumber = parseTrackableTicketNumber(req.body.ticketNumber); const ticketNumber = parseTrackableTicketNumber(req.body.ticketNumber);
const activity = requireString(req.body.activity, "activity"); const activity = requireString(req.body.activity, "activity");
@@ -2438,17 +2408,17 @@ app.post("/api/sessions", async (req, res) => {
res.status(201).json(created); res.status(201).json(created);
}); });
app.get("/api/periods/:periodType/:period/overview", async (req, res) => { app.get("/api/periods/:periodType/:period/overview", requireUser, async (req, res) => {
const { config, period } = parsePeriod(req.params.periodType, req.params.period); const { config, period } = parsePeriod(String(req.params.periodType), String(req.params.period));
res.json(await getPeriodOverview(config, period, currentUser(req).id)); res.json(await getPeriodOverview(config, period, currentUser(req).id));
}); });
app.get("/api/statistics/months/:month", async (req, res) => { app.get("/api/statistics/months/:month", requireUser, async (req, res) => {
const month = parseMonth(req.params.month); const month = parseMonth(String(req.params.month));
res.json(await getStatisticsOverview(month, currentUser(req).id)); res.json(await getStatisticsOverview(month, currentUser(req).id));
}); });
app.get("/api/tickets/lookup", async (req, res) => { app.get("/api/tickets/lookup", requireUser, async (req, res) => {
const ticketNumber = parseTicketNumber(req.query.ticketNumber); const ticketNumber = parseTicketNumber(req.query.ticketNumber);
const result = await query( const result = await query(
` `
@@ -2471,7 +2441,7 @@ app.get("/api/tickets/lookup", async (req, res) => {
}); });
}); });
app.patch("/api/tickets/:ticketId", async (req, res) => { app.patch("/api/tickets/:ticketId", requireUser, async (req, res) => {
const ticketId = parsePositiveInteger(req.params.ticketId, "ticketId"); const ticketId = parsePositiveInteger(req.params.ticketId, "ticketId");
const ticketNumber = parseTrackableTicketNumber(req.body.ticketNumber); const ticketNumber = parseTrackableTicketNumber(req.body.ticketNumber);
const organizationId = parseOrganizationId(req.body.organizationId); const organizationId = parseOrganizationId(req.body.organizationId);
@@ -2537,8 +2507,8 @@ app.patch("/api/tickets/:ticketId", async (req, res) => {
} }
}); });
app.get("/api/periods/:periodType/:period/tickets/:ticketId", async (req, res) => { app.get("/api/periods/:periodType/:period/tickets/:ticketId", requireUser, async (req, res) => {
const { config, period } = parsePeriod(req.params.periodType, req.params.period); const { config, period } = parsePeriod(String(req.params.periodType), String(req.params.period));
const ticketId = parsePositiveInteger(req.params.ticketId, "ticketId"); const ticketId = parsePositiveInteger(req.params.ticketId, "ticketId");
const result = await getPeriodTicket(config, period, ticketId, currentUser(req).id); const result = await getPeriodTicket(config, period, ticketId, currentUser(req).id);
@@ -2550,9 +2520,9 @@ app.get("/api/periods/:periodType/:period/tickets/:ticketId", async (req, res) =
res.json(result); res.json(result);
}); });
app.patch("/api/tickets/:ticketId/day-billings/:day", async (req, res) => { app.patch("/api/tickets/:ticketId/day-billings/:day", requireUser, async (req, res) => {
const ticketId = parsePositiveInteger(req.params.ticketId, "ticketId"); const ticketId = parsePositiveInteger(req.params.ticketId, "ticketId");
const day = parseDay(req.params.day); const day = parseDay(String(req.params.day));
const billedMinutes = parseOptionalBilledMinutes(req.body.billedMinutes); const billedMinutes = parseOptionalBilledMinutes(req.body.billedMinutes);
const userId = currentUser(req).id; const userId = currentUser(req).id;
@@ -2612,9 +2582,9 @@ app.patch("/api/tickets/:ticketId/day-billings/:day", async (req, res) => {
res.json({ dayBilling: result.rows[0] }); res.json({ dayBilling: result.rows[0] });
}); });
app.patch("/api/tickets/:ticketId/day-billings/:day/acknowledgement", async (req, res) => { app.patch("/api/tickets/:ticketId/day-billings/:day/acknowledgement", requireUser, async (req, res) => {
const ticketId = parsePositiveInteger(req.params.ticketId, "ticketId"); const ticketId = parsePositiveInteger(req.params.ticketId, "ticketId");
const day = parseDay(req.params.day); const day = parseDay(String(req.params.day));
const acknowledged = req.body.acknowledged !== false; const acknowledged = req.body.acknowledged !== false;
const userId = currentUser(req).id; const userId = currentUser(req).id;
@@ -2664,20 +2634,20 @@ app.patch("/api/tickets/:ticketId/day-billings/:day/acknowledgement", async (req
res.json({ acknowledged: true, acknowledgement: result.rows[0] }); res.json({ acknowledged: true, acknowledgement: result.rows[0] });
}); });
app.post("/api/periods/:periodType/:period/tickets/:ticketId/close", async (req, res) => { app.post("/api/periods/:periodType/:period/tickets/:ticketId/close", requireUser, async (req, res) => {
parsePeriod(req.params.periodType, req.params.period); parsePeriod(String(req.params.periodType), String(req.params.period));
parsePositiveInteger(req.params.ticketId, "ticketId"); parsePositiveInteger(req.params.ticketId, "ticketId");
res.status(410).json({ error: "Ticketabschlüsse werden nicht verwendet. Bitte Sessions bewerten und den Monat abschließen." }); res.status(410).json({ error: "Ticketabschlüsse werden nicht verwendet. Bitte Sessions bewerten und den Monat abschließen." });
}); });
app.post("/api/periods/:periodType/:period/tickets/:ticketId/reopen", async (req, res) => { app.post("/api/periods/:periodType/:period/tickets/:ticketId/reopen", requireUser, async (req, res) => {
parsePeriod(req.params.periodType, req.params.period); parsePeriod(String(req.params.periodType), String(req.params.period));
parsePositiveInteger(req.params.ticketId, "ticketId"); parsePositiveInteger(req.params.ticketId, "ticketId");
res.status(410).json({ error: "Ticketabschlüsse werden nicht verwendet. Bitte den Monat öffnen." }); res.status(410).json({ error: "Ticketabschlüsse werden nicht verwendet. Bitte den Monat öffnen." });
}); });
app.post("/api/periods/:periodType/:period/close", async (req, res) => { app.post("/api/periods/:periodType/:period/close", requireUser, async (req, res) => {
const { config, period } = parsePeriod(req.params.periodType, req.params.period); const { config, period } = parsePeriod(String(req.params.periodType), String(req.params.period));
const userId = currentUser(req).id; const userId = currentUser(req).id;
if (config.type !== "month") { if (config.type !== "month") {
@@ -2727,8 +2697,8 @@ app.post("/api/periods/:periodType/:period/close", async (req, res) => {
res.json({ closure: result.rows[0] }); res.json({ closure: result.rows[0] });
}); });
app.post("/api/periods/:periodType/:period/reopen", async (req, res) => { app.post("/api/periods/:periodType/:period/reopen", requireUser, async (req, res) => {
const { config, period } = parsePeriod(req.params.periodType, req.params.period); const { config, period } = parsePeriod(String(req.params.periodType), String(req.params.period));
const userId = currentUser(req).id; const userId = currentUser(req).id;
if (config.type !== "month") { if (config.type !== "month") {
@@ -2752,8 +2722,8 @@ app.use("/api/months", (_req, res) => {
res.status(410).json({ error: "Legacy month API removed. Use /api/periods/months instead." }); res.status(410).json({ error: "Legacy month API removed. Use /api/periods/months instead." });
}); });
app.get("/api/months/:month/overview", async (req, res) => { app.get("/api/months/:month/overview", requireUser, async (req, res) => {
const month = parseMonth(req.params.month); const month = parseMonth(String(req.params.month));
const [ticketsResult, openResult, monthClosureResult] = await Promise.all([ const [ticketsResult, openResult, monthClosureResult] = await Promise.all([
query( query(
@@ -2824,8 +2794,8 @@ app.get("/api/months/:month/overview", async (req, res) => {
}); });
}); });
app.get("/api/months/:month/tickets/:ticketId", async (req, res) => { app.get("/api/months/:month/tickets/:ticketId", requireUser, async (req, res) => {
const month = parseMonth(req.params.month); const month = parseMonth(String(req.params.month));
const ticketId = parsePositiveInteger(req.params.ticketId, "ticketId"); const ticketId = parsePositiveInteger(req.params.ticketId, "ticketId");
const ticketResult = await query( const ticketResult = await query(
@@ -2887,7 +2857,7 @@ app.get("/api/months/:month/tickets/:ticketId", async (req, res) => {
}); });
}); });
app.patch("/api/sessions/:sessionId/billing", async (req, res) => { app.patch("/api/sessions/:sessionId/billing", requireUser, async (req, res) => {
const sessionId = parsePositiveInteger(req.params.sessionId, "sessionId"); const sessionId = parsePositiveInteger(req.params.sessionId, "sessionId");
const billingStatus = parseBillingStatus(req.body.billingStatus); const billingStatus = parseBillingStatus(req.body.billingStatus);
const userId = currentUser(req).id; const userId = currentUser(req).id;
@@ -2949,7 +2919,7 @@ app.patch("/api/sessions/:sessionId/billing", async (req, res) => {
res.json({ session: result.rows[0] }); res.json({ session: result.rows[0] });
}); });
app.patch("/api/sessions/:sessionId/details", async (req, res) => { app.patch("/api/sessions/:sessionId/details", requireUser, async (req, res) => {
const sessionId = parsePositiveInteger(req.params.sessionId, "sessionId"); const sessionId = parsePositiveInteger(req.params.sessionId, "sessionId");
const userId = currentUser(req).id; const userId = currentUser(req).id;
const organizationId = parseOrganizationId(req.body.organizationId); const organizationId = parseOrganizationId(req.body.organizationId);
@@ -3042,7 +3012,7 @@ app.patch("/api/sessions/:sessionId/details", async (req, res) => {
res.json({ session: updated }); res.json({ session: updated });
}); });
app.delete("/api/sessions/:sessionId", async (req, res) => { app.delete("/api/sessions/:sessionId", requireUser, async (req, res) => {
const sessionId = parsePositiveInteger(req.params.sessionId, "sessionId"); const sessionId = parsePositiveInteger(req.params.sessionId, "sessionId");
const userId = currentUser(req).id; const userId = currentUser(req).id;
@@ -3125,8 +3095,8 @@ app.delete("/api/sessions/:sessionId", async (req, res) => {
res.json({ deleted }); res.json({ deleted });
}); });
app.post("/api/months/:month/tickets/:ticketId/close", async (req, res) => { app.post("/api/months/:month/tickets/:ticketId/close", requireUser, async (req, res) => {
const month = parseMonth(req.params.month); const month = parseMonth(String(req.params.month));
const ticketId = parsePositiveInteger(req.params.ticketId, "ticketId"); const ticketId = parsePositiveInteger(req.params.ticketId, "ticketId");
const statusResult = await query( const statusResult = await query(
@@ -3168,8 +3138,8 @@ app.post("/api/months/:month/tickets/:ticketId/close", async (req, res) => {
res.json({ closure: closureResult.rows[0] }); res.json({ closure: closureResult.rows[0] });
}); });
app.post("/api/months/:month/tickets/:ticketId/reopen", async (req, res) => { app.post("/api/months/:month/tickets/:ticketId/reopen", requireUser, async (req, res) => {
const month = parseMonth(req.params.month); const month = parseMonth(String(req.params.month));
const ticketId = parsePositiveInteger(req.params.ticketId, "ticketId"); const ticketId = parsePositiveInteger(req.params.ticketId, "ticketId");
const result = await withTransaction(async (client) => { const result = await withTransaction(async (client) => {
@@ -3200,8 +3170,8 @@ app.post("/api/months/:month/tickets/:ticketId/reopen", async (req, res) => {
res.json(result); res.json(result);
}); });
app.post("/api/months/:month/close", async (req, res) => { app.post("/api/months/:month/close", requireUser, async (req, res) => {
const month = parseMonth(req.params.month); const month = parseMonth(String(req.params.month));
const blockersResult = await query( const blockersResult = await query(
` `
@@ -3244,8 +3214,8 @@ app.post("/api/months/:month/close", async (req, res) => {
res.json({ closure: result.rows[0] }); res.json({ closure: result.rows[0] });
}); });
app.post("/api/months/:month/reopen", async (req, res) => { app.post("/api/months/:month/reopen", requireUser, async (req, res) => {
const month = parseMonth(req.params.month); const month = parseMonth(String(req.params.month));
const result = await query( const result = await query(
` `
+3
View File
@@ -76,6 +76,9 @@ export async function migrate() {
const adminId = await ensureMigrationAdmin(); const adminId = await ensureMigrationAdmin();
await query("UPDATE users SET role = 'user', updated_at = now() WHERE role = 'admin' AND id <> $1;", [adminId]);
await query("CREATE UNIQUE INDEX IF NOT EXISTS idx_users_single_admin ON users ((role)) WHERE role = 'admin';");
await query(` await query(`
CREATE TABLE IF NOT EXISTS app_settings ( CREATE TABLE IF NOT EXISTS app_settings (
key TEXT PRIMARY KEY, key TEXT PRIMARY KEY,
+11 -8
View File
@@ -80,7 +80,7 @@ Die gemessene Zeit wird auf Minuten gerundet. Pausen werden von der Gesamtdauer
Sessions koennen manuell nachgetragen werden, zum Beispiel wenn ein Timer vergessen wurde. Sessions koennen manuell nachgetragen werden, zum Beispiel wenn ein Timer vergessen wurde.
Normale Benutzer koennen eigene Sessions nachtragen. Administratoren koennen Sessions fuer andere Benutzer nachtragen. Normale Benutzer koennen eigene Sessions nachtragen. Der feste Admin erfasst keine Sessions.
Beim Nachtragen werden angegeben: Beim Nachtragen werden angegeben:
@@ -142,6 +142,8 @@ Der Graph zeigt abgerechneten Aufwand im Zeitraum:
- In der Monatsansicht pro Tag - In der Monatsansicht pro Tag
- In der Tagesansicht pro Stunde - In der Tagesansicht pro Stunde
In der Monatsansicht kann ueber `Monat exportieren` eine CSV-Datei fuer den eigenen Benutzer heruntergeladen werden. Der Export enthaelt alle eigenen Sessions des Monats mit Ticket, Organisation, Art, Datum, Von/Bis, Bewertung, Session-Minuten, Teamspace-Minuten und Taetigkeit. Er ist fuer Kontrolle, Ablage oder Weiterverarbeitung gedacht.
## Tickets im Zeitraum ## Tickets im Zeitraum
Unter `Tickets im Zeitraum` werden alle Tickets angezeigt, die im gewaehlten Zeitraum Sessions haben. Unter `Tickets im Zeitraum` werden alle Tickets angezeigt, die im gewaehlten Zeitraum Sessions haben.
@@ -154,7 +156,6 @@ Pro Ticket sind sichtbar:
- Anzahl Sessions - Anzahl Sessions
- Abgerechnete Zeit - Abgerechnete Zeit
- Anzahl abgerechneter Sessions - Anzahl abgerechneter Sessions
- Anzahl und Zeit nicht abrechenbarer Sessions
- Status - Status
Neben der Ticketnummer gibt es ein Copy-Icon. Ein Klick kopiert die Ticketnummer in die Zwischenablage. Neben der Ticketnummer gibt es ein Copy-Icon. Ein Klick kopiert die Ticketnummer in die Zwischenablage.
@@ -415,19 +416,21 @@ Passwortaenderungen erfordern das aktuelle Passwort. Bleiben die Passwortfelder
## Adminbereich ## Adminbereich
Administratoren koennen: Der feste Admin ist nur fuer die Systemverwaltung vorgesehen. Er kann:
- Benutzer anlegen - Benutzer anlegen
- Benutzer bearbeiten - Benutzer bearbeiten
- Passwoerter fuer Benutzer setzen - Passwoerter fuer Benutzer setzen
- Sessions aller Benutzer sehen
- Sessions anderen Benutzern zuweisen
- Sessions fuer Benutzer nachtragen
- Zammad-Organisationen synchronisieren - Zammad-Organisationen synchronisieren
- Restore-faehige SQL-Backups herunterladen
Bei der Auswertung normaler Benutzer werden nur die Sessions des angemeldeten Benutzers beruecksichtigt. Ticketdaten koennen trotzdem von Sessions anderer Benutzer uebernommen werden, wenn dasselbe Ticket schon existiert. Neue Benutzer erhalten automatisch die Rolle `User`. Eine Rollenauswahl gibt es nicht. Der feste Admin kann nicht deaktiviert oder zur User-Rolle herabgestuft werden.
Beim Umverteilen einer Session werden betroffene Monatsabschluesse fuer alten und neuen Besitzer wieder geoeffnet, damit Auswertung und Abschluss konsistent bleiben. Der Admin sieht keine Timer, keine Auswertungen, keine Statistiken, keine fixen Abrechnungen und keinen Monatsabschluss. Diese Bereiche sind nur fuer normale Benutzer vorgesehen.
Der Datenbankexport im Adminbereich erzeugt eine SQL-Datei fuer einen kompletten Restore. Fuer einen Umzug wird auf dem Zielsystem zuerst TicketTracker gestartet, damit die Migrationen die Tabellen anlegen. Danach wird die SQL-Datei mit `psql` in die Ziel-Datenbank eingespielt.
Der Export enthaelt alle Tabellen, die fuer den Stand des Systems notwendig sind: Benutzer, Einstellungen, Organisationen, Tickets, Sessions, Teamspace-Werte, fixe Abrechnungen, Ausnahmen und Monatsabschluesse. Er ist kein reiner CSV-Auszug, sondern ein Restore-Backup.
## Zammad Sync ## Zammad Sync
+71 -29
View File
@@ -378,13 +378,21 @@ export function App() {
return; return;
} }
if (currentUser.role === "admin") {
setTimers([]);
setSelectedTimerId(null);
setTimerOwnerId(currentUser.id);
setTimersLoaded(true);
return;
}
setTimers(readStoredTimers(currentUser.id)); setTimers(readStoredTimers(currentUser.id));
setTimerOwnerId(currentUser.id); setTimerOwnerId(currentUser.id);
setTimersLoaded(true); setTimersLoaded(true);
}, [currentUser]); }, [currentUser]);
useEffect(() => { useEffect(() => {
if (!currentUser || !timersLoaded || timerOwnerId !== currentUser.id) { if (!currentUser || currentUser.role === "admin" || !timersLoaded || timerOwnerId !== currentUser.id) {
return; return;
} }
@@ -401,7 +409,7 @@ export function App() {
}, [currentUser, timers, selectedTimerId, timersLoaded, timerOwnerId]); }, [currentUser, timers, selectedTimerId, timersLoaded, timerOwnerId]);
useEffect(() => { useEffect(() => {
if (!currentUser || !timersLoaded || timerOwnerId !== currentUser.id) { if (!currentUser || currentUser.role === "admin" || !timersLoaded || timerOwnerId !== currentUser.id) {
return; return;
} }
@@ -467,7 +475,26 @@ export function App() {
return () => window.clearInterval(interval); return () => window.clearInterval(interval);
}, []); }, []);
useEffect(() => {
if (!currentUser || currentUser.role !== "admin") {
return;
}
if (route.page === "admin-users" || route.page === "profile" || route.page === "faq") {
return;
}
navigate("/admin/users");
}, [currentUser, route.page]);
async function startQuickTimer(rawTicketNumber: string) { async function startQuickTimer(rawTicketNumber: string) {
if (currentUser?.role === "admin") {
toast.error("Admin erfasst keine Sessions", {
description: "Der Adminbereich ist nur für die Systemverwaltung vorgesehen."
});
return false;
}
const ticketNumber = rawTicketNumber.trim(); const ticketNumber = rawTicketNumber.trim();
if (!ticketPattern.test(ticketNumber)) { if (!ticketPattern.test(ticketNumber)) {
@@ -532,21 +559,29 @@ export function App() {
} }
const navItems: NavMenuEntry[] = [ const navItems: NavMenuEntry[] = [
{ type: "item", href: "/timer", label: "Timer", icon: Timer, active: path.startsWith("/timer") || path === "/" },
{ type: "item", href: "/analysis", label: "Auswertung", icon: BarChart3, active: path.startsWith("/analysis") },
{ type: "separator", key: "after-analysis" },
{ type: "item", href: "/monthly-close", label: "Monatsabschluss", icon: ClipboardCheck, active: path.startsWith("/monthly-close") },
{ type: "separator", key: "after-monthly-close" },
{ type: "item", href: "/statistics", label: "Statistiken", icon: ChartNoAxesCombined, active: path.startsWith("/statistics") },
{ type: "item", href: "/recurring", label: "Fixe Abrechnung", icon: Repeat, active: path.startsWith("/recurring") },
{ type: "separator", key: "before-profile" },
{ type: "item", href: "/profile", label: "Profil", icon: UserCog, active: path.startsWith("/profile") },
...(currentUser.role === "admin" ...(currentUser.role === "admin"
? [{ type: "item" as const, href: "/admin/users", label: "Benutzer", icon: Shield, active: path.startsWith("/admin/users") }] ? [
: []), { type: "item" as const, href: "/admin/users", label: "Administration", icon: Shield, active: path.startsWith("/admin/users") },
{ type: "separator", key: "before-faq" }, { type: "separator" as const, key: "before-profile" },
{ type: "item", href: "/faq", label: "FAQ", icon: CircleHelp, active: path.startsWith("/faq") }, { type: "item" as const, href: "/profile", label: "Profil", icon: UserCog, active: path.startsWith("/profile") },
{ type: "separator" as const, key: "before-faq" },
{ type: "item" as const, href: "/faq", label: "FAQ", icon: CircleHelp, active: path.startsWith("/faq") }
]
: [
{ type: "item" as const, href: "/timer", label: "Session starten", icon: Timer, active: path.startsWith("/timer") || path === "/" },
{ type: "item" as const, href: "/analysis", label: "Auswertung", icon: BarChart3, active: path.startsWith("/analysis") },
{ type: "separator" as const, key: "after-analysis" },
{ type: "item" as const, href: "/monthly-close", label: "Monatsabschluss", icon: ClipboardCheck, active: path.startsWith("/monthly-close") },
{ type: "separator" as const, key: "after-monthly-close" },
{ type: "item" as const, href: "/statistics", label: "Statistiken", icon: ChartNoAxesCombined, active: path.startsWith("/statistics") },
{ type: "item" as const, href: "/recurring", label: "Fixe Abrechnung", icon: Repeat, active: path.startsWith("/recurring") },
{ type: "separator" as const, key: "before-profile" },
{ type: "item" as const, href: "/profile", label: "Profil", icon: UserCog, active: path.startsWith("/profile") },
{ type: "separator" as const, key: "before-faq" },
{ type: "item" as const, href: "/faq", label: "FAQ", icon: CircleHelp, active: path.startsWith("/faq") }
])
]; ];
const isAdmin = currentUser.role === "admin";
const runningTimer = timers.find((timer) => timer.phase === "running") ?? null; const runningTimer = timers.find((timer) => timer.phase === "running") ?? null;
const runningElapsedSeconds = runningTimer ? Math.floor(activeElapsedMs(runningTimer, tick) / 1000) : 0; const runningElapsedSeconds = runningTimer ? Math.floor(activeElapsedMs(runningTimer, tick) / 1000) : 0;
@@ -564,7 +599,7 @@ export function App() {
<SidebarMenu> <SidebarMenu>
<SidebarMenuItem> <SidebarMenuItem>
<SidebarMenuButton asChild> <SidebarMenuButton asChild>
<a href="/timer" onClick={navHandler("/timer")}> <a href={isAdmin ? "/admin/users" : "/timer"} onClick={navHandler(isAdmin ? "/admin/users" : "/timer")}>
<Command /> <Command />
<span className="font-semibold text-base">TicketTracker</span> <span className="font-semibold text-base">TicketTracker</span>
</a> </a>
@@ -574,6 +609,8 @@ export function App() {
</SidebarHeader> </SidebarHeader>
<SidebarContent> <SidebarContent>
{!isAdmin ? (
<>
<SidebarGroup> <SidebarGroup>
<SidebarGroupContent className="flex flex-col gap-2"> <SidebarGroupContent className="flex flex-col gap-2">
<SidebarMenu> <SidebarMenu>
@@ -601,9 +638,11 @@ export function App() {
tick={tick} tick={tick}
onOpenTimer={() => navigate("/timer")} onOpenTimer={() => navigate("/timer")}
/> />
</>
) : null}
<SidebarGroup> <SidebarGroup>
<SidebarGroupLabel>Workflows</SidebarGroupLabel> <SidebarGroupLabel>{isAdmin ? "Verwaltung" : "Workflow"}</SidebarGroupLabel>
<SidebarGroupContent> <SidebarGroupContent>
<SidebarMenu> <SidebarMenu>
{navItems.map((item) => {navItems.map((item) =>
@@ -629,13 +668,13 @@ export function App() {
</SidebarContent> </SidebarContent>
<SidebarFooter> <SidebarFooter>
<div className="mx-2 rounded-lg border bg-card p-3 text-sm shadow-xs group-data-[collapsible=icon]:hidden"> {!isAdmin ? <div className="mx-2 rounded-lg border bg-card p-3 text-sm shadow-xs group-data-[collapsible=icon]:hidden">
<div className="mb-2 flex items-center gap-2 font-medium"> <div className="mb-2 flex items-center gap-2 font-medium">
<CheckCircle2 className="size-4 text-muted-foreground" /> <CheckCircle2 className="size-4 text-muted-foreground" />
Abschluss Abschluss
</div> </div>
<p className="text-muted-foreground">Offene Bewertungen findest du im Monatsabschluss.</p> <p className="text-muted-foreground">Offene Bewertungen findest du im Monatsabschluss.</p>
</div> </div> : null}
<SidebarMenu> <SidebarMenu>
<SidebarMenuItem> <SidebarMenuItem>
<SidebarMenuButton tooltip="Worklog"> <SidebarMenuButton tooltip="Worklog">
@@ -684,11 +723,13 @@ export function App() {
<span className="truncate text-sm font-semibold">TicketTracker</span> <span className="truncate text-sm font-semibold">TicketTracker</span>
</div> </div>
</div> </div>
{!isAdmin ? (
<QuickTimerStarter <QuickTimerStarter
inputId="quick-ticket-number-desktop" inputId="quick-ticket-number-desktop"
className="mx-3 hidden max-w-md flex-1 md:flex" className="mx-3 hidden max-w-md flex-1 md:flex"
onStartTimer={startQuickTimer} onStartTimer={startQuickTimer}
/> />
) : null}
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<ThemeSwitcher /> <ThemeSwitcher />
<DropdownMenu> <DropdownMenu>
@@ -716,12 +757,12 @@ export function App() {
</div> </div>
</div> </div>
</header> </header>
<div className="border-b bg-background/95 px-3 py-2 md:hidden"> {!isAdmin ? <div className="border-b bg-background/95 px-3 py-2 md:hidden">
<QuickTimerStarter inputId="quick-ticket-number-mobile" onStartTimer={startQuickTimer} /> <QuickTimerStarter inputId="quick-ticket-number-mobile" onStartTimer={startQuickTimer} />
</div> </div> : null}
<main className="min-h-0 min-w-0 flex-1 overflow-x-hidden p-3 sm:p-4 md:p-5"> <main className="min-h-0 min-w-0 flex-1 overflow-x-hidden p-3 sm:p-4 md:p-5">
<div className="mx-auto w-full max-w-screen-2xl"> <div className="mx-auto w-full max-w-screen-2xl">
{route.page === "timer" ? ( {!isAdmin && route.page === "timer" ? (
<TimerPage <TimerPage
timers={timers} timers={timers}
setTimers={setTimers} setTimers={setTimers}
@@ -730,14 +771,14 @@ export function App() {
tick={tick} tick={tick}
/> />
) : null} ) : null}
{route.page === "analysis" ? <AnalysisPage onNavigate={navigate} /> : null} {!isAdmin && route.page === "analysis" ? <AnalysisPage onNavigate={navigate} /> : null}
{route.page === "monthly-close" ? <MonthlyClosePage onNavigate={navigate} /> : null} {!isAdmin && route.page === "monthly-close" ? <MonthlyClosePage onNavigate={navigate} /> : null}
{route.page === "statistics" ? <StatisticsPage onNavigate={navigate} /> : null} {!isAdmin && route.page === "statistics" ? <StatisticsPage onNavigate={navigate} /> : null}
{route.page === "recurring" ? <RecurringBillingsPage /> : null} {!isAdmin && route.page === "recurring" ? <RecurringBillingsPage /> : null}
{route.page === "faq" ? <FaqPage /> : null} {route.page === "faq" ? <FaqPage /> : null}
{route.page === "profile" ? <ProfilePage currentUser={currentUser} onUserUpdated={setCurrentUser} /> : null} {route.page === "profile" ? <ProfilePage currentUser={currentUser} onUserUpdated={setCurrentUser} /> : null}
{route.page === "admin-users" && currentUser.role === "admin" ? <AdminUsersPage currentUser={currentUser} /> : null} {route.page === "admin-users" && isAdmin ? <AdminUsersPage currentUser={currentUser} /> : null}
{route.page === "admin-users" && currentUser.role !== "admin" ? ( {route.page === "admin-users" && !isAdmin ? (
<TimerPage <TimerPage
timers={timers} timers={timers}
setTimers={setTimers} setTimers={setTimers}
@@ -746,9 +787,10 @@ export function App() {
tick={tick} tick={tick}
/> />
) : null} ) : null}
{route.page === "ticket" ? ( {!isAdmin && route.page === "ticket" ? (
<TicketDetailPage periodType={route.periodType} period={route.period} ticketId={route.ticketId} onNavigate={navigate} /> <TicketDetailPage periodType={route.periodType} period={route.period} ticketId={route.ticketId} onNavigate={navigate} />
) : null} ) : null}
{isAdmin && route.page !== "admin-users" && route.page !== "profile" && route.page !== "faq" ? <AdminUsersPage currentUser={currentUser} /> : null}
</div> </div>
</main> </main>
</SidebarInset> </SidebarInset>
+36 -28
View File
@@ -1,5 +1,4 @@
import type { import type {
AdminSession,
AdminUser, AdminUser,
AuthUser, AuthUser,
BillingStatus, BillingStatus,
@@ -10,7 +9,6 @@ import type {
StatisticsOverview, StatisticsOverview,
TicketMeta, TicketMeta,
TicketPeriod, TicketPeriod,
UserRole,
WorkType WorkType
} from "./types"; } from "./types";
@@ -99,7 +97,6 @@ export function createAdminUser(payload: {
username: string; username: string;
displayName: string; displayName: string;
password: string; password: string;
role: UserRole;
active: boolean; active: boolean;
}) { }) {
return request<{ user: AdminUser }>("/api/admin/users", { return request<{ user: AdminUser }>("/api/admin/users", {
@@ -114,7 +111,6 @@ export function updateAdminUser(
username: string; username: string;
displayName: string; displayName: string;
password?: string; password?: string;
role: UserRole;
active: boolean; active: boolean;
} }
) { ) {
@@ -124,8 +120,42 @@ export function updateAdminUser(
}); });
} }
export function getAdminSessions() { export async function downloadDatabaseExport() {
return request<{ sessions: AdminSession[] }>("/api/admin/sessions"); const response = await fetch("/api/admin/export/database", {
credentials: "same-origin"
});
if (!response.ok) {
const body = await response.json().catch(() => ({}));
throw new ApiError(body.error ?? "Export fehlgeschlagen", response.status);
}
const contentDisposition = response.headers.get("content-disposition") ?? "";
const filenameMatch = contentDisposition.match(/filename="([^"]+)"/);
return {
blob: await response.blob(),
filename: filenameMatch?.[1] ?? `tickettracker-backup-${new Date().toISOString().replace(/[:.]/g, "-")}.sql`
};
}
export async function downloadMonthExport(month: string) {
const response = await fetch(`/api/export/months/${month}`, {
credentials: "same-origin"
});
if (!response.ok) {
const body = await response.json().catch(() => ({}));
throw new ApiError(body.error ?? "Export fehlgeschlagen", response.status);
}
const contentDisposition = response.headers.get("content-disposition") ?? "";
const filenameMatch = contentDisposition.match(/filename="([^"]+)"/);
return {
blob: await response.blob(),
filename: filenameMatch?.[1] ?? `tickettracker-monat-${month}.csv`
};
} }
export function getOrganizations(search = "") { export function getOrganizations(search = "") {
@@ -214,28 +244,6 @@ export function deleteRecurringBilling(billingId: string) {
}); });
} }
export function createAdminSession(payload: {
userId: string;
ticketNumber: string;
organizationId: string;
activity: string;
workType: WorkType;
startedAt: string;
endedAt: string;
}) {
return request<{ session: AdminSession }>("/api/admin/sessions", {
method: "POST",
body: JSON.stringify(payload)
});
}
export function reassignAdminSession(sessionId: string, userId: string) {
return request<{ session: AdminSession }>(`/api/admin/sessions/${sessionId}/owner`, {
method: "PATCH",
body: JSON.stringify({ userId })
});
}
export function createSession(payload: CreateSessionPayload) { export function createSession(payload: CreateSessionPayload) {
return request("/api/sessions", { return request("/api/sessions", {
method: "POST", method: "POST",
+14 -13
View File
@@ -107,7 +107,7 @@ export const helpSections: HelpSection[] = [
description: "Nachtragen ist für vergessene Zeiten gedacht, wenn kein Timer gelaufen ist.", description: "Nachtragen ist für vergessene Zeiten gedacht, wenn kein Timer gelaufen ist.",
quickItems: [ quickItems: [
"Datum, Von, Bis und Tätigkeit eintragen.", "Datum, Von, Bis und Tätigkeit eintragen.",
"Benutzer tragen eigene Sessions nach; Admins können Benutzer auswählen.", "Benutzer tragen eigene Sessions nach.",
"Tätigkeiten sind mehrzeilig." "Tätigkeiten sind mehrzeilig."
], ],
details: [ details: [
@@ -116,7 +116,6 @@ 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.",
"Nachgetragene Sessions starten immer offen. Sie müssen später als Abgerechnet oder Nicht abrechenbar bewertet werden.", "Nachgetragene Sessions starten immer offen. Sie müssen später als Abgerechnet oder Nicht abrechenbar bewertet werden.",
"Admins können im Adminbereich zusätzlich bestimmen, welchem Benutzer die nachgetragene Session gehört.",
"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."
] ]
}, },
@@ -162,12 +161,15 @@ export const helpSections: HelpSection[] = [
quickItems: [ quickItems: [
"Monat oder Tag wählen; die Ansicht aktualisiert automatisch.", "Monat oder Tag wählen; die Ansicht aktualisiert automatisch.",
"Tickets können gefiltert, gruppiert und sortiert werden.", "Tickets können gefiltert, gruppiert und sortiert werden.",
"Die Einstellungen bleiben nach Refresh und Browserneustart erhalten." "Die Einstellungen bleiben nach Refresh und Browserneustart erhalten.",
"Monate können als CSV exportiert werden."
], ],
details: [ details: [
"Die Monatsansicht zeigt alle Tickets, die im gewählten Monat mindestens eine Session haben.", "Die Monatsansicht zeigt alle Tickets, die im gewählten Monat mindestens eine Session haben.",
"Die Tagesansicht zeigt dieselben Datenlogiken auf einen einzelnen Tag gefiltert. Es gibt keinen Tagesabschluss.", "Die Tagesansicht zeigt dieselben Datenlogiken auf einen einzelnen Tag gefiltert. Es gibt keinen Tagesabschluss.",
"Die Pfeile neben der Zeitraum-Eingabe springen jeweils einen Monat oder einen Tag vor beziehungsweise zurück.", "Die Pfeile neben der Zeitraum-Eingabe springen jeweils einen Monat oder einen Tag vor beziehungsweise zurück.",
"Über Monat exportieren lädst du eine CSV-Datei deiner eigenen Sessions dieses Monats herunter.",
"Der CSV-Export enthält Ticket, Organisation, Art, Datum, Von/Bis, Bewertung, Session-Minuten, Teamspace-Minuten und Tätigkeit.",
"Der Graph zeigt abgerechnete Zeit im Zeitraum. In der Monatsansicht pro Tag, in der Tagesansicht pro Stunde.", "Der Graph zeigt abgerechnete Zeit im Zeitraum. In der Monatsansicht pro Tag, in der Tagesansicht pro Stunde.",
"Die Ticketliste kann nach Typ, Organisation oder Status gruppiert werden. Gruppenüberschriften zeigen Anzahl, abgerechnete Zeit und offene Bewertungen.", "Die Ticketliste kann nach Typ, Organisation oder Status gruppiert werden. Gruppenüberschriften zeigen Anzahl, abgerechnete Zeit und offene Bewertungen.",
"Filter und Sortierungen bleiben im Browser gespeichert und können über Zurücksetzen wieder auf Standard gestellt werden.", "Filter und Sortierungen bleiben im Browser gespeichert und können über Zurücksetzen wieder auf Standard gestellt werden.",
@@ -434,20 +436,19 @@ export const helpSections: HelpSection[] = [
{ {
id: "admin", id: "admin",
title: "Adminbereich", title: "Adminbereich",
description: "Der Adminbereich verwaltet Benutzer, Session-Zuordnung, manuelle Admin-Nachträge und Zammad-Sync.", description: "Der feste Adminbereich verwaltet Benutzer, Zammad-Sync und restore-fähige Datenbankexporte.",
quickItems: [ quickItems: [
"Admins legen Benutzer an und bearbeiten Rollen.", "Neue Benutzer erhalten automatisch die Rolle User.",
"Sessions können anderen Benutzern zugewiesen werden.", "Der Admin erfasst keine Sessions und sieht keine Auswertungen.",
"Zammad-Organisationen werden über Data-Sync aktualisiert." "SQL-Backups sind für einen kompletten Restore gedacht."
], ],
details: [ details: [
"Neue Benutzer bekommen Benutzername, vollen Namen, Passwort, Rolle und Aktivstatus.", "Neue Benutzer bekommen Benutzername, vollen Namen, Passwort und Aktivstatus. Eine Rollenauswahl gibt es nicht.",
"Admins können bestehende Benutzer bearbeiten, Passwörter setzen und Benutzer deaktivieren.", "Admins können bestehende Benutzer bearbeiten, Passwörter setzen und Benutzer deaktivieren.",
"Der eigene Admin-Zugang kann nicht versehentlich deaktiviert oder zur normalen User-Rolle herabgestuft werden.", "Der feste Admin-Zugang kann nicht deaktiviert oder zur normalen User-Rolle herabgestuft werden.",
"In der Session-Zuordnung sehen Admins die letzten Sessions inklusive Besitzer und können einzelne Sessions umverteilen.", "Der Data-Sync speichert Zammad-Zugangsdaten und synchronisiert Organisationen in die lokale Datenbank.",
"Beim Umverteilen werden die betroffenen Monatsabschlüsse für alten und neuen Besitzer wieder geöffnet, damit Bewertungen und Auswertungen konsistent bleiben.", "Der Datenbankexport erzeugt eine SQL-Datei mit Benutzern, Einstellungen, Organisationen, Tickets, Sessions, Teamspace-Werten, fixen Abrechnungen und Abschlüssen.",
"Admins können Sessions für andere Benutzer nachtragen, wenn Zeiten vergessen wurden.", "Für einen Restore lässt du auf dem Zielsystem zuerst die TicketTracker-Migrationen laufen und spielst danach die SQL-Datei mit psql ein."
"Der Data-Sync speichert Zammad-Zugangsdaten und synchronisiert Organisationen in die lokale Datenbank."
] ]
} }
]; ];
-23
View File
@@ -16,29 +16,6 @@ export type AdminUser = AuthUser & {
updated_at: string; updated_at: string;
}; };
export type AdminSession = {
id: string;
ticket_id: string;
user_id: string;
owner_username: string;
owner_display_name: string;
ticket_number: string;
organization_id: string | null;
organization_name: string | null;
customer_name: string;
activity: string;
work_type: WorkType;
started_at: string;
ended_at: string;
duration_seconds: number;
rounded_minutes: number;
billing_status: BillingStatus;
created_at: string;
recurring_billing_id: string | null;
recurring_billing_slot_id: string | null;
recurring_occurrence_date: string | null;
};
export type Organization = { export type Organization = {
id: string; id: string;
zammad_id: string; zammad_id: string;
+74 -398
View File
@@ -1,115 +1,76 @@
import { FormEvent, useEffect, useState } from "react"; import { FormEvent, useEffect, useState } from "react";
import { CalendarPlus, DatabaseZap, Save, Shield, Shuffle, UserPlus } from "lucide-react"; import { DatabaseBackup, DatabaseZap, Save, Shield, UserPlus } from "lucide-react";
import { toast } from "sonner"; import { toast } from "sonner";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Switch } from "@/components/ui/switch"; import { Switch } from "@/components/ui/switch";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { Textarea } from "@/components/ui/textarea";
import { HelpLink } from "@/components/HelpLink"; import { HelpLink } from "@/components/HelpLink";
import { OrganizationSelect } from "@/components/OrganizationSelect";
import { import {
createAdminSession,
createAdminUser, createAdminUser,
getAdminSessions, downloadDatabaseExport,
getAdminUsers, getAdminUsers,
getZammadSettings, getZammadSettings,
reassignAdminSession,
saveZammadSettings, saveZammadSettings,
syncZammadOrganizations, syncZammadOrganizations,
updateAdminUser updateAdminUser
} from "../api"; } from "../api";
import { currentDay, formatDateTime, formatMinutes } from "../format"; import type { AdminUser, AuthUser } from "../types";
import type { AdminSession, AdminUser, AuthUser, UserRole, WorkType } from "../types";
type UserFormState = { type UserFormState = {
username: string; username: string;
displayName: string; displayName: string;
password: string; password: string;
role: UserRole;
active: boolean; active: boolean;
}; };
type ManualSessionFormState = {
userId: string;
ticketNumber: string;
organizationId: string;
organizationName: string | null;
activity: string;
workType: WorkType;
day: string;
startTime: string;
endTime: string;
};
const emptyForm: UserFormState = { const emptyForm: UserFormState = {
username: "", username: "",
displayName: "", displayName: "",
password: "", password: "",
role: "user",
active: true active: true
}; };
function emptyManualSession(): ManualSessionFormState {
return {
userId: "",
ticketNumber: "",
organizationId: "",
organizationName: null,
activity: "",
workType: "support",
day: currentDay(),
startTime: "09:00",
endTime: "09:30"
};
}
function formFromUser(user: AdminUser): UserFormState { function formFromUser(user: AdminUser): UserFormState {
return { return {
username: user.username, username: user.username,
displayName: user.display_name, displayName: user.display_name,
password: "", password: "",
role: user.role,
active: user.active active: user.active
}; };
} }
export function AdminUsersPage({ currentUser }: { currentUser: AuthUser }) { export function AdminUsersPage({ currentUser }: { currentUser: AuthUser }) {
const [users, setUsers] = useState<AdminUser[]>([]); const [users, setUsers] = useState<AdminUser[]>([]);
const [sessions, setSessions] = useState<AdminSession[]>([]);
const [forms, setForms] = useState<Record<string, UserFormState>>({}); const [forms, setForms] = useState<Record<string, UserFormState>>({});
const [sessionOwners, setSessionOwners] = useState<Record<string, string>>({});
const [newUser, setNewUser] = useState<UserFormState>(emptyForm); const [newUser, setNewUser] = useState<UserFormState>(emptyForm);
const [manualSession, setManualSession] = useState<ManualSessionFormState>(() => emptyManualSession());
const [zammadBaseUrl, setZammadBaseUrl] = useState(""); const [zammadBaseUrl, setZammadBaseUrl] = useState("");
const [zammadApiKey, setZammadApiKey] = useState(""); const [zammadApiKey, setZammadApiKey] = useState("");
const [hasZammadApiKey, setHasZammadApiKey] = useState(false); const [hasZammadApiKey, setHasZammadApiKey] = useState(false);
const [syncingOrganizations, setSyncingOrganizations] = useState(false); const [syncingOrganizations, setSyncingOrganizations] = useState(false);
const [savingZammadSettings, setSavingZammadSettings] = useState(false); const [savingZammadSettings, setSavingZammadSettings] = useState(false);
const [exportingDatabase, setExportingDatabase] = useState(false);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [savingId, setSavingId] = useState<string | null>(null); const [savingId, setSavingId] = useState<string | null>(null);
const [movingId, setMovingId] = useState<string | null>(null);
const [creating, setCreating] = useState(false); const [creating, setCreating] = useState(false);
const [creatingSession, setCreatingSession] = useState(false);
async function load(options: { preserveEdits?: boolean } = {}) { async function load(options: { preserveEdits?: boolean } = {}) {
setLoading(true); setLoading(true);
try { try {
const [usersResult, sessionsResult, zammadSettingsResult] = await Promise.all([getAdminUsers(), getAdminSessions(), getZammadSettings()]); const [usersResult, zammadSettingsResult] = await Promise.all([getAdminUsers(), getZammadSettings()]);
setUsers(usersResult.users); setUsers(usersResult.users);
setSessions(sessionsResult.sessions);
if (!options.preserveEdits) { if (!options.preserveEdits) {
setZammadBaseUrl(zammadSettingsResult.settings.baseUrl); setZammadBaseUrl(zammadSettingsResult.settings.baseUrl);
setHasZammadApiKey(zammadSettingsResult.settings.hasApiKey); setHasZammadApiKey(zammadSettingsResult.settings.hasApiKey);
} }
setForms((current) => setForms((current) =>
Object.fromEntries(usersResult.users.map((user) => [user.id, options.preserveEdits ? current[user.id] ?? formFromUser(user) : formFromUser(user)])) Object.fromEntries(usersResult.users.map((user) => [user.id, options.preserveEdits ? current[user.id] ?? formFromUser(user) : formFromUser(user)]))
); );
setSessionOwners((current) =>
Object.fromEntries(sessionsResult.sessions.map((session) => [session.id, options.preserveEdits ? current[session.id] ?? session.user_id : session.user_id]))
);
} catch (error) { } catch (error) {
toast.error("Adminbereich konnte nicht geladen werden", { toast.error("Adminbereich konnte nicht geladen werden", {
description: error instanceof Error ? error.message : "Unbekannter Fehler" description: error instanceof Error ? error.message : "Unbekannter Fehler"
@@ -157,7 +118,9 @@ export function AdminUsersPage({ currentUser }: { currentUser: AuthUser }) {
try { try {
await createAdminUser(newUser); await createAdminUser(newUser);
toast.success("Benutzer angelegt"); toast.success("Benutzer angelegt", {
description: "Neue Accounts erhalten automatisch die Rolle User."
});
setNewUser(emptyForm); setNewUser(emptyForm);
await load(); await load();
} catch (error) { } catch (error) {
@@ -181,7 +144,6 @@ export function AdminUsersPage({ currentUser }: { currentUser: AuthUser }) {
await updateAdminUser(user.id, { await updateAdminUser(user.id, {
username: form.username, username: form.username,
displayName: form.displayName, displayName: form.displayName,
role: form.role,
active: form.active, active: form.active,
password: form.password || undefined password: form.password || undefined
}); });
@@ -196,91 +158,6 @@ export function AdminUsersPage({ currentUser }: { currentUser: AuthUser }) {
} }
} }
async function moveSession(session: AdminSession) {
const nextUserId = sessionOwners[session.id] ?? session.user_id;
if (nextUserId === session.user_id) {
toast.info("Keine Änderung", {
description: "Diese Session gehört bereits diesem Benutzer."
});
return;
}
setMovingId(session.id);
try {
await reassignAdminSession(session.id, nextUserId);
toast.success("Session umverteilt", {
description: "Betroffene Tages- und Monatsabschlüsse wurden wieder geöffnet."
});
await load();
} catch (error) {
toast.error("Session konnte nicht umverteilt werden", {
description: error instanceof Error ? error.message : "Unbekannter Fehler"
});
} finally {
setMovingId(null);
}
}
const activeUsers = users.filter((user) => user.active);
const manualSessionUserId = manualSession.userId || activeUsers[0]?.id || "";
async function createManualSession(event: FormEvent) {
event.preventDefault();
if (!manualSessionUserId) {
toast.error("Kein aktiver Benutzer vorhanden");
return;
}
if (!manualSession.organizationId) {
toast.error("Organisation wählen");
return;
}
const startedAt = new Date(`${manualSession.day}T${manualSession.startTime}:00`);
const endedAt = new Date(`${manualSession.day}T${manualSession.endTime}:00`);
if (Number.isNaN(startedAt.getTime()) || Number.isNaN(endedAt.getTime())) {
toast.error("Datum oder Uhrzeit prüfen");
return;
}
if (endedAt <= startedAt) {
toast.error("Ende muss nach Beginn liegen");
return;
}
setCreatingSession(true);
try {
await createAdminSession({
userId: manualSessionUserId,
ticketNumber: manualSession.ticketNumber,
organizationId: manualSession.organizationId,
activity: manualSession.activity,
workType: manualSession.workType,
startedAt: startedAt.toISOString(),
endedAt: endedAt.toISOString()
});
toast.success("Session nachgetragen", {
description: "Der Eintrag ist in der Auswertung des gewählten Benutzers offen."
});
setManualSession({
...emptyManualSession(),
userId: manualSessionUserId,
day: manualSession.day
});
await load();
} catch (error) {
toast.error("Session konnte nicht nachgetragen werden", {
description: error instanceof Error ? error.message : "Unbekannter Fehler"
});
} finally {
setCreatingSession(false);
}
}
async function syncOrganizations(event: FormEvent) { async function syncOrganizations(event: FormEvent) {
event.preventDefault(); event.preventDefault();
setSyncingOrganizations(true); setSyncingOrganizations(true);
@@ -327,13 +204,41 @@ export function AdminUsersPage({ currentUser }: { currentUser: AuthUser }) {
} }
} }
async function exportDatabase() {
setExportingDatabase(true);
try {
const result = await downloadDatabaseExport();
const url = window.URL.createObjectURL(result.blob);
const link = document.createElement("a");
link.href = url;
link.download = result.filename;
document.body.append(link);
link.click();
link.remove();
window.URL.revokeObjectURL(url);
toast.success("Datenbankexport erstellt", {
description: result.filename
});
} catch (error) {
toast.error("Datenbankexport fehlgeschlagen", {
description: error instanceof Error ? error.message : "Unbekannter Fehler"
});
} finally {
setExportingDatabase(false);
}
}
const regularUserCount = users.filter((user) => user.role === "user").length;
return ( return (
<div className="space-y-4"> <div className="space-y-4">
<div> <div>
<h2 className="text-xl font-semibold tracking-normal sm:text-2xl">Benutzer</h2> <h2 className="text-xl font-semibold tracking-normal sm:text-2xl">Administration</h2>
<p className="text-sm text-muted-foreground">Accounts verwalten, Besitzer von Sessions prüfen und Einträge umverteilen.</p> <p className="text-sm text-muted-foreground">Systemverwaltung für Benutzer, Zammad-Organisationen und restore-fähige Backups.</p>
</div> </div>
<div className="grid gap-4 xl:grid-cols-[1.35fr_0.65fr]">
<Card> <Card>
<CardHeader className="p-4"> <CardHeader className="p-4">
<div className="flex items-start justify-between gap-3"> <div className="flex items-start justify-between gap-3">
@@ -380,6 +285,29 @@ export function AdminUsersPage({ currentUser }: { currentUser: AuthUser }) {
</CardContent> </CardContent>
</Card> </Card>
<Card>
<CardHeader className="p-4">
<div className="flex items-start justify-between gap-3">
<div className="flex items-center gap-2">
<DatabaseBackup className="size-5 text-muted-foreground" />
<CardTitle>Datenbankexport</CardTitle>
</div>
<HelpLink anchor="admin" label="Hilfe zum Datenbankexport" />
</div>
<CardDescription>SQL-Backup für einen vollständigen Restore auf einer frischen TicketTracker-Installation.</CardDescription>
</CardHeader>
<CardContent className="space-y-3 px-4 pb-4">
<p className="text-sm text-muted-foreground">
Enthält Benutzer, Einstellungen, Organisationen, Tickets, Sessions, Teamspace-Werte, fixe Abrechnungen und Abschlüsse.
</p>
<Button className="w-full" disabled={exportingDatabase} onClick={() => void exportDatabase()}>
<DatabaseBackup className="size-4" />
{exportingDatabase ? "Exportiert..." : "SQL-Backup herunterladen"}
</Button>
</CardContent>
</Card>
</div>
<Card> <Card>
<CardHeader className="p-4"> <CardHeader className="p-4">
<div className="flex items-start justify-between gap-3"> <div className="flex items-start justify-between gap-3">
@@ -391,7 +319,7 @@ export function AdminUsersPage({ currentUser }: { currentUser: AuthUser }) {
</div> </div>
</CardHeader> </CardHeader>
<CardContent className="px-4 pb-4"> <CardContent className="px-4 pb-4">
<form className="grid gap-3 lg:grid-cols-[160px_minmax(180px,1fr)_160px_130px_auto_auto] lg:items-end" onSubmit={createUser}> <form className="grid gap-3 lg:grid-cols-[160px_minmax(180px,1fr)_160px_110px_auto] lg:items-end" onSubmit={createUser}>
<div className="space-y-2"> <div className="space-y-2">
<label className="text-sm font-medium" htmlFor="new-username">Benutzername</label> <label className="text-sm font-medium" htmlFor="new-username">Benutzername</label>
<Input id="new-username" value={newUser.username} onChange={(event) => setNewUser({ ...newUser, username: event.currentTarget.value })} required /> <Input id="new-username" value={newUser.username} onChange={(event) => setNewUser({ ...newUser, username: event.currentTarget.value })} required />
@@ -405,16 +333,8 @@ export function AdminUsersPage({ currentUser }: { currentUser: AuthUser }) {
<Input id="new-password" type="password" value={newUser.password} onChange={(event) => setNewUser({ ...newUser, password: event.currentTarget.value })} required /> <Input id="new-password" type="password" value={newUser.password} onChange={(event) => setNewUser({ ...newUser, password: event.currentTarget.value })} required />
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<label className="text-sm font-medium" htmlFor="new-role">Rolle</label> <span className="text-sm font-medium">Rolle</span>
<select <Badge variant="secondary" className="flex h-8 w-full items-center justify-center">User</Badge>
id="new-role"
className="h-8 w-full rounded-lg border border-input bg-background px-2 text-sm focus-visible:outline-none focus-visible:ring-3 focus-visible:ring-ring/50"
value={newUser.role}
onChange={(event) => setNewUser({ ...newUser, role: event.currentTarget.value as UserRole })}
>
<option value="user">User</option>
<option value="admin">Admin</option>
</select>
</div> </div>
<label className="flex h-8 items-center gap-2 text-sm"> <label className="flex h-8 items-center gap-2 text-sm">
<Switch checked={newUser.active} onCheckedChange={(active) => setNewUser({ ...newUser, active })} /> <Switch checked={newUser.active} onCheckedChange={(active) => setNewUser({ ...newUser, active })} />
@@ -428,130 +348,11 @@ export function AdminUsersPage({ currentUser }: { currentUser: AuthUser }) {
</CardContent> </CardContent>
</Card> </Card>
<Card>
<CardHeader className="p-4">
<div className="flex items-start justify-between gap-3">
<div className="flex items-center gap-2">
<CalendarPlus className="size-5 text-muted-foreground" />
<CardTitle>Session nachtragen</CardTitle>
</div>
<HelpLink anchor="session-nachtragen" label="Hilfe zum Nachtragen für Benutzer" />
</div>
<CardDescription>Vergessene Zeiten manuell erfassen und direkt einem Benutzer zuweisen.</CardDescription>
</CardHeader>
<CardContent className="px-4 pb-4">
<form className="space-y-4" onSubmit={createManualSession}>
<div className="grid gap-3 lg:grid-cols-[160px_minmax(180px,1fr)_150px_150px]">
<div className="space-y-2">
<label className="text-sm font-medium" htmlFor="manual-ticket-number">Ticket</label>
<Input
id="manual-ticket-number"
placeholder="Ticket#123456"
value={manualSession.ticketNumber}
onChange={(event) => setManualSession({ ...manualSession, ticketNumber: event.currentTarget.value })}
required
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium" htmlFor="manual-organization">Organisation</label>
<OrganizationSelect
value={manualSession.organizationId}
selectedName={manualSession.organizationName}
onChange={(organization) =>
setManualSession({
...manualSession,
organizationId: organization.id,
organizationName: organization.name
})
}
required
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium" htmlFor="manual-work-type">Art</label>
<select
id="manual-work-type"
className="h-8 w-full rounded-lg border border-input bg-background px-2 text-sm focus-visible:outline-none focus-visible:ring-3 focus-visible:ring-ring/50"
value={manualSession.workType}
onChange={(event) => setManualSession({ ...manualSession, workType: event.currentTarget.value as WorkType })}
>
<option value="support">Support</option>
<option value="consulting">Consulting</option>
</select>
</div>
<div className="space-y-2">
<label className="text-sm font-medium" htmlFor="manual-user">Benutzer</label>
<select
id="manual-user"
className="h-8 w-full rounded-lg border border-input bg-background px-2 text-sm focus-visible:outline-none focus-visible:ring-3 focus-visible:ring-ring/50"
value={manualSessionUserId}
onChange={(event) => setManualSession({ ...manualSession, userId: event.currentTarget.value })}
required
>
{activeUsers.map((user) => (
<option key={user.id} value={user.id}>
{user.display_name}
</option>
))}
</select>
</div>
</div>
<div className="grid gap-3 lg:grid-cols-[160px_120px_120px_minmax(220px,1fr)_auto] lg:items-end">
<div className="space-y-2">
<label className="text-sm font-medium" htmlFor="manual-day">Datum</label>
<Input
id="manual-day"
type="date"
value={manualSession.day}
onChange={(event) => setManualSession({ ...manualSession, day: event.currentTarget.value })}
required
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium" htmlFor="manual-start">Von</label>
<Input
id="manual-start"
type="time"
value={manualSession.startTime}
onChange={(event) => setManualSession({ ...manualSession, startTime: event.currentTarget.value })}
required
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium" htmlFor="manual-end">Bis</label>
<Input
id="manual-end"
type="time"
value={manualSession.endTime}
onChange={(event) => setManualSession({ ...manualSession, endTime: event.currentTarget.value })}
required
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium" htmlFor="manual-activity">Tätigkeit</label>
<Textarea
id="manual-activity"
className="min-h-20"
value={manualSession.activity}
onChange={(event) => setManualSession({ ...manualSession, activity: event.currentTarget.value })}
required
/>
</div>
<Button type="submit" disabled={creatingSession || activeUsers.length === 0}>
<CalendarPlus className="size-4" />
{creatingSession ? "Speichert..." : "Nachtragen"}
</Button>
</div>
</form>
</CardContent>
</Card>
<Card> <Card>
<CardHeader className="gap-3 p-4 sm:flex-row sm:items-center sm:justify-between sm:space-y-0"> <CardHeader className="gap-3 p-4 sm:flex-row sm:items-center sm:justify-between sm:space-y-0">
<div> <div>
<CardTitle>Vorhandene Benutzer</CardTitle> <CardTitle>Vorhandene Benutzer</CardTitle>
<CardDescription>{loading ? "Lädt..." : `${users.length} Account(s)`}</CardDescription> <CardDescription>{loading ? "Lädt..." : `${regularUserCount} User, 1 fester Admin`}</CardDescription>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Badge variant="outline">{currentUser.display_name}</Badge> <Badge variant="outline">{currentUser.display_name}</Badge>
@@ -574,6 +375,7 @@ export function AdminUsersPage({ currentUser }: { currentUser: AuthUser }) {
<TableBody> <TableBody>
{users.map((user) => { {users.map((user) => {
const form = forms[user.id] ?? formFromUser(user); const form = forms[user.id] ?? formFromUser(user);
const isFixedAdmin = user.role === "admin";
return ( return (
<TableRow key={user.id}> <TableRow key={user.id}>
@@ -584,18 +386,11 @@ export function AdminUsersPage({ currentUser }: { currentUser: AuthUser }) {
<Input className="h-8" value={form.displayName} onChange={(event) => updateForm(user.id, { displayName: event.currentTarget.value })} /> <Input className="h-8" value={form.displayName} onChange={(event) => updateForm(user.id, { displayName: event.currentTarget.value })} />
</TableCell> </TableCell>
<TableCell> <TableCell>
<select <Badge variant={isFixedAdmin ? "default" : "secondary"}>{isFixedAdmin ? "Fester Admin" : "User"}</Badge>
className="h-8 w-full rounded-lg border border-input bg-background px-2 text-sm focus-visible:outline-none focus-visible:ring-3 focus-visible:ring-ring/50"
value={form.role}
onChange={(event) => updateForm(user.id, { role: event.currentTarget.value as UserRole })}
>
<option value="user">User</option>
<option value="admin">Admin</option>
</select>
</TableCell> </TableCell>
<TableCell> <TableCell>
<label className="flex items-center gap-2 text-sm"> <label className="flex items-center gap-2 text-sm">
<Switch checked={form.active} onCheckedChange={(active) => updateForm(user.id, { active })} /> <Switch checked={form.active} disabled={isFixedAdmin} onCheckedChange={(active) => updateForm(user.id, { active })} />
{form.active ? "aktiv" : "inaktiv"} {form.active ? "aktiv" : "inaktiv"}
</label> </label>
</TableCell> </TableCell>
@@ -624,6 +419,7 @@ export function AdminUsersPage({ currentUser }: { currentUser: AuthUser }) {
<div className="space-y-2 xl:hidden"> <div className="space-y-2 xl:hidden">
{users.map((user) => { {users.map((user) => {
const form = forms[user.id] ?? formFromUser(user); const form = forms[user.id] ?? formFromUser(user);
const isFixedAdmin = user.role === "admin";
return ( return (
<div key={user.id} className="space-y-3 rounded-md border bg-background p-3"> <div key={user.id} className="space-y-3 rounded-md border bg-background p-3">
@@ -632,18 +428,10 @@ export function AdminUsersPage({ currentUser }: { currentUser: AuthUser }) {
<Shield className="size-4 text-muted-foreground" /> <Shield className="size-4 text-muted-foreground" />
<span className="font-medium">{user.username}</span> <span className="font-medium">{user.username}</span>
</div> </div>
<Badge variant={form.role === "admin" ? "default" : "secondary"}>{form.role === "admin" ? "Admin" : "User"}</Badge> <Badge variant={isFixedAdmin ? "default" : "secondary"}>{isFixedAdmin ? "Fester Admin" : "User"}</Badge>
</div> </div>
<Input value={form.username} onChange={(event) => updateForm(user.id, { username: event.currentTarget.value })} /> <Input value={form.username} onChange={(event) => updateForm(user.id, { username: event.currentTarget.value })} />
<Input value={form.displayName} onChange={(event) => updateForm(user.id, { displayName: event.currentTarget.value })} /> <Input value={form.displayName} onChange={(event) => updateForm(user.id, { displayName: event.currentTarget.value })} />
<select
className="h-9 w-full rounded-lg border border-input bg-background px-2 text-sm focus-visible:outline-none focus-visible:ring-3 focus-visible:ring-ring/50"
value={form.role}
onChange={(event) => updateForm(user.id, { role: event.currentTarget.value as UserRole })}
>
<option value="user">User</option>
<option value="admin">Admin</option>
</select>
<Input <Input
type="password" type="password"
placeholder="Passwort unverändert lassen" placeholder="Passwort unverändert lassen"
@@ -652,7 +440,7 @@ export function AdminUsersPage({ currentUser }: { currentUser: AuthUser }) {
/> />
<div className="flex items-center justify-between gap-2"> <div className="flex items-center justify-between gap-2">
<label className="flex items-center gap-2 text-sm"> <label className="flex items-center gap-2 text-sm">
<Switch checked={form.active} onCheckedChange={(active) => updateForm(user.id, { active })} /> <Switch checked={form.active} disabled={isFixedAdmin} onCheckedChange={(active) => updateForm(user.id, { active })} />
{form.active ? "aktiv" : "inaktiv"} {form.active ? "aktiv" : "inaktiv"}
</label> </label>
<Button size="sm" onClick={() => void saveUser(user)} disabled={savingId === user.id}> <Button size="sm" onClick={() => void saveUser(user)} disabled={savingId === user.id}>
@@ -666,118 +454,6 @@ export function AdminUsersPage({ currentUser }: { currentUser: AuthUser }) {
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
<Card>
<CardHeader className="gap-3 p-4 sm:flex-row sm:items-center sm:justify-between sm:space-y-0">
<div>
<CardTitle>Session-Zuordnung</CardTitle>
<CardDescription>{loading ? "Lädt..." : `${sessions.length} letzte Session(s), inklusive Besitzer.`}</CardDescription>
</div>
<HelpLink anchor="admin" label="Hilfe zur Session-Zuordnung" />
</CardHeader>
<CardContent className="px-4 pb-4">
<div className="hidden xl:block">
<Table>
<TableHeader>
<TableRow>
<TableHead>Besitzer</TableHead>
<TableHead>Ticket</TableHead>
<TableHead>Organisation</TableHead>
<TableHead>Tätigkeit</TableHead>
<TableHead>Beginn</TableHead>
<TableHead>Zeit</TableHead>
<TableHead>Zuordnen zu</TableHead>
<TableHead />
</TableRow>
</TableHeader>
<TableBody>
{sessions.map((session) => (
<TableRow key={session.id}>
<TableCell>
<div className="font-medium">{session.owner_display_name}</div>
<div className="text-xs text-muted-foreground">{session.owner_username}</div>
</TableCell>
<TableCell className="font-semibold">{session.ticket_number}</TableCell>
<TableCell>{session.customer_name}</TableCell>
<TableCell className="max-w-xs whitespace-normal">{session.activity}</TableCell>
<TableCell className="whitespace-nowrap">{formatDateTime(session.started_at)}</TableCell>
<TableCell>{formatMinutes(session.rounded_minutes)}</TableCell>
<TableCell>
<select
className="h-8 w-full min-w-40 rounded-lg border border-input bg-background px-2 text-sm focus-visible:outline-none focus-visible:ring-3 focus-visible:ring-ring/50"
value={sessionOwners[session.id] ?? session.user_id}
onChange={(event) =>
setSessionOwners((current) => ({
...current,
[session.id]: event.currentTarget.value
}))
}
>
{activeUsers.map((user) => (
<option key={user.id} value={user.id}>
{user.display_name}
</option>
))}
</select>
</TableCell>
<TableCell className="text-right">
<Button size="sm" onClick={() => void moveSession(session)} disabled={movingId === session.id}>
<Shuffle className="size-4" />
{movingId === session.id ? "Speichert..." : "Umverteilen"}
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
<div className="space-y-2 xl:hidden">
{sessions.map((session) => (
<div key={session.id} className="space-y-3 rounded-md border bg-background p-3">
<div className="flex items-start justify-between gap-2">
<div>
<p className="font-semibold">{session.ticket_number}</p>
<p className="text-sm text-muted-foreground">{session.customer_name}</p>
</div>
<Badge variant="outline">{formatMinutes(session.rounded_minutes)}</Badge>
</div>
<div className="rounded-md bg-muted/40 p-2 text-sm">
<p className="font-medium">{session.owner_display_name}</p>
<p className="text-muted-foreground">{session.owner_username} · {formatDateTime(session.started_at)}</p>
</div>
<p className="text-sm">{session.activity}</p>
<div className="grid gap-2 sm:grid-cols-[1fr_auto]">
<select
className="h-9 w-full rounded-lg border border-input bg-background px-2 text-sm focus-visible:outline-none focus-visible:ring-3 focus-visible:ring-ring/50"
value={sessionOwners[session.id] ?? session.user_id}
onChange={(event) =>
setSessionOwners((current) => ({
...current,
[session.id]: event.currentTarget.value
}))
}
>
{activeUsers.map((user) => (
<option key={user.id} value={user.id}>
{user.display_name}
</option>
))}
</select>
<Button size="sm" onClick={() => void moveSession(session)} disabled={movingId === session.id}>
<Shuffle className="size-4" />
Umverteilen
</Button>
</div>
</div>
))}
</div>
{sessions.length === 0 ? (
<p className="py-8 text-center text-sm text-muted-foreground">Noch keine Sessions vorhanden.</p>
) : null}
</CardContent>
</Card>
</div> </div>
); );
} }
+48 -9
View File
@@ -1,7 +1,8 @@
import { ChevronLeft, ChevronRight, Clock3, ExternalLink, RotateCcw, SlidersHorizontal, Ticket, UserCheck, Users } from "lucide-react"; import { ChevronLeft, ChevronRight, Clock3, Download, ExternalLink, RotateCcw, SlidersHorizontal, Ticket, UserCheck, Users } from "lucide-react";
import type { LucideIcon } from "lucide-react"; import type { LucideIcon } from "lucide-react";
import { Fragment, useEffect, useMemo, useRef, useState } from "react"; import { Fragment, useEffect, useMemo, useRef, useState } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import { Alert } from "@/components/ui/alert";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
@@ -10,8 +11,8 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { CopyTicketButton } from "@/components/CopyTicketButton"; import { CopyTicketButton } from "@/components/CopyTicketButton";
import { HelpLink } from "@/components/HelpLink"; import { HelpLink } from "@/components/HelpLink";
import { getPeriodOverview } from "../api"; import { downloadMonthExport, getPeriodOverview } from "../api";
import { currentDay, currentMonth, formatMinutes } from "../format"; import { currentDay, currentMonth, formatDateTime, formatMinutes } from "../format";
import type { PeriodOverview, PeriodType, TicketSummary } from "../types"; import type { PeriodOverview, PeriodType, TicketSummary } from "../types";
type AnalysisPageProps = { type AnalysisPageProps = {
@@ -304,6 +305,7 @@ export function AnalysisPage({ onNavigate }: AnalysisPageProps) {
const [period, setPeriod] = useState(currentMonth()); const [period, setPeriod] = useState(currentMonth());
const [overview, setOverview] = useState<PeriodOverview | null>(null); const [overview, setOverview] = useState<PeriodOverview | null>(null);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [exportingMonth, setExportingMonth] = useState(false);
const [ticketViewSettings, setTicketViewSettings] = useState<AnalysisTicketViewSettings>(() => readStoredTicketViewSettings()); const [ticketViewSettings, setTicketViewSettings] = useState<AnalysisTicketViewSettings>(() => readStoredTicketViewSettings());
const loadRequestId = useRef(0); const loadRequestId = useRef(0);
@@ -379,6 +381,35 @@ export function AnalysisPage({ onNavigate }: AnalysisPageProps) {
} }
async function exportMonth() {
if (periodType !== "month") {
return;
}
setExportingMonth(true);
try {
const { blob, filename } = await downloadMonthExport(period);
const url = window.URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = filename;
document.body.appendChild(link);
link.click();
link.remove();
window.URL.revokeObjectURL(url);
toast.success("Monatsexport erstellt", {
description: filename
});
} catch (error) {
toast.error("Monatsexport fehlgeschlagen", {
description: error instanceof Error ? error.message : "Unbekannter Fehler"
});
} finally {
setExportingMonth(false);
}
}
const totals = overview?.totals; const totals = overview?.totals;
const periodLabel = periodType === "month" ? "Monat" : "Tag"; const periodLabel = periodType === "month" ? "Monat" : "Tag";
const metricCards: Array<{ label: string; value: string | number; detail?: string; icon: LucideIcon }> = [ const metricCards: Array<{ label: string; value: string | number; detail?: string; icon: LucideIcon }> = [
@@ -493,9 +524,21 @@ export function AnalysisPage({ onNavigate }: AnalysisPageProps) {
</Button> </Button>
</div> </div>
</div> </div>
{periodType === "month" ? (
<Button type="button" variant="secondary" className="sm:col-span-2" disabled={exportingMonth} onClick={() => void exportMonth()}>
<Download className="size-4" />
{exportingMonth ? "Exportiert..." : "Monat exportieren"}
</Button>
) : null}
</div> </div>
</div> </div>
{periodType === "month" && overview?.closed ? (
<Alert variant="success" className="py-3 text-sm">
Dieser Monat wurde am {formatDateTime(overview.closedAt!)} abgeschlossen. Bewertungen und Änderungen sind geschützt, bis der Monat im Monatsabschluss wieder geöffnet wird.
</Alert>
) : null}
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-5"> <div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-5">
{metricCards.map(({ label, value, detail, icon: Icon }) => ( {metricCards.map(({ label, value, detail, icon: Icon }) => (
<Card key={label}> <Card key={label}>
@@ -834,19 +877,15 @@ export function AnalysisPage({ onNavigate }: AnalysisPageProps) {
</div> </div>
<TicketStatus ticket={ticket} /> <TicketStatus ticket={ticket} />
</div> </div>
<div className="grid grid-cols-3 gap-2 text-sm"> <div className="grid grid-cols-2 gap-2 text-sm">
<div className="rounded-md bg-muted/50 p-2"> <div className="rounded-md bg-muted/50 p-2">
<p className="text-muted-foreground">Abr.</p> <p className="text-muted-foreground">Abr.</p>
<p className="font-medium">{formatMinutes(ticket.total_minutes)}</p> <p className="font-medium">{formatMinutes(ticket.total_minutes)}</p>
</div> </div>
<div className="rounded-md bg-muted/50 p-2"> <div className="rounded-md bg-muted/50 p-2">
<p className="text-muted-foreground">Abr.</p> <p className="text-muted-foreground">Sessions</p>
<p className="font-medium">{ticket.billed_count}</p> <p className="font-medium">{ticket.billed_count}</p>
</div> </div>
<div className="rounded-md bg-muted/50 p-2">
<p className="text-muted-foreground">Nicht</p>
<p className="font-medium">{formatMinutes(ticket.non_billable_minutes)}</p>
</div>
</div> </div>
<Button className="w-full" size="sm" variant="secondary" onClick={() => onNavigate(`/analysis/${periodType}/${period}/tickets/${ticket.id}`)}> <Button className="w-full" size="sm" variant="secondary" onClick={() => onNavigate(`/analysis/${periodType}/${period}/tickets/${ticket.id}`)}>
Öffnen Öffnen
+22 -6
View File
@@ -280,10 +280,26 @@ export function MonthlyClosePage({ onNavigate }: MonthlyClosePageProps) {
const canClose = Boolean(stats && !stats.closed && stats.totals.sessions > 0 && openSessions.length === 0); const canClose = Boolean(stats && !stats.closed && stats.totals.sessions > 0 && openSessions.length === 0);
const checklist = useMemo( const checklist = useMemo(
() => [ () => [
{ label: "Offene Bewertungen", count: openSessions.length, detail: `${openTicketGroups.length} Ticket(s)`, blocker: true }, {
{ label: "Tage ohne Teamspace-Wert", count: missingCrmDays.length, detail: `${acknowledgedMissingCrmDays.length} zur Kenntnis genommen`, blocker: false } label: "Offene Bewertungen",
ok: openSessions.length === 0,
value: openSessions.length,
detail: openSessions.length === 0 ? "Alle Sessions sind bewertet." : `${openTicketGroups.length} Ticket(s) bearbeiten`
},
{
label: "Teamspace geprüft",
ok: missingCrmDays.length === 0,
value: missingCrmDays.length,
detail: missingCrmDays.length === 0 ? "Keine offenen Teamspace-Prüfpunkte." : `${acknowledgedMissingCrmDays.length} zur Kenntnis genommen`
},
{
label: "Monat abschließbar",
ok: Boolean(stats?.closed || canClose),
value: stats?.closed ? "geschlossen" : canClose ? "bereit" : "offen",
detail: stats?.closed ? "Monat ist gesperrt." : canClose ? "Kann abgeschlossen werden." : "Bewertungen prüfen."
}
], ],
[acknowledgedMissingCrmDays.length, openSessions.length, openTicketGroups.length, missingCrmDays.length] [acknowledgedMissingCrmDays.length, canClose, openSessions.length, openTicketGroups.length, missingCrmDays.length, stats?.closed]
); );
return ( return (
@@ -348,14 +364,14 @@ export function MonthlyClosePage({ onNavigate }: MonthlyClosePageProps) {
<HelpLink anchor="monatsabschluss" label="Hilfe zum Monatsabschluss" /> <HelpLink anchor="monatsabschluss" label="Hilfe zum Monatsabschluss" />
</div> </div>
</CardHeader> </CardHeader>
<CardContent className="grid gap-2 px-4 pb-4 md:grid-cols-2"> <CardContent className="grid gap-2 px-4 pb-4 md:grid-cols-3">
{checklist.map((item) => ( {checklist.map((item) => (
<div key={item.label} className="rounded-md border bg-background p-3"> <div key={item.label} className="rounded-md border bg-background p-3">
<div className="mb-2 flex items-center justify-between gap-2"> <div className="mb-2 flex items-center justify-between gap-2">
<span className="text-sm font-medium">{item.label}</span> <span className="text-sm font-medium">{item.label}</span>
<Badge variant={item.count > 0 ? "warning" : "success"}>{item.count}</Badge> <Badge variant={item.ok ? "success" : "warning"}>{item.value}</Badge>
</div> </div>
<p className="text-xs text-muted-foreground">{item.blocker ? "Muss erledigt sein." : item.detail}</p> <p className="text-xs text-muted-foreground">{item.detail}</p>
</div> </div>
))} ))}
</CardContent> </CardContent>
+7
View File
@@ -1,6 +1,7 @@
import { ArrowLeft, Pencil, Save, Trash2 } from "lucide-react"; import { ArrowLeft, Pencil, Save, 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 { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
@@ -502,6 +503,12 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate }: T
</Badge> </Badge>
</div> </div>
{periodType === "month" && data?.closed ? (
<Alert variant="success" className="py-3 text-sm">
Dieser Monat ist abgeschlossen. Bewertungen und Änderungen sind geschützt, bis du den Monat im Monatsabschluss wieder öffnest.
</Alert>
) : null}
<Card> <Card>
<CardHeader className="p-4"> <CardHeader className="p-4">
<div className="flex items-start justify-between gap-3"> <div className="flex items-start justify-between gap-3">
+2 -1
View File
@@ -318,6 +318,7 @@ export function TimerPage({ timers, setTimers, selectedTimerId, setSelectedTimer
id="ticket-number" id="ticket-number"
placeholder="Ticket#123456" placeholder="Ticket#123456"
value={ticketNumber} value={ticketNumber}
autoFocus
onChange={(event) => setTicketNumber(event.currentTarget.value)} onChange={(event) => setTicketNumber(event.currentTarget.value)}
onKeyDown={(event) => { onKeyDown={(event) => {
if (event.key === "Enter") { if (event.key === "Enter") {
@@ -525,7 +526,7 @@ export function TimerPage({ timers, setTimers, selectedTimerId, setSelectedTimer
<label className="text-sm font-medium" htmlFor="activity"> <label className="text-sm font-medium" htmlFor="activity">
Tätigkeit Tätigkeit
</label> </label>
<Textarea id="activity" value={activity} onChange={(event) => setActivity(event.currentTarget.value)} required /> <Textarea id="activity" value={activity} onChange={(event) => setActivity(event.currentTarget.value)} autoFocus required />
</div> </div>
{finished?.ticket?.organization_id && finished.ticket.work_type ? null : ( {finished?.ticket?.organization_id && finished.ticket.work_type ? null : (
<div className="space-y-2"> <div className="space-y-2">