Initial TicketTracker release

This commit is contained in:
michael committed 2026-09-09 09:42:48 +02:00
1 parent d1da86beab
commit c2063fe1ed
11 files changed
+1124 -175

No files matched your search

+13 -2
View File
@@ -15,6 +15,8 @@ export type AuthUser = {
display_name: string;
role: UserRole;
active: boolean;
zammad_user_id: string | null;
has_zammad_api_token: boolean;
};
declare global {
@@ -73,7 +75,9 @@ function publicUser(user: AuthUser) {
username: user.username,
display_name: user.display_name,
role: user.role,
active: user.active
active: user.active,
zammad_user_id: user.zammad_user_id ?? null,
has_zammad_api_token: Boolean(user.has_zammad_api_token)
};
}
@@ -123,7 +127,14 @@ export async function requireAuth(req: Request, res: Response, next: NextFunctio
const tokenHash = hashToken(token);
const result = await query<AuthUser>(
`
SELECT u.id, u.username, u.display_name, u.role, u.active
SELECT
u.id,
u.username,
u.display_name,
u.role,
u.active,
u.zammad_user_id::text AS zammad_user_id,
(u.zammad_api_token IS NOT NULL) AS has_zammad_api_token
FROM auth_sessions s
JOIN users u ON u.id = s.user_id
WHERE s.token_hash = $1
+313 -26
View File
@@ -175,11 +175,30 @@ type ProjectRow = {
manual_consumption_minutes: number;
teamspace_consumption_minutes?: number;
consumption_minutes?: number;
teamspace_entry_count?: number;
teamspace_entries?: ProjectTeamspaceEntryRow[];
closed_at: string | null;
created_at: string;
updated_at: string;
};
type ProjectTeamspaceEntryRow = {
id: string;
project_id?: string;
teamspace_project: string | null;
is_default: boolean;
hour_budget_minutes: number | null;
manual_consumption_minutes: number;
teamspace_consumption_minutes?: number;
consumption_minutes?: number;
display_order?: number;
};
type ProjectWithEntries = ProjectRow & {
teamspace_entries: ProjectTeamspaceEntryRow[];
teamspace_entry_count: number;
};
const zammadBaseUrlSettingKey = "zammad.base_url";
const zammadApiKeySettingKey = "zammad.api_key";
@@ -200,6 +219,36 @@ async function setAppSetting(key: string, value: string) {
);
}
async function fetchZammadUserIdForToken(apiToken: string) {
const savedBaseUrl = await getAppSetting(zammadBaseUrlSettingKey);
if (!savedBaseUrl) {
throw badRequest("Zammad URL muss zuerst im Adminbereich gespeichert werden");
}
const baseUrl = parseZammadBaseUrl(savedBaseUrl);
const response = await fetch(`${baseUrl}/api/v1/users/me`, {
headers: {
Authorization: `Token token=${apiToken}`,
Accept: "application/json"
}
});
if (!response.ok) {
const body = await response.text().catch(() => "");
throw Object.assign(new Error(`Zammad Token konnte nicht geprüft werden (${response.status})${body ? `: ${body.slice(0, 250)}` : ""}`), { status: 502 });
}
const payload = (await response.json()) as { id?: unknown };
const zammadUserId = Number(payload.id);
if (!Number.isInteger(zammadUserId) || zammadUserId < 1) {
throw Object.assign(new Error("Zammad Antwort enthält keine gültige User-ID"), { status: 502 });
}
return zammadUserId;
}
async function getOrganizationByZammadId(
client: { query: (text: string, params?: unknown[]) => Promise<{ rows: OrganizationRow[]; rowCount: number | null }> },
zammadId: number
@@ -315,6 +364,58 @@ function parseOptionalNonNegativeInteger(value: unknown, field: string) {
return number;
}
function parseProjectTeamspaceEntries(
value: unknown,
fallback: { teamspaceProject: string | null; hourBudgetMinutes: number | null; manualConsumptionMinutes: number }
) {
const source = Array.isArray(value) && value.length > 0
? value
: [
{
teamspaceProject: fallback.teamspaceProject,
isDefault: true,
hourBudgetMinutes: fallback.hourBudgetMinutes,
manualConsumptionMinutes: fallback.manualConsumptionMinutes
}
];
if (source.length > 20) {
throw badRequest("teamspaceEntries must contain at most 20 entries");
}
let hasDefault = false;
const entries = source.map((item, index) => {
if (!item || typeof item !== "object" || Array.isArray(item)) {
throw badRequest("teamspaceEntries must be an array of objects");
}
const entry = item as Record<string, unknown>;
const isDefault = entry.isDefault === undefined ? index === 0 : optionalBoolean(entry.isDefault, index === 0);
if (isDefault) {
if (hasDefault) {
throw badRequest("Only one teamspace entry can be default");
}
hasDefault = true;
}
return {
teamspaceProject: optionalString(entry.teamspaceProject),
isDefault,
hourBudgetMinutes: parseOptionalNonNegativeInteger(entry.hourBudgetMinutes, "teamspaceEntries.hourBudgetMinutes"),
manualConsumptionMinutes: parseOptionalNonNegativeInteger(entry.manualConsumptionMinutes, "teamspaceEntries.manualConsumptionMinutes") ?? 0,
displayOrder: index * 1024
};
});
if (!hasDefault) {
entries[0] = { ...entries[0], isDefault: true };
}
return entries;
}
function parseTodoPriority(value: unknown) {
if (value !== "low" && value !== "medium" && value !== "high" && value !== "urgent") {
throw badRequest("priority must be low, medium, high or urgent");
@@ -709,20 +810,37 @@ async function cleanupTicketDayBillingIfEmpty(
}
async function ensureProjectForTicket(
client: { query: (text: string, params?: unknown[]) => Promise<unknown> },
client: { query: <T = any>(text: string, params?: unknown[]) => Promise<{ rows: T[] }> },
ticketId: string,
userId: string
) {
await client.query(
const result = await client.query<{ id: string; teamspace_project: string | null; hour_budget_minutes: number | null; manual_consumption_minutes: number }>(
`
INSERT INTO projects (user_id, ticket_id, teamspace_project, description, goals)
SELECT $2, t.id, COALESCE(t.title, ''), '', ''
FROM tickets t
WHERE t.id = $1
ON CONFLICT (user_id, ticket_id) DO NOTHING;
ON CONFLICT (user_id, ticket_id) DO NOTHING
RETURNING id::text, teamspace_project, hour_budget_minutes, manual_consumption_minutes;
`,
[ticketId, userId]
);
if ("rows" in result && result.rows[0]) {
await client.query(
`
INSERT INTO project_teamspace_entries (project_id, teamspace_project, is_default, hour_budget_minutes, manual_consumption_minutes, display_order)
VALUES ($1, $2, true, $3, $4, 0)
ON CONFLICT DO NOTHING;
`,
[
result.rows[0].id,
result.rows[0].teamspace_project,
result.rows[0].hour_budget_minutes,
result.rows[0].manual_consumption_minutes ?? 0
]
);
}
}
async function deleteProjectDataForTicket(
@@ -1600,8 +1718,8 @@ async function getTicketDetails(ticketId: number, userId: string, filters: { mon
p.goals,
p.starts_on::text AS starts_on,
p.ends_on::text AS ends_on,
p.hour_budget_minutes,
p.manual_consumption_minutes,
entry_totals.total_hour_budget_minutes AS hour_budget_minutes,
entry_totals.total_manual_consumption_minutes AS manual_consumption_minutes,
EXISTS (
SELECT 1
FROM recurring_billings rb
@@ -1609,7 +1727,9 @@ async function getTicketDetails(ticketId: number, userId: string, filters: { mon
AND COALESCE(rb.ticket_number, 'Fix#' || rb.id) = t.ticket_number
) AS has_recurring_billing,
COALESCE(teamspace_totals.teamspace_consumption_minutes, 0)::int AS teamspace_consumption_minutes,
(COALESCE(teamspace_totals.teamspace_consumption_minutes, 0) + p.manual_consumption_minutes)::int AS consumption_minutes,
(COALESCE(teamspace_totals.teamspace_consumption_minutes, 0) + entry_totals.total_manual_consumption_minutes)::int AS consumption_minutes,
entry_totals.teamspace_entry_count,
entry_totals.teamspace_entries,
p.closed_at,
p.created_at,
p.updated_at
@@ -1622,6 +1742,31 @@ async function getTicketDetails(ticketId: number, userId: string, filters: { mon
WHERE tdb.ticket_id = p.ticket_id
AND tdb.user_id = p.user_id
) teamspace_totals ON true
LEFT JOIN LATERAL (
SELECT
COALESCE(SUM(pte.hour_budget_minutes) FILTER (WHERE pte.hour_budget_minutes IS NOT NULL), NULL)::int AS total_hour_budget_minutes,
COALESCE(SUM(pte.manual_consumption_minutes), 0)::int AS total_manual_consumption_minutes,
COUNT(*)::int AS teamspace_entry_count,
COALESCE(
json_agg(
json_build_object(
'id', pte.id::text,
'project_id', pte.project_id::text,
'teamspace_project', pte.teamspace_project,
'is_default', pte.is_default,
'hour_budget_minutes', pte.hour_budget_minutes,
'manual_consumption_minutes', pte.manual_consumption_minutes,
'teamspace_consumption_minutes', CASE WHEN pte.is_default THEN COALESCE(teamspace_totals.teamspace_consumption_minutes, 0) ELSE 0 END,
'consumption_minutes', CASE WHEN pte.is_default THEN COALESCE(teamspace_totals.teamspace_consumption_minutes, 0) ELSE 0 END + pte.manual_consumption_minutes,
'display_order', pte.display_order
)
ORDER BY pte.is_default DESC, pte.display_order ASC, pte.id ASC
),
'[]'::json
) AS teamspace_entries
FROM project_teamspace_entries pte
WHERE pte.project_id = p.id
) entry_totals ON true
WHERE p.ticket_id = $1
AND p.user_id = $2;
`,
@@ -1720,11 +1865,13 @@ app.post("/api/setup/admin", async (req, res) => {
display_name: string;
role: "admin" | "user";
active: boolean;
zammad_user_id: string | null;
has_zammad_api_token: boolean;
}>(
`
INSERT INTO users (username, display_name, password_hash, role, active)
VALUES ($1, $2, $3, 'admin', true)
RETURNING id, username, display_name, role, active;
RETURNING id, username, display_name, role, active, zammad_user_id::text AS zammad_user_id, (zammad_api_token IS NOT NULL) AS has_zammad_api_token;
`,
[username, displayName, await hashPassword(password)]
);
@@ -1773,9 +1920,19 @@ app.post("/api/auth/login", async (req, res) => {
role: "admin" | "user";
active: boolean;
password_hash: string;
zammad_user_id: string | null;
has_zammad_api_token: boolean;
}>(
`
SELECT id, username, display_name, role, active, password_hash
SELECT
id,
username,
display_name,
role,
active,
password_hash,
zammad_user_id::text AS zammad_user_id,
(zammad_api_token IS NOT NULL) AS has_zammad_api_token
FROM users
WHERE lower(username) = lower($1);
`,
@@ -1796,7 +1953,9 @@ app.post("/api/auth/login", async (req, res) => {
username: user.username,
display_name: user.display_name,
role: user.role,
active: user.active
active: user.active,
zammad_user_id: user.zammad_user_id,
has_zammad_api_token: user.has_zammad_api_token
}
});
});
@@ -1867,6 +2026,9 @@ app.patch("/api/auth/me", async (req, res) => {
const displayName = requireString(req.body.displayName, "displayName");
const currentPassword = optionalString(req.body.currentPassword);
const newPassword = optionalString(req.body.newPassword);
const updatesZammadToken = Object.prototype.hasOwnProperty.call(req.body, "zammadApiToken") && user.role === "user";
const zammadApiToken = updatesZammadToken ? optionalString(req.body.zammadApiToken) : null;
const zammadUserId = zammadApiToken ? await fetchZammadUserIdForToken(zammadApiToken) : null;
if (newPassword && newPassword.length < 6) {
throw badRequest("newPassword must be at least 6 characters");
@@ -1892,11 +2054,13 @@ app.patch("/api/auth/me", async (req, res) => {
SET username = $1,
display_name = $2,
password_hash = COALESCE($3, password_hash),
zammad_api_token = CASE WHEN $5::boolean THEN $6 ELSE zammad_api_token END,
zammad_user_id = CASE WHEN $5::boolean THEN $7 ELSE zammad_user_id END,
updated_at = now()
WHERE id = $4
RETURNING id, username, display_name, role, active;
RETURNING id, username, display_name, role, active, zammad_user_id::text AS zammad_user_id, (zammad_api_token IS NOT NULL) AS has_zammad_api_token;
`,
[username, displayName, newPassword ? await hashPassword(newPassword) : null, user.id]
[username, displayName, newPassword ? await hashPassword(newPassword) : null, user.id, updatesZammadToken, zammadApiToken, zammadUserId]
);
if (result.rowCount === 0) {
@@ -1923,7 +2087,7 @@ app.patch("/api/auth/me", async (req, res) => {
app.get("/api/admin/users", requireAdmin, async (_req, res) => {
const result = await query(
`
SELECT id, username, display_name, role, active, created_at, updated_at
SELECT id, username, display_name, role, active, zammad_user_id::text AS zammad_user_id, (zammad_api_token IS NOT NULL) AS has_zammad_api_token, created_at, updated_at
FROM users
ORDER BY username ASC;
`
@@ -1947,7 +2111,7 @@ app.post("/api/admin/users", requireAdmin, async (req, res) => {
`
INSERT INTO users (username, display_name, password_hash, role, active)
VALUES ($1, $2, $3, $4, $5)
RETURNING id, username, display_name, role, active, created_at, updated_at;
RETURNING id, username, display_name, role, active, zammad_user_id::text AS zammad_user_id, (zammad_api_token IS NOT NULL) AS has_zammad_api_token, created_at, updated_at;
`,
[username, displayName, await hashPassword(password), "user", active]
);
@@ -2008,7 +2172,7 @@ app.patch("/api/admin/users/:userId", requireAdmin, async (req, res) => {
password_hash = COALESCE($4, password_hash),
updated_at = now()
WHERE id = $5
RETURNING id, username, display_name, role, active, created_at, updated_at;
RETURNING id, username, display_name, role, active, zammad_user_id::text AS zammad_user_id, (zammad_api_token IS NOT NULL) AS has_zammad_api_token, created_at, updated_at;
`,
[username, displayName, active, password ? await hashPassword(password) : null, userId]
);
@@ -2248,6 +2412,7 @@ const databaseExportTables = [
"organizations",
"tickets",
"projects",
"project_teamspace_entries",
"ticket_todos",
"recurring_billings",
"recurring_billing_slots",
@@ -3395,8 +3560,8 @@ app.get("/api/projects", requireUser, async (req, res) => {
p.goals,
p.starts_on::text AS starts_on,
p.ends_on::text AS ends_on,
p.hour_budget_minutes,
p.manual_consumption_minutes,
entry_totals.total_hour_budget_minutes AS hour_budget_minutes,
entry_totals.total_manual_consumption_minutes AS manual_consumption_minutes,
p.closed_at,
p.created_at,
p.updated_at,
@@ -3410,7 +3575,9 @@ app.get("/api/projects", requireUser, async (req, res) => {
AND COALESCE(rb.ticket_number, 'Fix#' || rb.id) = t.ticket_number
) AS has_recurring_billing,
COALESCE(teamspace_totals.teamspace_consumption_minutes, 0)::int AS teamspace_consumption_minutes,
(COALESCE(teamspace_totals.teamspace_consumption_minutes, 0) + p.manual_consumption_minutes)::int AS consumption_minutes,
(COALESCE(teamspace_totals.teamspace_consumption_minutes, 0) + entry_totals.total_manual_consumption_minutes)::int AS consumption_minutes,
entry_totals.teamspace_entry_count,
entry_totals.teamspace_entries,
COALESCE(todo_totals.open_todos, 0)::int AS open_todos,
COALESCE(todo_totals.overdue_todos, 0)::int AS overdue_todos,
COALESCE(todo_totals.done_todos, 0)::int AS done_todos
@@ -3432,6 +3599,31 @@ app.get("/api/projects", requireUser, async (req, res) => {
WHERE tdb.ticket_id = p.ticket_id
AND tdb.user_id = p.user_id
) teamspace_totals ON true
LEFT JOIN LATERAL (
SELECT
COALESCE(SUM(pte.hour_budget_minutes) FILTER (WHERE pte.hour_budget_minutes IS NOT NULL), NULL)::int AS total_hour_budget_minutes,
COALESCE(SUM(pte.manual_consumption_minutes), 0)::int AS total_manual_consumption_minutes,
COUNT(*)::int AS teamspace_entry_count,
COALESCE(
json_agg(
json_build_object(
'id', pte.id::text,
'project_id', pte.project_id::text,
'teamspace_project', pte.teamspace_project,
'is_default', pte.is_default,
'hour_budget_minutes', pte.hour_budget_minutes,
'manual_consumption_minutes', pte.manual_consumption_minutes,
'teamspace_consumption_minutes', CASE WHEN pte.is_default THEN COALESCE(teamspace_totals.teamspace_consumption_minutes, 0) ELSE 0 END,
'consumption_minutes', CASE WHEN pte.is_default THEN COALESCE(teamspace_totals.teamspace_consumption_minutes, 0) ELSE 0 END + pte.manual_consumption_minutes,
'display_order', pte.display_order
)
ORDER BY pte.is_default DESC, pte.display_order ASC, pte.id ASC
),
'[]'::json
) AS teamspace_entries
FROM project_teamspace_entries pte
WHERE pte.project_id = p.id
) entry_totals ON true
LEFT JOIN LATERAL (
SELECT
COUNT(*) FILTER (WHERE status = 'open')::int AS open_todos,
@@ -3473,8 +3665,8 @@ app.get("/api/projects/:projectId", requireUser, async (req, res) => {
p.goals,
p.starts_on::text AS starts_on,
p.ends_on::text AS ends_on,
p.hour_budget_minutes,
p.manual_consumption_minutes,
entry_totals.total_hour_budget_minutes AS hour_budget_minutes,
entry_totals.total_manual_consumption_minutes AS manual_consumption_minutes,
EXISTS (
SELECT 1
FROM recurring_billings rb
@@ -3482,7 +3674,9 @@ app.get("/api/projects/:projectId", requireUser, async (req, res) => {
AND COALESCE(rb.ticket_number, 'Fix#' || rb.id) = t.ticket_number
) AS has_recurring_billing,
COALESCE(teamspace_totals.teamspace_consumption_minutes, 0)::int AS teamspace_consumption_minutes,
(COALESCE(teamspace_totals.teamspace_consumption_minutes, 0) + p.manual_consumption_minutes)::int AS consumption_minutes,
(COALESCE(teamspace_totals.teamspace_consumption_minutes, 0) + entry_totals.total_manual_consumption_minutes)::int AS consumption_minutes,
entry_totals.teamspace_entry_count,
entry_totals.teamspace_entries,
p.closed_at,
p.created_at,
p.updated_at
@@ -3495,6 +3689,31 @@ app.get("/api/projects/:projectId", requireUser, async (req, res) => {
WHERE tdb.ticket_id = p.ticket_id
AND tdb.user_id = p.user_id
) teamspace_totals ON true
LEFT JOIN LATERAL (
SELECT
COALESCE(SUM(pte.hour_budget_minutes) FILTER (WHERE pte.hour_budget_minutes IS NOT NULL), NULL)::int AS total_hour_budget_minutes,
COALESCE(SUM(pte.manual_consumption_minutes), 0)::int AS total_manual_consumption_minutes,
COUNT(*)::int AS teamspace_entry_count,
COALESCE(
json_agg(
json_build_object(
'id', pte.id::text,
'project_id', pte.project_id::text,
'teamspace_project', pte.teamspace_project,
'is_default', pte.is_default,
'hour_budget_minutes', pte.hour_budget_minutes,
'manual_consumption_minutes', pte.manual_consumption_minutes,
'teamspace_consumption_minutes', CASE WHEN pte.is_default THEN COALESCE(teamspace_totals.teamspace_consumption_minutes, 0) ELSE 0 END,
'consumption_minutes', CASE WHEN pte.is_default THEN COALESCE(teamspace_totals.teamspace_consumption_minutes, 0) ELSE 0 END + pte.manual_consumption_minutes,
'display_order', pte.display_order
)
ORDER BY pte.is_default DESC, pte.display_order ASC, pte.id ASC
),
'[]'::json
) AS teamspace_entries
FROM project_teamspace_entries pte
WHERE pte.project_id = p.id
) entry_totals ON true
WHERE p.id = $1
AND p.user_id = $2;
`,
@@ -3521,6 +3740,8 @@ app.post("/api/projects", requireUser, async (req, res) => {
const endsOn = parseOptionalDateOnly(req.body.endsOn, "endsOn");
const hourBudgetMinutes = parseOptionalNonNegativeInteger(req.body.hourBudgetMinutes, "hourBudgetMinutes");
const manualConsumptionMinutes = parseOptionalNonNegativeInteger(req.body.manualConsumptionMinutes, "manualConsumptionMinutes") ?? 0;
const teamspaceEntries = parseProjectTeamspaceEntries(req.body.teamspaceEntries, { teamspaceProject, hourBudgetMinutes, manualConsumptionMinutes });
const defaultTeamspaceEntry = teamspaceEntries.find((entry) => entry.isDefault) ?? teamspaceEntries[0];
if (startsOn && endsOn && endsOn < startsOn) {
throw badRequest("endsOn must be after startsOn");
@@ -3624,13 +3845,13 @@ app.post("/api/projects", requireUser, async (req, res) => {
[
userId,
ticketId,
teamspaceProject,
defaultTeamspaceEntry.teamspaceProject,
description,
goals,
startsOn,
endsOn,
hourBudgetMinutes,
manualConsumptionMinutes,
defaultTeamspaceEntry.hourBudgetMinutes,
defaultTeamspaceEntry.manualConsumptionMinutes,
ticketNumber,
organization.id,
organization.name,
@@ -3639,7 +3860,31 @@ app.post("/api/projects", requireUser, async (req, res) => {
]
);
return projectResult.rows[0];
const project = projectResult.rows[0];
for (const entry of teamspaceEntries) {
await client.query(
`
INSERT INTO project_teamspace_entries (project_id, teamspace_project, is_default, hour_budget_minutes, manual_consumption_minutes, display_order)
VALUES ($1, $2, $3, $4, $5, $6);
`,
[project.id, entry.teamspaceProject, entry.isDefault, entry.hourBudgetMinutes, entry.manualConsumptionMinutes, entry.displayOrder]
);
}
project.teamspace_entries = teamspaceEntries.map((entry, index) => ({
id: `new-${index}`,
teamspace_project: entry.teamspaceProject,
is_default: entry.isDefault,
hour_budget_minutes: entry.hourBudgetMinutes,
manual_consumption_minutes: entry.manualConsumptionMinutes,
teamspace_consumption_minutes: entry.isDefault ? 0 : 0,
consumption_minutes: entry.manualConsumptionMinutes,
display_order: entry.displayOrder
}));
project.teamspace_entry_count = teamspaceEntries.length;
return project;
});
if (created === "organization-required") {
@@ -3676,6 +3921,8 @@ app.patch("/api/projects/:projectId", requireUser, async (req, res) => {
const endsOn = parseOptionalDateOnly(req.body.endsOn, "endsOn");
const hourBudgetMinutes = parseOptionalNonNegativeInteger(req.body.hourBudgetMinutes, "hourBudgetMinutes");
const manualConsumptionMinutes = parseOptionalNonNegativeInteger(req.body.manualConsumptionMinutes, "manualConsumptionMinutes") ?? 0;
const teamspaceEntries = parseProjectTeamspaceEntries(req.body.teamspaceEntries, { teamspaceProject, hourBudgetMinutes, manualConsumptionMinutes });
const defaultTeamspaceEntry = teamspaceEntries.find((entry) => entry.isDefault) ?? teamspaceEntries[0];
if (startsOn && endsOn && endsOn < startsOn) {
throw badRequest("endsOn must be after startsOn");
@@ -3774,10 +4021,50 @@ app.patch("/api/projects/:projectId", requireUser, async (req, res) => {
created_at,
updated_at;
`,
[teamspaceProject, description, goals, startsOn, endsOn, hourBudgetMinutes, manualConsumptionMinutes, projectId, userId, ticketNumber, organization.id, organization.name, title]
[
defaultTeamspaceEntry.teamspaceProject,
description,
goals,
startsOn,
endsOn,
defaultTeamspaceEntry.hourBudgetMinutes,
defaultTeamspaceEntry.manualConsumptionMinutes,
projectId,
userId,
ticketNumber,
organization.id,
organization.name,
title
]
);
return result.rows[0];
const project = result.rows[0];
await client.query("DELETE FROM project_teamspace_entries WHERE project_id = $1;", [project.id]);
for (const entry of teamspaceEntries) {
await client.query(
`
INSERT INTO project_teamspace_entries (project_id, teamspace_project, is_default, hour_budget_minutes, manual_consumption_minutes, display_order)
VALUES ($1, $2, $3, $4, $5, $6);
`,
[project.id, entry.teamspaceProject, entry.isDefault, entry.hourBudgetMinutes, entry.manualConsumptionMinutes, entry.displayOrder]
);
}
project.teamspace_entries = teamspaceEntries.map((entry, index) => ({
id: `new-${index}`,
teamspace_project: entry.teamspaceProject,
is_default: entry.isDefault,
hour_budget_minutes: entry.hourBudgetMinutes,
manual_consumption_minutes: entry.manualConsumptionMinutes,
teamspace_consumption_minutes: entry.isDefault ? Number(project.teamspace_consumption_minutes ?? 0) : 0,
consumption_minutes: (entry.isDefault ? Number(project.teamspace_consumption_minutes ?? 0) : 0) + entry.manualConsumptionMinutes,
display_order: entry.displayOrder
}));
project.teamspace_entry_count = teamspaceEntries.length;
return project;
});
if (updated === "organization-not-found") {
+32
View File
@@ -60,6 +60,8 @@ export async function migrate() {
DROP CONSTRAINT IF EXISTS users_username_format,
ADD CONSTRAINT users_username_format CHECK (char_length(username) BETWEEN 3 AND 40 AND username !~ '\\s');
`);
await query("ALTER TABLE users ADD COLUMN IF NOT EXISTS zammad_api_token TEXT;");
await query("ALTER TABLE users ADD COLUMN IF NOT EXISTS zammad_user_id BIGINT;");
await query(`
CREATE TABLE IF NOT EXISTS auth_sessions (
@@ -485,6 +487,25 @@ export async function migrate() {
END $$;
`);
await query(`
CREATE TABLE IF NOT EXISTS project_teamspace_entries (
id BIGSERIAL PRIMARY KEY,
project_id BIGINT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
teamspace_project TEXT,
is_default BOOLEAN NOT NULL DEFAULT false,
hour_budget_minutes INTEGER,
manual_consumption_minutes INTEGER NOT NULL DEFAULT 0,
display_order INTEGER NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
CONSTRAINT project_teamspace_entries_budget_non_negative CHECK (hour_budget_minutes IS NULL OR hour_budget_minutes >= 0),
CONSTRAINT project_teamspace_entries_manual_non_negative CHECK (manual_consumption_minutes >= 0)
);
`);
await query("CREATE UNIQUE INDEX IF NOT EXISTS idx_project_teamspace_entries_default ON project_teamspace_entries(project_id) WHERE is_default;");
await query("CREATE INDEX IF NOT EXISTS idx_project_teamspace_entries_project_order ON project_teamspace_entries(project_id, display_order, id);");
await query(`
UPDATE tickets t
SET work_type = 'consulting'
@@ -506,6 +527,17 @@ export async function migrate() {
ON CONFLICT (user_id, ticket_id) DO NOTHING;
`);
await query(`
INSERT INTO project_teamspace_entries (project_id, teamspace_project, is_default, hour_budget_minutes, manual_consumption_minutes, display_order)
SELECT p.id, p.teamspace_project, true, p.hour_budget_minutes, p.manual_consumption_minutes, 0
FROM projects p
WHERE NOT EXISTS (
SELECT 1
FROM project_teamspace_entries pte
WHERE pte.project_id = p.id
);
`);
await query(`
CREATE TABLE IF NOT EXISTS ticket_todos (
id BIGSERIAL PRIMARY KEY,
+7
View File
@@ -510,9 +510,16 @@ Unter `Profil` kann jeder Benutzer seine eigenen Daten pflegen:
- Benutzername
- Voller Name
- Passwort
- persoenlicher Zammad API-Token
Passwortaenderungen erfordern das aktuelle Passwort. Bleiben die Passwortfelder leer, werden nur Benutzername und voller Name gespeichert.
Normale Benutzer koennen im Profil einen persoenlichen Zammad API-Token hinterlegen. Beim Speichern eines neuen Tokens ruft TicketTracker Zammad ueber `/api/v1/users/me` auf und speichert die User-ID, die Zammad fuer diesen Token zurueckliefert. Die User-ID wird danach read-only im Profil angezeigt.
Die Pruefung findet nur statt, wenn der Token im Profil neu eingetragen oder entfernt wird. Wenn nur Benutzername, voller Name oder Passwort gespeichert werden, wird Zammad nicht erneut abgefragt.
Der persoenliche Zammad Token ist unabhaengig vom Admin-Token fuer den Organisations-Sync. Der gleiche Token kann technisch in beiden Bereichen eingetragen sein, wird aber getrennt gespeichert und getrennt verwendet. Im Admin-Profil gibt es keinen persoenlichen Zammad-Token.
## Adminbereich
Der feste Admin ist nur fuer die Systemverwaltung vorgesehen. Er kann:
+13
View File
@@ -94,6 +94,7 @@ export function updateCurrentUser(payload: {
displayName: string;
currentPassword?: string;
newPassword?: string;
zammadApiToken?: string;
}) {
return request<{ user: AuthUser }>("/api/auth/me", {
method: "PATCH",
@@ -338,6 +339,12 @@ export function createProject(payload: {
title?: string | null;
organizationId?: string | null;
teamspaceProject?: string | null;
teamspaceEntries?: Array<{
teamspaceProject?: string | null;
isDefault: boolean;
hourBudgetMinutes?: number | null;
manualConsumptionMinutes?: number | null;
}>;
description: string;
goals: string;
startsOn?: string | null;
@@ -358,6 +365,12 @@ export function updateProject(
title?: string | null;
organizationId: string;
teamspaceProject?: string | null;
teamspaceEntries?: Array<{
teamspaceProject?: string | null;
isDefault: boolean;
hourBudgetMinutes?: number | null;
manualConsumptionMinutes?: number | null;
}>;
description: string;
goals: string;
startsOn?: string | null;
+7
View File
@@ -500,6 +500,7 @@ export const helpSections: HelpSection[] = [
quickItems: [
"Benutzername und voller Name sind änderbar.",
"Passwortänderung benötigt das aktuelle Passwort.",
"Normale Benutzer können ihren persönlichen Zammad API-Token hinterlegen.",
"Der volle Name wird in der Oberfläche angezeigt."
],
details: [
@@ -507,6 +508,12 @@ export const helpSections: HelpSection[] = [
"Der volle Name erscheint in der App, zum Beispiel im Kopfbereich oder im Adminbereich.",
"Wenn du dein Passwort ändern willst, musst du das aktuelle Passwort sowie das neue Passwort zweimal eingeben.",
"Bleiben die Passwortfelder leer, werden nur Benutzername und voller Name gespeichert.",
"Normale Benutzer können im Profil einen persönlichen Zammad API-Token speichern.",
"Beim Speichern eines neuen Tokens fragt TicketTracker Zammad über /api/v1/users/me ab und speichert die zurückgelieferte Zammad User-ID.",
"Die gespeicherte Zammad User-ID wird danach read-only im Profil angezeigt.",
"Die Token-Prüfung läuft nur, wenn der Token im Profil geändert oder entfernt wird. Normale Profiländerungen prüfen Zammad nicht erneut.",
"Der persönliche Zammad Token ist vom Admin-Token für den Organisations-Sync getrennt. Es kann zufällig derselbe Token sein, wird aber separat gespeichert und verwendet.",
"Im Admin-Profil wird kein persönlicher Zammad-Token angezeigt.",
"Admins können Benutzer ebenfalls verwalten, aber jeder Benutzer kann die eigenen Daten jederzeit selbst pflegen."
]
},
+16
View File
@@ -10,6 +10,8 @@ export type AuthUser = {
display_name: string;
role: UserRole;
active: boolean;
zammad_user_id: string | null;
has_zammad_api_token: boolean;
};
export type DashboardWidgetType = "today" | "month" | "openRatings" | "projectBudgets" | "todos" | "teamspace" | "favorites" | "divider" | "statistic" | "notes";
@@ -139,6 +141,20 @@ export type ProjectSummary = {
open_todos?: number;
overdue_todos?: number;
done_todos?: number;
teamspace_entry_count?: number;
teamspace_entries?: ProjectTeamspaceEntry[];
};
export type ProjectTeamspaceEntry = {
id: string;
project_id?: string;
teamspace_project: string | null;
is_default: boolean;
hour_budget_minutes: number | null;
manual_consumption_minutes: number;
teamspace_consumption_minutes?: number;
consumption_minutes?: number;
display_order?: number;
};
export type TodoPriority = "low" | "medium" | "high" | "urgent";
+112 -4
View File
@@ -54,6 +54,7 @@ import type {
DashboardWidget,
DashboardWidgetType,
PeriodOverview,
ProjectTeamspaceEntry,
ProjectSummary,
RecurringBilling,
StatisticsOverview,
@@ -571,6 +572,25 @@ function projectConsumptionPercent(project: ProjectSummary) {
return Math.round(((project.consumption_minutes ?? 0) / budgetMinutes) * 100);
}
function projectTeamspaceEntries(project: ProjectSummary): ProjectTeamspaceEntry[] {
if (project.teamspace_entries?.length) {
return project.teamspace_entries;
}
return [
{
id: `${project.id}-default`,
teamspace_project: project.teamspace_project,
is_default: true,
hour_budget_minutes: project.hour_budget_minutes,
manual_consumption_minutes: project.manual_consumption_minutes ?? 0,
teamspace_consumption_minutes: project.teamspace_consumption_minutes ?? 0,
consumption_minutes: project.consumption_minutes ?? project.teamspace_consumption_minutes ?? 0,
display_order: 0
}
];
}
function openRatingFilterGroup(filter: string) {
if (filter === "fixedBilling" || filter === "manualBilling") {
return "source";
@@ -1648,18 +1668,48 @@ function ProjectBudgetWidget({ widget, editing, projects, stats, onNavigate, onU
const consumptionMinutes = project.consumption_minutes ?? 0;
const budgetMinutes = project.hour_budget_minutes ?? 0;
const percent = projectConsumptionPercent(project);
const entries = projectTeamspaceEntries(project);
const hasMultipleTeamspaceEntries = entries.length > 1;
return (
<button key={project.id} type="button" className="block w-full min-w-0 rounded-md px-2 py-1.5 text-left hover:bg-muted" onClick={() => onNavigate(`/tickets/${project.ticket_id}?month=${currentMonth()}`)}>
<span className="block truncate text-xs font-medium">{project.title || project.ticket_number}</span>
<span className="block truncate text-[11px] text-muted-foreground">
{project.ticket_number} · {budgetMinutes > 0 ? `${decimalHours(consumptionMinutes)} / ${decimalHours(budgetMinutes)} · ${percent ?? 0}%` : "ohne Kontingent"}
{project.ticket_number} · {hasMultipleTeamspaceEntries ? `${entries.length} Teamspace · ` : ""}{budgetMinutes > 0 ? `${decimalHours(consumptionMinutes)} / ${decimalHours(budgetMinutes)} · ${percent ?? 0}%` : "ohne Kontingent"}
</span>
{percent !== null ? (
<span className="mt-1 block h-1.5 overflow-hidden rounded-full bg-muted" title={`${percent}% Verbrauch`} aria-label={`${percent}% Verbrauch`}>
<span className={cn("block h-full rounded-full", consumptionBarClass(percent))} style={{ width: `${Math.min(100, percent)}%` }} />
</span>
) : null}
{hasMultipleTeamspaceEntries ? (
<span className="mt-1 grid gap-1">
{entries.map((entry) => {
const entryBudgetMinutes = entry.hour_budget_minutes ?? 0;
const entryConsumptionMinutes = entry.consumption_minutes ?? 0;
const entryPercent = entryBudgetMinutes > 0 ? Math.round((entryConsumptionMinutes / entryBudgetMinutes) * 100) : null;
return (
<span key={entry.id} className="block">
<span className="flex min-w-0 items-center justify-between gap-2 text-[10px] text-muted-foreground">
<span className="truncate">
{entry.teamspace_project || (entry.is_default ? "Standard" : "Ohne Teamspace-ID")}
{entry.is_default ? " · Standard" : ""}
</span>
<span className="shrink-0">
{entryBudgetMinutes > 0 ? `${decimalHours(entryConsumptionMinutes)} / ${decimalHours(entryBudgetMinutes)}` : decimalHours(entryConsumptionMinutes)}
</span>
</span>
{entryPercent !== null ? (
<span className="mt-0.5 block h-1 overflow-hidden rounded-full bg-muted">
<span className={cn("block h-full rounded-full", consumptionBarClass(entryPercent))} style={{ width: `${Math.min(100, entryPercent)}%` }} />
</span>
) : null}
</span>
);
})}
</span>
) : null}
</button>
);
})}
@@ -1973,6 +2023,46 @@ function NotesWidget({ widget, editing, onUpdateConfig }: WidgetContentProps) {
const { notes, activeNoteId } = readWidgetNotes(widget.config);
const activeNote = notes.find((note) => note.id === activeNoteId) ?? notes[0];
const tabsScrollerRef = useRef<HTMLDivElement | null>(null);
const [tabScrollState, setTabScrollState] = useState({ canScrollLeft: false, canScrollRight: false });
function updateTabScrollState() {
const scroller = tabsScrollerRef.current;
if (!scroller) {
setTabScrollState({ canScrollLeft: false, canScrollRight: false });
return;
}
const maxScrollLeft = scroller.scrollWidth - scroller.clientWidth;
setTabScrollState({
canScrollLeft: scroller.scrollLeft > 1,
canScrollRight: scroller.scrollLeft < maxScrollLeft - 1
});
}
useEffect(() => {
updateTabScrollState();
const scroller = tabsScrollerRef.current;
if (!scroller) {
return;
}
const handleScroll = () => updateTabScrollState();
const handleResize = () => updateTabScrollState();
scroller.addEventListener("scroll", handleScroll, { passive: true });
window.addEventListener("resize", handleResize);
return () => {
scroller.removeEventListener("scroll", handleScroll);
window.removeEventListener("resize", handleResize);
};
}, [notes.length]);
useEffect(() => {
updateTabScrollState();
}, [activeNoteId, notes.length]);
function saveNotes(nextNotes: DashboardNote[], nextActiveNoteId = activeNoteId) {
const safeActiveNoteId = nextNotes.some((note) => note.id === nextActiveNoteId) ? nextActiveNoteId : nextNotes[0]?.id;
@@ -2005,10 +2095,27 @@ function NotesWidget({ widget, editing, onUpdateConfig }: WidgetContentProps) {
return (
<Tabs value={activeNoteId} onValueChange={(value) => onUpdateConfig({ activeNoteId: value })} className="flex h-full min-h-0 overflow-hidden gap-1">
<div className="flex h-8 min-w-0 shrink-0 items-center gap-1 overflow-hidden">
<Button type="button" size="icon" variant="ghost" className="size-7 shrink-0" onClick={() => scrollTabs(-1)}>
<Button type="button" size="icon" variant="ghost" className="size-7 shrink-0" disabled={!tabScrollState.canScrollLeft} onClick={() => scrollTabs(-1)}>
<ChevronLeft className="size-3.5" />
</Button>
<div ref={tabsScrollerRef} className="min-w-0 flex-1 overflow-x-auto overflow-y-hidden">
<div className="relative min-w-0 flex-1">
<div
className={cn(
"pointer-events-none absolute inset-y-0 left-0 z-10 w-8 bg-gradient-to-r from-card to-transparent transition-opacity",
tabScrollState.canScrollLeft ? "opacity-100" : "opacity-0"
)}
/>
<div
className={cn(
"pointer-events-none absolute inset-y-0 right-0 z-10 w-8 bg-gradient-to-l from-card to-transparent transition-opacity",
tabScrollState.canScrollRight ? "opacity-100" : "opacity-0"
)}
/>
<div
ref={tabsScrollerRef}
className="min-w-0 flex-1 overflow-x-auto overflow-y-hidden [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"
onScroll={updateTabScrollState}
>
<TabsList variant="line" className="h-8 w-max min-w-max justify-start rounded-none p-0 pb-1">
{notes.map((note) => (
<TabsTrigger key={note.id} value={note.id} className="h-7 w-32 shrink-0 flex-none px-2 text-xs">
@@ -2016,8 +2123,9 @@ function NotesWidget({ widget, editing, onUpdateConfig }: WidgetContentProps) {
</TabsTrigger>
))}
</TabsList>
</div>
</div>
<Button type="button" size="icon" variant="ghost" className="size-7 shrink-0" onClick={() => scrollTabs(1)}>
<Button type="button" size="icon" variant="ghost" className="size-7 shrink-0" disabled={!tabScrollState.canScrollRight} onClick={() => scrollTabs(1)}>
<ChevronRight className="size-3.5" />
</Button>
<Button type="button" size="icon" variant="ghost" className="size-7 shrink-0" onClick={addNote}>
+70 -7
View File
@@ -1,5 +1,5 @@
import { FormEvent, useEffect, useState } from "react";
import { KeyRound, Save, UserCircle } from "lucide-react";
import { KeyRound, Save, Unplug, UserCircle, X } from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
@@ -19,11 +19,15 @@ export function ProfilePage({ currentUser, onUserUpdated }: ProfilePageProps) {
const [currentPassword, setCurrentPassword] = useState("");
const [newPassword, setNewPassword] = useState("");
const [confirmPassword, setConfirmPassword] = useState("");
const [zammadApiToken, setZammadApiToken] = useState("");
const [zammadTokenDirty, setZammadTokenDirty] = useState(false);
const [saving, setSaving] = useState(false);
useEffect(() => {
setUsername(currentUser.username);
setDisplayName(currentUser.display_name);
setZammadApiToken("");
setZammadTokenDirty(false);
}, [currentUser]);
async function submit(event: FormEvent) {
@@ -45,17 +49,22 @@ export function ProfilePage({ currentUser, onUserUpdated }: ProfilePageProps) {
setSaving(true);
const payload = {
username,
displayName,
currentPassword: wantsPasswordChange ? currentPassword : undefined,
newPassword: wantsPasswordChange ? newPassword : undefined,
...(currentUser.role === "user" && zammadTokenDirty ? { zammadApiToken } : {})
};
try {
const result = await updateCurrentUser({
username,
displayName,
currentPassword: wantsPasswordChange ? currentPassword : undefined,
newPassword: wantsPasswordChange ? newPassword : undefined
});
const result = await updateCurrentUser(payload);
onUserUpdated(result.user);
setCurrentPassword("");
setNewPassword("");
setConfirmPassword("");
setZammadApiToken("");
setZammadTokenDirty(false);
toast.success("Profil gespeichert");
} catch (error) {
toast.error("Profil konnte nicht gespeichert werden", {
@@ -146,6 +155,60 @@ export function ProfilePage({ currentUser, onUserUpdated }: ProfilePageProps) {
</div>
</div>
{currentUser.role === "user" ? (
<div className="rounded-md border bg-muted/30 p-3">
<div className="mb-3 flex items-center justify-between gap-3">
<div className="flex items-center gap-2">
<Unplug className="size-4 text-muted-foreground" />
<p className="text-sm font-medium">Zammad API</p>
</div>
<HelpLink anchor="profil" label="Hilfe zu Zammad im Profil" />
</div>
<div className="grid gap-3 md:grid-cols-[minmax(220px,1fr)_180px]">
<div className="space-y-2">
<label className="text-sm font-medium" htmlFor="profile-zammad-token">
Persönlicher API-Token
</label>
<Input
id="profile-zammad-token"
type="password"
value={zammadApiToken}
placeholder={currentUser.has_zammad_api_token ? "••••••••••••••••" : "Token aus Zammad einfügen"}
autoComplete="off"
onChange={(event) => {
setZammadApiToken(event.currentTarget.value);
setZammadTokenDirty(true);
}}
/>
<p className="text-xs text-muted-foreground">
Die Zammad-User-ID wird nur geprüft, wenn du hier einen neuen Token speicherst.
</p>
</div>
<div className="space-y-2">
<label className="text-sm font-medium" htmlFor="profile-zammad-user-id">
Zammad User-ID
</label>
<Input id="profile-zammad-user-id" value={currentUser.zammad_user_id ?? "nicht verknüpft"} readOnly />
{currentUser.has_zammad_api_token ? (
<Button
type="button"
size="sm"
variant="secondary"
className="w-full"
onClick={() => {
setZammadApiToken("");
setZammadTokenDirty(true);
}}
>
<X className="size-4" />
Token entfernen
</Button>
) : null}
</div>
</div>
</div>
) : null}
<div className="flex justify-end">
<Button type="submit" disabled={saving}>
<Save className="size-4" />
+282 -79
View File
@@ -1,4 +1,4 @@
import { ArrowDown, ArrowUp, CheckCircle2, MoreHorizontal, Pencil, Play, Plus, RotateCcw, Save, Search, SlidersHorizontal, Star } from "lucide-react";
import { ArrowDown, ArrowUp, CheckCircle2, MoreHorizontal, Pencil, Play, Plus, RotateCcw, Save, Search, SlidersHorizontal, Star, Trash2 } from "lucide-react";
import { FormEvent, useEffect, useMemo, useState } from "react";
import { toast } from "sonner";
@@ -17,7 +17,7 @@ import { addFavoriteTicket, createProject, getProjects, lookupTicket, updateProj
import { currentMonth, formatMinutes } from "../format";
import { projectHasRecurringBilling } from "../recurring-billings";
import { ticketPattern } from "../timers";
import type { ProjectSummary, TicketMeta } from "../types";
import type { ProjectSummary, ProjectTeamspaceEntry, TicketMeta } from "../types";
type ProjectsPageProps = {
onNavigate: (to: string) => void;
@@ -29,12 +29,18 @@ type ProjectForm = {
title: string;
organizationId: string;
organizationName: string | null;
teamspaceProject: string;
description: string;
startsOn: string;
endsOn: string;
teamspaceEntries: ProjectTeamspaceEntryForm[];
};
type ProjectTeamspaceEntryForm = {
clientId: string;
teamspaceProject: string;
hourBudget: string;
manualConsumption: string;
isDefault: boolean;
};
type ProjectStatusFilter = "open" | "all" | "closed";
@@ -88,6 +94,24 @@ const projectTodoOptions: Array<{ value: ProjectTodoFilter; label: string }> = [
const projectSortValues: ProjectSortBy[] = ["updated", "ticket", "title", "organization", "status", "consumption", "consumptionPercent", "remaining", "budget", "todos"];
function projectEntryId() {
if (typeof crypto !== "undefined" && "randomUUID" in crypto) {
return crypto.randomUUID();
}
return `entry-${Date.now()}-${Math.random().toString(16).slice(2)}`;
}
function emptyProjectTeamspaceEntry(isDefault = false): ProjectTeamspaceEntryForm {
return {
clientId: projectEntryId(),
teamspaceProject: "",
hourBudget: "",
manualConsumption: "",
isDefault
};
}
function readStoredProjectViewSettings(): ProjectViewSettings {
if (typeof window === "undefined") {
return defaultProjectViewSettings;
@@ -122,12 +146,10 @@ function emptyProjectForm(): ProjectForm {
title: "",
organizationId: "",
organizationName: null,
teamspaceProject: "",
description: "",
startsOn: "",
endsOn: "",
hourBudget: "",
manualConsumption: ""
teamspaceEntries: [emptyProjectTeamspaceEntry(true)]
};
}
@@ -147,6 +169,41 @@ function formatDecimalHours(minutes: number) {
return `${new Intl.NumberFormat("de-DE", { maximumFractionDigits: 2 }).format(minutes / 60)} h`;
}
function projectTeamspaceEntries(project: ProjectSummary): ProjectTeamspaceEntry[] {
if (project.teamspace_entries?.length) {
return project.teamspace_entries;
}
return [
{
id: `${project.id}-default`,
teamspace_project: project.teamspace_project,
is_default: true,
hour_budget_minutes: project.hour_budget_minutes,
manual_consumption_minutes: project.manual_consumption_minutes ?? 0,
teamspace_consumption_minutes: project.teamspace_consumption_minutes ?? 0,
consumption_minutes: project.consumption_minutes ?? project.teamspace_consumption_minutes ?? 0,
display_order: 0
}
];
}
function projectTeamspaceEntryCount(project: ProjectSummary) {
return Number(project.teamspace_entry_count ?? projectTeamspaceEntries(project).length);
}
function projectEntryConsumption(entry: ProjectTeamspaceEntry) {
return Number(entry.consumption_minutes ?? 0);
}
function projectEntryPercent(entry: ProjectTeamspaceEntry) {
if (!entry.hour_budget_minutes || entry.hour_budget_minutes <= 0) {
return null;
}
return Math.round((projectEntryConsumption(entry) / entry.hour_budget_minutes) * 100);
}
function projectConsumptionBarClass(percent: number) {
if (percent >= 100) {
return "bg-blue-500";
@@ -224,6 +281,7 @@ function projectMatchesQuery(project: ProjectSummary, query: string) {
project.title ?? "",
projectOrganizationLabel(project),
project.teamspace_project ?? "",
...projectTeamspaceEntries(project).map((entry) => entry.teamspace_project ?? ""),
project.description,
project.goals
];
@@ -268,17 +326,24 @@ function compareProjects(left: ProjectSummary, right: ProjectSummary, sortBy: Pr
}
function formFromProject(project: ProjectSummary): ProjectForm {
const entries = projectTeamspaceEntries(project).map((entry, index) => ({
clientId: entry.id || projectEntryId(),
teamspaceProject: entry.teamspace_project ?? "",
hourBudget: formatHoursInput(entry.hour_budget_minutes),
manualConsumption: formatHoursInput(entry.manual_consumption_minutes),
isDefault: entry.is_default || index === 0
}));
const hasDefault = entries.some((entry) => entry.isDefault);
return {
ticketNumber: project.ticket_number,
title: project.title ?? "",
organizationId: project.organization_id ?? "",
organizationName: project.organization_name ?? project.customer_name ?? null,
teamspaceProject: project.teamspace_project ?? "",
description: combinedProjectDescription(project),
startsOn: project.starts_on ?? "",
endsOn: project.ends_on ?? "",
hourBudget: formatHoursInput(project.hour_budget_minutes),
manualConsumption: formatHoursInput(project.manual_consumption_minutes)
teamspaceEntries: hasDefault ? entries : entries.map((entry, index) => ({ ...entry, isDefault: index === 0 }))
};
}
@@ -292,6 +357,132 @@ type ProjectFormFieldsProps = {
onSubmit: (event: FormEvent) => void;
};
function normalizeProjectTeamspaceEntryForms(entries: ProjectTeamspaceEntryForm[]) {
const source = entries.length ? entries : [emptyProjectTeamspaceEntry(true)];
const defaultIndex = Math.max(0, source.findIndex((entry) => entry.isDefault));
return source.map((entry, index) => ({
...entry,
isDefault: index === defaultIndex
}));
}
function parseProjectTeamspaceEntryForms(entries: ProjectTeamspaceEntryForm[]) {
const normalizedEntries = normalizeProjectTeamspaceEntryForms(entries);
const parsedEntries = normalizedEntries.map((entry) => {
const hourBudgetMinutes = parseHoursInput(entry.hourBudget);
const manualConsumptionMinutes = parseHoursInput(entry.manualConsumption);
if (hourBudgetMinutes === undefined || manualConsumptionMinutes === undefined) {
return undefined;
}
return {
teamspaceProject: entry.teamspaceProject.trim() || null,
isDefault: entry.isDefault,
hourBudgetMinutes,
manualConsumptionMinutes: manualConsumptionMinutes ?? 0
};
});
if (parsedEntries.some((entry) => entry === undefined)) {
return undefined;
}
return parsedEntries as Array<{
teamspaceProject: string | null;
isDefault: boolean;
hourBudgetMinutes: number | null;
manualConsumptionMinutes: number;
}>;
}
function defaultProjectTeamspacePayload(entries: ReturnType<typeof parseProjectTeamspaceEntryForms>) {
return entries?.find((entry) => entry.isDefault) ?? entries?.[0] ?? null;
}
function ProjectTeamspaceEntriesFields({
entries,
onChange
}: {
entries: ProjectTeamspaceEntryForm[];
onChange: (entries: ProjectTeamspaceEntryForm[]) => void;
}) {
const normalizedEntries = normalizeProjectTeamspaceEntryForms(entries);
function updateEntry(clientId: string, changes: Partial<ProjectTeamspaceEntryForm>) {
onChange(normalizeProjectTeamspaceEntryForms(normalizedEntries.map((entry) => (entry.clientId === clientId ? { ...entry, ...changes } : entry))));
}
function setDefault(clientId: string) {
onChange(normalizedEntries.map((entry) => ({ ...entry, isDefault: entry.clientId === clientId })));
}
function removeEntry(clientId: string) {
onChange(normalizeProjectTeamspaceEntryForms(normalizedEntries.filter((entry) => entry.clientId !== clientId)));
}
return (
<div className="space-y-2">
<div className="flex items-center justify-between gap-2">
<label className="text-sm font-medium">Teamspace-Einträge</label>
<Button type="button" size="sm" variant="secondary" onClick={() => onChange([...normalizedEntries, emptyProjectTeamspaceEntry(false)])}>
<Plus className="size-3.5" />
Eintrag
</Button>
</div>
<div className="space-y-2">
{normalizedEntries.map((entry, index) => (
<div key={entry.clientId} className="grid gap-2 rounded-lg border bg-muted/10 p-3 lg:grid-cols-[minmax(180px,1fr)_130px_150px_120px_36px] lg:items-end">
<div className="space-y-1.5">
<label className="text-xs font-medium text-muted-foreground" htmlFor={`project-teamspace-${entry.clientId}`}>
Teamspace-Projekt {entry.isDefault ? "(Standard)" : ""}
</label>
<Input
id={`project-teamspace-${entry.clientId}`}
value={entry.teamspaceProject}
onChange={(event) => updateEntry(entry.clientId, { teamspaceProject: event.currentTarget.value })}
/>
</div>
<div className="space-y-1.5">
<label className="text-xs font-medium text-muted-foreground" htmlFor={`project-budget-${entry.clientId}`}>
Budget h
</label>
<Input
id={`project-budget-${entry.clientId}`}
inputMode="decimal"
value={entry.hourBudget}
onChange={(event) => updateEntry(entry.clientId, { hourBudget: event.currentTarget.value })}
/>
</div>
<div className="space-y-1.5">
<label className="text-xs font-medium text-muted-foreground" htmlFor={`project-manual-${entry.clientId}`}>
Manueller Verbrauch h
</label>
<Input
id={`project-manual-${entry.clientId}`}
inputMode="decimal"
value={entry.manualConsumption}
onChange={(event) => updateEntry(entry.clientId, { manualConsumption: event.currentTarget.value })}
/>
</div>
<Button type="button" size="sm" variant={entry.isDefault ? "default" : "secondary"} onClick={() => setDefault(entry.clientId)}>
{entry.isDefault ? "Standard" : "Als Standard"}
</Button>
<Button type="button" size="icon" variant="ghost" disabled={normalizedEntries.length === 1} onClick={() => removeEntry(entry.clientId)}>
<Trash2 className="size-4 text-destructive" />
<span className="sr-only">Teamspace-Eintrag {index + 1} löschen</span>
</Button>
</div>
))}
</div>
<p className="text-xs text-muted-foreground">
Der Standard-Eintrag bekommt die aus Teamspace gebuchten Tageswerte. Manueller Verbrauch zählt nur im Projektverbrauch.
</p>
</div>
);
}
function ProjectFormFields({ form, setForm, organizationLocked, lookupStatus, saving, submitLabel, onSubmit }: ProjectFormFieldsProps) {
return (
<form className="space-y-4" onSubmit={onSubmit}>
@@ -331,7 +522,7 @@ function ProjectFormFields({ form, setForm, organizationLocked, lookupStatus, sa
</div>
</div>
<div className="grid gap-3 lg:grid-cols-[minmax(240px,1fr)_minmax(220px,1fr)]">
<div className="grid gap-3 lg:grid-cols-[minmax(240px,1fr)_320px_320px]">
<div className="space-y-2">
<label className="text-sm font-medium">Organisation</label>
<OrganizationSelect
@@ -342,15 +533,6 @@ function ProjectFormFields({ form, setForm, organizationLocked, lookupStatus, sa
required
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium" htmlFor="teamspace-project">
Teamspace-Projekt
</label>
<Input id="teamspace-project" value={form.teamspaceProject} onChange={(event) => setForm({ ...form, teamspaceProject: event.currentTarget.value })} />
</div>
</div>
<div className="grid gap-3 lg:grid-cols-4">
<div className="space-y-2">
<label className="text-sm font-medium" htmlFor="project-starts-on">
Zeitraum von
@@ -363,26 +545,10 @@ function ProjectFormFields({ form, setForm, organizationLocked, lookupStatus, sa
</label>
<Input id="project-ends-on" type="date" value={form.endsOn} onChange={(event) => setForm({ ...form, endsOn: event.currentTarget.value })} />
</div>
<div className="space-y-2">
<label className="text-sm font-medium" htmlFor="project-budget">
Kontingent h
</label>
<Input id="project-budget" inputMode="decimal" value={form.hourBudget} onChange={(event) => setForm({ ...form, hourBudget: event.currentTarget.value })} />
</div>
<div className="space-y-2">
<label className="text-sm font-medium" htmlFor="project-manual-consumption">
Manueller Verbrauch h
</label>
<Input
id="project-manual-consumption"
inputMode="decimal"
value={form.manualConsumption}
onChange={(event) => setForm({ ...form, manualConsumption: event.currentTarget.value })}
/>
<p className="text-xs text-muted-foreground">Nur für Projektverbrauch, nicht für Auswertungen.</p>
</div>
</div>
<ProjectTeamspaceEntriesFields entries={form.teamspaceEntries} onChange={(teamspaceEntries) => setForm({ ...form, teamspaceEntries })} />
<div className="space-y-2">
<label className="text-sm font-medium" htmlFor="project-description">
Beschreibung/Ziele
@@ -610,24 +776,16 @@ export function ProjectsPage({ onNavigate, onStartTimer }: ProjectsPageProps) {
return;
}
const hourBudgetMinutes = parseHoursInput(form.hourBudget);
const parsedManualConsumptionMinutes = parseHoursInput(form.manualConsumption);
const teamspaceEntries = parseProjectTeamspaceEntryForms(form.teamspaceEntries);
if (hourBudgetMinutes === undefined) {
toast.error("Stundenkontingent prüfen", {
if (!teamspaceEntries) {
toast.error("Teamspace-Einträge prüfen", {
description: "Bitte Stunden als Zahl eintragen, z.B. 12,5."
});
return;
}
if (parsedManualConsumptionMinutes === undefined) {
toast.error("Manuellen Verbrauch prüfen", {
description: "Bitte Stunden als Zahl eintragen, z.B. 12,5."
});
return;
}
const manualConsumptionMinutes = parsedManualConsumptionMinutes ?? 0;
const defaultTeamspaceEntry = defaultProjectTeamspacePayload(teamspaceEntries);
setSaving(true);
try {
@@ -635,13 +793,14 @@ export function ProjectsPage({ onNavigate, onStartTimer }: ProjectsPageProps) {
ticketNumber,
title: form.title || null,
organizationId: form.organizationId,
teamspaceProject: form.teamspaceProject,
teamspaceProject: defaultTeamspaceEntry?.teamspaceProject ?? null,
teamspaceEntries,
description: form.description,
goals: "",
startsOn: form.startsOn || null,
endsOn: form.endsOn || null,
hourBudgetMinutes,
manualConsumptionMinutes
hourBudgetMinutes: defaultTeamspaceEntry?.hourBudgetMinutes ?? null,
manualConsumptionMinutes: defaultTeamspaceEntry?.manualConsumptionMinutes ?? 0
});
toast.success("Projekt angelegt", {
description: `${result.project.ticket_number} ist jetzt als Consulting-Projekt markiert.`
@@ -661,20 +820,16 @@ export function ProjectsPage({ onNavigate, onStartTimer }: ProjectsPageProps) {
}
async function saveProject(project: ProjectSummary) {
const hourBudgetMinutes = parseHoursInput(editForm.hourBudget);
const parsedManualConsumptionMinutes = parseHoursInput(editForm.manualConsumption);
const teamspaceEntries = parseProjectTeamspaceEntryForms(editForm.teamspaceEntries);
if (hourBudgetMinutes === undefined) {
toast.error("Stundenkontingent prüfen");
if (!teamspaceEntries) {
toast.error("Teamspace-Einträge prüfen", {
description: "Bitte Stunden als Zahl eintragen, z.B. 12,5."
});
return;
}
if (parsedManualConsumptionMinutes === undefined) {
toast.error("Manuellen Verbrauch prüfen");
return;
}
const manualConsumptionMinutes = parsedManualConsumptionMinutes ?? 0;
const defaultTeamspaceEntry = defaultProjectTeamspacePayload(teamspaceEntries);
if (!editForm.organizationId) {
toast.error("Organisation wählen");
@@ -687,13 +842,14 @@ export function ProjectsPage({ onNavigate, onStartTimer }: ProjectsPageProps) {
ticketNumber: editForm.ticketNumber,
title: editForm.title || null,
organizationId: editForm.organizationId,
teamspaceProject: editForm.teamspaceProject,
teamspaceProject: defaultTeamspaceEntry?.teamspaceProject ?? null,
teamspaceEntries,
description: editForm.description,
goals: "",
startsOn: editForm.startsOn || null,
endsOn: editForm.endsOn || null,
hourBudgetMinutes,
manualConsumptionMinutes
hourBudgetMinutes: defaultTeamspaceEntry?.hourBudgetMinutes ?? null,
manualConsumptionMinutes: defaultTeamspaceEntry?.manualConsumptionMinutes ?? 0
});
toast.success("Projekt gespeichert");
setEditingProjectId(null);
@@ -765,6 +921,29 @@ export function ProjectsPage({ onNavigate, onStartTimer }: ProjectsPageProps) {
<span className="font-semibold">{openTodos}</span>
</div>
</div>
<Dialog open={createOpen} onOpenChange={setCreateOpen}>
<DialogTrigger asChild>
<Button type="button" className="shrink-0">
<Plus className="size-4" />
Projekt
</Button>
</DialogTrigger>
<DialogContent className="max-h-[90vh] overflow-y-auto sm:max-w-5xl">
<DialogHeader>
<DialogTitle>Projekt anlegen</DialogTitle>
<DialogDescription>Consulting-Projekt mit einem oder mehreren Teamspace-Einträgen anlegen.</DialogDescription>
</DialogHeader>
<ProjectFormFields
form={form}
setForm={setForm}
organizationLocked={organizationLocked}
lookupStatus={lookupStatus}
saving={saving}
submitLabel="Projekt anlegen"
onSubmit={submitProject}
/>
</DialogContent>
</Dialog>
<HelpLink anchor="projekte" label="Hilfe zu Projekten" className="shrink-0" />
</div>
</div>
@@ -915,9 +1094,11 @@ export function ProjectsPage({ onNavigate, onStartTimer }: ProjectsPageProps) {
</div>
{filteredProjects.map((project) => {
const isEditing = editingProjectId === project.id;
const teamspaceEntries = projectTeamspaceEntries(project);
const consumptionMinutes = project.consumption_minutes ?? 0;
const remaining = projectRemaining(project);
const usedPercent = projectConsumptionPercent(project);
const hasMultipleTeamspaceEntries = teamspaceEntries.length > 1;
return (
<div key={project.id} className="border-b last:border-b-0">
@@ -943,6 +1124,7 @@ export function ProjectsPage({ onNavigate, onStartTimer }: ProjectsPageProps) {
<p className={cn("text-sm font-semibold", remaining !== null && remaining < 0 ? "text-destructive" : undefined)}>
{formatDecimalHours(consumptionMinutes)} / {project.hour_budget_minutes ? formatDecimalHours(project.hour_budget_minutes) : "offen"}
</p>
{hasMultipleTeamspaceEntries ? <p className="text-xs text-muted-foreground">{projectTeamspaceEntryCount(project)} Teamspace</p> : null}
</div>
<div className="min-w-0 text-sm">
<p className="truncate font-medium xl:hidden">Verbrauch %</p>
@@ -999,7 +1181,7 @@ export function ProjectsPage({ onNavigate, onStartTimer }: ProjectsPageProps) {
</DropdownMenu>
</div>
</div>
<div className="px-4 pb-3">
<div className="space-y-2 px-4 pb-3">
{usedPercent !== null ? (
<div className="h-1.5 overflow-hidden rounded-full bg-muted" title={`${usedPercent}% Verbrauch`} aria-label={`${usedPercent}% Verbrauch`}>
<div className={cn("h-full rounded-full", projectConsumptionBarClass(usedPercent))} style={{ width: `${Math.min(100, usedPercent)}%` }} />
@@ -1009,6 +1191,35 @@ export function ProjectsPage({ onNavigate, onStartTimer }: ProjectsPageProps) {
<div className="h-full rounded-full bg-muted-foreground/10" style={{ width: "0%" }} />
</div>
)}
{hasMultipleTeamspaceEntries ? (
<div className="grid gap-1.5 md:grid-cols-2">
{teamspaceEntries.map((entry) => {
const entryPercent = projectEntryPercent(entry);
const entryConsumption = projectEntryConsumption(entry);
const label = entry.teamspace_project || (entry.is_default ? "Standard" : "Ohne Teamspace-ID");
return (
<div key={entry.id} className="min-w-0 rounded-md border bg-background/50 px-2 py-1">
<div className="mb-1 flex min-w-0 items-center justify-between gap-2 text-[11px]">
<span className="truncate text-muted-foreground">
{label}
{entry.is_default ? " · Standard" : ""}
</span>
<span className="shrink-0 font-medium">
{formatMinutes(entryConsumption)} / {entry.hour_budget_minutes ? formatMinutes(entry.hour_budget_minutes) : "offen"}
</span>
</div>
<div className="h-1 overflow-hidden rounded-full bg-muted">
<div
className={cn("h-full rounded-full", entryPercent === null ? "bg-muted-foreground/20" : projectConsumptionBarClass(entryPercent))}
style={{ width: `${entryPercent === null ? 0 : Math.min(100, entryPercent)}%` }}
/>
</div>
</div>
);
})}
</div>
) : null}
</div>
{isEditing ? (
<div className="border-t bg-muted/10 p-4">
@@ -1024,22 +1235,14 @@ export function ProjectsPage({ onNavigate, onStartTimer }: ProjectsPageProps) {
<div className="flex h-9 items-center rounded-lg border bg-muted/30 px-3 text-sm">Consulting</div>
</div> */}
<Input placeholder="Titel" value={editForm.title} onChange={(event) => setEditForm({ ...editForm, title: event.currentTarget.value })} />
<Input
placeholder="Teamspace-Projekt"
value={editForm.teamspaceProject}
onChange={(event) => setEditForm({ ...editForm, teamspaceProject: event.currentTarget.value })}
/>
<div className="grid gap-3 md:grid-cols-4">
<div className="grid gap-3 md:grid-cols-2">
<Input type="date" value={editForm.startsOn} onChange={(event) => setEditForm({ ...editForm, startsOn: event.currentTarget.value })} />
<Input type="date" value={editForm.endsOn} onChange={(event) => setEditForm({ ...editForm, endsOn: event.currentTarget.value })} />
<Input inputMode="decimal" placeholder="Kontingent h" value={editForm.hourBudget} onChange={(event) => setEditForm({ ...editForm, hourBudget: event.currentTarget.value })} />
<Input
inputMode="decimal"
placeholder="Manueller Verbrauch h"
value={editForm.manualConsumption}
onChange={(event) => setEditForm({ ...editForm, manualConsumption: event.currentTarget.value })}
/>
</div>
<ProjectTeamspaceEntriesFields
entries={editForm.teamspaceEntries}
onChange={(teamspaceEntries) => setEditForm({ ...editForm, teamspaceEntries })}
/>
<Textarea className="min-h-24" value={editForm.description} onChange={(event) => setEditForm({ ...editForm, description: event.currentTarget.value })} />
<div className="flex flex-wrap justify-end gap-2">
<Button
+259 -57
View File
@@ -29,7 +29,7 @@ import {
} from "../api";
import { currentDay, currentMonth, formatDate, formatDateTime, formatMinutes, formatTimeRange } from "../format";
import { ticketHasRecurringBilling } from "../recurring-billings";
import type { BillingStatus, PeriodType, ProjectSummary, SessionEntry, TicketDetailPeriodType, TicketPeriod, WorkType } from "../types";
import type { BillingStatus, PeriodType, ProjectSummary, ProjectTeamspaceEntry, SessionEntry, TicketDetailPeriodType, TicketPeriod, WorkType } from "../types";
import { cn } from "@/lib/utils";
type TicketDetailPageProps = {
@@ -64,12 +64,18 @@ type ProjectEditFormState = {
title: string;
organizationId: string;
organizationName: string | null;
teamspaceProject: string;
description: string;
startsOn: string;
endsOn: string;
teamspaceEntries: ProjectTeamspaceEntryForm[];
};
type ProjectTeamspaceEntryForm = {
clientId: string;
teamspaceProject: string;
hourBudget: string;
manualConsumption: string;
isDefault: boolean;
};
function emptyTicketSessionForm(): TicketSessionFormState {
@@ -84,17 +90,51 @@ function emptyTicketSessionForm(): TicketSessionFormState {
};
}
function projectEntryId() {
if (typeof crypto !== "undefined" && "randomUUID" in crypto) {
return crypto.randomUUID();
}
return `entry-${Date.now()}-${Math.random().toString(16).slice(2)}`;
}
function projectTeamspaceEntries(project: ProjectSummary): ProjectTeamspaceEntry[] {
if (project.teamspace_entries?.length) {
return project.teamspace_entries;
}
return [
{
id: `${project.id}-default`,
teamspace_project: project.teamspace_project,
is_default: true,
hour_budget_minutes: project.hour_budget_minutes,
manual_consumption_minutes: project.manual_consumption_minutes ?? 0,
teamspace_consumption_minutes: project.teamspace_consumption_minutes ?? 0,
consumption_minutes: project.consumption_minutes ?? project.teamspace_consumption_minutes ?? 0,
display_order: 0
}
];
}
function projectEditForm(project: ProjectSummary): ProjectEditFormState {
const entries = projectTeamspaceEntries(project).map((entry, index) => ({
clientId: entry.id || projectEntryId(),
teamspaceProject: entry.teamspace_project ?? "",
hourBudget: formatHoursInput(entry.hour_budget_minutes),
manualConsumption: formatHoursInput(entry.manual_consumption_minutes),
isDefault: entry.is_default || index === 0
}));
const hasDefault = entries.some((entry) => entry.isDefault);
return {
title: project.title ?? "",
organizationId: project.organization_id ?? "",
organizationName: project.organization_name ?? project.customer_name ?? null,
teamspaceProject: project.teamspace_project ?? "",
description: combinedProjectDescription(project),
startsOn: project.starts_on ?? "",
endsOn: project.ends_on ?? "",
hourBudget: formatHoursInput(project.hour_budget_minutes),
manualConsumption: formatHoursInput(project.manual_consumption_minutes)
teamspaceEntries: hasDefault ? entries : entries.map((entry, index) => ({ ...entry, isDefault: index === 0 }))
};
}
@@ -186,10 +226,145 @@ function parseHoursInput(value: string) {
return Math.round(hours * 60);
}
function projectConsumptionBarClass(percent: number) {
if (percent >= 100) {
return "bg-blue-500";
}
if (percent >= 90) {
return "bg-red-500";
}
return "bg-emerald-500";
}
function projectConsumptionPercent(consumptionMinutes: number, budgetMinutes?: number | null) {
if (!budgetMinutes || budgetMinutes <= 0) {
return null;
}
return Math.round((consumptionMinutes / budgetMinutes) * 100);
}
function isPartialHoursInput(value: string) {
return /^\d*(?:[,.]\d*)?$/.test(value.trim());
}
function normalizeProjectTeamspaceEntryForms(entries: ProjectTeamspaceEntryForm[]) {
const source = entries.length
? entries
: [
{
clientId: projectEntryId(),
teamspaceProject: "",
hourBudget: "",
manualConsumption: "",
isDefault: true
}
];
const defaultIndex = Math.max(0, source.findIndex((entry) => entry.isDefault));
return source.map((entry, index) => ({
...entry,
isDefault: index === defaultIndex
}));
}
function parseProjectTeamspaceEntryForms(entries: ProjectTeamspaceEntryForm[]) {
const normalizedEntries = normalizeProjectTeamspaceEntryForms(entries);
const parsedEntries = normalizedEntries.map((entry) => {
const hourBudgetMinutes = parseHoursInput(entry.hourBudget);
const manualConsumptionMinutes = parseHoursInput(entry.manualConsumption);
if (hourBudgetMinutes === undefined || manualConsumptionMinutes === undefined) {
return undefined;
}
return {
teamspaceProject: entry.teamspaceProject.trim() || null,
isDefault: entry.isDefault,
hourBudgetMinutes,
manualConsumptionMinutes: manualConsumptionMinutes ?? 0
};
});
if (parsedEntries.some((entry) => entry === undefined)) {
return undefined;
}
return parsedEntries as Array<{
teamspaceProject: string | null;
isDefault: boolean;
hourBudgetMinutes: number | null;
manualConsumptionMinutes: number;
}>;
}
function ProjectTeamspaceEntriesEditor({
entries,
onChange
}: {
entries: ProjectTeamspaceEntryForm[];
onChange: (entries: ProjectTeamspaceEntryForm[]) => void;
}) {
const normalizedEntries = normalizeProjectTeamspaceEntryForms(entries);
function updateEntry(clientId: string, changes: Partial<ProjectTeamspaceEntryForm>) {
onChange(normalizeProjectTeamspaceEntryForms(normalizedEntries.map((entry) => (entry.clientId === clientId ? { ...entry, ...changes } : entry))));
}
function removeEntry(clientId: string) {
onChange(normalizeProjectTeamspaceEntryForms(normalizedEntries.filter((entry) => entry.clientId !== clientId)));
}
return (
<div className="space-y-2">
<div className="flex items-center justify-between gap-2">
<label className="text-sm font-medium">Teamspace-Einträge</label>
<Button type="button" size="sm" variant="secondary" onClick={() => onChange([...normalizedEntries, { clientId: projectEntryId(), teamspaceProject: "", hourBudget: "", manualConsumption: "", isDefault: false }])}>
<Plus className="size-3.5" />
Eintrag
</Button>
</div>
{normalizedEntries.map((entry, index) => (
<div key={entry.clientId} className="grid gap-2 rounded-lg border bg-muted/10 p-3 lg:grid-cols-[minmax(180px,1fr)_120px_150px_120px_36px] lg:items-end">
<div className="space-y-1.5">
<label className="text-xs font-medium text-muted-foreground" htmlFor={`project-ticket-teamspace-${entry.clientId}`}>
Teamspace-Projekt {entry.isDefault ? "(Standard)" : ""}
</label>
<Input
id={`project-ticket-teamspace-${entry.clientId}`}
value={entry.teamspaceProject}
onChange={(event) => updateEntry(entry.clientId, { teamspaceProject: event.currentTarget.value })}
/>
</div>
<div className="space-y-1.5">
<label className="text-xs font-medium text-muted-foreground" htmlFor={`project-ticket-budget-${entry.clientId}`}>Budget h</label>
<Input id={`project-ticket-budget-${entry.clientId}`} inputMode="decimal" value={entry.hourBudget} onChange={(event) => updateEntry(entry.clientId, { hourBudget: event.currentTarget.value })} />
</div>
<div className="space-y-1.5">
<label className="text-xs font-medium text-muted-foreground" htmlFor={`project-ticket-manual-${entry.clientId}`}>Manueller Verbrauch h</label>
<Input id={`project-ticket-manual-${entry.clientId}`} inputMode="decimal" value={entry.manualConsumption} onChange={(event) => updateEntry(entry.clientId, { manualConsumption: event.currentTarget.value })} />
</div>
<Button
type="button"
size="sm"
variant={entry.isDefault ? "default" : "secondary"}
onClick={() => onChange(normalizedEntries.map((candidate) => ({ ...candidate, isDefault: candidate.clientId === entry.clientId })))}
>
{entry.isDefault ? "Standard" : "Als Standard"}
</Button>
<Button type="button" size="icon" variant="ghost" disabled={normalizedEntries.length === 1} onClick={() => removeEntry(entry.clientId)}>
<Trash2 className="size-4 text-destructive" />
<span className="sr-only">Teamspace-Eintrag {index + 1} löschen</span>
</Button>
</div>
))}
<p className="text-xs text-muted-foreground">Der Standard-Eintrag erhält die gebuchten Teamspace-Tageswerte. Manuelle Stunden zählen nur im Projektverbrauch.</p>
</div>
);
}
export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onOpenTicketChange, returnPath }: TicketDetailPageProps) {
const initialGlobalMonthFilter = periodType === "all" && period !== "all" ? period : currentMonth();
const [data, setData] = useState<TicketPeriod | null>(null);
@@ -226,12 +401,18 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onO
title: "",
organizationId: "",
organizationName: null,
teamspaceProject: "",
description: "",
startsOn: "",
endsOn: "",
hourBudget: "",
manualConsumption: ""
teamspaceEntries: [
{
clientId: projectEntryId(),
teamspaceProject: "",
hourBudget: "",
manualConsumption: "",
isDefault: true
}
]
});
const [todoFilter, setTodoFilter] = useState<"open" | "all" | "done">("open");
const [expandedSessionIds, setExpandedSessionIds] = useState<string[]>([]);
@@ -298,6 +479,11 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onO
const project = data?.project ?? null;
const isRecurringBillingTicket = ticketHasRecurringBilling(ticket);
const projectDescription = project ? combinedProjectDescription(project) : "";
const projectEntries = useMemo(() => (project ? projectTeamspaceEntries(project) : []), [project]);
const hasMultipleProjectTeamspaceEntries = projectEntries.length > 1;
const projectConsumptionMinutes = project?.consumption_minutes ?? 0;
const projectBudgetMinutes = project?.hour_budget_minutes ?? null;
const projectConsumptionUsagePercent = project ? projectConsumptionPercent(projectConsumptionMinutes, projectBudgetMinutes) : null;
const isProject = Boolean(project);
const isGlobalTicket = periodType === "all";
const isPeriodClosed = Boolean(data?.closed);
@@ -558,24 +744,16 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onO
return;
}
const hourBudgetMinutes = parseHoursInput(projectForm.hourBudget);
const parsedManualConsumptionMinutes = parseHoursInput(projectForm.manualConsumption);
const teamspaceEntries = parseProjectTeamspaceEntryForms(projectForm.teamspaceEntries);
if (hourBudgetMinutes === undefined) {
toast.error("Stundenkontingent prüfen", {
if (!teamspaceEntries) {
toast.error("Teamspace-Einträge prüfen", {
description: "Bitte Stunden als Zahl eintragen, z.B. 12,5."
});
return;
}
if (parsedManualConsumptionMinutes === undefined) {
toast.error("Manuellen Verbrauch prüfen", {
description: "Bitte Stunden als Zahl eintragen, z.B. 12,5."
});
return;
}
const manualConsumptionMinutes = parsedManualConsumptionMinutes ?? 0;
const defaultTeamspaceEntry = teamspaceEntries.find((entry) => entry.isDefault) ?? teamspaceEntries[0];
setSavingProject(true);
try {
@@ -583,13 +761,14 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onO
ticketNumber: ticket.ticket_number,
title: projectForm.title || null,
organizationId: projectForm.organizationId,
teamspaceProject: projectForm.teamspaceProject || null,
teamspaceProject: defaultTeamspaceEntry.teamspaceProject,
teamspaceEntries,
description: projectForm.description,
goals: "",
startsOn: projectForm.startsOn || null,
endsOn: projectForm.endsOn || null,
hourBudgetMinutes,
manualConsumptionMinutes
hourBudgetMinutes: defaultTeamspaceEntry.hourBudgetMinutes,
manualConsumptionMinutes: defaultTeamspaceEntry.manualConsumptionMinutes
});
toast.success("Projekt gespeichert");
setEditingProject(false);
@@ -983,7 +1162,11 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onO
Projekt
{project.closed_at ? <Badge variant="secondary">geschlossen</Badge> : <Badge variant="success">offen</Badge>}
</CardTitle>
<CardDescription>{project.teamspace_project || "Kein Teamspace-Projekt hinterlegt"}</CardDescription>
<CardDescription>
{hasMultipleProjectTeamspaceEntries
? `${projectEntries.length} Teamspace-Einträge`
: project.teamspace_project || "Kein Teamspace-Projekt hinterlegt"}
</CardDescription>
</div>
<div className="flex shrink-0 items-center gap-1">
<Button
@@ -1007,11 +1190,53 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onO
<CardContent className="space-y-3 px-4 pb-4">
<div className="flex flex-wrap gap-2 text-sm">
<Badge variant="secondary">Zeitraum {project.starts_on || "offen"} - {project.ends_on || "offen"}</Badge>
<Badge variant="secondary">Kontingent {project.hour_budget_minutes ? formatMinutes(project.hour_budget_minutes) : "offen"}</Badge>
<Badge variant="secondary">Verbrauch {formatMinutes(project.consumption_minutes ?? 0)}</Badge>
{project.manual_consumption_minutes > 0 ? <Badge variant="outline">Manuell {formatMinutes(project.manual_consumption_minutes)}</Badge> : null}
{project.teamspace_project ? <Badge variant="outline">{project.teamspace_project}</Badge> : null}
<Badge variant="secondary">Kontingent {projectBudgetMinutes ? formatMinutes(projectBudgetMinutes) : "offen"}</Badge>
<Badge variant="secondary">Verbrauch {formatMinutes(projectConsumptionMinutes)}</Badge>
{hasMultipleProjectTeamspaceEntries ? <Badge variant="outline">{projectEntries.length} Teamspace</Badge> : null}
</div>
{hasMultipleProjectTeamspaceEntries ? (
<div className="space-y-2">
<div>
<div className="mb-1 flex items-center justify-between gap-2 text-xs text-muted-foreground">
<span>Gesamtverbrauch</span>
<span>{projectConsumptionUsagePercent !== null ? `${projectConsumptionUsagePercent}%` : "ohne Gesamtbudget"}</span>
</div>
<div className="h-1.5 overflow-hidden rounded-full bg-muted">
<div
className={cn("h-full rounded-full", projectConsumptionUsagePercent === null ? "bg-muted-foreground/20" : projectConsumptionBarClass(projectConsumptionUsagePercent))}
style={{ width: `${projectConsumptionUsagePercent === null ? 0 : Math.min(100, projectConsumptionUsagePercent)}%` }}
/>
</div>
</div>
<div className="grid gap-1.5 md:grid-cols-2">
{projectEntries.map((entry) => {
const consumption = Number(entry.consumption_minutes ?? 0);
const percent = projectConsumptionPercent(consumption, entry.hour_budget_minutes);
const label = entry.teamspace_project || (entry.is_default ? "Standard" : "Ohne Teamspace-ID");
return (
<div key={entry.id} className="rounded-md border bg-background/50 px-2 py-1">
<div className="mb-1 flex min-w-0 items-center justify-between gap-2 text-[11px]">
<span className="truncate text-muted-foreground">
{label}
{entry.is_default ? " · Standard" : ""}
</span>
<span className="shrink-0 font-medium">
{formatMinutes(consumption)} / {entry.hour_budget_minutes ? formatMinutes(entry.hour_budget_minutes) : "offen"}
</span>
</div>
<div className="h-1 overflow-hidden rounded-full bg-muted">
<div
className={cn("h-full rounded-full", percent === null ? "bg-muted-foreground/20" : projectConsumptionBarClass(percent))}
style={{ width: `${percent === null ? 0 : Math.min(100, percent)}%` }}
/>
</div>
</div>
);
})}
</div>
</div>
) : null}
<div className="text-sm">
<div className="min-w-0">
<div className="mb-1 flex items-center gap-2 font-medium">
@@ -1065,16 +1290,6 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onO
placeholder="optional"
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium" htmlFor="project-edit-teamspace">Teamspace-Projekt</label>
<Input
id="project-edit-teamspace"
value={projectForm.teamspaceProject}
onChange={(event) => setProjectForm({ ...projectForm, teamspaceProject: event.currentTarget.value })}
/>
</div>
</div>
<div className="grid gap-3 lg:grid-cols-[minmax(220px,1fr)_140px_140px_150px_170px]">
<div className="space-y-2">
<label className="text-sm font-medium">Organisation</label>
<OrganizationSelect
@@ -1090,6 +1305,8 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onO
required
/>
</div>
</div>
<div className="grid gap-3 sm:grid-cols-2">
<div className="space-y-2">
<label className="text-sm font-medium" htmlFor="project-edit-start">Von</label>
<Input id="project-edit-start" type="date" value={projectForm.startsOn} onChange={(event) => setProjectForm({ ...projectForm, startsOn: event.currentTarget.value })} />
@@ -1098,26 +1315,11 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onO
<label className="text-sm font-medium" htmlFor="project-edit-end">Bis</label>
<Input id="project-edit-end" type="date" value={projectForm.endsOn} onChange={(event) => setProjectForm({ ...projectForm, endsOn: event.currentTarget.value })} />
</div>
<div className="space-y-2">
<label className="text-sm font-medium" htmlFor="project-edit-budget">Kontingent h</label>
<Input
id="project-edit-budget"
inputMode="decimal"
value={projectForm.hourBudget}
onChange={(event) => setProjectForm({ ...projectForm, hourBudget: event.currentTarget.value })}
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium" htmlFor="project-edit-manual-consumption">Manueller Verbrauch h</label>
<Input
id="project-edit-manual-consumption"
inputMode="decimal"
value={projectForm.manualConsumption}
onChange={(event) => setProjectForm({ ...projectForm, manualConsumption: event.currentTarget.value })}
/>
</div>
</div>
<p className="text-xs text-muted-foreground">Manueller Verbrauch wird nur im Projektverbrauch verrechnet, nicht in Auswertungen, Statistiken oder Monatsabschluss.</p>
<ProjectTeamspaceEntriesEditor
entries={projectForm.teamspaceEntries}
onChange={(teamspaceEntries) => setProjectForm({ ...projectForm, teamspaceEntries })}
/>
<div className="space-y-2">
<label className="text-sm font-medium" htmlFor="project-edit-description">Beschreibung/Ziele</label>
<Textarea