Restrict admin to system management
This commit is contained in:
@@ -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
@@ -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") {
|
|
||||||
res.status(404).json({ error: "Organisation nicht gefunden oder inaktiv" });
|
|
||||||
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.patch("/api/admin/sessions/:sessionId/owner", requireAdmin, async (_req, res) => {
|
||||||
const sessionId = parsePositiveInteger(req.params.sessionId, "sessionId");
|
res.status(410).json({ error: "Session-Verwaltung ist für den festen Admin deaktiviert" });
|
||||||
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) => {
|
app.post("/api/sessions", requireUser, 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(
|
||||||
`
|
`
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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",
|
||||||
|
|||||||
@@ -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."
|
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
Reference in New Issue
Block a user