Initial TicketTracker release
This commit is contained in:
1 parent
d1da86beab
commit
c2063fe1ed
11 files changed
+1124
-175
No files matched your search
+13
-2
@@ -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
@@ -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") {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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."
|
||||
]
|
||||
},
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user