Update
This commit is contained in:
1 parent
b7f7d7e793
commit
c01d747352
14 files changed
+987
-294
No files matched your search
@@ -1,5 +1,6 @@
|
|||||||
DATABASE_URL=postgresql://ticket:ticket@10.10.10.21:5433/tickettracker_dev
|
PP_PASSWORD='rabenNest7$'
|
||||||
APP_PORT=3910
|
DATABASE_URL=postgresql://tickettracker:${PP_PASSWORD}@172.22.0.2:5432/tickettracker
|
||||||
|
APP_PORT=3911
|
||||||
TICKETTRACKER_IMAGE=git.d-razz.de/michael/tickettracker:latest
|
TICKETTRACKER_IMAGE=git.d-razz.de/michael/tickettracker:latest
|
||||||
COOKIE_SECURE=false
|
COOKIE_SECURE=false
|
||||||
DB_POOL_SIZE=10
|
DB_POOL_SIZE=10
|
||||||
+405
-170
@@ -60,6 +60,46 @@ function logDebug(event: string, details: Record<string, unknown> = {}) {
|
|||||||
console.info(`[tickettracker:${event}] ${JSON.stringify(details)}`);
|
console.info(`[tickettracker:${event}] ${JSON.stringify(details)}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
process.on("uncaughtException", (error) => {
|
||||||
|
logDebug("process", {
|
||||||
|
event: "uncaughtException",
|
||||||
|
message: error instanceof Error ? error.message : String(error),
|
||||||
|
stack: error instanceof Error ? error.stack : null
|
||||||
|
});
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
process.on("unhandledRejection", (reason) => {
|
||||||
|
logDebug("process", {
|
||||||
|
event: "unhandledRejection",
|
||||||
|
message: reason instanceof Error ? reason.message : String(reason),
|
||||||
|
stack: reason instanceof Error ? reason.stack : null
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
app.use("/api", (req, res, next) => {
|
||||||
|
const requestId = randomUUID();
|
||||||
|
const startedAt = Date.now();
|
||||||
|
|
||||||
|
logDebug("request-start", {
|
||||||
|
requestId,
|
||||||
|
method: req.method,
|
||||||
|
path: req.originalUrl
|
||||||
|
});
|
||||||
|
|
||||||
|
res.on("finish", () => {
|
||||||
|
logDebug("request-finish", {
|
||||||
|
requestId,
|
||||||
|
method: req.method,
|
||||||
|
path: req.originalUrl,
|
||||||
|
status: res.statusCode,
|
||||||
|
durationMs: Date.now() - startedAt
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
next();
|
||||||
|
});
|
||||||
|
|
||||||
function updateRestoreDebug(id: string, update: Partial<RestoreDebugState>) {
|
function updateRestoreDebug(id: string, update: Partial<RestoreDebugState>) {
|
||||||
if (!lastRestoreDebugState || lastRestoreDebugState.id !== id) {
|
if (!lastRestoreDebugState || lastRestoreDebugState.id !== id) {
|
||||||
return;
|
return;
|
||||||
@@ -256,6 +296,8 @@ type ProjectRow = {
|
|||||||
organization_name: string | null;
|
organization_name: string | null;
|
||||||
customer_name: string | null;
|
customer_name: string | null;
|
||||||
work_type: "support" | "consulting" | null;
|
work_type: "support" | "consulting" | null;
|
||||||
|
zammad_priority_id?: string | null;
|
||||||
|
zammad_state_id?: string | null;
|
||||||
teamspace_project: string | null;
|
teamspace_project: string | null;
|
||||||
description: string;
|
description: string;
|
||||||
goals: string;
|
goals: string;
|
||||||
@@ -279,6 +321,7 @@ type ProjectTeamspaceEntryRow = {
|
|||||||
teamspace_project: string | null;
|
teamspace_project: string | null;
|
||||||
is_default: boolean;
|
is_default: boolean;
|
||||||
hour_budget_minutes: number | null;
|
hour_budget_minutes: number | null;
|
||||||
|
budget_sync_disabled?: boolean;
|
||||||
manual_consumption_minutes: number;
|
manual_consumption_minutes: number;
|
||||||
teamspace_consumption_minutes?: number;
|
teamspace_consumption_minutes?: number;
|
||||||
consumption_minutes?: number;
|
consumption_minutes?: number;
|
||||||
@@ -310,6 +353,7 @@ type ZammadTicketImport = {
|
|||||||
preferences: Record<string, unknown>;
|
preferences: Record<string, unknown>;
|
||||||
teamspaceBudgetMinutes: number | null;
|
teamspaceBudgetMinutes: number | null;
|
||||||
teamspaceProject: string | null;
|
teamspaceProject: string | null;
|
||||||
|
teamspaceProjects: string[];
|
||||||
};
|
};
|
||||||
|
|
||||||
type ZammadSyncExistingFields = {
|
type ZammadSyncExistingFields = {
|
||||||
@@ -318,6 +362,7 @@ type ZammadSyncExistingFields = {
|
|||||||
type: boolean;
|
type: boolean;
|
||||||
projectName: boolean;
|
projectName: boolean;
|
||||||
budget: boolean;
|
budget: boolean;
|
||||||
|
overrideBudget: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
type ZammadSyncChange = {
|
type ZammadSyncChange = {
|
||||||
@@ -440,6 +485,17 @@ function parseZammadBudgetMinutes(value: unknown) {
|
|||||||
return Math.round(hours * 60);
|
return Math.round(hours * 60);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function parseZammadTeamspaceProjects(value: unknown) {
|
||||||
|
if (typeof value !== "string") {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return value
|
||||||
|
.split("+")
|
||||||
|
.map((part) => part.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
function zammadPreferenceValue(ticket: Record<string, unknown>, key: string) {
|
function zammadPreferenceValue(ticket: Record<string, unknown>, key: string) {
|
||||||
const preferences = isPlainObject(ticket.preferences) ? ticket.preferences : {};
|
const preferences = isPlainObject(ticket.preferences) ? ticket.preferences : {};
|
||||||
return preferences[key] ?? ticket[key];
|
return preferences[key] ?? ticket[key];
|
||||||
@@ -629,6 +685,7 @@ function zammadTicketImportFromRaw(rawTicket: Record<string, unknown>, fallbackT
|
|||||||
}
|
}
|
||||||
|
|
||||||
const preferences = isPlainObject(rawTicket.preferences) ? rawTicket.preferences : {};
|
const preferences = isPlainObject(rawTicket.preferences) ? rawTicket.preferences : {};
|
||||||
|
const teamspaceProjects = parseZammadTeamspaceProjects(zammadPreferenceValue(rawTicket, "project"));
|
||||||
|
|
||||||
return {
|
return {
|
||||||
zammadTicketId,
|
zammadTicketId,
|
||||||
@@ -642,10 +699,8 @@ function zammadTicketImportFromRaw(rawTicket: Record<string, unknown>, fallbackT
|
|||||||
workType,
|
workType,
|
||||||
preferences,
|
preferences,
|
||||||
teamspaceBudgetMinutes: parseZammadBudgetMinutes(zammadPreferenceValue(rawTicket, "gesamtbudget")),
|
teamspaceBudgetMinutes: parseZammadBudgetMinutes(zammadPreferenceValue(rawTicket, "gesamtbudget")),
|
||||||
teamspaceProject:
|
teamspaceProject: teamspaceProjects[0] ?? null,
|
||||||
typeof zammadPreferenceValue(rawTicket, "project") === "string" && String(zammadPreferenceValue(rawTicket, "project")).trim()
|
teamspaceProjects
|
||||||
? String(zammadPreferenceValue(rawTicket, "project")).trim()
|
|
||||||
: null
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -855,6 +910,7 @@ async function importZammadTicketForUser(client: QueryClient, zammadTicket: Zamm
|
|||||||
|
|
||||||
await ensureProjectForTicket(client, result.rows[0].id, ownerUserId, {
|
await ensureProjectForTicket(client, result.rows[0].id, ownerUserId, {
|
||||||
teamspaceProject: zammadTicket.teamspaceProject,
|
teamspaceProject: zammadTicket.teamspaceProject,
|
||||||
|
teamspaceProjects: zammadTicket.teamspaceProjects,
|
||||||
hourBudgetMinutes: zammadTicket.teamspaceBudgetMinutes,
|
hourBudgetMinutes: zammadTicket.teamspaceBudgetMinutes,
|
||||||
closeReviewRequired: zammadTicket.zammadStateId === 4
|
closeReviewRequired: zammadTicket.zammadStateId === 4
|
||||||
});
|
});
|
||||||
@@ -1018,6 +1074,7 @@ function parseProjectTeamspaceEntries(
|
|||||||
teamspaceProject: optionalString(entry.teamspaceProject),
|
teamspaceProject: optionalString(entry.teamspaceProject),
|
||||||
isDefault,
|
isDefault,
|
||||||
hourBudgetMinutes: parseOptionalNonNegativeInteger(entry.hourBudgetMinutes, "teamspaceEntries.hourBudgetMinutes"),
|
hourBudgetMinutes: parseOptionalNonNegativeInteger(entry.hourBudgetMinutes, "teamspaceEntries.hourBudgetMinutes"),
|
||||||
|
budgetSyncDisabled: optionalBoolean(entry.budgetSyncDisabled, false),
|
||||||
manualConsumptionMinutes: parseOptionalNonNegativeInteger(entry.manualConsumptionMinutes, "teamspaceEntries.manualConsumptionMinutes") ?? 0,
|
manualConsumptionMinutes: parseOptionalNonNegativeInteger(entry.manualConsumptionMinutes, "teamspaceEntries.manualConsumptionMinutes") ?? 0,
|
||||||
displayOrder: index * 1024
|
displayOrder: index * 1024
|
||||||
};
|
};
|
||||||
@@ -1427,10 +1484,14 @@ async function ensureProjectForTicket(
|
|||||||
client: QueryClient,
|
client: QueryClient,
|
||||||
ticketId: string,
|
ticketId: string,
|
||||||
userId: string,
|
userId: string,
|
||||||
defaults: { teamspaceProject?: string | null; hourBudgetMinutes?: number | null; closeReviewRequired?: boolean } = {}
|
defaults: { teamspaceProject?: string | null; teamspaceProjects?: string[]; hourBudgetMinutes?: number | null; closeReviewRequired?: boolean } = {}
|
||||||
) {
|
) {
|
||||||
const hasTeamspaceProjectDefault = Object.prototype.hasOwnProperty.call(defaults, "teamspaceProject");
|
const teamspaceProjects = defaults.teamspaceProjects?.length
|
||||||
const teamspaceProjectDefault = hasTeamspaceProjectDefault ? defaults.teamspaceProject ?? "" : null;
|
? defaults.teamspaceProjects
|
||||||
|
: defaults.teamspaceProject
|
||||||
|
? [defaults.teamspaceProject]
|
||||||
|
: [];
|
||||||
|
const teamspaceProjectDefault = teamspaceProjects[0] ?? (Object.prototype.hasOwnProperty.call(defaults, "teamspaceProject") ? defaults.teamspaceProject ?? "" : null);
|
||||||
const result = await client.query<{ id: string; teamspace_project: string | null; hour_budget_minutes: number | null; manual_consumption_minutes: number }>(
|
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, close_review_required)
|
INSERT INTO projects (user_id, ticket_id, teamspace_project, description, goals, close_review_required)
|
||||||
@@ -1459,8 +1520,8 @@ async function ensureProjectForTicket(
|
|||||||
|
|
||||||
await client.query(
|
await client.query(
|
||||||
`
|
`
|
||||||
INSERT INTO project_teamspace_entries (project_id, teamspace_project, is_default, hour_budget_minutes, manual_consumption_minutes, display_order)
|
INSERT INTO project_teamspace_entries (project_id, teamspace_project, is_default, hour_budget_minutes, budget_sync_disabled, manual_consumption_minutes, display_order)
|
||||||
VALUES ($1, $2, true, $3, $4, 0)
|
VALUES ($1, $2, true, $3, false, $4, 0)
|
||||||
ON CONFLICT DO NOTHING;
|
ON CONFLICT DO NOTHING;
|
||||||
`,
|
`,
|
||||||
[
|
[
|
||||||
@@ -1472,6 +1533,24 @@ async function ensureProjectForTicket(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const projectResult = result.rows[0]
|
||||||
|
? { rows: [{ id: result.rows[0].id }] }
|
||||||
|
: await client.query<{ id: string }>(
|
||||||
|
`
|
||||||
|
SELECT id::text AS id
|
||||||
|
FROM projects
|
||||||
|
WHERE ticket_id = $1
|
||||||
|
AND user_id = $2
|
||||||
|
LIMIT 1;
|
||||||
|
`,
|
||||||
|
[ticketId, userId]
|
||||||
|
);
|
||||||
|
const projectId = projectResult.rows[0]?.id;
|
||||||
|
|
||||||
|
if (result.rows[0] && projectId && teamspaceProjects.length > 1) {
|
||||||
|
await syncProjectTeamspaceEntriesFromZammad(client, projectId, teamspaceProjects, defaults.hourBudgetMinutes ?? null, false);
|
||||||
|
}
|
||||||
|
|
||||||
if (defaults.closeReviewRequired) {
|
if (defaults.closeReviewRequired) {
|
||||||
await client.query(
|
await client.query(
|
||||||
`
|
`
|
||||||
@@ -1502,6 +1581,8 @@ async function getProjectRowForTicket(client: QueryClient, ticketId: string, pro
|
|||||||
o.name AS organization_name,
|
o.name AS organization_name,
|
||||||
COALESCE(o.name, t.customer_name) AS customer_name,
|
COALESCE(o.name, t.customer_name) AS customer_name,
|
||||||
t.work_type,
|
t.work_type,
|
||||||
|
t.zammad_priority_id::text AS zammad_priority_id,
|
||||||
|
t.zammad_state_id::text AS zammad_state_id,
|
||||||
p.teamspace_project,
|
p.teamspace_project,
|
||||||
p.description,
|
p.description,
|
||||||
p.goals,
|
p.goals,
|
||||||
@@ -1561,6 +1642,7 @@ async function getProjectRowForTicket(client: QueryClient, ticketId: string, pro
|
|||||||
'teamspace_project', pte.teamspace_project,
|
'teamspace_project', pte.teamspace_project,
|
||||||
'is_default', pte.is_default,
|
'is_default', pte.is_default,
|
||||||
'hour_budget_minutes', pte.hour_budget_minutes,
|
'hour_budget_minutes', pte.hour_budget_minutes,
|
||||||
|
'budget_sync_disabled', pte.budget_sync_disabled,
|
||||||
'manual_consumption_minutes', pte.manual_consumption_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,
|
'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,
|
'consumption_minutes', CASE WHEN pte.is_default THEN COALESCE(teamspace_totals.teamspace_consumption_minutes, 0) ELSE 0 END + pte.manual_consumption_minutes,
|
||||||
@@ -1800,7 +1882,8 @@ function zammadSyncExistingFields(value: unknown): ZammadSyncExistingFields {
|
|||||||
organization: optionalBoolean(source.organization, false),
|
organization: optionalBoolean(source.organization, false),
|
||||||
type: optionalBoolean(source.type, false),
|
type: optionalBoolean(source.type, false),
|
||||||
projectName: optionalBoolean(source.projectName, false),
|
projectName: optionalBoolean(source.projectName, false),
|
||||||
budget: optionalBoolean(source.budget, false)
|
budget: optionalBoolean(source.budget, false),
|
||||||
|
overrideBudget: optionalBoolean(source.overrideBudget, false)
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1858,6 +1941,15 @@ type SyncProjectSnapshot = {
|
|||||||
closed_at: string | null;
|
closed_at: string | null;
|
||||||
default_teamspace_project: string | null;
|
default_teamspace_project: string | null;
|
||||||
default_hour_budget_minutes: number | null;
|
default_hour_budget_minutes: number | null;
|
||||||
|
default_budget_sync_disabled: boolean;
|
||||||
|
teamspace_entries: Array<{
|
||||||
|
id: string;
|
||||||
|
teamspace_project: string | null;
|
||||||
|
is_default: boolean;
|
||||||
|
hour_budget_minutes: number | null;
|
||||||
|
budget_sync_disabled: boolean;
|
||||||
|
display_order: number;
|
||||||
|
}>;
|
||||||
};
|
};
|
||||||
|
|
||||||
function syncComparableValue(value: unknown) {
|
function syncComparableValue(value: unknown) {
|
||||||
@@ -1887,10 +1979,14 @@ function syncIdComparableValue(value: unknown) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function syncComparableValueForField(field: string, value: unknown) {
|
function syncComparableValueForField(field: string, value: unknown) {
|
||||||
if (field === "Organisation") {
|
if (field === "Organisation" || field === "Zammad-Priorität" || field === "Zammad-Status") {
|
||||||
return syncIdComparableValue(value);
|
return syncIdComparableValue(value);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (field === "Teamspace-Projekte" && Array.isArray(value)) {
|
||||||
|
return value.map((entry) => syncComparableValue(entry));
|
||||||
|
}
|
||||||
|
|
||||||
return syncComparableValue(value);
|
return syncComparableValue(value);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1917,6 +2013,7 @@ function zammadSyncSnapshot(zammadTicket: ZammadTicketImport) {
|
|||||||
workType: zammadTicket.workType,
|
workType: zammadTicket.workType,
|
||||||
preferences: zammadTicket.preferences,
|
preferences: zammadTicket.preferences,
|
||||||
teamspaceProject: zammadTicket.teamspaceProject,
|
teamspaceProject: zammadTicket.teamspaceProject,
|
||||||
|
teamspaceProjects: zammadTicket.teamspaceProjects,
|
||||||
teamspaceBudgetMinutes: zammadTicket.teamspaceBudgetMinutes
|
teamspaceBudgetMinutes: zammadTicket.teamspaceBudgetMinutes
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -1951,7 +2048,10 @@ function zammadTicketFromSyncSnapshot(snapshot: Record<string, unknown> | null):
|
|||||||
workType,
|
workType,
|
||||||
preferences: isPlainObject(snapshot.preferences) ? snapshot.preferences : {},
|
preferences: isPlainObject(snapshot.preferences) ? snapshot.preferences : {},
|
||||||
teamspaceBudgetMinutes: typeof snapshot.teamspaceBudgetMinutes === "number" ? snapshot.teamspaceBudgetMinutes : null,
|
teamspaceBudgetMinutes: typeof snapshot.teamspaceBudgetMinutes === "number" ? snapshot.teamspaceBudgetMinutes : null,
|
||||||
teamspaceProject: typeof snapshot.teamspaceProject === "string" && snapshot.teamspaceProject.trim() ? snapshot.teamspaceProject.trim() : null
|
teamspaceProject: typeof snapshot.teamspaceProject === "string" && snapshot.teamspaceProject.trim() ? snapshot.teamspaceProject.trim() : null,
|
||||||
|
teamspaceProjects: Array.isArray(snapshot.teamspaceProjects)
|
||||||
|
? snapshot.teamspaceProjects.map((entry) => (typeof entry === "string" ? entry.trim() : "")).filter(Boolean)
|
||||||
|
: parseZammadTeamspaceProjects(snapshot.teamspaceProject)
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1977,7 +2077,10 @@ function localSyncSnapshot(localTicket: LocalZammadSyncTicket, project: SyncProj
|
|||||||
closeReviewRequired: project.close_review_required,
|
closeReviewRequired: project.close_review_required,
|
||||||
closedAt: project.closed_at,
|
closedAt: project.closed_at,
|
||||||
defaultTeamspaceProject: project.default_teamspace_project,
|
defaultTeamspaceProject: project.default_teamspace_project,
|
||||||
defaultHourBudgetMinutes: project.default_hour_budget_minutes
|
defaultHourBudgetMinutes: project.default_hour_budget_minutes,
|
||||||
|
defaultBudgetSyncDisabled: project.default_budget_sync_disabled,
|
||||||
|
teamspaceProjects: project.teamspace_entries.map((entry) => entry.teamspace_project).filter(Boolean),
|
||||||
|
teamspaceEntries: project.teamspace_entries
|
||||||
}
|
}
|
||||||
: null
|
: null
|
||||||
};
|
};
|
||||||
@@ -1994,16 +2097,36 @@ async function getProjectSnapshotForTicketOwner(client: QueryClient, ticketId: s
|
|||||||
p.close_review_required,
|
p.close_review_required,
|
||||||
p.closed_at,
|
p.closed_at,
|
||||||
pte.teamspace_project AS default_teamspace_project,
|
pte.teamspace_project AS default_teamspace_project,
|
||||||
pte.hour_budget_minutes AS default_hour_budget_minutes
|
pte.hour_budget_minutes AS default_hour_budget_minutes,
|
||||||
|
COALESCE(pte.budget_sync_disabled, false) AS default_budget_sync_disabled,
|
||||||
|
COALESCE(entries.teamspace_entries, '[]'::json) AS teamspace_entries
|
||||||
FROM projects p
|
FROM projects p
|
||||||
LEFT JOIN LATERAL (
|
LEFT JOIN LATERAL (
|
||||||
SELECT teamspace_project, hour_budget_minutes
|
SELECT teamspace_project, hour_budget_minutes, budget_sync_disabled
|
||||||
FROM project_teamspace_entries
|
FROM project_teamspace_entries
|
||||||
WHERE project_id = p.id
|
WHERE project_id = p.id
|
||||||
AND is_default
|
AND is_default
|
||||||
ORDER BY id ASC
|
ORDER BY id ASC
|
||||||
LIMIT 1
|
LIMIT 1
|
||||||
) pte ON true
|
) pte ON true
|
||||||
|
LEFT JOIN LATERAL (
|
||||||
|
SELECT json_agg(
|
||||||
|
json_build_object(
|
||||||
|
'id', ordered.id::text,
|
||||||
|
'teamspace_project', ordered.teamspace_project,
|
||||||
|
'is_default', ordered.is_default,
|
||||||
|
'hour_budget_minutes', ordered.hour_budget_minutes,
|
||||||
|
'budget_sync_disabled', ordered.budget_sync_disabled,
|
||||||
|
'display_order', ordered.display_order
|
||||||
|
)
|
||||||
|
) AS teamspace_entries
|
||||||
|
FROM (
|
||||||
|
SELECT id, teamspace_project, is_default, hour_budget_minutes, budget_sync_disabled, display_order
|
||||||
|
FROM project_teamspace_entries
|
||||||
|
WHERE project_id = p.id
|
||||||
|
ORDER BY is_default DESC, display_order ASC, id ASC
|
||||||
|
) ordered
|
||||||
|
) entries ON true
|
||||||
WHERE p.ticket_id = $1
|
WHERE p.ticket_id = $1
|
||||||
AND p.user_id = $2
|
AND p.user_id = $2
|
||||||
LIMIT 1;
|
LIMIT 1;
|
||||||
@@ -2029,6 +2152,93 @@ async function ticketHasAnyProject(client: QueryClient, ticketId: string) {
|
|||||||
return Boolean(result.rows[0]?.exists);
|
return Boolean(result.rows[0]?.exists);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function zammadTeamspaceProjectList(zammadTicket: ZammadTicketImport) {
|
||||||
|
return zammadTicket.teamspaceProjects.length ? zammadTicket.teamspaceProjects : zammadTicket.teamspaceProject ? [zammadTicket.teamspaceProject] : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTeamspaceProjectList(projects: Array<string | null | undefined>) {
|
||||||
|
return projects.map((project) => project?.trim()).filter(Boolean).join(" + ");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function syncProjectTeamspaceEntriesFromZammad(
|
||||||
|
client: QueryClient,
|
||||||
|
projectId: string,
|
||||||
|
teamspaceProjects: string[],
|
||||||
|
budgetMinutes: number | null,
|
||||||
|
syncBudget: boolean
|
||||||
|
) {
|
||||||
|
if (teamspaceProjects.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const existingResult = await client.query<{ id: string }>(
|
||||||
|
`
|
||||||
|
SELECT id::text AS id
|
||||||
|
FROM project_teamspace_entries
|
||||||
|
WHERE project_id = $1
|
||||||
|
ORDER BY is_default DESC, display_order ASC, id ASC
|
||||||
|
FOR UPDATE;
|
||||||
|
`,
|
||||||
|
[projectId]
|
||||||
|
);
|
||||||
|
const savedIds: string[] = [];
|
||||||
|
|
||||||
|
await client.query("UPDATE project_teamspace_entries SET is_default = false, updated_at = now() WHERE project_id = $1;", [projectId]);
|
||||||
|
|
||||||
|
for (const [index, teamspaceProject] of teamspaceProjects.entries()) {
|
||||||
|
const existing = existingResult.rows[index];
|
||||||
|
const isDefault = index === 0;
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
await client.query(
|
||||||
|
`
|
||||||
|
UPDATE project_teamspace_entries
|
||||||
|
SET teamspace_project = $1,
|
||||||
|
is_default = $2,
|
||||||
|
hour_budget_minutes = CASE WHEN $3::boolean THEN $4 ELSE hour_budget_minutes END,
|
||||||
|
budget_sync_disabled = CASE WHEN $3::boolean THEN false ELSE budget_sync_disabled END,
|
||||||
|
display_order = $5,
|
||||||
|
updated_at = now()
|
||||||
|
WHERE id = $6
|
||||||
|
AND project_id = $7;
|
||||||
|
`,
|
||||||
|
[teamspaceProject, isDefault, syncBudget && isDefault, budgetMinutes, index * 1024, existing.id, projectId]
|
||||||
|
);
|
||||||
|
savedIds.push(existing.id);
|
||||||
|
} else {
|
||||||
|
const inserted = await client.query<{ id: string }>(
|
||||||
|
`
|
||||||
|
INSERT INTO project_teamspace_entries (project_id, teamspace_project, is_default, hour_budget_minutes, budget_sync_disabled, manual_consumption_minutes, display_order)
|
||||||
|
VALUES ($1, $2, $3, $4, false, 0, $5)
|
||||||
|
RETURNING id::text AS id;
|
||||||
|
`,
|
||||||
|
[projectId, teamspaceProject, isDefault, syncBudget && isDefault ? budgetMinutes : null, index * 1024]
|
||||||
|
);
|
||||||
|
savedIds.push(inserted.rows[0].id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await client.query(
|
||||||
|
`
|
||||||
|
DELETE FROM project_teamspace_entries
|
||||||
|
WHERE project_id = $1
|
||||||
|
AND NOT (id = ANY($2::bigint[]));
|
||||||
|
`,
|
||||||
|
[projectId, savedIds]
|
||||||
|
);
|
||||||
|
|
||||||
|
await client.query(
|
||||||
|
`
|
||||||
|
UPDATE projects
|
||||||
|
SET teamspace_project = $2,
|
||||||
|
hour_budget_minutes = CASE WHEN $3::boolean THEN $4 ELSE hour_budget_minutes END,
|
||||||
|
updated_at = now()
|
||||||
|
WHERE id = $1;
|
||||||
|
`,
|
||||||
|
[projectId, teamspaceProjects[0] ?? null, syncBudget, budgetMinutes]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
async function planExistingZammadTicketSync(
|
async function planExistingZammadTicketSync(
|
||||||
client: QueryClient,
|
client: QueryClient,
|
||||||
localTicket: LocalZammadSyncTicket,
|
localTicket: LocalZammadSyncTicket,
|
||||||
@@ -2036,8 +2246,22 @@ async function planExistingZammadTicketSync(
|
|||||||
userId: string,
|
userId: string,
|
||||||
fields: ZammadSyncExistingFields
|
fields: ZammadSyncExistingFields
|
||||||
): Promise<ZammadSyncItem | null> {
|
): Promise<ZammadSyncItem | null> {
|
||||||
|
const changes: ZammadSyncChange[] = [];
|
||||||
|
|
||||||
|
addSyncChange(changes, "Zammad-Priorität", localTicket.zammad_priority_id, zammadTicket.zammadPriorityId);
|
||||||
|
addSyncChange(changes, "Zammad-Status", localTicket.zammad_state_id, zammadTicket.zammadStateId);
|
||||||
|
|
||||||
if (zammadTicket.workType === "support" && zammadTicket.zammadStateId === 4) {
|
if (zammadTicket.workType === "support" && zammadTicket.zammadStateId === 4) {
|
||||||
return null;
|
return changes.length > 0
|
||||||
|
? {
|
||||||
|
ticketNumber: localTicket.ticket_number,
|
||||||
|
action: "update",
|
||||||
|
reason: "Zammad-Metadaten werden aktualisiert.",
|
||||||
|
local: localSyncSnapshot(localTicket),
|
||||||
|
zammad: zammadSyncSnapshot(zammadTicket),
|
||||||
|
changes
|
||||||
|
}
|
||||||
|
: null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const organization = fields.organization || !localTicket.work_type ? await getOrganizationByZammadId(client, zammadTicket.organizationId) : null;
|
const organization = fields.organization || !localTicket.work_type ? await getOrganizationByZammadId(client, zammadTicket.organizationId) : null;
|
||||||
@@ -2053,8 +2277,6 @@ async function planExistingZammadTicketSync(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const changes: ZammadSyncChange[] = [];
|
|
||||||
|
|
||||||
if (fields.title) {
|
if (fields.title) {
|
||||||
addSyncChange(changes, "Titel", localTicket.title, zammadTicket.title);
|
addSyncChange(changes, "Titel", localTicket.title, zammadTicket.title);
|
||||||
}
|
}
|
||||||
@@ -2090,6 +2312,11 @@ async function planExistingZammadTicketSync(
|
|||||||
if (shouldManageConsultingProject) {
|
if (shouldManageConsultingProject) {
|
||||||
const projectOwnerUserId = await projectOwnerUserIdForZammadOwner(client, zammadTicket.zammadOwnerId, userId);
|
const projectOwnerUserId = await projectOwnerUserIdForZammadOwner(client, zammadTicket.zammadOwnerId, userId);
|
||||||
project = await getProjectSnapshotForTicketOwner(client, localTicket.id, projectOwnerUserId);
|
project = await getProjectSnapshotForTicketOwner(client, localTicket.id, projectOwnerUserId);
|
||||||
|
const zammadTeamspaceProjects = zammadTeamspaceProjectList(zammadTicket);
|
||||||
|
const localTeamspaceProjects = project?.teamspace_entries?.length
|
||||||
|
? project.teamspace_entries.map((entry) => entry.teamspace_project)
|
||||||
|
: [project?.teamspace_project ?? null];
|
||||||
|
const canSyncBudget = fields.budget && (!project?.default_budget_sync_disabled || fields.overrideBudget);
|
||||||
|
|
||||||
if (zammadTicket.zammadStateId === 4 && project?.closed_at) {
|
if (zammadTicket.zammadStateId === 4 && project?.closed_at) {
|
||||||
return null;
|
return null;
|
||||||
@@ -2100,11 +2327,11 @@ async function planExistingZammadTicketSync(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (fields.projectName) {
|
if (fields.projectName) {
|
||||||
addSyncChange(changes, "Teamspace-Projekt", project?.teamspace_project ?? null, zammadTicket.teamspaceProject);
|
addSyncChange(changes, "Teamspace-Projekte", localTeamspaceProjects, zammadTeamspaceProjects);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (fields.budget) {
|
if (canSyncBudget) {
|
||||||
addSyncChange(changes, "Teamspace-Budget", project?.hour_budget_minutes ?? null, zammadTicket.teamspaceBudgetMinutes);
|
addSyncChange(changes, "Teamspace-Budget", project?.default_hour_budget_minutes ?? project?.hour_budget_minutes ?? null, zammadTicket.teamspaceBudgetMinutes);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (zammadTicket.zammadStateId === 4 && project?.close_review_required !== true) {
|
if (zammadTicket.zammadStateId === 4 && project?.close_review_required !== true) {
|
||||||
@@ -2164,7 +2391,7 @@ async function planConsultingImport(client: QueryClient, zammadTicket: ZammadTic
|
|||||||
{ field: "Titel", from: null, to: zammadTicket.title },
|
{ field: "Titel", from: null, to: zammadTicket.title },
|
||||||
{ field: "Organisation", from: null, to: organization.id },
|
{ field: "Organisation", from: null, to: organization.id },
|
||||||
{ field: "Typ", from: null, to: zammadTicket.workType },
|
{ field: "Typ", from: null, to: zammadTicket.workType },
|
||||||
{ field: "Projekt", from: null, to: zammadTicket.teamspaceProject ?? "" },
|
{ field: "Projekt", from: null, to: formatTeamspaceProjectList(zammadTeamspaceProjectList(zammadTicket)) },
|
||||||
{ field: "Teamspace-Budget", from: null, to: zammadTicket.teamspaceBudgetMinutes },
|
{ field: "Teamspace-Budget", from: null, to: zammadTicket.teamspaceBudgetMinutes },
|
||||||
...(zammadTicket.zammadStateId === 4 ? [{ field: "TS/TT-Prüfung", from: false, to: true }] : [])
|
...(zammadTicket.zammadStateId === 4 ? [{ field: "TS/TT-Prüfung", from: false, to: true }] : [])
|
||||||
]
|
]
|
||||||
@@ -2220,6 +2447,7 @@ async function updateDefaultProjectTeamspaceEntry(
|
|||||||
if (Object.prototype.hasOwnProperty.call(updates, "budgetMinutes")) {
|
if (Object.prototype.hasOwnProperty.call(updates, "budgetMinutes")) {
|
||||||
params.push(updates.budgetMinutes ?? null);
|
params.push(updates.budgetMinutes ?? null);
|
||||||
setParts.push(`hour_budget_minutes = $${params.length}`);
|
setParts.push(`hour_budget_minutes = $${params.length}`);
|
||||||
|
setParts.push("budget_sync_disabled = false");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (setParts.length === 0) {
|
if (setParts.length === 0) {
|
||||||
@@ -2239,6 +2467,58 @@ async function updateDefaultProjectTeamspaceEntry(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function applyZammadConsultingProjectSync(
|
||||||
|
client: QueryClient,
|
||||||
|
ticketId: string,
|
||||||
|
projectOwnerUserId: string,
|
||||||
|
zammadTicket: ZammadTicketImport,
|
||||||
|
fields: ZammadSyncExistingFields
|
||||||
|
) {
|
||||||
|
await ensureProjectForTicket(client, ticketId, projectOwnerUserId, {
|
||||||
|
teamspaceProject: zammadTicket.teamspaceProject,
|
||||||
|
teamspaceProjects: zammadTicket.teamspaceProjects,
|
||||||
|
hourBudgetMinutes: zammadTicket.teamspaceBudgetMinutes,
|
||||||
|
closeReviewRequired: zammadTicket.zammadStateId === 4
|
||||||
|
});
|
||||||
|
|
||||||
|
const project = await getProjectSnapshotForTicketOwner(client, ticketId, projectOwnerUserId);
|
||||||
|
|
||||||
|
if (!project) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const syncBudget = fields.budget && (!project.default_budget_sync_disabled || fields.overrideBudget);
|
||||||
|
|
||||||
|
if (fields.projectName) {
|
||||||
|
await syncProjectTeamspaceEntriesFromZammad(client, project.id, zammadTeamspaceProjectList(zammadTicket), zammadTicket.teamspaceBudgetMinutes, syncBudget);
|
||||||
|
} else if (syncBudget) {
|
||||||
|
await updateDefaultProjectTeamspaceEntry(client, project.id, {
|
||||||
|
budgetMinutes: zammadTicket.teamspaceBudgetMinutes
|
||||||
|
});
|
||||||
|
await client.query(
|
||||||
|
`
|
||||||
|
UPDATE projects
|
||||||
|
SET hour_budget_minutes = $1,
|
||||||
|
updated_at = now()
|
||||||
|
WHERE id = $2;
|
||||||
|
`,
|
||||||
|
[zammadTicket.teamspaceBudgetMinutes, project.id]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (zammadTicket.zammadStateId === 4) {
|
||||||
|
await client.query(
|
||||||
|
`
|
||||||
|
UPDATE projects
|
||||||
|
SET close_review_required = true,
|
||||||
|
updated_at = now()
|
||||||
|
WHERE id = $1;
|
||||||
|
`,
|
||||||
|
[project.id]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function applyZammadTicketSync(
|
async function applyZammadTicketSync(
|
||||||
client: QueryClient,
|
client: QueryClient,
|
||||||
localTicket: { id: string; ticket_number: string; work_type: "support" | "consulting" | null },
|
localTicket: { id: string; ticket_number: string; work_type: "support" | "consulting" | null },
|
||||||
@@ -2246,9 +2526,11 @@ async function applyZammadTicketSync(
|
|||||||
userId: string,
|
userId: string,
|
||||||
fields: ZammadSyncExistingFields
|
fields: ZammadSyncExistingFields
|
||||||
) {
|
) {
|
||||||
const organization = fields.organization || !localTicket.work_type ? await getOrganizationByZammadId(client, zammadTicket.organizationId) : null;
|
const metadataOnly = zammadTicket.workType === "support" && zammadTicket.zammadStateId === 4;
|
||||||
|
const shouldResolveOrganization = !metadataOnly && (fields.organization || !localTicket.work_type);
|
||||||
|
const organization = shouldResolveOrganization ? await getOrganizationByZammadId(client, zammadTicket.organizationId) : null;
|
||||||
|
|
||||||
if ((fields.organization || !localTicket.work_type) && !organization) {
|
if (shouldResolveOrganization && !organization) {
|
||||||
return "organization-missing" as const;
|
return "organization-missing" as const;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2269,12 +2551,12 @@ async function applyZammadTicketSync(
|
|||||||
`,
|
`,
|
||||||
[
|
[
|
||||||
localTicket.id,
|
localTicket.id,
|
||||||
fields.title,
|
!metadataOnly && fields.title,
|
||||||
zammadTicket.title,
|
zammadTicket.title,
|
||||||
fields.organization,
|
!metadataOnly && fields.organization,
|
||||||
organization?.id ?? null,
|
organization?.id ?? null,
|
||||||
organization?.name ?? null,
|
organization?.name ?? null,
|
||||||
fields.type,
|
!metadataOnly && fields.type,
|
||||||
zammadTicket.workType,
|
zammadTicket.workType,
|
||||||
zammadTicket.zammadTicketId,
|
zammadTicket.zammadTicketId,
|
||||||
zammadTicket.zammadGroupId,
|
zammadTicket.zammadGroupId,
|
||||||
@@ -2285,7 +2567,7 @@ async function applyZammadTicketSync(
|
|||||||
]
|
]
|
||||||
);
|
);
|
||||||
|
|
||||||
if (fields.organization && organization) {
|
if (!metadataOnly && fields.organization && organization) {
|
||||||
await client.query(
|
await client.query(
|
||||||
`
|
`
|
||||||
UPDATE sessions
|
UPDATE sessions
|
||||||
@@ -2297,7 +2579,7 @@ async function applyZammadTicketSync(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (fields.type && zammadTicket.workType === "support") {
|
if (!metadataOnly && fields.type && zammadTicket.workType === "support") {
|
||||||
await deleteAllProjectDataForTicket(client, localTicket.id);
|
await deleteAllProjectDataForTicket(client, localTicket.id);
|
||||||
await client.query(
|
await client.query(
|
||||||
`
|
`
|
||||||
@@ -2314,11 +2596,7 @@ async function applyZammadTicketSync(
|
|||||||
if (fields.type && zammadTicket.workType === "consulting") {
|
if (fields.type && zammadTicket.workType === "consulting") {
|
||||||
const projectOwnerUserId = await projectOwnerUserIdForZammadOwner(client, zammadTicket.zammadOwnerId, userId);
|
const projectOwnerUserId = await projectOwnerUserIdForZammadOwner(client, zammadTicket.zammadOwnerId, userId);
|
||||||
|
|
||||||
await ensureProjectForTicket(client, localTicket.id, projectOwnerUserId, {
|
await applyZammadConsultingProjectSync(client, localTicket.id, projectOwnerUserId, zammadTicket, fields);
|
||||||
teamspaceProject: zammadTicket.teamspaceProject,
|
|
||||||
hourBudgetMinutes: zammadTicket.teamspaceBudgetMinutes,
|
|
||||||
closeReviewRequired: zammadTicket.zammadStateId === 4
|
|
||||||
});
|
|
||||||
await client.query(
|
await client.query(
|
||||||
`
|
`
|
||||||
UPDATE sessions
|
UPDATE sessions
|
||||||
@@ -2328,55 +2606,6 @@ async function applyZammadTicketSync(
|
|||||||
[localTicket.id]
|
[localTicket.id]
|
||||||
);
|
);
|
||||||
|
|
||||||
const projectUpdateParts: string[] = [];
|
|
||||||
const projectUpdateParams: unknown[] = [];
|
|
||||||
|
|
||||||
if (fields.projectName) {
|
|
||||||
projectUpdateParams.push(zammadTicket.teamspaceProject);
|
|
||||||
projectUpdateParts.push(`teamspace_project = $${projectUpdateParams.length}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (fields.budget) {
|
|
||||||
projectUpdateParams.push(zammadTicket.teamspaceBudgetMinutes);
|
|
||||||
projectUpdateParts.push(`hour_budget_minutes = $${projectUpdateParams.length}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (zammadTicket.zammadStateId === 4) {
|
|
||||||
projectUpdateParts.push("close_review_required = true");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (projectUpdateParts.length > 0) {
|
|
||||||
projectUpdateParams.push(localTicket.id, projectOwnerUserId);
|
|
||||||
await client.query(
|
|
||||||
`
|
|
||||||
UPDATE projects
|
|
||||||
SET ${projectUpdateParts.join(", ")},
|
|
||||||
updated_at = now()
|
|
||||||
WHERE ticket_id = $${projectUpdateParams.length - 1}
|
|
||||||
AND user_id = $${projectUpdateParams.length};
|
|
||||||
`,
|
|
||||||
projectUpdateParams
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const projectResult = await client.query<{ id: string }>(
|
|
||||||
`
|
|
||||||
SELECT id::text AS id
|
|
||||||
FROM projects
|
|
||||||
WHERE ticket_id = $1
|
|
||||||
AND user_id = $2
|
|
||||||
LIMIT 1;
|
|
||||||
`,
|
|
||||||
[localTicket.id, projectOwnerUserId]
|
|
||||||
);
|
|
||||||
|
|
||||||
if (projectResult.rows[0]) {
|
|
||||||
await updateDefaultProjectTeamspaceEntry(client, projectResult.rows[0].id, {
|
|
||||||
...(fields.projectName ? { projectName: zammadTicket.teamspaceProject } : {}),
|
|
||||||
...(fields.budget ? { budgetMinutes: zammadTicket.teamspaceBudgetMinutes } : {})
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
await autoAssignSingleProjectTeamspaceEntry(client, localTicket.id);
|
await autoAssignSingleProjectTeamspaceEntry(client, localTicket.id);
|
||||||
return zammadTicket.zammadStateId === 4 ? "consulting-close-review" as const : "consulting" as const;
|
return zammadTicket.zammadStateId === 4 ? "consulting-close-review" as const : "consulting" as const;
|
||||||
}
|
}
|
||||||
@@ -2384,61 +2613,7 @@ async function applyZammadTicketSync(
|
|||||||
if (zammadTicket.workType === "consulting" && localTicket.work_type === "consulting") {
|
if (zammadTicket.workType === "consulting" && localTicket.work_type === "consulting") {
|
||||||
const projectOwnerUserId = await projectOwnerUserIdForZammadOwner(client, zammadTicket.zammadOwnerId, userId);
|
const projectOwnerUserId = await projectOwnerUserIdForZammadOwner(client, zammadTicket.zammadOwnerId, userId);
|
||||||
|
|
||||||
await ensureProjectForTicket(client, localTicket.id, projectOwnerUserId, {
|
await applyZammadConsultingProjectSync(client, localTicket.id, projectOwnerUserId, zammadTicket, fields);
|
||||||
teamspaceProject: zammadTicket.teamspaceProject,
|
|
||||||
hourBudgetMinutes: zammadTicket.teamspaceBudgetMinutes,
|
|
||||||
closeReviewRequired: zammadTicket.zammadStateId === 4
|
|
||||||
});
|
|
||||||
|
|
||||||
const projectUpdateParts: string[] = [];
|
|
||||||
const projectUpdateParams: unknown[] = [];
|
|
||||||
|
|
||||||
if (fields.projectName) {
|
|
||||||
projectUpdateParams.push(zammadTicket.teamspaceProject);
|
|
||||||
projectUpdateParts.push(`teamspace_project = $${projectUpdateParams.length}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (fields.budget) {
|
|
||||||
projectUpdateParams.push(zammadTicket.teamspaceBudgetMinutes);
|
|
||||||
projectUpdateParts.push(`hour_budget_minutes = $${projectUpdateParams.length}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (zammadTicket.zammadStateId === 4) {
|
|
||||||
projectUpdateParts.push("close_review_required = true");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (projectUpdateParts.length > 0) {
|
|
||||||
projectUpdateParams.push(localTicket.id, projectOwnerUserId);
|
|
||||||
await client.query(
|
|
||||||
`
|
|
||||||
UPDATE projects
|
|
||||||
SET ${projectUpdateParts.join(", ")},
|
|
||||||
updated_at = now()
|
|
||||||
WHERE ticket_id = $${projectUpdateParams.length - 1}
|
|
||||||
AND user_id = $${projectUpdateParams.length};
|
|
||||||
`,
|
|
||||||
projectUpdateParams
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const projectResult = await client.query<{ id: string }>(
|
|
||||||
`
|
|
||||||
SELECT id::text AS id
|
|
||||||
FROM projects
|
|
||||||
WHERE ticket_id = $1
|
|
||||||
AND user_id = $2
|
|
||||||
LIMIT 1;
|
|
||||||
`,
|
|
||||||
[localTicket.id, projectOwnerUserId]
|
|
||||||
);
|
|
||||||
|
|
||||||
if (projectResult.rows[0]) {
|
|
||||||
await updateDefaultProjectTeamspaceEntry(client, projectResult.rows[0].id, {
|
|
||||||
...(fields.projectName ? { projectName: zammadTicket.teamspaceProject } : {}),
|
|
||||||
...(fields.budget ? { budgetMinutes: zammadTicket.teamspaceBudgetMinutes } : {})
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
await autoAssignSingleProjectTeamspaceEntry(client, localTicket.id);
|
await autoAssignSingleProjectTeamspaceEntry(client, localTicket.id);
|
||||||
return zammadTicket.zammadStateId === 4 ? "close-review" as const : "updated" as const;
|
return zammadTicket.zammadStateId === 4 ? "close-review" as const : "updated" as const;
|
||||||
}
|
}
|
||||||
@@ -3015,6 +3190,8 @@ async function getPeriodTicket(config: PeriodConfig, period: ParsedPeriod, ticke
|
|||||||
o.name AS organization_name,
|
o.name AS organization_name,
|
||||||
COALESCE(o.name, t.customer_name) AS customer_name,
|
COALESCE(o.name, t.customer_name) AS customer_name,
|
||||||
t.work_type,
|
t.work_type,
|
||||||
|
t.zammad_priority_id::text AS zammad_priority_id,
|
||||||
|
t.zammad_state_id::text AS zammad_state_id,
|
||||||
NULL::timestamptz AS closed_at,
|
NULL::timestamptz AS closed_at,
|
||||||
pc.closed_at AS period_closed_at,
|
pc.closed_at AS period_closed_at,
|
||||||
COALESCE(period_sessions.session_count, 0)::int AS ticket_session_count,
|
COALESCE(period_sessions.session_count, 0)::int AS ticket_session_count,
|
||||||
@@ -3150,6 +3327,8 @@ async function getTicketDetails(ticketId: number, userId: string, filters: { mon
|
|||||||
o.name AS organization_name,
|
o.name AS organization_name,
|
||||||
COALESCE(o.name, t.customer_name) AS customer_name,
|
COALESCE(o.name, t.customer_name) AS customer_name,
|
||||||
t.work_type,
|
t.work_type,
|
||||||
|
t.zammad_priority_id::text AS zammad_priority_id,
|
||||||
|
t.zammad_state_id::text AS zammad_state_id,
|
||||||
NULL::timestamptz AS closed_at,
|
NULL::timestamptz AS closed_at,
|
||||||
NULL::timestamptz AS period_closed_at,
|
NULL::timestamptz AS period_closed_at,
|
||||||
COALESCE(all_sessions.session_count, 0)::int AS ticket_session_count,
|
COALESCE(all_sessions.session_count, 0)::int AS ticket_session_count,
|
||||||
@@ -3291,6 +3470,8 @@ async function getTicketDetails(ticketId: number, userId: string, filters: { mon
|
|||||||
o.name AS organization_name,
|
o.name AS organization_name,
|
||||||
COALESCE(o.name, t.customer_name) AS customer_name,
|
COALESCE(o.name, t.customer_name) AS customer_name,
|
||||||
t.work_type,
|
t.work_type,
|
||||||
|
t.zammad_priority_id::text AS zammad_priority_id,
|
||||||
|
t.zammad_state_id::text AS zammad_state_id,
|
||||||
p.teamspace_project,
|
p.teamspace_project,
|
||||||
p.description,
|
p.description,
|
||||||
p.goals,
|
p.goals,
|
||||||
@@ -3335,6 +3516,7 @@ async function getTicketDetails(ticketId: number, userId: string, filters: { mon
|
|||||||
'teamspace_project', pte.teamspace_project,
|
'teamspace_project', pte.teamspace_project,
|
||||||
'is_default', pte.is_default,
|
'is_default', pte.is_default,
|
||||||
'hour_budget_minutes', pte.hour_budget_minutes,
|
'hour_budget_minutes', pte.hour_budget_minutes,
|
||||||
|
'budget_sync_disabled', pte.budget_sync_disabled,
|
||||||
'manual_consumption_minutes', pte.manual_consumption_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,
|
'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,
|
'consumption_minutes', CASE WHEN pte.is_default THEN COALESCE(teamspace_totals.teamspace_consumption_minutes, 0) ELSE 0 END + pte.manual_consumption_minutes,
|
||||||
@@ -3520,8 +3702,13 @@ app.post("/api/setup/restore/validate", async (req, res) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
app.post("/api/auth/login", async (req, res) => {
|
app.post("/api/auth/login", async (req, res) => {
|
||||||
|
const startedAt = Date.now();
|
||||||
const username = requireString(req.body.username, "username");
|
const username = requireString(req.body.username, "username");
|
||||||
const password = requireString(req.body.password, "password");
|
const password = requireString(req.body.password, "password");
|
||||||
|
logDebug("login", {
|
||||||
|
phase: "start",
|
||||||
|
username
|
||||||
|
});
|
||||||
|
|
||||||
const result = await query<{
|
const result = await query<{
|
||||||
id: string;
|
id: string;
|
||||||
@@ -3550,13 +3737,35 @@ app.post("/api/auth/login", async (req, res) => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const user = result.rows[0];
|
const user = result.rows[0];
|
||||||
|
logDebug("login", {
|
||||||
|
phase: "user-loaded",
|
||||||
|
username,
|
||||||
|
found: Boolean(user),
|
||||||
|
userId: user?.id ?? null,
|
||||||
|
role: user?.role ?? null,
|
||||||
|
active: user?.active ?? null
|
||||||
|
});
|
||||||
|
|
||||||
if (!user || !user.active || !(await verifyPassword(password, user.password_hash))) {
|
const passwordValid = user && user.active ? await verifyPassword(password, user.password_hash) : false;
|
||||||
|
logDebug("login", {
|
||||||
|
phase: "password-checked",
|
||||||
|
username,
|
||||||
|
userId: user?.id ?? null,
|
||||||
|
ok: passwordValid
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!user || !user.active || !passwordValid) {
|
||||||
res.status(401).json({ error: "Benutzername oder Passwort ist falsch" });
|
res.status(401).json({ error: "Benutzername oder Passwort ist falsch" });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
await createAuthSession(res, user.id);
|
await createAuthSession(res, user.id);
|
||||||
|
logDebug("login", {
|
||||||
|
phase: "session-created",
|
||||||
|
username,
|
||||||
|
userId: user.id,
|
||||||
|
durationMs: Date.now() - startedAt
|
||||||
|
});
|
||||||
res.json({
|
res.json({
|
||||||
user: {
|
user: {
|
||||||
id: user.id,
|
id: user.id,
|
||||||
@@ -4856,6 +5065,7 @@ app.post("/api/sessions", requireUser, async (req, res) => {
|
|||||||
);
|
);
|
||||||
await ensureProjectForTicket(client, ticket.id, projectOwnerUserId, {
|
await ensureProjectForTicket(client, ticket.id, projectOwnerUserId, {
|
||||||
teamspaceProject: zammadTicket?.teamspaceProject ?? null,
|
teamspaceProject: zammadTicket?.teamspaceProject ?? null,
|
||||||
|
teamspaceProjects: zammadTicket?.teamspaceProjects ?? [],
|
||||||
hourBudgetMinutes: zammadTicket?.teamspaceBudgetMinutes ?? null,
|
hourBudgetMinutes: zammadTicket?.teamspaceBudgetMinutes ?? null,
|
||||||
closeReviewRequired: zammadTicket?.zammadStateId === 4
|
closeReviewRequired: zammadTicket?.zammadStateId === 4
|
||||||
});
|
});
|
||||||
@@ -4981,6 +5191,7 @@ app.get("/api/tickets/lookup", requireUser, async (req, res) => {
|
|||||||
'teamspace_project', pte.teamspace_project,
|
'teamspace_project', pte.teamspace_project,
|
||||||
'is_default', pte.is_default,
|
'is_default', pte.is_default,
|
||||||
'hour_budget_minutes', pte.hour_budget_minutes,
|
'hour_budget_minutes', pte.hour_budget_minutes,
|
||||||
|
'budget_sync_disabled', pte.budget_sync_disabled,
|
||||||
'manual_consumption_minutes', pte.manual_consumption_minutes,
|
'manual_consumption_minutes', pte.manual_consumption_minutes,
|
||||||
'display_order', pte.display_order
|
'display_order', pte.display_order
|
||||||
)
|
)
|
||||||
@@ -5005,18 +5216,18 @@ app.get("/api/tickets/lookup", requireUser, async (req, res) => {
|
|||||||
throw badRequest("Zammad Organisation ist lokal nicht synchronisiert");
|
throw badRequest("Zammad Organisation ist lokal nicht synchronisiert");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const zammadTeamspaceProjects = zammadTeamspaceProjectList(zammadTicket);
|
||||||
const previewEntries =
|
const previewEntries =
|
||||||
zammadTicket.workType === "consulting" && (zammadTicket.teamspaceProject || zammadTicket.teamspaceBudgetMinutes !== null)
|
zammadTicket.workType === "consulting" && (zammadTeamspaceProjects.length > 0 || zammadTicket.teamspaceBudgetMinutes !== null)
|
||||||
? [
|
? (zammadTeamspaceProjects.length > 0 ? zammadTeamspaceProjects : [zammadTicket.teamspaceProject]).map((teamspaceProject, index) => ({
|
||||||
{
|
id: `zammad-${zammadTicket.zammadTicketId}-${index}`,
|
||||||
id: `zammad-${zammadTicket.zammadTicketId}-default`,
|
teamspace_project: teamspaceProject,
|
||||||
teamspace_project: zammadTicket.teamspaceProject,
|
is_default: index === 0,
|
||||||
is_default: true,
|
hour_budget_minutes: index === 0 ? zammadTicket.teamspaceBudgetMinutes : null,
|
||||||
hour_budget_minutes: zammadTicket.teamspaceBudgetMinutes,
|
budget_sync_disabled: false,
|
||||||
manual_consumption_minutes: 0,
|
manual_consumption_minutes: 0,
|
||||||
display_order: 0
|
display_order: index * 1024
|
||||||
}
|
}))
|
||||||
]
|
|
||||||
: [];
|
: [];
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
@@ -5035,7 +5246,7 @@ app.get("/api/tickets/lookup", requireUser, async (req, res) => {
|
|||||||
zammad_owner_id: zammadTicket.zammadOwnerId === null ? null : String(zammadTicket.zammadOwnerId),
|
zammad_owner_id: zammadTicket.zammadOwnerId === null ? null : String(zammadTicket.zammadOwnerId),
|
||||||
project_id: null,
|
project_id: null,
|
||||||
is_project: zammadTicket.workType === "consulting",
|
is_project: zammadTicket.workType === "consulting",
|
||||||
project_name: zammadTicket.teamspaceProject,
|
project_name: formatTeamspaceProjectList(zammadTeamspaceProjects),
|
||||||
project_hour_budget_minutes: zammadTicket.teamspaceBudgetMinutes,
|
project_hour_budget_minutes: zammadTicket.teamspaceBudgetMinutes,
|
||||||
project_teamspace_entry_count: previewEntries.length,
|
project_teamspace_entry_count: previewEntries.length,
|
||||||
project_teamspace_entries: previewEntries,
|
project_teamspace_entries: previewEntries,
|
||||||
@@ -5655,6 +5866,8 @@ app.get("/api/projects", requireUser, async (req, res) => {
|
|||||||
o.name AS organization_name,
|
o.name AS organization_name,
|
||||||
COALESCE(o.name, t.customer_name) AS customer_name,
|
COALESCE(o.name, t.customer_name) AS customer_name,
|
||||||
t.work_type,
|
t.work_type,
|
||||||
|
t.zammad_priority_id::text AS zammad_priority_id,
|
||||||
|
t.zammad_state_id::text AS zammad_state_id,
|
||||||
p.teamspace_project,
|
p.teamspace_project,
|
||||||
p.description,
|
p.description,
|
||||||
p.goals,
|
p.goals,
|
||||||
@@ -5720,6 +5933,7 @@ app.get("/api/projects", requireUser, async (req, res) => {
|
|||||||
'teamspace_project', pte.teamspace_project,
|
'teamspace_project', pte.teamspace_project,
|
||||||
'is_default', pte.is_default,
|
'is_default', pte.is_default,
|
||||||
'hour_budget_minutes', pte.hour_budget_minutes,
|
'hour_budget_minutes', pte.hour_budget_minutes,
|
||||||
|
'budget_sync_disabled', pte.budget_sync_disabled,
|
||||||
'manual_consumption_minutes', pte.manual_consumption_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,
|
'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,
|
'consumption_minutes', CASE WHEN pte.is_default THEN COALESCE(teamspace_totals.teamspace_consumption_minutes, 0) ELSE 0 END + pte.manual_consumption_minutes,
|
||||||
@@ -5812,6 +6026,8 @@ app.get("/api/projects/:projectId", requireUser, async (req, res) => {
|
|||||||
o.name AS organization_name,
|
o.name AS organization_name,
|
||||||
COALESCE(o.name, t.customer_name) AS customer_name,
|
COALESCE(o.name, t.customer_name) AS customer_name,
|
||||||
t.work_type,
|
t.work_type,
|
||||||
|
t.zammad_priority_id::text AS zammad_priority_id,
|
||||||
|
t.zammad_state_id::text AS zammad_state_id,
|
||||||
p.teamspace_project,
|
p.teamspace_project,
|
||||||
p.description,
|
p.description,
|
||||||
p.goals,
|
p.goals,
|
||||||
@@ -5856,6 +6072,7 @@ app.get("/api/projects/:projectId", requireUser, async (req, res) => {
|
|||||||
'teamspace_project', pte.teamspace_project,
|
'teamspace_project', pte.teamspace_project,
|
||||||
'is_default', pte.is_default,
|
'is_default', pte.is_default,
|
||||||
'hour_budget_minutes', pte.hour_budget_minutes,
|
'hour_budget_minutes', pte.hour_budget_minutes,
|
||||||
|
'budget_sync_disabled', pte.budget_sync_disabled,
|
||||||
'manual_consumption_minutes', pte.manual_consumption_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,
|
'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,
|
'consumption_minutes', CASE WHEN pte.is_default THEN COALESCE(teamspace_totals.teamspace_consumption_minutes, 0) ELSE 0 END + pte.manual_consumption_minutes,
|
||||||
@@ -5932,15 +6149,29 @@ app.post("/api/projects", requireUser, async (req, res) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (zammadTicket) {
|
if (zammadTicket) {
|
||||||
teamspaceEntries = teamspaceEntries.map((entry) =>
|
const zammadTeamspaceProjects = zammadTeamspaceProjectList(zammadTicket);
|
||||||
entry.isDefault
|
const currentDefaultTeamspaceEntry = teamspaceEntries.find((entry) => entry.isDefault) ?? teamspaceEntries[0];
|
||||||
? {
|
|
||||||
...entry,
|
teamspaceEntries = zammadTeamspaceProjects.length > 0
|
||||||
teamspaceProject: entry.teamspaceProject ?? zammadTicket.teamspaceProject,
|
? zammadTeamspaceProjects.map((teamspaceProjectName, index) => ({
|
||||||
hourBudgetMinutes: entry.hourBudgetMinutes ?? zammadTicket.teamspaceBudgetMinutes
|
id: null,
|
||||||
}
|
teamspaceProject: teamspaceProjectName,
|
||||||
: entry
|
isDefault: index === 0,
|
||||||
);
|
hourBudgetMinutes: index === 0 ? currentDefaultTeamspaceEntry.hourBudgetMinutes ?? zammadTicket.teamspaceBudgetMinutes : null,
|
||||||
|
budgetSyncDisabled: false,
|
||||||
|
manualConsumptionMinutes: index === 0 ? currentDefaultTeamspaceEntry.manualConsumptionMinutes : 0,
|
||||||
|
displayOrder: index * 1024
|
||||||
|
}))
|
||||||
|
: teamspaceEntries.map((entry) =>
|
||||||
|
entry.isDefault
|
||||||
|
? {
|
||||||
|
...entry,
|
||||||
|
teamspaceProject: entry.teamspaceProject ?? zammadTicket.teamspaceProject,
|
||||||
|
hourBudgetMinutes: entry.hourBudgetMinutes ?? zammadTicket.teamspaceBudgetMinutes,
|
||||||
|
budgetSyncDisabled: false
|
||||||
|
}
|
||||||
|
: entry
|
||||||
|
);
|
||||||
defaultTeamspaceEntry = teamspaceEntries.find((entry) => entry.isDefault) ?? teamspaceEntries[0];
|
defaultTeamspaceEntry = teamspaceEntries.find((entry) => entry.isDefault) ?? teamspaceEntries[0];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -6014,6 +6245,7 @@ app.post("/api/projects", requireUser, async (req, res) => {
|
|||||||
if (zammadTicket && projectOwnerUserId !== userId) {
|
if (zammadTicket && projectOwnerUserId !== userId) {
|
||||||
await ensureProjectForTicket(client, ticketId, projectOwnerUserId, {
|
await ensureProjectForTicket(client, ticketId, projectOwnerUserId, {
|
||||||
teamspaceProject: zammadTicket.teamspaceProject,
|
teamspaceProject: zammadTicket.teamspaceProject,
|
||||||
|
teamspaceProjects: zammadTicket.teamspaceProjects,
|
||||||
hourBudgetMinutes: zammadTicket.teamspaceBudgetMinutes,
|
hourBudgetMinutes: zammadTicket.teamspaceBudgetMinutes,
|
||||||
closeReviewRequired: zammadTicket.zammadStateId === 4
|
closeReviewRequired: zammadTicket.zammadStateId === 4
|
||||||
});
|
});
|
||||||
@@ -6105,10 +6337,10 @@ app.post("/api/projects", requireUser, async (req, res) => {
|
|||||||
for (const entry of teamspaceEntries) {
|
for (const entry of teamspaceEntries) {
|
||||||
await client.query(
|
await client.query(
|
||||||
`
|
`
|
||||||
INSERT INTO project_teamspace_entries (project_id, teamspace_project, is_default, hour_budget_minutes, manual_consumption_minutes, display_order)
|
INSERT INTO project_teamspace_entries (project_id, teamspace_project, is_default, hour_budget_minutes, budget_sync_disabled, manual_consumption_minutes, display_order)
|
||||||
VALUES ($1, $2, $3, $4, $5, $6);
|
VALUES ($1, $2, $3, $4, $5, $6, $7);
|
||||||
`,
|
`,
|
||||||
[project.id, entry.teamspaceProject, entry.isDefault, entry.hourBudgetMinutes, entry.manualConsumptionMinutes, entry.displayOrder]
|
[project.id, entry.teamspaceProject, entry.isDefault, entry.hourBudgetMinutes, entry.budgetSyncDisabled, entry.manualConsumptionMinutes, entry.displayOrder]
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
await autoAssignSingleProjectTeamspaceEntry(client, project.ticket_id);
|
await autoAssignSingleProjectTeamspaceEntry(client, project.ticket_id);
|
||||||
@@ -6118,6 +6350,7 @@ app.post("/api/projects", requireUser, async (req, res) => {
|
|||||||
teamspace_project: entry.teamspaceProject,
|
teamspace_project: entry.teamspaceProject,
|
||||||
is_default: entry.isDefault,
|
is_default: entry.isDefault,
|
||||||
hour_budget_minutes: entry.hourBudgetMinutes,
|
hour_budget_minutes: entry.hourBudgetMinutes,
|
||||||
|
budget_sync_disabled: entry.budgetSyncDisabled,
|
||||||
manual_consumption_minutes: entry.manualConsumptionMinutes,
|
manual_consumption_minutes: entry.manualConsumptionMinutes,
|
||||||
teamspace_consumption_minutes: entry.isDefault ? 0 : 0,
|
teamspace_consumption_minutes: entry.isDefault ? 0 : 0,
|
||||||
consumption_minutes: entry.manualConsumptionMinutes,
|
consumption_minutes: entry.manualConsumptionMinutes,
|
||||||
@@ -6294,14 +6527,15 @@ app.patch("/api/projects/:projectId", requireUser, async (req, res) => {
|
|||||||
SET teamspace_project = $1,
|
SET teamspace_project = $1,
|
||||||
is_default = $2,
|
is_default = $2,
|
||||||
hour_budget_minutes = $3,
|
hour_budget_minutes = $3,
|
||||||
manual_consumption_minutes = $4,
|
budget_sync_disabled = $4,
|
||||||
display_order = $5,
|
manual_consumption_minutes = $5,
|
||||||
|
display_order = $6,
|
||||||
updated_at = now()
|
updated_at = now()
|
||||||
WHERE id = $6
|
WHERE id = $7
|
||||||
AND project_id = $7
|
AND project_id = $8
|
||||||
RETURNING id::text AS id;
|
RETURNING id::text AS id;
|
||||||
`,
|
`,
|
||||||
[entry.teamspaceProject, entry.isDefault, entry.hourBudgetMinutes, entry.manualConsumptionMinutes, entry.displayOrder, entry.id, project.id]
|
[entry.teamspaceProject, entry.isDefault, entry.hourBudgetMinutes, entry.budgetSyncDisabled, entry.manualConsumptionMinutes, entry.displayOrder, entry.id, project.id]
|
||||||
);
|
);
|
||||||
|
|
||||||
if (updatedEntry.rowCount === 0) {
|
if (updatedEntry.rowCount === 0) {
|
||||||
@@ -6312,11 +6546,11 @@ app.patch("/api/projects/:projectId", requireUser, async (req, res) => {
|
|||||||
} else {
|
} else {
|
||||||
const insertedEntry = await client.query<{ id: string }>(
|
const insertedEntry = await client.query<{ id: string }>(
|
||||||
`
|
`
|
||||||
INSERT INTO project_teamspace_entries (project_id, teamspace_project, is_default, hour_budget_minutes, manual_consumption_minutes, display_order)
|
INSERT INTO project_teamspace_entries (project_id, teamspace_project, is_default, hour_budget_minutes, budget_sync_disabled, manual_consumption_minutes, display_order)
|
||||||
VALUES ($1, $2, $3, $4, $5, $6)
|
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||||
RETURNING id::text AS id;
|
RETURNING id::text AS id;
|
||||||
`,
|
`,
|
||||||
[project.id, entry.teamspaceProject, entry.isDefault, entry.hourBudgetMinutes, entry.manualConsumptionMinutes, entry.displayOrder]
|
[project.id, entry.teamspaceProject, entry.isDefault, entry.hourBudgetMinutes, entry.budgetSyncDisabled, entry.manualConsumptionMinutes, entry.displayOrder]
|
||||||
);
|
);
|
||||||
|
|
||||||
savedEntryIds.push(insertedEntry.rows[0].id);
|
savedEntryIds.push(insertedEntry.rows[0].id);
|
||||||
@@ -6338,6 +6572,7 @@ app.patch("/api/projects/:projectId", requireUser, async (req, res) => {
|
|||||||
teamspace_project: entry.teamspaceProject,
|
teamspace_project: entry.teamspaceProject,
|
||||||
is_default: entry.isDefault,
|
is_default: entry.isDefault,
|
||||||
hour_budget_minutes: entry.hourBudgetMinutes,
|
hour_budget_minutes: entry.hourBudgetMinutes,
|
||||||
|
budget_sync_disabled: entry.budgetSyncDisabled,
|
||||||
manual_consumption_minutes: entry.manualConsumptionMinutes,
|
manual_consumption_minutes: entry.manualConsumptionMinutes,
|
||||||
teamspace_consumption_minutes: entry.isDefault ? Number(project.teamspace_consumption_minutes ?? 0) : 0,
|
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,
|
consumption_minutes: (entry.isDefault ? Number(project.teamspace_consumption_minutes ?? 0) : 0) + entry.manualConsumptionMinutes,
|
||||||
|
|||||||
@@ -503,6 +503,7 @@ export async function migrate() {
|
|||||||
teamspace_project TEXT,
|
teamspace_project TEXT,
|
||||||
is_default BOOLEAN NOT NULL DEFAULT false,
|
is_default BOOLEAN NOT NULL DEFAULT false,
|
||||||
hour_budget_minutes INTEGER,
|
hour_budget_minutes INTEGER,
|
||||||
|
budget_sync_disabled BOOLEAN NOT NULL DEFAULT false,
|
||||||
manual_consumption_minutes INTEGER NOT NULL DEFAULT 0,
|
manual_consumption_minutes INTEGER NOT NULL DEFAULT 0,
|
||||||
display_order INTEGER NOT NULL DEFAULT 0,
|
display_order INTEGER NOT NULL DEFAULT 0,
|
||||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
@@ -512,6 +513,7 @@ export async function migrate() {
|
|||||||
);
|
);
|
||||||
`);
|
`);
|
||||||
|
|
||||||
|
await query("ALTER TABLE project_teamspace_entries ADD COLUMN IF NOT EXISTS budget_sync_disabled BOOLEAN NOT NULL DEFAULT false;");
|
||||||
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 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("CREATE INDEX IF NOT EXISTS idx_project_teamspace_entries_project_order ON project_teamspace_entries(project_id, display_order, id);");
|
||||||
await query(`
|
await query(`
|
||||||
|
|||||||
+13
-2
@@ -48,6 +48,7 @@ export type ZammadSyncExistingFields = {
|
|||||||
type: boolean;
|
type: boolean;
|
||||||
projectName: boolean;
|
projectName: boolean;
|
||||||
budget: boolean;
|
budget: boolean;
|
||||||
|
overrideBudget: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type ZammadSyncChange = {
|
export type ZammadSyncChange = {
|
||||||
@@ -163,6 +164,14 @@ async function requestRestoreEndpoint<T>(path: string, options: ApiRequestInit)
|
|||||||
return request<T>(path, options);
|
return request<T>(path, options);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function backupValidationSql(sql: string) {
|
||||||
|
const lines = sql.replace(/^\uFEFF/, "").split(/\r?\n/);
|
||||||
|
const marker = lines.find((line) => line.includes("TicketTracker Datenbankexport")) ?? "-- TicketTracker Datenbankexport";
|
||||||
|
const structuralLines = lines.filter((line) => /^--\s*(?:[a-z_][a-z0-9_]*:\s*\d+\s+Datensatz|columns\s+[a-z_][a-z0-9_]*:)/i.test(line.trim()));
|
||||||
|
|
||||||
|
return [marker, ...structuralLines].join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
export function login(payload: { username: string; password: string }) {
|
export function login(payload: { username: string; password: string }) {
|
||||||
return request<{ user: AuthUser }>("/api/auth/login", {
|
return request<{ user: AuthUser }>("/api/auth/login", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
@@ -242,7 +251,7 @@ export function validateSetupBackup(sql: string) {
|
|||||||
method: "POST",
|
method: "POST",
|
||||||
timeoutMs: 60_000,
|
timeoutMs: 60_000,
|
||||||
headers: { "Content-Type": "text/plain; charset=utf-8" },
|
headers: { "Content-Type": "text/plain; charset=utf-8" },
|
||||||
body: sql
|
body: backupValidationSql(sql)
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -310,7 +319,7 @@ export function validateDatabaseBackup(sql: string) {
|
|||||||
method: "POST",
|
method: "POST",
|
||||||
timeoutMs: 60_000,
|
timeoutMs: 60_000,
|
||||||
headers: { "Content-Type": "text/plain; charset=utf-8" },
|
headers: { "Content-Type": "text/plain; charset=utf-8" },
|
||||||
body: sql
|
body: backupValidationSql(sql)
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -531,6 +540,7 @@ export function createProject(payload: {
|
|||||||
teamspaceProject?: string | null;
|
teamspaceProject?: string | null;
|
||||||
isDefault: boolean;
|
isDefault: boolean;
|
||||||
hourBudgetMinutes?: number | null;
|
hourBudgetMinutes?: number | null;
|
||||||
|
budgetSyncDisabled?: boolean;
|
||||||
manualConsumptionMinutes?: number | null;
|
manualConsumptionMinutes?: number | null;
|
||||||
}>;
|
}>;
|
||||||
description: string;
|
description: string;
|
||||||
@@ -558,6 +568,7 @@ export function updateProject(
|
|||||||
teamspaceProject?: string | null;
|
teamspaceProject?: string | null;
|
||||||
isDefault: boolean;
|
isDefault: boolean;
|
||||||
hourBudgetMinutes?: number | null;
|
hourBudgetMinutes?: number | null;
|
||||||
|
budgetSyncDisabled?: boolean;
|
||||||
manualConsumptionMinutes?: number | null;
|
manualConsumptionMinutes?: number | null;
|
||||||
}>;
|
}>;
|
||||||
description: string;
|
description: string;
|
||||||
|
|||||||
@@ -136,6 +136,8 @@ export type ProjectSummary = {
|
|||||||
organization_name: string | null;
|
organization_name: string | null;
|
||||||
customer_name: string | null;
|
customer_name: string | null;
|
||||||
work_type: WorkType | null;
|
work_type: WorkType | null;
|
||||||
|
zammad_priority_id?: string | null;
|
||||||
|
zammad_state_id?: string | null;
|
||||||
teamspace_project: string | null;
|
teamspace_project: string | null;
|
||||||
description: string;
|
description: string;
|
||||||
goals: string;
|
goals: string;
|
||||||
@@ -169,6 +171,7 @@ export type ProjectTeamspaceEntry = {
|
|||||||
teamspace_project: string | null;
|
teamspace_project: string | null;
|
||||||
is_default: boolean;
|
is_default: boolean;
|
||||||
hour_budget_minutes: number | null;
|
hour_budget_minutes: number | null;
|
||||||
|
budget_sync_disabled?: boolean;
|
||||||
manual_consumption_minutes: number;
|
manual_consumption_minutes: number;
|
||||||
teamspace_consumption_minutes?: number;
|
teamspace_consumption_minutes?: number;
|
||||||
consumption_minutes?: number;
|
consumption_minutes?: number;
|
||||||
@@ -313,6 +316,8 @@ export type TicketPeriod = {
|
|||||||
organization_name: string | null;
|
organization_name: string | null;
|
||||||
customer_name: string | null;
|
customer_name: string | null;
|
||||||
work_type: WorkType | null;
|
work_type: WorkType | null;
|
||||||
|
zammad_priority_id?: string | null;
|
||||||
|
zammad_state_id?: string | null;
|
||||||
closed_at: string | null;
|
closed_at: string | null;
|
||||||
requires_closure: boolean;
|
requires_closure: boolean;
|
||||||
recurring_session_count: number;
|
recurring_session_count: number;
|
||||||
|
|||||||
@@ -47,6 +47,16 @@ import {
|
|||||||
} from "../api";
|
} from "../api";
|
||||||
import { currentDay, currentMonth, formatDate, formatMinutes } from "../format";
|
import { currentDay, currentMonth, formatDate, formatMinutes } from "../format";
|
||||||
import { ticketPattern } from "../timers";
|
import { ticketPattern } from "../timers";
|
||||||
|
import {
|
||||||
|
matchesZammadPriorityFilter,
|
||||||
|
matchesZammadStateFilter,
|
||||||
|
ZammadPriorityBadge,
|
||||||
|
zammadPriorityFilterOptions,
|
||||||
|
zammadPrioritySortRank,
|
||||||
|
ZammadStateBadge,
|
||||||
|
zammadStateFilterOptions,
|
||||||
|
zammadStateSortRank
|
||||||
|
} from "../zammad-meta";
|
||||||
import type {
|
import type {
|
||||||
DashboardFavorite,
|
DashboardFavorite,
|
||||||
DashboardPreference,
|
DashboardPreference,
|
||||||
@@ -161,7 +171,27 @@ function widgetAllowedFilters(type: DashboardWidgetType) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (type === "projectBudgets") {
|
if (type === "projectBudgets") {
|
||||||
return ["budgeted", "withoutBudget", "critical90", "overBudget", "open", "closed", "openTodos", "overdueTodos", "fixedBilling", "manualBilling"];
|
return [
|
||||||
|
"budgeted",
|
||||||
|
"withoutBudget",
|
||||||
|
"critical90",
|
||||||
|
"overBudget",
|
||||||
|
"open",
|
||||||
|
"closed",
|
||||||
|
"zammadState:2",
|
||||||
|
"zammadState:3",
|
||||||
|
"zammadState:4",
|
||||||
|
"zammadState:7",
|
||||||
|
"zammadState:none",
|
||||||
|
"zammadPriority:1",
|
||||||
|
"zammadPriority:2",
|
||||||
|
"zammadPriority:3",
|
||||||
|
"zammadPriority:none",
|
||||||
|
"openTodos",
|
||||||
|
"overdueTodos",
|
||||||
|
"fixedBilling",
|
||||||
|
"manualBilling"
|
||||||
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
if (type === "openRatings") {
|
if (type === "openRatings") {
|
||||||
@@ -169,7 +199,23 @@ function widgetAllowedFilters(type: DashboardWidgetType) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (type === "favorites") {
|
if (type === "favorites") {
|
||||||
return ["projects", "openTodos", "overdueTodos", "missingMeta", "fixedBilling", "manualBilling"];
|
return [
|
||||||
|
"projects",
|
||||||
|
"openTodos",
|
||||||
|
"overdueTodos",
|
||||||
|
"zammadState:2",
|
||||||
|
"zammadState:3",
|
||||||
|
"zammadState:4",
|
||||||
|
"zammadState:7",
|
||||||
|
"zammadState:none",
|
||||||
|
"zammadPriority:1",
|
||||||
|
"zammadPriority:2",
|
||||||
|
"zammadPriority:3",
|
||||||
|
"zammadPriority:none",
|
||||||
|
"missingMeta",
|
||||||
|
"fixedBilling",
|
||||||
|
"manualBilling"
|
||||||
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
return [];
|
return [];
|
||||||
@@ -498,6 +544,14 @@ function projectFilterGroup(filter: string) {
|
|||||||
return "status";
|
return "status";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (filter.startsWith("zammadState:")) {
|
||||||
|
return "zammadState";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filter.startsWith("zammadPriority:")) {
|
||||||
|
return "zammadPriority";
|
||||||
|
}
|
||||||
|
|
||||||
if (filter === "openTodos" || filter === "overdueTodos") {
|
if (filter === "openTodos" || filter === "overdueTodos") {
|
||||||
return "todos";
|
return "todos";
|
||||||
}
|
}
|
||||||
@@ -547,6 +601,14 @@ function matchesSingleProjectFilter(project: ProjectSummary, filterBy: string, f
|
|||||||
return Boolean(project.closed_at);
|
return Boolean(project.closed_at);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (filterBy.startsWith("zammadState:")) {
|
||||||
|
return matchesZammadStateFilter(project.zammad_state_id, filterBy.slice("zammadState:".length) as Parameters<typeof matchesZammadStateFilter>[1]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filterBy.startsWith("zammadPriority:")) {
|
||||||
|
return matchesZammadPriorityFilter(project.zammad_priority_id, filterBy.slice("zammadPriority:".length) as Parameters<typeof matchesZammadPriorityFilter>[1]);
|
||||||
|
}
|
||||||
|
|
||||||
if (filterBy === "openTodos") {
|
if (filterBy === "openTodos") {
|
||||||
return Number(project.open_todos ?? 0) > 0;
|
return Number(project.open_todos ?? 0) > 0;
|
||||||
}
|
}
|
||||||
@@ -608,11 +670,19 @@ function favoriteFilterGroup(filter: string) {
|
|||||||
return "todos";
|
return "todos";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (filter.startsWith("zammadState:")) {
|
||||||
|
return "zammadState";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filter.startsWith("zammadPriority:")) {
|
||||||
|
return "zammadPriority";
|
||||||
|
}
|
||||||
|
|
||||||
return filter;
|
return filter;
|
||||||
}
|
}
|
||||||
|
|
||||||
function sortedFavorites(favorites: DashboardFavorite[], metas: Record<string, TicketMeta | null>, config: Record<string, unknown>) {
|
function sortedFavorites(favorites: DashboardFavorite[], metas: Record<string, TicketMeta | null>, config: Record<string, unknown>) {
|
||||||
const sortBy = config.sortBy === "ticket" || config.sortBy === "title" || config.sortBy === "recent" ? config.sortBy : "custom";
|
const sortBy = config.sortBy === "ticket" || config.sortBy === "title" || config.sortBy === "recent" || config.sortBy === "zammadStatus" || config.sortBy === "zammadPriority" ? config.sortBy : "custom";
|
||||||
const direction: DashboardSortDirection = config.sortDirection === "desc" ? "desc" : "asc";
|
const direction: DashboardSortDirection = config.sortDirection === "desc" ? "desc" : "asc";
|
||||||
const factor = directionFactor(direction);
|
const factor = directionFactor(direction);
|
||||||
|
|
||||||
@@ -629,6 +699,14 @@ function sortedFavorites(favorites: DashboardFavorite[], metas: Record<string, T
|
|||||||
return compareOptionalTimestamp(left.addedAt, right.addedAt, direction) || compareText(left.ticketNumber, right.ticketNumber);
|
return compareOptionalTimestamp(left.addedAt, right.addedAt, direction) || compareText(left.ticketNumber, right.ticketNumber);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (sortBy === "zammadStatus") {
|
||||||
|
return (compareNumber(zammadStateSortRank(metas[left.ticketNumber]?.zammad_state_id), zammadStateSortRank(metas[right.ticketNumber]?.zammad_state_id)) * factor) || compareText(left.ticketNumber, right.ticketNumber);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sortBy === "zammadPriority") {
|
||||||
|
return (compareNumber(zammadPrioritySortRank(metas[left.ticketNumber]?.zammad_priority_id), zammadPrioritySortRank(metas[right.ticketNumber]?.zammad_priority_id)) * factor) || compareText(left.ticketNumber, right.ticketNumber);
|
||||||
|
}
|
||||||
|
|
||||||
return (compareNumber(left.sortOrder, right.sortOrder) * factor) || compareText(left.ticketNumber, right.ticketNumber);
|
return (compareNumber(left.sortOrder, right.sortOrder) * factor) || compareText(left.ticketNumber, right.ticketNumber);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -1568,12 +1646,34 @@ function WidgetSettings({
|
|||||||
function ProjectBudgetWidget({ widget, editing, projects, stats, onNavigate, onUpdateConfig }: WidgetContentProps) {
|
function ProjectBudgetWidget({ widget, editing, projects, stats, onNavigate, onUpdateConfig }: WidgetContentProps) {
|
||||||
const filterValues = readFilterValues(
|
const filterValues = readFilterValues(
|
||||||
widget.config,
|
widget.config,
|
||||||
["budgeted", "withoutBudget", "critical90", "overBudget", "open", "closed", "openTodos", "overdueTodos", "fixedBilling", "manualBilling"],
|
[
|
||||||
|
"budgeted",
|
||||||
|
"withoutBudget",
|
||||||
|
"critical90",
|
||||||
|
"overBudget",
|
||||||
|
"open",
|
||||||
|
"closed",
|
||||||
|
"zammadState:2",
|
||||||
|
"zammadState:3",
|
||||||
|
"zammadState:4",
|
||||||
|
"zammadState:7",
|
||||||
|
"zammadState:none",
|
||||||
|
"zammadPriority:1",
|
||||||
|
"zammadPriority:2",
|
||||||
|
"zammadPriority:3",
|
||||||
|
"zammadPriority:none",
|
||||||
|
"openTodos",
|
||||||
|
"overdueTodos",
|
||||||
|
"fixedBilling",
|
||||||
|
"manualBilling"
|
||||||
|
],
|
||||||
["budgeted"]
|
["budgeted"]
|
||||||
);
|
);
|
||||||
const sortBy =
|
const sortBy =
|
||||||
widget.config?.sortBy === "consumption" ||
|
widget.config?.sortBy === "consumption" ||
|
||||||
widget.config?.sortBy === "consumptionPercent" ||
|
widget.config?.sortBy === "consumptionPercent" ||
|
||||||
|
widget.config?.sortBy === "zammadStatus" ||
|
||||||
|
widget.config?.sortBy === "zammadPriority" ||
|
||||||
widget.config?.sortBy === "title" ||
|
widget.config?.sortBy === "title" ||
|
||||||
widget.config?.sortBy === "ticket" ||
|
widget.config?.sortBy === "ticket" ||
|
||||||
widget.config?.sortBy === "organization" ||
|
widget.config?.sortBy === "organization" ||
|
||||||
@@ -1598,6 +1698,14 @@ function ProjectBudgetWidget({ widget, editing, projects, stats, onNavigate, onU
|
|||||||
return (compareText(left.organization_name || left.customer_name, right.organization_name || right.customer_name) * factor) || compareText(left.ticket_number, right.ticket_number);
|
return (compareText(left.organization_name || left.customer_name, right.organization_name || right.customer_name) * factor) || compareText(left.ticket_number, right.ticket_number);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (sortBy === "zammadStatus") {
|
||||||
|
return (compareNumber(zammadStateSortRank(left.zammad_state_id), zammadStateSortRank(right.zammad_state_id)) * factor) || compareText(left.ticket_number, right.ticket_number);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sortBy === "zammadPriority") {
|
||||||
|
return (compareNumber(zammadPrioritySortRank(left.zammad_priority_id), zammadPrioritySortRank(right.zammad_priority_id)) * factor) || compareText(left.ticket_number, right.ticket_number);
|
||||||
|
}
|
||||||
|
|
||||||
if (sortBy === "consumption") {
|
if (sortBy === "consumption") {
|
||||||
return (compareNumber(left.consumption_minutes ?? 0, right.consumption_minutes ?? 0) * factor) || compareText(left.ticket_number, right.ticket_number);
|
return (compareNumber(left.consumption_minutes ?? 0, right.consumption_minutes ?? 0) * factor) || compareText(left.ticket_number, right.ticket_number);
|
||||||
}
|
}
|
||||||
@@ -1640,6 +1748,8 @@ function ProjectBudgetWidget({ widget, editing, projects, stats, onNavigate, onU
|
|||||||
{ value: "withoutBudget", label: "Ohne Kontingent" },
|
{ value: "withoutBudget", label: "Ohne Kontingent" },
|
||||||
{ value: "open", label: "Offene Projekte" },
|
{ value: "open", label: "Offene Projekte" },
|
||||||
{ value: "closed", label: "Geschlossene Projekte" },
|
{ value: "closed", label: "Geschlossene Projekte" },
|
||||||
|
...zammadStateFilterOptions.filter((option) => option.value !== "all").map((option) => ({ value: `zammadState:${option.value}`, label: `Status: ${option.label}` })),
|
||||||
|
...zammadPriorityFilterOptions.filter((option) => option.value !== "all").map((option) => ({ value: `zammadPriority:${option.value}`, label: `Prio: ${option.label}` })),
|
||||||
{ value: "openTodos", label: "Mit offenen ToDos" },
|
{ value: "openTodos", label: "Mit offenen ToDos" },
|
||||||
{ value: "overdueTodos", label: "Mit überfälligen ToDos" },
|
{ value: "overdueTodos", label: "Mit überfälligen ToDos" },
|
||||||
{ value: "fixedBilling", label: "Fixe Abrechnungen" },
|
{ value: "fixedBilling", label: "Fixe Abrechnungen" },
|
||||||
@@ -1655,7 +1765,9 @@ function ProjectBudgetWidget({ widget, editing, projects, stats, onNavigate, onU
|
|||||||
{ value: "todos", label: "ToDos" },
|
{ value: "todos", label: "ToDos" },
|
||||||
{ value: "title", label: "Titel" },
|
{ value: "title", label: "Titel" },
|
||||||
{ value: "ticket", label: "Ticket" },
|
{ value: "ticket", label: "Ticket" },
|
||||||
{ value: "organization", label: "Organisation" }
|
{ value: "organization", label: "Organisation" },
|
||||||
|
{ value: "zammadStatus", label: "Zammad-Status" },
|
||||||
|
{ value: "zammadPriority", label: "Zammad-Prio" }
|
||||||
]}
|
]}
|
||||||
onChange={onUpdateConfig}
|
onChange={onUpdateConfig}
|
||||||
/>
|
/>
|
||||||
@@ -1677,6 +1789,10 @@ function ProjectBudgetWidget({ widget, editing, projects, stats, onNavigate, onU
|
|||||||
<span className="block truncate text-[11px] text-muted-foreground">
|
<span className="block truncate text-[11px] text-muted-foreground">
|
||||||
{project.ticket_number} · {hasMultipleTeamspaceEntries ? `${entries.length} Teamspace · ` : ""}{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>
|
</span>
|
||||||
|
<span className="mt-1 flex flex-wrap gap-1">
|
||||||
|
<ZammadStateBadge stateId={project.zammad_state_id} />
|
||||||
|
<ZammadPriorityBadge priorityId={project.zammad_priority_id} />
|
||||||
|
</span>
|
||||||
{percent !== null ? (
|
{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="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 className={cn("block h-full rounded-full", consumptionBarClass(percent))} style={{ width: `${Math.min(100, percent)}%` }} />
|
||||||
@@ -2196,6 +2312,14 @@ function FavoritesWidget({
|
|||||||
return todos.some((todo) => todo.ticket_number === favorite.ticketNumber && todo.status === "open" && todo.ends_on && todo.ends_on < today);
|
return todos.some((todo) => todo.ticket_number === favorite.ticketNumber && todo.status === "open" && todo.ends_on && todo.ends_on < today);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (filter.startsWith("zammadState:")) {
|
||||||
|
return matchesZammadStateFilter(meta?.zammad_state_id, filter.slice("zammadState:".length) as Parameters<typeof matchesZammadStateFilter>[1]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filter.startsWith("zammadPriority:")) {
|
||||||
|
return matchesZammadPriorityFilter(meta?.zammad_priority_id, filter.slice("zammadPriority:".length) as Parameters<typeof matchesZammadPriorityFilter>[1]);
|
||||||
|
}
|
||||||
|
|
||||||
if (filter === "missingMeta") {
|
if (filter === "missingMeta") {
|
||||||
return favoriteMetas[favorite.ticketNumber] === null;
|
return favoriteMetas[favorite.ticketNumber] === null;
|
||||||
}
|
}
|
||||||
@@ -2233,6 +2357,8 @@ function FavoritesWidget({
|
|||||||
{ value: "projects", label: "Nur Projekte" },
|
{ value: "projects", label: "Nur Projekte" },
|
||||||
{ value: "openTodos", label: "Mit offenen ToDos" },
|
{ value: "openTodos", label: "Mit offenen ToDos" },
|
||||||
{ value: "overdueTodos", label: "Mit überfälligen ToDos" },
|
{ value: "overdueTodos", label: "Mit überfälligen ToDos" },
|
||||||
|
...zammadStateFilterOptions.filter((option) => option.value !== "all").map((option) => ({ value: `zammadState:${option.value}`, label: `Status: ${option.label}` })),
|
||||||
|
...zammadPriorityFilterOptions.filter((option) => option.value !== "all").map((option) => ({ value: `zammadPriority:${option.value}`, label: `Prio: ${option.label}` })),
|
||||||
{ value: "missingMeta", label: "Nicht gefunden" },
|
{ value: "missingMeta", label: "Nicht gefunden" },
|
||||||
{ value: "fixedBilling", label: "Fixe Abrechnungen" },
|
{ value: "fixedBilling", label: "Fixe Abrechnungen" },
|
||||||
{ value: "manualBilling", label: "Ohne fixe Abrechnung" }
|
{ value: "manualBilling", label: "Ohne fixe Abrechnung" }
|
||||||
@@ -2243,7 +2369,9 @@ function FavoritesWidget({
|
|||||||
{ value: "custom", label: "Frei" },
|
{ value: "custom", label: "Frei" },
|
||||||
{ value: "ticket", label: "Ticket" },
|
{ value: "ticket", label: "Ticket" },
|
||||||
{ value: "title", label: "Titel" },
|
{ value: "title", label: "Titel" },
|
||||||
{ value: "recent", label: "Neu" }
|
{ value: "recent", label: "Neu" },
|
||||||
|
{ value: "zammadStatus", label: "Zammad-Status" },
|
||||||
|
{ value: "zammadPriority", label: "Zammad-Prio" }
|
||||||
]}
|
]}
|
||||||
onChange={onUpdateConfig}
|
onChange={onUpdateConfig}
|
||||||
/>
|
/>
|
||||||
@@ -2261,6 +2389,10 @@ function FavoritesWidget({
|
|||||||
<button type="button" className="min-w-0 flex-1 text-left" onClick={() => meta?.id && onNavigate(`/tickets/${meta.id}?month=${currentMonth()}`)}>
|
<button type="button" className="min-w-0 flex-1 text-left" onClick={() => meta?.id && onNavigate(`/tickets/${meta.id}?month=${currentMonth()}`)}>
|
||||||
<span className="block truncate text-xs font-medium">{favorite.ticketNumber}</span>
|
<span className="block truncate text-xs font-medium">{favorite.ticketNumber}</span>
|
||||||
<span className="block truncate text-[11px] text-muted-foreground">{meta?.title || meta?.organization_name || "Favorit"}</span>
|
<span className="block truncate text-[11px] text-muted-foreground">{meta?.title || meta?.organization_name || "Favorit"}</span>
|
||||||
|
<span className="mt-1 flex flex-wrap gap-1">
|
||||||
|
<ZammadStateBadge stateId={meta?.zammad_state_id} />
|
||||||
|
<ZammadPriorityBadge priorityId={meta?.zammad_priority_id} />
|
||||||
|
</span>
|
||||||
</button>
|
</button>
|
||||||
{editing && sortBy === "custom" ? (
|
{editing && sortBy === "custom" ? (
|
||||||
<>
|
<>
|
||||||
|
|||||||
@@ -181,7 +181,7 @@ export function ProfilePage({ currentUser, onUserUpdated }: ProfilePageProps) {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
Die Zammad-User-ID wird nur geprüft, wenn du hier einen neuen Token speicherst.
|
Die Zammad-ID wird nur geprüft, wenn du hier einen neuen Token speicherst.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { toast } from "sonner";
|
|||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
|
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
|
||||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu";
|
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
@@ -18,6 +19,19 @@ import { currentMonth, formatMinutes } from "../format";
|
|||||||
import { projectHasRecurringBilling } from "../recurring-billings";
|
import { projectHasRecurringBilling } from "../recurring-billings";
|
||||||
import { ticketPattern } from "../timers";
|
import { ticketPattern } from "../timers";
|
||||||
import type { ProjectSummary, ProjectTeamspaceEntry, TicketMeta } from "../types";
|
import type { ProjectSummary, ProjectTeamspaceEntry, TicketMeta } from "../types";
|
||||||
|
import {
|
||||||
|
matchesZammadPriorityFilter,
|
||||||
|
matchesZammadStateFilter,
|
||||||
|
ZammadPriorityBadge,
|
||||||
|
zammadPriorityFilterOptions,
|
||||||
|
zammadPriorityLabel,
|
||||||
|
zammadPrioritySortRank,
|
||||||
|
ZammadStateBadge,
|
||||||
|
zammadStateFilterOptions,
|
||||||
|
zammadStateLabel,
|
||||||
|
zammadStateSortRank
|
||||||
|
} from "../zammad-meta";
|
||||||
|
import type { ZammadPriorityFilter, ZammadStateFilter } from "../zammad-meta";
|
||||||
|
|
||||||
type ProjectsPageProps = {
|
type ProjectsPageProps = {
|
||||||
onNavigate: (to: string) => void;
|
onNavigate: (to: string) => void;
|
||||||
@@ -39,22 +53,23 @@ type ProjectTeamspaceEntryForm = {
|
|||||||
clientId: string;
|
clientId: string;
|
||||||
teamspaceProject: string;
|
teamspaceProject: string;
|
||||||
hourBudget: string;
|
hourBudget: string;
|
||||||
|
budgetSyncDisabled: boolean;
|
||||||
manualConsumption: string;
|
manualConsumption: string;
|
||||||
isDefault: boolean;
|
isDefault: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
type ProjectStatusFilter = "open" | "all" | "closed" | "hidden";
|
type ProjectStatusFilter = "open" | "all" | "closed" | "hidden";
|
||||||
type ProjectBudgetFilter = "all" | "with-budget" | "without-budget" | "remaining" | "over-budget";
|
type ProjectBudgetFilter = "all" | "with-budget" | "without-budget" | "remaining" | "over-budget";
|
||||||
type ProjectTodoFilter = "all" | "open" | "overdue" | "none-open";
|
type ProjectSortBy = "updated" | "ticket" | "title" | "organization" | "status" | "zammadStatus" | "zammadPriority" | "consumption" | "consumptionPercent" | "remaining" | "budget";
|
||||||
type ProjectSortBy = "updated" | "ticket" | "title" | "organization" | "status" | "consumption" | "consumptionPercent" | "remaining" | "budget" | "todos";
|
|
||||||
type ProjectSortDirection = "asc" | "desc";
|
type ProjectSortDirection = "asc" | "desc";
|
||||||
|
|
||||||
type ProjectViewSettings = {
|
type ProjectViewSettings = {
|
||||||
query: string;
|
query: string;
|
||||||
status: ProjectStatusFilter;
|
status: ProjectStatusFilter;
|
||||||
|
zammadStatus: ZammadStateFilter;
|
||||||
|
zammadPriority: ZammadPriorityFilter;
|
||||||
organization: string;
|
organization: string;
|
||||||
budget: ProjectBudgetFilter;
|
budget: ProjectBudgetFilter;
|
||||||
todos: ProjectTodoFilter;
|
|
||||||
sortBy: ProjectSortBy;
|
sortBy: ProjectSortBy;
|
||||||
sortDirection: ProjectSortDirection;
|
sortDirection: ProjectSortDirection;
|
||||||
};
|
};
|
||||||
@@ -64,9 +79,10 @@ const projectViewSettingsKey = "tickettracker.projects.viewSettings";
|
|||||||
const defaultProjectViewSettings: ProjectViewSettings = {
|
const defaultProjectViewSettings: ProjectViewSettings = {
|
||||||
query: "",
|
query: "",
|
||||||
status: "open",
|
status: "open",
|
||||||
|
zammadStatus: "all",
|
||||||
|
zammadPriority: "all",
|
||||||
organization: "all",
|
organization: "all",
|
||||||
budget: "all",
|
budget: "all",
|
||||||
todos: "all",
|
|
||||||
sortBy: "updated",
|
sortBy: "updated",
|
||||||
sortDirection: "desc"
|
sortDirection: "desc"
|
||||||
};
|
};
|
||||||
@@ -86,14 +102,7 @@ const projectBudgetOptions: Array<{ value: ProjectBudgetFilter; label: string }>
|
|||||||
{ value: "over-budget", label: "Überzogen" }
|
{ value: "over-budget", label: "Überzogen" }
|
||||||
];
|
];
|
||||||
|
|
||||||
const projectTodoOptions: Array<{ value: ProjectTodoFilter; label: string }> = [
|
const projectSortValues: ProjectSortBy[] = ["updated", "ticket", "title", "organization", "status", "zammadStatus", "zammadPriority", "consumption", "consumptionPercent", "remaining", "budget"];
|
||||||
{ value: "all", label: "Alle ToDos" },
|
|
||||||
{ value: "open", label: "Mit offenen ToDos" },
|
|
||||||
{ value: "overdue", label: "Mit überfälligen ToDos" },
|
|
||||||
{ value: "none-open", label: "Keine offenen ToDos" }
|
|
||||||
];
|
|
||||||
|
|
||||||
const projectSortValues: ProjectSortBy[] = ["updated", "ticket", "title", "organization", "status", "consumption", "consumptionPercent", "remaining", "budget", "todos"];
|
|
||||||
|
|
||||||
const projectSortOptions: Array<{ value: ProjectSortBy; label: string }> = [
|
const projectSortOptions: Array<{ value: ProjectSortBy; label: string }> = [
|
||||||
{ value: "updated", label: "Aktualisierung" },
|
{ value: "updated", label: "Aktualisierung" },
|
||||||
@@ -101,11 +110,12 @@ const projectSortOptions: Array<{ value: ProjectSortBy; label: string }> = [
|
|||||||
{ value: "title", label: "Titel" },
|
{ value: "title", label: "Titel" },
|
||||||
{ value: "organization", label: "Organisation" },
|
{ value: "organization", label: "Organisation" },
|
||||||
{ value: "status", label: "Status" },
|
{ value: "status", label: "Status" },
|
||||||
|
{ value: "zammadStatus", label: "Zammad-Status" },
|
||||||
|
{ value: "zammadPriority", label: "Zammad-Priorität" },
|
||||||
{ value: "consumption", label: "Verbrauch" },
|
{ value: "consumption", label: "Verbrauch" },
|
||||||
{ value: "consumptionPercent", label: "Verbrauch %" },
|
{ value: "consumptionPercent", label: "Verbrauch %" },
|
||||||
{ value: "remaining", label: "Reststunden" },
|
{ value: "remaining", label: "Reststunden" },
|
||||||
{ value: "budget", label: "Kontingent" },
|
{ value: "budget", label: "Kontingent" }
|
||||||
{ value: "todos", label: "ToDos" }
|
|
||||||
];
|
];
|
||||||
|
|
||||||
function projectEntryId() {
|
function projectEntryId() {
|
||||||
@@ -121,6 +131,7 @@ function emptyProjectTeamspaceEntry(isDefault = false): ProjectTeamspaceEntryFor
|
|||||||
clientId: projectEntryId(),
|
clientId: projectEntryId(),
|
||||||
teamspaceProject: "",
|
teamspaceProject: "",
|
||||||
hourBudget: "",
|
hourBudget: "",
|
||||||
|
budgetSyncDisabled: false,
|
||||||
manualConsumption: "",
|
manualConsumption: "",
|
||||||
isDefault
|
isDefault
|
||||||
};
|
};
|
||||||
@@ -143,9 +154,10 @@ function readStoredProjectViewSettings(): ProjectViewSettings {
|
|||||||
return {
|
return {
|
||||||
query: typeof parsed.query === "string" ? parsed.query : defaultProjectViewSettings.query,
|
query: typeof parsed.query === "string" ? parsed.query : defaultProjectViewSettings.query,
|
||||||
status: projectStatusOptions.find((option) => option.value === parsed.status)?.value ?? defaultProjectViewSettings.status,
|
status: projectStatusOptions.find((option) => option.value === parsed.status)?.value ?? defaultProjectViewSettings.status,
|
||||||
|
zammadStatus: zammadStateFilterOptions.find((option) => option.value === parsed.zammadStatus)?.value ?? defaultProjectViewSettings.zammadStatus,
|
||||||
|
zammadPriority: zammadPriorityFilterOptions.find((option) => option.value === parsed.zammadPriority)?.value ?? defaultProjectViewSettings.zammadPriority,
|
||||||
organization: typeof parsed.organization === "string" ? parsed.organization : defaultProjectViewSettings.organization,
|
organization: typeof parsed.organization === "string" ? parsed.organization : defaultProjectViewSettings.organization,
|
||||||
budget: projectBudgetOptions.find((option) => option.value === parsed.budget)?.value ?? defaultProjectViewSettings.budget,
|
budget: projectBudgetOptions.find((option) => option.value === parsed.budget)?.value ?? defaultProjectViewSettings.budget,
|
||||||
todos: projectTodoOptions.find((option) => option.value === parsed.todos)?.value ?? defaultProjectViewSettings.todos,
|
|
||||||
sortBy: projectSortValues.find((value) => value === parsed.sortBy) ?? defaultProjectViewSettings.sortBy,
|
sortBy: projectSortValues.find((value) => value === parsed.sortBy) ?? defaultProjectViewSettings.sortBy,
|
||||||
sortDirection: parsed.sortDirection === "asc" ? "asc" : defaultProjectViewSettings.sortDirection
|
sortDirection: parsed.sortDirection === "asc" ? "asc" : defaultProjectViewSettings.sortDirection
|
||||||
};
|
};
|
||||||
@@ -295,6 +307,8 @@ function projectMatchesQuery(project: ProjectSummary, query: string) {
|
|||||||
project.title ?? "",
|
project.title ?? "",
|
||||||
projectOrganizationLabel(project),
|
projectOrganizationLabel(project),
|
||||||
project.teamspace_project ?? "",
|
project.teamspace_project ?? "",
|
||||||
|
zammadStateLabel(project.zammad_state_id),
|
||||||
|
zammadPriorityLabel(project.zammad_priority_id),
|
||||||
...projectTeamspaceEntries(project).map((entry) => entry.teamspace_project ?? ""),
|
...projectTeamspaceEntries(project).map((entry) => entry.teamspace_project ?? ""),
|
||||||
project.description,
|
project.description,
|
||||||
project.goals
|
project.goals
|
||||||
@@ -315,6 +329,10 @@ function compareProjects(left: ProjectSummary, right: ProjectSummary, sortBy: Pr
|
|||||||
comparison = compareText(projectOrganizationLabel(left), projectOrganizationLabel(right));
|
comparison = compareText(projectOrganizationLabel(left), projectOrganizationLabel(right));
|
||||||
} else if (sortBy === "status") {
|
} else if (sortBy === "status") {
|
||||||
comparison = Number(Boolean(left.closed_at)) - Number(Boolean(right.closed_at));
|
comparison = Number(Boolean(left.closed_at)) - Number(Boolean(right.closed_at));
|
||||||
|
} else if (sortBy === "zammadStatus") {
|
||||||
|
comparison = zammadStateSortRank(left.zammad_state_id) - zammadStateSortRank(right.zammad_state_id);
|
||||||
|
} else if (sortBy === "zammadPriority") {
|
||||||
|
comparison = zammadPrioritySortRank(left.zammad_priority_id) - zammadPrioritySortRank(right.zammad_priority_id);
|
||||||
} else if (sortBy === "consumption") {
|
} else if (sortBy === "consumption") {
|
||||||
comparison = projectConsumption(left) - projectConsumption(right);
|
comparison = projectConsumption(left) - projectConsumption(right);
|
||||||
} else if (sortBy === "consumptionPercent") {
|
} else if (sortBy === "consumptionPercent") {
|
||||||
@@ -330,8 +348,6 @@ function compareProjects(left: ProjectSummary, right: ProjectSummary, sortBy: Pr
|
|||||||
comparison = (projectRemaining(left) ?? Number.POSITIVE_INFINITY) - (projectRemaining(right) ?? Number.POSITIVE_INFINITY);
|
comparison = (projectRemaining(left) ?? Number.POSITIVE_INFINITY) - (projectRemaining(right) ?? Number.POSITIVE_INFINITY);
|
||||||
} else if (sortBy === "budget") {
|
} else if (sortBy === "budget") {
|
||||||
comparison = (left.hour_budget_minutes ?? Number.POSITIVE_INFINITY) - (right.hour_budget_minutes ?? Number.POSITIVE_INFINITY);
|
comparison = (left.hour_budget_minutes ?? Number.POSITIVE_INFINITY) - (right.hour_budget_minutes ?? Number.POSITIVE_INFINITY);
|
||||||
} else if (sortBy === "todos") {
|
|
||||||
comparison = Number(left.open_todos ?? 0) - Number(right.open_todos ?? 0);
|
|
||||||
} else {
|
} else {
|
||||||
comparison = new Date(left.updated_at).getTime() - new Date(right.updated_at).getTime();
|
comparison = new Date(left.updated_at).getTime() - new Date(right.updated_at).getTime();
|
||||||
}
|
}
|
||||||
@@ -344,6 +360,7 @@ function formFromProject(project: ProjectSummary): ProjectForm {
|
|||||||
clientId: entry.id || projectEntryId(),
|
clientId: entry.id || projectEntryId(),
|
||||||
teamspaceProject: entry.teamspace_project ?? "",
|
teamspaceProject: entry.teamspace_project ?? "",
|
||||||
hourBudget: formatHoursInput(entry.hour_budget_minutes),
|
hourBudget: formatHoursInput(entry.hour_budget_minutes),
|
||||||
|
budgetSyncDisabled: entry.budget_sync_disabled ?? false,
|
||||||
manualConsumption: formatHoursInput(entry.manual_consumption_minutes),
|
manualConsumption: formatHoursInput(entry.manual_consumption_minutes),
|
||||||
isDefault: entry.is_default || index === 0
|
isDefault: entry.is_default || index === 0
|
||||||
}));
|
}));
|
||||||
@@ -398,6 +415,7 @@ function parseProjectTeamspaceEntryForms(entries: ProjectTeamspaceEntryForm[]) {
|
|||||||
teamspaceProject: entry.teamspaceProject.trim() || null,
|
teamspaceProject: entry.teamspaceProject.trim() || null,
|
||||||
isDefault: entry.isDefault,
|
isDefault: entry.isDefault,
|
||||||
hourBudgetMinutes,
|
hourBudgetMinutes,
|
||||||
|
budgetSyncDisabled: entry.budgetSyncDisabled,
|
||||||
manualConsumptionMinutes: manualConsumptionMinutes ?? 0
|
manualConsumptionMinutes: manualConsumptionMinutes ?? 0
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
@@ -411,6 +429,7 @@ function parseProjectTeamspaceEntryForms(entries: ProjectTeamspaceEntryForm[]) {
|
|||||||
teamspaceProject: string | null;
|
teamspaceProject: string | null;
|
||||||
isDefault: boolean;
|
isDefault: boolean;
|
||||||
hourBudgetMinutes: number | null;
|
hourBudgetMinutes: number | null;
|
||||||
|
budgetSyncDisabled: boolean;
|
||||||
manualConsumptionMinutes: number;
|
manualConsumptionMinutes: number;
|
||||||
}>;
|
}>;
|
||||||
}
|
}
|
||||||
@@ -451,7 +470,7 @@ function ProjectTeamspaceEntriesFields({
|
|||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{normalizedEntries.map((entry, index) => (
|
{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 key={entry.clientId} className="grid gap-2 rounded-lg border bg-muted/10 p-3 lg:grid-cols-[minmax(180px,1fr)_130px_150px_130px_120px_36px] lg:items-end">
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<label className="text-xs font-medium text-muted-foreground" htmlFor={`project-teamspace-${entry.clientId}`}>
|
<label className="text-xs font-medium text-muted-foreground" htmlFor={`project-teamspace-${entry.clientId}`}>
|
||||||
Teamspace-Projekt {entry.isDefault ? "(Standard)" : ""}
|
Teamspace-Projekt {entry.isDefault ? "(Standard)" : ""}
|
||||||
@@ -470,9 +489,13 @@ function ProjectTeamspaceEntriesFields({
|
|||||||
id={`project-budget-${entry.clientId}`}
|
id={`project-budget-${entry.clientId}`}
|
||||||
inputMode="decimal"
|
inputMode="decimal"
|
||||||
value={entry.hourBudget}
|
value={entry.hourBudget}
|
||||||
onChange={(event) => updateEntry(entry.clientId, { hourBudget: event.currentTarget.value })}
|
onChange={(event) => updateEntry(entry.clientId, { hourBudget: event.currentTarget.value, budgetSyncDisabled: true })}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
<label className="flex min-h-9 items-center gap-2 rounded-md border bg-background px-3 text-xs">
|
||||||
|
<Checkbox checked={entry.budgetSyncDisabled} onCheckedChange={(checked) => updateEntry(entry.clientId, { budgetSyncDisabled: checked === true })} />
|
||||||
|
<span>Budget schützen</span>
|
||||||
|
</label>
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<label className="text-xs font-medium text-muted-foreground" htmlFor={`project-manual-${entry.clientId}`}>
|
<label className="text-xs font-medium text-muted-foreground" htmlFor={`project-manual-${entry.clientId}`}>
|
||||||
Manueller Verbrauch h
|
Manueller Verbrauch h
|
||||||
@@ -716,6 +739,14 @@ export function ProjectsPage({ onNavigate, onStartTimer }: ProjectsPageProps) {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!matchesZammadStateFilter(project.zammad_state_id, viewSettings.zammadStatus)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!matchesZammadPriorityFilter(project.zammad_priority_id, viewSettings.zammadPriority)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
if (viewSettings.organization !== "all" && projectOrganizationKey(project) !== viewSettings.organization) {
|
if (viewSettings.organization !== "all" && projectOrganizationKey(project) !== viewSettings.organization) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -742,23 +773,10 @@ export function ProjectsPage({ onNavigate, onStartTimer }: ProjectsPageProps) {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (viewSettings.todos === "open" && Number(project.open_todos ?? 0) === 0) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (viewSettings.todos === "overdue" && Number(project.overdue_todos ?? 0) === 0) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (viewSettings.todos === "none-open" && Number(project.open_todos ?? 0) > 0) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
})
|
})
|
||||||
.sort((left, right) => compareProjects(left, right, viewSettings.sortBy, viewSettings.sortDirection));
|
.sort((left, right) => compareProjects(left, right, viewSettings.sortBy, viewSettings.sortDirection));
|
||||||
}, [projects, viewSettings]);
|
}, [projects, viewSettings]);
|
||||||
const openTodos = useMemo(() => filteredProjects.reduce((sum, project) => sum + Number(project.open_todos ?? 0), 0), [filteredProjects]);
|
|
||||||
const projectConsumptionMinutes = useMemo(() => filteredProjects.reduce((sum, project) => sum + projectConsumption(project), 0), [filteredProjects]);
|
const projectConsumptionMinutes = useMemo(() => filteredProjects.reduce((sum, project) => sum + projectConsumption(project), 0), [filteredProjects]);
|
||||||
const remainingMinutes = useMemo(
|
const remainingMinutes = useMemo(
|
||||||
() => filteredProjects.reduce((sum, project) => sum + Math.max(projectRemaining(project) ?? 0, 0), 0),
|
() => filteredProjects.reduce((sum, project) => sum + Math.max(projectRemaining(project) ?? 0, 0), 0),
|
||||||
@@ -767,9 +785,10 @@ export function ProjectsPage({ onNavigate, onStartTimer }: ProjectsPageProps) {
|
|||||||
const activeFilterCount = [
|
const activeFilterCount = [
|
||||||
viewSettings.query.trim() !== "",
|
viewSettings.query.trim() !== "",
|
||||||
viewSettings.status !== defaultProjectViewSettings.status,
|
viewSettings.status !== defaultProjectViewSettings.status,
|
||||||
|
viewSettings.zammadStatus !== defaultProjectViewSettings.zammadStatus,
|
||||||
|
viewSettings.zammadPriority !== defaultProjectViewSettings.zammadPriority,
|
||||||
viewSettings.organization !== defaultProjectViewSettings.organization,
|
viewSettings.organization !== defaultProjectViewSettings.organization,
|
||||||
viewSettings.budget !== defaultProjectViewSettings.budget,
|
viewSettings.budget !== defaultProjectViewSettings.budget,
|
||||||
viewSettings.todos !== defaultProjectViewSettings.todos,
|
|
||||||
viewSettings.sortBy !== defaultProjectViewSettings.sortBy,
|
viewSettings.sortBy !== defaultProjectViewSettings.sortBy,
|
||||||
viewSettings.sortDirection !== defaultProjectViewSettings.sortDirection
|
viewSettings.sortDirection !== defaultProjectViewSettings.sortDirection
|
||||||
].filter(Boolean).length;
|
].filter(Boolean).length;
|
||||||
@@ -808,17 +827,28 @@ export function ProjectsPage({ onNavigate, onStartTimer }: ProjectsPageProps) {
|
|||||||
setLookupStatus("found");
|
setLookupStatus("found");
|
||||||
setUseZammadForCreate(true);
|
setUseZammadForCreate(true);
|
||||||
setForm((current) => {
|
setForm((current) => {
|
||||||
const entries = normalizeProjectTeamspaceEntryForms(current.teamspaceEntries);
|
const zammadEntries = result.ticket?.project_teamspace_entries ?? [];
|
||||||
const defaultIndex = Math.max(0, entries.findIndex((entry) => entry.isDefault));
|
const nextEntries = zammadEntries.length > 0
|
||||||
const nextEntries = entries.map((entry, index) =>
|
? zammadEntries.map((entry, index) => ({
|
||||||
index === defaultIndex
|
clientId: entry.id || projectEntryId(),
|
||||||
? {
|
teamspaceProject: entry.teamspace_project ?? "",
|
||||||
...entry,
|
hourBudget: formatHoursInput(entry.hour_budget_minutes),
|
||||||
teamspaceProject: entry.teamspaceProject || result.ticket?.project_name || "",
|
budgetSyncDisabled: entry.budget_sync_disabled ?? false,
|
||||||
hourBudget: entry.hourBudget || formatHoursInput(result.ticket?.project_hour_budget_minutes ?? null)
|
manualConsumption: "",
|
||||||
}
|
isDefault: entry.is_default || index === 0
|
||||||
: entry
|
}))
|
||||||
);
|
: normalizeProjectTeamspaceEntryForms(current.teamspaceEntries).map((entry, index, entries) => {
|
||||||
|
const defaultIndex = Math.max(0, entries.findIndex((candidate) => candidate.isDefault));
|
||||||
|
|
||||||
|
return index === defaultIndex
|
||||||
|
? {
|
||||||
|
...entry,
|
||||||
|
teamspaceProject: entry.teamspaceProject || result.ticket?.project_name || "",
|
||||||
|
hourBudget: entry.hourBudget || formatHoursInput(result.ticket?.project_hour_budget_minutes ?? null),
|
||||||
|
budgetSyncDisabled: entry.hourBudget ? entry.budgetSyncDisabled : false
|
||||||
|
}
|
||||||
|
: entry;
|
||||||
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...current,
|
...current,
|
||||||
@@ -1050,10 +1080,6 @@ export function ProjectsPage({ onNavigate, onStartTimer }: ProjectsPageProps) {
|
|||||||
<span className="block text-muted-foreground">Rest offen</span>
|
<span className="block text-muted-foreground">Rest offen</span>
|
||||||
<span className="font-semibold">{formatMinutes(remainingMinutes)}</span>
|
<span className="font-semibold">{formatMinutes(remainingMinutes)}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="w-28 shrink-0 rounded-md border bg-muted/20 px-3 py-2">
|
|
||||||
<span className="block text-muted-foreground">Offene ToDos</span>
|
|
||||||
<span className="font-semibold">{openTodos}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<HelpLink anchor="projekte" label="Hilfe zu Projekten" className="shrink-0" />
|
<HelpLink anchor="projekte" label="Hilfe zu Projekten" className="shrink-0" />
|
||||||
</div>
|
</div>
|
||||||
@@ -1076,7 +1102,7 @@ export function ProjectsPage({ onNavigate, onStartTimer }: ProjectsPageProps) {
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="grid gap-3 px-4 pb-4 md:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-[minmax(220px,1.4fr)_150px_minmax(180px,1fr)_170px_170px_170px_150px]">
|
<CardContent className="grid gap-3 px-4 pb-4 md:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-4">
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<label className="text-xs font-medium text-muted-foreground" htmlFor="project-search">
|
<label className="text-xs font-medium text-muted-foreground" htmlFor="project-search">
|
||||||
Suche
|
Suche
|
||||||
@@ -1105,6 +1131,32 @@ export function ProjectsPage({ onNavigate, onStartTimer }: ProjectsPageProps) {
|
|||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<label className="text-xs font-medium text-muted-foreground">Zammad-Status</label>
|
||||||
|
<Select value={viewSettings.zammadStatus} onValueChange={(value) => setViewSettings((current) => ({ ...current, zammadStatus: value as ZammadStateFilter }))}>
|
||||||
|
<SelectTrigger size="sm" className="w-full">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{zammadStateFilterOptions.map((option) => (
|
||||||
|
<SelectItem key={option.value} value={option.value}>{option.label}</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<label className="text-xs font-medium text-muted-foreground">Zammad-Priorität</label>
|
||||||
|
<Select value={viewSettings.zammadPriority} onValueChange={(value) => setViewSettings((current) => ({ ...current, zammadPriority: value as ZammadPriorityFilter }))}>
|
||||||
|
<SelectTrigger size="sm" className="w-full">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{zammadPriorityFilterOptions.map((option) => (
|
||||||
|
<SelectItem key={option.value} value={option.value}>{option.label}</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<label className="text-xs font-medium text-muted-foreground">Organisation</label>
|
<label className="text-xs font-medium text-muted-foreground">Organisation</label>
|
||||||
<Select value={viewSettings.organization} onValueChange={(value) => setViewSettings((current) => ({ ...current, organization: value }))}>
|
<Select value={viewSettings.organization} onValueChange={(value) => setViewSettings((current) => ({ ...current, organization: value }))}>
|
||||||
@@ -1132,19 +1184,6 @@ export function ProjectsPage({ onNavigate, onStartTimer }: ProjectsPageProps) {
|
|||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-1.5">
|
|
||||||
<label className="text-xs font-medium text-muted-foreground">ToDos</label>
|
|
||||||
<Select value={viewSettings.todos} onValueChange={(value) => setViewSettings((current) => ({ ...current, todos: value as ProjectTodoFilter }))}>
|
|
||||||
<SelectTrigger size="sm" className="w-full">
|
|
||||||
<SelectValue />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
{projectTodoOptions.map((option) => (
|
|
||||||
<SelectItem key={option.value} value={option.value}>{option.label}</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<label className="text-xs font-medium text-muted-foreground">Sortierung</label>
|
<label className="text-xs font-medium text-muted-foreground">Sortierung</label>
|
||||||
<Select value={viewSettings.sortBy} onValueChange={(value) => setViewSettings((current) => ({ ...current, sortBy: value as ProjectSortBy }))}>
|
<Select value={viewSettings.sortBy} onValueChange={(value) => setViewSettings((current) => ({ ...current, sortBy: value as ProjectSortBy }))}>
|
||||||
@@ -1236,13 +1275,14 @@ export function ProjectsPage({ onNavigate, onStartTimer }: ProjectsPageProps) {
|
|||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
</div>
|
</div>
|
||||||
<div className="hidden grid-cols-[minmax(220px,1.35fr)_minmax(180px,1fr)_100px_170px_95px_110px_44px] gap-3 border-b bg-muted/25 px-4 py-2 text-xs font-medium text-muted-foreground xl:grid">
|
<div className="hidden grid-cols-[minmax(220px,1.35fr)_minmax(170px,1fr)_105px_115px_105px_165px_90px_44px] gap-3 border-b bg-muted/25 px-4 py-2 text-xs font-medium text-muted-foreground xl:grid">
|
||||||
{sortHeader("Nummer/Titel", "title")}
|
{sortHeader("Nummer/Titel", "title")}
|
||||||
{sortHeader("Organisation", "organization")}
|
{sortHeader("Organisation", "organization")}
|
||||||
{sortHeader("Status", "status")}
|
{sortHeader("Status", "status")}
|
||||||
|
{sortHeader("Zammad", "zammadStatus")}
|
||||||
|
{sortHeader("Prio", "zammadPriority")}
|
||||||
{sortHeader("Verbrauch/Gesamt", "consumption")}
|
{sortHeader("Verbrauch/Gesamt", "consumption")}
|
||||||
{sortHeader("Verbrauch %", "consumptionPercent")}
|
{sortHeader("Verbrauch %", "consumptionPercent")}
|
||||||
{sortHeader("ToDos", "todos")}
|
|
||||||
<span className="text-right">Aktion</span>
|
<span className="text-right">Aktion</span>
|
||||||
</div>
|
</div>
|
||||||
{filteredProjects.map((project) => {
|
{filteredProjects.map((project) => {
|
||||||
@@ -1256,7 +1296,7 @@ export function ProjectsPage({ onNavigate, onStartTimer }: ProjectsPageProps) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={project.id} className="border-b last:border-b-0">
|
<div key={project.id} className="border-b last:border-b-0">
|
||||||
<div className="grid gap-3 px-4 py-3 xl:grid-cols-[minmax(220px,1.35fr)_minmax(180px,1fr)_100px_170px_95px_110px_44px] xl:items-center">
|
<div className="grid gap-3 px-4 py-3 xl:grid-cols-[minmax(220px,1.35fr)_minmax(170px,1fr)_105px_115px_105px_165px_90px_44px] xl:items-center">
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -1272,12 +1312,22 @@ export function ProjectsPage({ onNavigate, onStartTimer }: ProjectsPageProps) {
|
|||||||
<p className="truncate">{projectOrganizationLabel(project)}</p>
|
<p className="truncate">{projectOrganizationLabel(project)}</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-wrap gap-1.5">
|
<div className="flex flex-wrap gap-1.5">
|
||||||
{project.closed_at ? <Badge variant="secondary">geschlossen</Badge> : <Badge variant="success">offen</Badge>}
|
<p className="w-full truncate text-sm font-medium xl:hidden">Status</p>
|
||||||
|
{project.closed_at ? <Badge variant="secondary">Geschlossen</Badge> : <Badge variant="success">Offen</Badge>}
|
||||||
{!canEditProject ? <Badge variant="outline">geteilt</Badge> : null}
|
{!canEditProject ? <Badge variant="outline">geteilt</Badge> : null}
|
||||||
{project.close_review_required ? <Badge variant="warning">TS/TT prüfen</Badge> : null}
|
{project.close_review_required ? <Badge variant="warning">TS/TT prüfen</Badge> : null}
|
||||||
{(project.unassigned_teamspace_session_count ?? 0) > 0 ? <Badge variant="warning">{project.unassigned_teamspace_session_count} ohne TS</Badge> : null}
|
{(project.unassigned_teamspace_session_count ?? 0) > 0 ? <Badge variant="warning">{project.unassigned_teamspace_session_count} ohne TS</Badge> : null}
|
||||||
</div>
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-1.5">
|
||||||
|
<p className="w-full truncate text-sm font-medium xl:hidden">Zammad</p>
|
||||||
|
<ZammadStateBadge stateId={project.zammad_state_id} />
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-1.5">
|
||||||
|
<p className="w-full truncate text-sm font-medium xl:hidden">Prio</p>
|
||||||
|
<ZammadPriorityBadge priorityId={project.zammad_priority_id} />
|
||||||
|
</div>
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
|
<p className="truncate text-sm font-medium xl:hidden">Verbrauch/Gesamt</p>
|
||||||
<p className={cn("text-sm font-semibold", remaining !== null && remaining < 0 ? "text-destructive" : undefined)}>
|
<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"}
|
{formatDecimalHours(consumptionMinutes)} / {project.hour_budget_minutes ? formatDecimalHours(project.hour_budget_minutes) : "offen"}
|
||||||
</p>
|
</p>
|
||||||
@@ -1289,10 +1339,6 @@ export function ProjectsPage({ onNavigate, onStartTimer }: ProjectsPageProps) {
|
|||||||
{usedPercent !== null ? `${usedPercent}%` : "-"}
|
{usedPercent !== null ? `${usedPercent}%` : "-"}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-wrap gap-1.5">
|
|
||||||
<Badge variant={(project.open_todos ?? 0) > 0 ? "warning" : "success"}>{project.open_todos ?? 0} offen</Badge>
|
|
||||||
{(project.overdue_todos ?? 0) > 0 ? <Badge variant="destructive">{project.overdue_todos} üf</Badge> : null}
|
|
||||||
</div>
|
|
||||||
<div className="flex justify-end">
|
<div className="flex justify-end">
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
@@ -1383,6 +1429,7 @@ export function ProjectsPage({ onNavigate, onStartTimer }: ProjectsPageProps) {
|
|||||||
<span className="truncate text-muted-foreground">
|
<span className="truncate text-muted-foreground">
|
||||||
{label}
|
{label}
|
||||||
{entry.is_default ? " · Standard" : ""}
|
{entry.is_default ? " · Standard" : ""}
|
||||||
|
{entry.budget_sync_disabled ? " · Budget geschützt" : ""}
|
||||||
</span>
|
</span>
|
||||||
<span className="shrink-0 font-medium">
|
<span className="shrink-0 font-medium">
|
||||||
{formatMinutes(entryConsumption)} / {entry.hour_budget_minutes ? formatMinutes(entry.hour_budget_minutes) : "offen"}
|
{formatMinutes(entryConsumption)} / {entry.hour_budget_minutes ? formatMinutes(entry.hour_budget_minutes) : "offen"}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { Alert } from "@/components/ui/alert";
|
|||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Textarea } from "@/components/ui/textarea";
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
@@ -32,6 +33,7 @@ import {
|
|||||||
import { currentDay, currentMonth, formatDate, formatDateTime, formatMinutes, formatTimeRange } from "../format";
|
import { currentDay, currentMonth, formatDate, formatDateTime, formatMinutes, formatTimeRange } from "../format";
|
||||||
import { ticketHasRecurringBilling } from "../recurring-billings";
|
import { ticketHasRecurringBilling } from "../recurring-billings";
|
||||||
import type { BillingStatus, PeriodType, ProjectSummary, ProjectTeamspaceEntry, SessionEntry, TicketDetailPeriodType, TicketPeriod, WorkType } from "../types";
|
import type { BillingStatus, PeriodType, ProjectSummary, ProjectTeamspaceEntry, SessionEntry, TicketDetailPeriodType, TicketPeriod, WorkType } from "../types";
|
||||||
|
import { ZammadPriorityBadge, ZammadStateBadge } from "../zammad-meta";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
type TicketDetailPageProps = {
|
type TicketDetailPageProps = {
|
||||||
@@ -77,6 +79,7 @@ type ProjectTeamspaceEntryForm = {
|
|||||||
clientId: string;
|
clientId: string;
|
||||||
teamspaceProject: string;
|
teamspaceProject: string;
|
||||||
hourBudget: string;
|
hourBudget: string;
|
||||||
|
budgetSyncDisabled: boolean;
|
||||||
manualConsumption: string;
|
manualConsumption: string;
|
||||||
isDefault: boolean;
|
isDefault: boolean;
|
||||||
};
|
};
|
||||||
@@ -126,6 +129,7 @@ function projectEditForm(project: ProjectSummary): ProjectEditFormState {
|
|||||||
clientId: entry.id || projectEntryId(),
|
clientId: entry.id || projectEntryId(),
|
||||||
teamspaceProject: entry.teamspace_project ?? "",
|
teamspaceProject: entry.teamspace_project ?? "",
|
||||||
hourBudget: formatHoursInput(entry.hour_budget_minutes),
|
hourBudget: formatHoursInput(entry.hour_budget_minutes),
|
||||||
|
budgetSyncDisabled: entry.budget_sync_disabled ?? false,
|
||||||
manualConsumption: formatHoursInput(entry.manual_consumption_minutes),
|
manualConsumption: formatHoursInput(entry.manual_consumption_minutes),
|
||||||
isDefault: entry.is_default || index === 0
|
isDefault: entry.is_default || index === 0
|
||||||
}));
|
}));
|
||||||
@@ -262,6 +266,7 @@ function normalizeProjectTeamspaceEntryForms(entries: ProjectTeamspaceEntryForm[
|
|||||||
clientId: projectEntryId(),
|
clientId: projectEntryId(),
|
||||||
teamspaceProject: "",
|
teamspaceProject: "",
|
||||||
hourBudget: "",
|
hourBudget: "",
|
||||||
|
budgetSyncDisabled: false,
|
||||||
manualConsumption: "",
|
manualConsumption: "",
|
||||||
isDefault: true
|
isDefault: true
|
||||||
}
|
}
|
||||||
@@ -289,6 +294,7 @@ function parseProjectTeamspaceEntryForms(entries: ProjectTeamspaceEntryForm[]) {
|
|||||||
teamspaceProject: entry.teamspaceProject.trim() || null,
|
teamspaceProject: entry.teamspaceProject.trim() || null,
|
||||||
isDefault: entry.isDefault,
|
isDefault: entry.isDefault,
|
||||||
hourBudgetMinutes,
|
hourBudgetMinutes,
|
||||||
|
budgetSyncDisabled: entry.budgetSyncDisabled,
|
||||||
manualConsumptionMinutes: manualConsumptionMinutes ?? 0
|
manualConsumptionMinutes: manualConsumptionMinutes ?? 0
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
@@ -302,6 +308,7 @@ function parseProjectTeamspaceEntryForms(entries: ProjectTeamspaceEntryForm[]) {
|
|||||||
teamspaceProject: string | null;
|
teamspaceProject: string | null;
|
||||||
isDefault: boolean;
|
isDefault: boolean;
|
||||||
hourBudgetMinutes: number | null;
|
hourBudgetMinutes: number | null;
|
||||||
|
budgetSyncDisabled: boolean;
|
||||||
manualConsumptionMinutes: number;
|
manualConsumptionMinutes: number;
|
||||||
}>;
|
}>;
|
||||||
}
|
}
|
||||||
@@ -327,13 +334,13 @@ function ProjectTeamspaceEntriesEditor({
|
|||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<div className="flex items-center justify-between gap-2">
|
<div className="flex items-center justify-between gap-2">
|
||||||
<label className="text-sm font-medium">Teamspace-Einträge</label>
|
<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 }])}>
|
<Button type="button" size="sm" variant="secondary" onClick={() => onChange([...normalizedEntries, { clientId: projectEntryId(), teamspaceProject: "", hourBudget: "", budgetSyncDisabled: false, manualConsumption: "", isDefault: false }])}>
|
||||||
<Plus className="size-3.5" />
|
<Plus className="size-3.5" />
|
||||||
Eintrag
|
Eintrag
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
{normalizedEntries.map((entry, index) => (
|
{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 key={entry.clientId} className="grid gap-2 rounded-lg border bg-muted/10 p-3 lg:grid-cols-[minmax(180px,1fr)_120px_150px_130px_120px_36px] lg:items-end">
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<label className="text-xs font-medium text-muted-foreground" htmlFor={`project-ticket-teamspace-${entry.clientId}`}>
|
<label className="text-xs font-medium text-muted-foreground" htmlFor={`project-ticket-teamspace-${entry.clientId}`}>
|
||||||
Teamspace-Projekt {entry.isDefault ? "(Standard)" : ""}
|
Teamspace-Projekt {entry.isDefault ? "(Standard)" : ""}
|
||||||
@@ -346,8 +353,12 @@ function ProjectTeamspaceEntriesEditor({
|
|||||||
</div>
|
</div>
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<label className="text-xs font-medium text-muted-foreground" htmlFor={`project-ticket-budget-${entry.clientId}`}>Budget h</label>
|
<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 })} />
|
<Input id={`project-ticket-budget-${entry.clientId}`} inputMode="decimal" value={entry.hourBudget} onChange={(event) => updateEntry(entry.clientId, { hourBudget: event.currentTarget.value, budgetSyncDisabled: true })} />
|
||||||
</div>
|
</div>
|
||||||
|
<label className="flex min-h-9 items-center gap-2 rounded-md border bg-background px-3 text-xs">
|
||||||
|
<Checkbox checked={entry.budgetSyncDisabled} onCheckedChange={(checked) => updateEntry(entry.clientId, { budgetSyncDisabled: checked === true })} />
|
||||||
|
<span>Budget schützen</span>
|
||||||
|
</label>
|
||||||
<div className="space-y-1.5">
|
<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>
|
<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 })} />
|
<Input id={`project-ticket-manual-${entry.clientId}`} inputMode="decimal" value={entry.manualConsumption} onChange={(event) => updateEntry(entry.clientId, { manualConsumption: event.currentTarget.value })} />
|
||||||
@@ -418,6 +429,7 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onO
|
|||||||
clientId: projectEntryId(),
|
clientId: projectEntryId(),
|
||||||
teamspaceProject: "",
|
teamspaceProject: "",
|
||||||
hourBudget: "",
|
hourBudget: "",
|
||||||
|
budgetSyncDisabled: false,
|
||||||
manualConsumption: "",
|
manualConsumption: "",
|
||||||
isDefault: true
|
isDefault: true
|
||||||
}
|
}
|
||||||
@@ -1108,6 +1120,8 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onO
|
|||||||
<h2 className="text-xl font-semibold tracking-normal sm:text-2xl">{ticket?.title || ticket?.ticket_number || "Ticket"}</h2>
|
<h2 className="text-xl font-semibold tracking-normal sm:text-2xl">{ticket?.title || ticket?.ticket_number || "Ticket"}</h2>
|
||||||
{ticket?.ticket_number ? <CopyTicketButton ticketNumber={ticket.ticket_number} /> : null}
|
{ticket?.ticket_number ? <CopyTicketButton ticketNumber={ticket.ticket_number} /> : null}
|
||||||
{isProject ? <Badge variant="outline">Projekt</Badge> : null}
|
{isProject ? <Badge variant="outline">Projekt</Badge> : null}
|
||||||
|
<ZammadStateBadge stateId={ticket?.zammad_state_id} />
|
||||||
|
<ZammadPriorityBadge priorityId={ticket?.zammad_priority_id} />
|
||||||
{(periodType === "month" || (isGlobalTicket && globalMonthFilter !== "all")) && data?.closed ? <Badge variant="outline">Monat geschlossen</Badge> : null}
|
{(periodType === "month" || (isGlobalTicket && globalMonthFilter !== "all")) && data?.closed ? <Badge variant="outline">Monat geschlossen</Badge> : null}
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
@@ -1260,7 +1274,9 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onO
|
|||||||
<CardTitle className="flex items-center gap-2">
|
<CardTitle className="flex items-center gap-2">
|
||||||
<FolderKanban className="size-5 text-muted-foreground" />
|
<FolderKanban className="size-5 text-muted-foreground" />
|
||||||
Projekt
|
Projekt
|
||||||
{project.closed_at ? <Badge variant="secondary">geschlossen</Badge> : <Badge variant="success">offen</Badge>}
|
{project.closed_at ? <Badge variant="secondary">Geschlossen</Badge> : <Badge variant="success">Offen</Badge>}
|
||||||
|
<ZammadStateBadge stateId={project.zammad_state_id} />
|
||||||
|
<ZammadPriorityBadge priorityId={project.zammad_priority_id} />
|
||||||
{!canEditProject ? <Badge variant="outline">geteilt</Badge> : null}
|
{!canEditProject ? <Badge variant="outline">geteilt</Badge> : null}
|
||||||
{project.close_review_required ? <Badge variant="warning">TS/TT prüfen</Badge> : null}
|
{project.close_review_required ? <Badge variant="warning">TS/TT prüfen</Badge> : null}
|
||||||
{(project.unassigned_teamspace_session_count ?? 0) > 0 ? <Badge variant="warning">{project.unassigned_teamspace_session_count} ohne TS</Badge> : null}
|
{(project.unassigned_teamspace_session_count ?? 0) > 0 ? <Badge variant="warning">{project.unassigned_teamspace_session_count} ohne TS</Badge> : null}
|
||||||
@@ -1341,6 +1357,7 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onO
|
|||||||
<span className="truncate text-muted-foreground">
|
<span className="truncate text-muted-foreground">
|
||||||
{label}
|
{label}
|
||||||
{entry.is_default ? " · Standard" : ""}
|
{entry.is_default ? " · Standard" : ""}
|
||||||
|
{entry.budget_sync_disabled ? " · Budget geschützt" : ""}
|
||||||
</span>
|
</span>
|
||||||
<span className="shrink-0 font-medium">
|
<span className="shrink-0 font-medium">
|
||||||
{formatMinutes(consumption)} / {entry.hour_budget_minutes ? formatMinutes(entry.hour_budget_minutes) : "offen"}
|
{formatMinutes(consumption)} / {entry.hour_budget_minutes ? formatMinutes(entry.hour_budget_minutes) : "offen"}
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import {
|
|||||||
type ZammadSyncResult
|
type ZammadSyncResult
|
||||||
} from "../api";
|
} from "../api";
|
||||||
import type { AuthUser } from "../types";
|
import type { AuthUser } from "../types";
|
||||||
|
import { zammadPriorityLabel, zammadStateLabel } from "../zammad-meta";
|
||||||
|
|
||||||
type ZammadSyncPageProps = {
|
type ZammadSyncPageProps = {
|
||||||
currentUser: AuthUser;
|
currentUser: AuthUser;
|
||||||
@@ -32,10 +33,11 @@ const defaultFields: ZammadSyncExistingFields = {
|
|||||||
organization: true,
|
organization: true,
|
||||||
type: true,
|
type: true,
|
||||||
projectName: true,
|
projectName: true,
|
||||||
budget: true
|
budget: true,
|
||||||
|
overrideBudget: false
|
||||||
};
|
};
|
||||||
|
|
||||||
const fieldOptions: Array<{ key: keyof ZammadSyncExistingFields; label: string; description: string }> = [
|
const fieldOptions: Array<{ key: Exclude<keyof ZammadSyncExistingFields, "overrideBudget">; label: string; description: string }> = [
|
||||||
{ key: "title", label: "Titel", description: "Ticket-Titel aus Zammad übernehmen." },
|
{ key: "title", label: "Titel", description: "Ticket-Titel aus Zammad übernehmen." },
|
||||||
{ key: "organization", label: "Organisation", description: "Lokale Organisation anhand der Zammad-ID setzen." },
|
{ key: "organization", label: "Organisation", description: "Lokale Organisation anhand der Zammad-ID setzen." },
|
||||||
{ key: "type", label: "Typ", description: "Support/Consulting übernehmen, Projekte anlegen oder entfernen." },
|
{ key: "type", label: "Typ", description: "Support/Consulting übernehmen, Projekte anlegen oder entfernen." },
|
||||||
@@ -164,16 +166,8 @@ function formatZammadGroup(value: unknown) {
|
|||||||
function formatZammadPriority(value: unknown) {
|
function formatZammadPriority(value: unknown) {
|
||||||
const priorityId = numericValue(value);
|
const priorityId = numericValue(value);
|
||||||
|
|
||||||
if (priorityId === 1) {
|
if (priorityId !== null) {
|
||||||
return "1 (Niedrig)";
|
return `${priorityId} (${zammadPriorityLabel(priorityId)})`;
|
||||||
}
|
|
||||||
|
|
||||||
if (priorityId === 2) {
|
|
||||||
return "2 (Normal)";
|
|
||||||
}
|
|
||||||
|
|
||||||
if (priorityId === 3) {
|
|
||||||
return "3 (Hoch)";
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return formatValue(value);
|
return formatValue(value);
|
||||||
@@ -182,20 +176,8 @@ function formatZammadPriority(value: unknown) {
|
|||||||
function formatZammadState(value: unknown) {
|
function formatZammadState(value: unknown) {
|
||||||
const stateId = numericValue(value);
|
const stateId = numericValue(value);
|
||||||
|
|
||||||
if (stateId === 2) {
|
if (stateId !== null) {
|
||||||
return "2 (offen)";
|
return `${stateId} (${zammadStateLabel(stateId)})`;
|
||||||
}
|
|
||||||
|
|
||||||
if (stateId === 3) {
|
|
||||||
return "3 (warten auf Erinnerung)";
|
|
||||||
}
|
|
||||||
|
|
||||||
if (stateId === 4) {
|
|
||||||
return "4 (geschlossen)";
|
|
||||||
}
|
|
||||||
|
|
||||||
if (stateId === 7) {
|
|
||||||
return "7 (warten auf schließen)";
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return formatValue(value);
|
return formatValue(value);
|
||||||
@@ -222,6 +204,10 @@ function formatChangeValue(field: string, value: unknown) {
|
|||||||
return formatMinutes(value);
|
return formatMinutes(value);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (field === "Teamspace-Projekte") {
|
||||||
|
return formatTeamspaceProjectList(value) || "-";
|
||||||
|
}
|
||||||
|
|
||||||
if (field === "Typ") {
|
if (field === "Typ") {
|
||||||
return formatWorkType(value);
|
return formatWorkType(value);
|
||||||
}
|
}
|
||||||
@@ -242,7 +228,7 @@ function formatChangeValue(field: string, value: unknown) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function visibleTicketTrackerChanges(item: ZammadSyncItem) {
|
function visibleTicketTrackerChanges(item: ZammadSyncItem) {
|
||||||
return item.changes.filter((change) => !change.field.startsWith("Zammad-"));
|
return item.changes.filter((change) => !change.field.startsWith("Zammad-") || change.field === "Zammad-Status" || change.field === "Zammad-Priorität");
|
||||||
}
|
}
|
||||||
|
|
||||||
function changeAllowedByFields(item: ZammadSyncItem, change: ZammadSyncChange, fields: ZammadSyncExistingFields) {
|
function changeAllowedByFields(item: ZammadSyncItem, change: ZammadSyncChange, fields: ZammadSyncExistingFields) {
|
||||||
@@ -270,6 +256,10 @@ function changeAllowedByFields(item: ZammadSyncItem, change: ZammadSyncChange, f
|
|||||||
return fields.projectName && consultingProjectCanBeTouched;
|
return fields.projectName && consultingProjectCanBeTouched;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (change.field === "Teamspace-Projekte") {
|
||||||
|
return fields.projectName && consultingProjectCanBeTouched;
|
||||||
|
}
|
||||||
|
|
||||||
if (change.field === "Teamspace-Budget") {
|
if (change.field === "Teamspace-Budget") {
|
||||||
return fields.budget && consultingProjectCanBeTouched;
|
return fields.budget && consultingProjectCanBeTouched;
|
||||||
}
|
}
|
||||||
@@ -290,6 +280,23 @@ function cachedObject(source: Record<string, unknown> | null | undefined, key: s
|
|||||||
return value && typeof value === "object" && !Array.isArray(value) ? (value as Record<string, unknown>) : null;
|
return value && typeof value === "object" && !Array.isArray(value) ? (value as Record<string, unknown>) : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function cachedStringList(source: Record<string, unknown> | null | undefined, key: string) {
|
||||||
|
const value = cachedValue(source, key);
|
||||||
|
return Array.isArray(value) ? value.map((entry) => (typeof entry === "string" ? entry.trim() : "")).filter(Boolean) : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTeamspaceProjectList(value: unknown) {
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
return value.map((entry) => (typeof entry === "string" ? entry.trim() : "")).filter(Boolean).join(" + ");
|
||||||
|
}
|
||||||
|
|
||||||
|
return typeof value === "string" ? value.trim() : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function splitTeamspaceProjects(value: unknown) {
|
||||||
|
return typeof value === "string" ? value.split("+").map((entry) => entry.trim()).filter(Boolean) : [];
|
||||||
|
}
|
||||||
|
|
||||||
function comparablePreviewValue(value: unknown) {
|
function comparablePreviewValue(value: unknown) {
|
||||||
if (typeof value === "string" && value.trim().length === 0) {
|
if (typeof value === "string" && value.trim().length === 0) {
|
||||||
return null;
|
return null;
|
||||||
@@ -317,10 +324,14 @@ function comparablePreviewIdValue(value: unknown) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function comparablePreviewValueForField(field: string, value: unknown) {
|
function comparablePreviewValueForField(field: string, value: unknown) {
|
||||||
if (field === "Organisation") {
|
if (field === "Organisation" || field === "Zammad-Priorität" || field === "Zammad-Status") {
|
||||||
return comparablePreviewIdValue(value);
|
return comparablePreviewIdValue(value);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (field === "Teamspace-Projekte" && Array.isArray(value)) {
|
||||||
|
return value.map((entry) => comparablePreviewValue(entry));
|
||||||
|
}
|
||||||
|
|
||||||
return comparablePreviewValue(value);
|
return comparablePreviewValue(value);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -349,8 +360,16 @@ function changesFromExistingPreviewCache(item: ZammadSyncItem, fields: ZammadSyn
|
|||||||
const localWorkType = cachedValue(local, "workType");
|
const localWorkType = cachedValue(local, "workType");
|
||||||
const zammadWorkType = cachedValue(zammad, "workType");
|
const zammadWorkType = cachedValue(zammad, "workType");
|
||||||
const zammadStateId = numericValue(cachedValue(zammad, "stateId"));
|
const zammadStateId = numericValue(cachedValue(zammad, "stateId"));
|
||||||
|
const localTeamspaceProjects = cachedStringList(project, "teamspaceProjects");
|
||||||
|
const zammadTeamspaceProjects = cachedStringList(zammad, "teamspaceProjects");
|
||||||
|
const localTeamspaceProjectList = localTeamspaceProjects.length ? localTeamspaceProjects : [cachedValue(project, "teamspaceProject")].filter((entry): entry is string => typeof entry === "string");
|
||||||
|
const zammadTeamspaceProjectList = zammadTeamspaceProjects.length ? zammadTeamspaceProjects : splitTeamspaceProjects(cachedValue(zammad, "teamspaceProject"));
|
||||||
|
const canSyncBudget = fields.budget && (cachedValue(project, "defaultBudgetSyncDisabled") !== true || fields.overrideBudget);
|
||||||
const changes: ZammadSyncChange[] = [];
|
const changes: ZammadSyncChange[] = [];
|
||||||
|
|
||||||
|
addCachedChange(changes, "Zammad-Priorität", cachedValue(local, "zammadPriorityId"), cachedValue(zammad, "priorityId"));
|
||||||
|
addCachedChange(changes, "Zammad-Status", cachedValue(local, "zammadStateId"), cachedValue(zammad, "stateId"));
|
||||||
|
|
||||||
if (zammadWorkType === "support" && zammadStateId === 4) {
|
if (zammadWorkType === "support" && zammadStateId === 4) {
|
||||||
return changes;
|
return changes;
|
||||||
}
|
}
|
||||||
@@ -379,11 +398,11 @@ function changesFromExistingPreviewCache(item: ZammadSyncItem, fields: ZammadSyn
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (fields.projectName) {
|
if (fields.projectName) {
|
||||||
addCachedChange(changes, "Teamspace-Projekt", cachedValue(project, "teamspaceProject"), cachedValue(zammad, "teamspaceProject"));
|
addCachedChange(changes, "Teamspace-Projekte", localTeamspaceProjectList, zammadTeamspaceProjectList);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (fields.budget) {
|
if (canSyncBudget) {
|
||||||
addCachedChange(changes, "Teamspace-Budget", cachedValue(project, "hourBudgetMinutes"), cachedValue(zammad, "teamspaceBudgetMinutes"));
|
addCachedChange(changes, "Teamspace-Budget", cachedValue(project, "defaultHourBudgetMinutes") ?? cachedValue(project, "hourBudgetMinutes"), cachedValue(zammad, "teamspaceBudgetMinutes"));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (zammadStateId === 4 && cachedValue(project, "closedAt") === null && cachedValue(project, "closeReviewRequired") !== true) {
|
if (zammadStateId === 4 && cachedValue(project, "closedAt") === null && cachedValue(project, "closeReviewRequired") !== true) {
|
||||||
@@ -516,7 +535,7 @@ function zammadDetails(item: ZammadSyncItem) {
|
|||||||
`Owner ${formatValue(zammad.ownerId)}`,
|
`Owner ${formatValue(zammad.ownerId)}`,
|
||||||
`Org ${formatValue(zammad.organizationId)}`,
|
`Org ${formatValue(zammad.organizationId)}`,
|
||||||
`Titel ${formatValue(zammad.title)}`,
|
`Titel ${formatValue(zammad.title)}`,
|
||||||
`Projekt ${formatValue(zammad.teamspaceProject)}`,
|
`Projekt ${formatValue(formatTeamspaceProjectList(zammad.teamspaceProjects) || zammad.teamspaceProject)}`,
|
||||||
`Budget ${formatMinutes(zammad.teamspaceBudgetMinutes)}`
|
`Budget ${formatMinutes(zammad.teamspaceBudgetMinutes)}`
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
@@ -681,7 +700,7 @@ export function ZammadSyncPage({ currentUser }: ZammadSyncPageProps) {
|
|||||||
|
|
||||||
function setField(key: keyof ZammadSyncExistingFields, checked: boolean) {
|
function setField(key: keyof ZammadSyncExistingFields, checked: boolean) {
|
||||||
setZammadSyncPageState((current) => ({
|
setZammadSyncPageState((current) => ({
|
||||||
fields: { ...current.fields, [key]: checked }
|
fields: { ...current.fields, [key]: checked, ...(key === "budget" && !checked ? { overrideBudget: false } : {}) }
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -894,6 +913,15 @@ export function ZammadSyncPage({ currentUser }: ZammadSyncPageProps) {
|
|||||||
</label>
|
</label>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
{fields.budget ? (
|
||||||
|
<label className={cn("flex min-w-0 gap-3 rounded-md border p-3", fields.overrideBudget ? "bg-muted/30" : "bg-background")}>
|
||||||
|
<Checkbox checked={fields.overrideBudget} onCheckedChange={(checked) => setField("overrideBudget", checked === true)} />
|
||||||
|
<span className="min-w-0">
|
||||||
|
<span className="block text-sm font-medium">Manuelle Budget-Änderungen überschreiben</span>
|
||||||
|
<span className="block text-xs text-muted-foreground">Ausgeschaltet: geschützte Budgets bleiben in TicketTracker unverändert.</span>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
) : null}
|
||||||
<div className="flex flex-wrap justify-end gap-2">
|
<div className="flex flex-wrap justify-end gap-2">
|
||||||
{syncingExisting ? (
|
{syncingExisting ? (
|
||||||
<Button type="button" variant="outline" onClick={resetRunningState}>
|
<Button type="button" variant="outline" onClick={resetRunningState}>
|
||||||
|
|||||||
@@ -0,0 +1,149 @@
|
|||||||
|
import { AlertTriangle, ArrowDown, ArrowUp, CheckCircle2, CircleDot, CirclePause, Flag, Minus } from "lucide-react";
|
||||||
|
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
export type ZammadStateFilter = "all" | "none" | "2" | "3" | "4" | "7";
|
||||||
|
export type ZammadPriorityFilter = "all" | "none" | "1" | "2" | "3";
|
||||||
|
|
||||||
|
export const zammadStateFilterOptions: Array<{ value: ZammadStateFilter; label: string }> = [
|
||||||
|
{ value: "all", label: "Alle Zammad-Status" },
|
||||||
|
{ value: "2", label: "Offen" },
|
||||||
|
{ value: "3", label: "Wartend" },
|
||||||
|
{ value: "4", label: "Geschlossen" },
|
||||||
|
{ value: "7", label: "Abschluss" },
|
||||||
|
{ value: "none", label: "Ohne Zammad-Status" }
|
||||||
|
];
|
||||||
|
|
||||||
|
export const zammadPriorityFilterOptions: Array<{ value: ZammadPriorityFilter; label: string }> = [
|
||||||
|
{ value: "all", label: "Alle Prioritäten" },
|
||||||
|
{ value: "1", label: "Niedrig" },
|
||||||
|
{ value: "2", label: "Normal" },
|
||||||
|
{ value: "3", label: "Hoch" },
|
||||||
|
{ value: "none", label: "Ohne Priorität" }
|
||||||
|
];
|
||||||
|
|
||||||
|
const zammadStateMeta = {
|
||||||
|
"2": {
|
||||||
|
label: "Offen",
|
||||||
|
order: 10,
|
||||||
|
className: "border-emerald-500/30 bg-emerald-500/10 text-emerald-700 dark:text-emerald-300",
|
||||||
|
Icon: CircleDot
|
||||||
|
},
|
||||||
|
"3": {
|
||||||
|
label: "Wartend",
|
||||||
|
order: 20,
|
||||||
|
className: "border-blue-500/30 bg-blue-500/10 text-blue-700 dark:text-blue-300",
|
||||||
|
Icon: CirclePause
|
||||||
|
},
|
||||||
|
"7": {
|
||||||
|
label: "Abschluss",
|
||||||
|
order: 30,
|
||||||
|
className: "border-amber-500/30 bg-amber-500/10 text-amber-700 dark:text-amber-300",
|
||||||
|
Icon: Flag
|
||||||
|
},
|
||||||
|
"4": {
|
||||||
|
label: "Geschlossen",
|
||||||
|
order: 40,
|
||||||
|
className: "border-slate-500/30 bg-slate-500/10 text-slate-700 dark:text-slate-300",
|
||||||
|
Icon: CheckCircle2
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const zammadPriorityMeta = {
|
||||||
|
"1": {
|
||||||
|
label: "Niedrig",
|
||||||
|
order: 10,
|
||||||
|
className: "border-blue-500/30 bg-blue-500/10 text-blue-700 dark:text-blue-300",
|
||||||
|
Icon: ArrowDown
|
||||||
|
},
|
||||||
|
"2": {
|
||||||
|
label: "Normal",
|
||||||
|
order: 20,
|
||||||
|
className: "border-emerald-500/30 bg-emerald-500/10 text-emerald-700 dark:text-emerald-300",
|
||||||
|
Icon: Minus
|
||||||
|
},
|
||||||
|
"3": {
|
||||||
|
label: "Hoch",
|
||||||
|
order: 30,
|
||||||
|
className: "border-red-500/30 bg-red-500/10 text-red-700 dark:text-red-300",
|
||||||
|
Icon: ArrowUp
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export function zammadId(value: unknown) {
|
||||||
|
if (value === null || value === undefined || value === "") {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalized = String(value).trim();
|
||||||
|
return /^\d+$/.test(normalized) ? normalized.replace(/^0+(?=\d)/, "") : normalized || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function zammadStateLabel(value: unknown) {
|
||||||
|
const id = zammadId(value);
|
||||||
|
return id ? zammadStateMeta[id as keyof typeof zammadStateMeta]?.label ?? `Status ${id}` : "-";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function zammadPriorityLabel(value: unknown) {
|
||||||
|
const id = zammadId(value);
|
||||||
|
return id ? zammadPriorityMeta[id as keyof typeof zammadPriorityMeta]?.label ?? `Priorität ${id}` : "-";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function zammadStateSortRank(value: unknown) {
|
||||||
|
const id = zammadId(value);
|
||||||
|
const fallback = id ? Number(id) : Number.NaN;
|
||||||
|
return id ? zammadStateMeta[id as keyof typeof zammadStateMeta]?.order ?? (Number.isFinite(fallback) ? 900 + fallback : 999) : 999;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function zammadPrioritySortRank(value: unknown) {
|
||||||
|
const id = zammadId(value);
|
||||||
|
const fallback = id ? Number(id) : Number.NaN;
|
||||||
|
return id ? zammadPriorityMeta[id as keyof typeof zammadPriorityMeta]?.order ?? (Number.isFinite(fallback) ? 900 + fallback : 999) : 999;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function matchesZammadStateFilter(value: unknown, filter: ZammadStateFilter) {
|
||||||
|
const id = zammadId(value);
|
||||||
|
return filter === "all" || (filter === "none" ? !id : id === filter);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function matchesZammadPriorityFilter(value: unknown, filter: ZammadPriorityFilter) {
|
||||||
|
const id = zammadId(value);
|
||||||
|
return filter === "all" || (filter === "none" ? !id : id === filter);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ZammadStateBadge({ stateId, className }: { stateId: unknown; className?: string }) {
|
||||||
|
const id = zammadId(stateId);
|
||||||
|
|
||||||
|
if (!id) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const meta = zammadStateMeta[id as keyof typeof zammadStateMeta];
|
||||||
|
const Icon = meta?.Icon ?? AlertTriangle;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Badge variant="outline" className={cn(meta?.className ?? "border-border bg-muted/40 text-muted-foreground", className)}>
|
||||||
|
<Icon className="size-3" />
|
||||||
|
{meta?.label ?? `Status ${id}`}
|
||||||
|
</Badge>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ZammadPriorityBadge({ priorityId, className }: { priorityId: unknown; className?: string }) {
|
||||||
|
const id = zammadId(priorityId);
|
||||||
|
|
||||||
|
if (!id) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const meta = zammadPriorityMeta[id as keyof typeof zammadPriorityMeta];
|
||||||
|
const Icon = meta?.Icon ?? AlertTriangle;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Badge variant="outline" className={cn(meta?.className ?? "border-border bg-muted/40 text-muted-foreground", className)}>
|
||||||
|
<Icon className="size-3" />
|
||||||
|
{meta?.label ?? `Priorität ${id}`}
|
||||||
|
</Badge>
|
||||||
|
);
|
||||||
|
}
|
||||||
Executable
+24
@@ -0,0 +1,24 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
APP_NAME="${APP_NAME:-tickettracker}"
|
||||||
|
GITEA_REGISTRY="${GITEA_REGISTRY:-git.d-razz.de}"
|
||||||
|
GITEA_OWNER="${GITEA_OWNER:-michael}"
|
||||||
|
IMAGE_TAG="${IMAGE_TAG:-latest}"
|
||||||
|
PLATFORM="${PLATFORM:-linux/amd64}"
|
||||||
|
IMAGE="${IMAGE:-${GITEA_REGISTRY}/${GITEA_OWNER}/${APP_NAME}:${IMAGE_TAG}}"
|
||||||
|
|
||||||
|
echo "Baue ${IMAGE} fuer ${PLATFORM}"
|
||||||
|
docker buildx build \
|
||||||
|
--platform "${PLATFORM}" \
|
||||||
|
--tag "${IMAGE}" \
|
||||||
|
--push \
|
||||||
|
.
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "Image gepusht: ${IMAGE}"
|
||||||
|
echo "Auf dem Docker-Server:"
|
||||||
|
echo " export TICKETTRACKER_IMAGE='${IMAGE}'"
|
||||||
|
echo " docker compose --env-file .env.docker-external -f compose.yml up -d"
|
||||||
|
echo "oder mit PostgreSQL 17 im Stack:"
|
||||||
|
echo " docker compose --env-file .env.docker-postgres -f compose.postgres.yml up -d"
|
||||||
@@ -10,6 +10,7 @@ IMAGE="${IMAGE:-${GITEA_REGISTRY}/${GITEA_OWNER}/${APP_NAME}:${IMAGE_TAG}}"
|
|||||||
|
|
||||||
echo "Baue ${IMAGE} fuer ${PLATFORM}"
|
echo "Baue ${IMAGE} fuer ${PLATFORM}"
|
||||||
docker buildx build \
|
docker buildx build \
|
||||||
|
--builder tickettracker-builder \
|
||||||
--platform "${PLATFORM}" \
|
--platform "${PLATFORM}" \
|
||||||
--tag "${IMAGE}" \
|
--tag "${IMAGE}" \
|
||||||
--push \
|
--push \
|
||||||
|
|||||||
+47
-6
@@ -1,23 +1,40 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
APP_PORT="${APP_PORT:-3910}"
|
|
||||||
BACKEND_PORT="${BACKEND_PORT:-3001}"
|
|
||||||
export API_PROXY_TARGET="${API_PROXY_TARGET:-http://127.0.0.1:${BACKEND_PORT}}"
|
|
||||||
export NEXT_TELEMETRY_DISABLED=1
|
|
||||||
|
|
||||||
if [[ -f ".env_dev" ]]; then
|
if [[ -f ".env_dev" ]]; then
|
||||||
set -a
|
set -a
|
||||||
source .env_dev
|
source .env_dev
|
||||||
set +a
|
set +a
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
APP_PORT="${APP_PORT:-3911}"
|
||||||
|
BACKEND_PORT="${BACKEND_PORT:-3002}"
|
||||||
|
API_PROXY_TARGET="${API_PROXY_TARGET:-http://127.0.0.1:${BACKEND_PORT}}"
|
||||||
|
export APP_PORT BACKEND_PORT API_PROXY_TARGET
|
||||||
|
export NEXT_TELEMETRY_DISABLED=1
|
||||||
|
|
||||||
if [[ -z "${DATABASE_URL:-}" ]]; then
|
if [[ -z "${DATABASE_URL:-}" ]]; then
|
||||||
echo "DATABASE_URL fehlt. Beispiel:"
|
echo "DATABASE_URL fehlt. Beispiel:"
|
||||||
echo " export DATABASE_URL='postgresql://user:pass@localhost:5432/tickettracker'"
|
echo " export DATABASE_URL='postgresql://user:pass@localhost:5432/tickettracker'"
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
DATABASE_HOST="$(node -e 'const url = new URL(process.env.DATABASE_URL); process.stdout.write(url.hostname)' 2>/dev/null || true)"
|
||||||
|
POSTGRES_CONTAINER="${POSTGRES_CONTAINER:-tickettracker-postgres}"
|
||||||
|
|
||||||
|
if [[ "${DATABASE_HOST}" == "postgres" || "${DATABASE_HOST}" == "postgres-tickettracker" ]]; then
|
||||||
|
if ! getent hosts "${DATABASE_HOST}" >/dev/null 2>&1 && command -v docker >/dev/null 2>&1 && docker inspect "${POSTGRES_CONTAINER}" >/dev/null 2>&1; then
|
||||||
|
POSTGRES_CONTAINER_IP="$(docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' "${POSTGRES_CONTAINER}")"
|
||||||
|
|
||||||
|
if [[ -n "${POSTGRES_CONTAINER_IP}" ]]; then
|
||||||
|
export DATABASE_URL="$(
|
||||||
|
DATABASE_HOST="${POSTGRES_CONTAINER_IP}" node -e 'const url = new URL(process.env.DATABASE_URL); url.hostname = process.env.DATABASE_HOST; process.stdout.write(url.toString())'
|
||||||
|
)"
|
||||||
|
echo "Nutze Docker-Postgres ${POSTGRES_CONTAINER} unter ${POSTGRES_CONTAINER_IP}:5432 für den lokalen Start."
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
NODE_MAJOR="$(node -p 'Number(process.versions.node.split(".")[0])' 2>/dev/null || echo 0)"
|
NODE_MAJOR="$(node -p 'Number(process.versions.node.split(".")[0])' 2>/dev/null || echo 0)"
|
||||||
|
|
||||||
if [[ "${NODE_MAJOR}" -lt 20 ]]; then
|
if [[ "${NODE_MAJOR}" -lt 20 ]]; then
|
||||||
@@ -37,8 +54,32 @@ cleanup() {
|
|||||||
trap cleanup EXIT INT TERM
|
trap cleanup EXIT INT TERM
|
||||||
|
|
||||||
echo "Starte Backend auf Port ${BACKEND_PORT}"
|
echo "Starte Backend auf Port ${BACKEND_PORT}"
|
||||||
SERVE_FRONTEND=false PORT="${BACKEND_PORT}" npm run start --workspace backend &
|
BACKEND_LOG="${TMPDIR:-/tmp}/tickettracker-backend-local.log"
|
||||||
|
rm -f "${BACKEND_LOG}"
|
||||||
|
SERVE_FRONTEND=false PORT="${BACKEND_PORT}" npm run start --workspace backend >"${BACKEND_LOG}" 2>&1 &
|
||||||
BACKEND_PID=$!
|
BACKEND_PID=$!
|
||||||
|
|
||||||
|
echo "Warte auf Backend ${API_PROXY_TARGET}"
|
||||||
|
for _ in $(seq 1 50); do
|
||||||
|
if ! kill -0 "${BACKEND_PID}" 2>/dev/null; then
|
||||||
|
echo "Backend konnte nicht gestartet werden:"
|
||||||
|
cat "${BACKEND_LOG}"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if curl -fsS "${API_PROXY_TARGET}/api/debug/status" >/dev/null 2>&1; then
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
|
||||||
|
sleep 0.2
|
||||||
|
done
|
||||||
|
|
||||||
|
if ! curl -fsS "${API_PROXY_TARGET}/api/debug/status" >/dev/null 2>&1; then
|
||||||
|
echo "Backend antwortet nicht unter ${API_PROXY_TARGET}."
|
||||||
|
echo "Letzte Backend-Ausgabe:"
|
||||||
|
tail -n 80 "${BACKEND_LOG}" 2>/dev/null || true
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
echo "Starte Frontend auf http://localhost:${APP_PORT}"
|
echo "Starte Frontend auf http://localhost:${APP_PORT}"
|
||||||
PORT="${APP_PORT}" npm run start --workspace frontend
|
PORT="${APP_PORT}" npm run start --workspace frontend
|
||||||
Reference in new issue
Block a user