v1.0.11-rc1

This commit is contained in:
michael committed 2026-10-02 08:50:10 +02:00
1 parent d5952d82bb
commit e1456ed4ca
8 files changed
+1315 -501

No files matched your search

+1 -1
View File
@@ -1,5 +1,5 @@
PP_PASSWORD='$ouzs4D$@RAi9' PP_PASSWORD='$ouzs4D$@RAi9'
DATABASE_URL=postgresql://tickettracker:${PP_PASSWORD}@172.22.0.3:5432/tickettracker_dev DATABASE_URL=postgresql://tickettracker:${PP_PASSWORD}@172.22.0.4:5432/tickettracker_dev
APP_PORT=3911 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
+1126 -481
View File
File diff suppressed because it is too large. Load diff
+103 -3
View File
@@ -206,6 +206,7 @@ export async function migrate() {
await query("ALTER TABLE tickets ADD COLUMN IF NOT EXISTS zammad_state_id BIGINT;"); await query("ALTER TABLE tickets ADD COLUMN IF NOT EXISTS zammad_state_id BIGINT;");
await query("UPDATE tickets SET zammad_state_id = 2 WHERE zammad_state_id = 1;"); await query("UPDATE tickets SET zammad_state_id = 2 WHERE zammad_state_id = 1;");
await query("ALTER TABLE tickets ADD COLUMN IF NOT EXISTS zammad_owner_id BIGINT;"); await query("ALTER TABLE tickets ADD COLUMN IF NOT EXISTS zammad_owner_id BIGINT;");
await query("ALTER TABLE tickets ADD COLUMN IF NOT EXISTS user_id BIGINT REFERENCES users(id) ON DELETE SET NULL;");
await query("ALTER TABLE tickets ADD COLUMN IF NOT EXISTS zammad_merged_from_ticket_id BIGINT;"); await query("ALTER TABLE tickets ADD COLUMN IF NOT EXISTS zammad_merged_from_ticket_id BIGINT;");
await query("ALTER TABLE tickets ADD COLUMN IF NOT EXISTS zammad_merged_from_ticket_number TEXT;"); await query("ALTER TABLE tickets ADD COLUMN IF NOT EXISTS zammad_merged_from_ticket_number TEXT;");
await query("ALTER TABLE tickets ADD COLUMN IF NOT EXISTS zammad_preferences JSONB NOT NULL DEFAULT '{}'::jsonb;"); await query("ALTER TABLE tickets ADD COLUMN IF NOT EXISTS zammad_preferences JSONB NOT NULL DEFAULT '{}'::jsonb;");
@@ -225,6 +226,55 @@ export async function migrate() {
END IF; END IF;
END $$; END $$;
`); `);
await query(`
WITH unique_zammad_users AS (
SELECT zammad_user_id, MIN(id) AS user_id
FROM users
WHERE role = 'user'
AND active
AND zammad_user_id IS NOT NULL
GROUP BY zammad_user_id
HAVING COUNT(*) = 1
),
zammad_ticket_owners AS (
SELECT t.id, uzu.user_id
FROM tickets t
LEFT JOIN unique_zammad_users uzu ON uzu.zammad_user_id = t.zammad_owner_id
WHERE t.zammad_owner_id IS NOT NULL
)
UPDATE tickets t
SET user_id = zto.user_id
FROM zammad_ticket_owners zto
WHERE t.id = zto.id
AND t.user_id IS DISTINCT FROM zto.user_id;
`);
await query(`
UPDATE tickets t
SET user_id = owner.user_id
FROM (
SELECT ticket_id, MIN(user_id) AS user_id
FROM projects
GROUP BY ticket_id
) owner
WHERE t.user_id IS NULL
AND t.zammad_owner_id IS NULL
AND t.id = owner.ticket_id;
`);
await query(`
UPDATE tickets t
SET user_id = owner.user_id
FROM (
SELECT ticket_id, MIN(user_id) AS user_id
FROM sessions
WHERE user_id IS NOT NULL
GROUP BY ticket_id
) owner
WHERE t.user_id IS NULL
AND t.zammad_owner_id IS NULL
AND t.id = owner.ticket_id;
`);
await query("CREATE INDEX IF NOT EXISTS idx_tickets_user ON tickets(user_id);");
await query("CREATE INDEX IF NOT EXISTS idx_tickets_zammad_owner ON tickets(zammad_owner_id);");
await query(` await query(`
CREATE TABLE IF NOT EXISTS sessions ( CREATE TABLE IF NOT EXISTS sessions (
@@ -708,6 +758,43 @@ export async function migrate() {
await query("ALTER TABLE projects ADD COLUMN IF NOT EXISTS closed_at TIMESTAMPTZ;"); await query("ALTER TABLE projects ADD COLUMN IF NOT EXISTS closed_at TIMESTAMPTZ;");
await query("ALTER TABLE projects ADD COLUMN IF NOT EXISTS close_review_required BOOLEAN NOT NULL DEFAULT false;"); await query("ALTER TABLE projects ADD COLUMN IF NOT EXISTS close_review_required BOOLEAN NOT NULL DEFAULT false;");
await query("ALTER TABLE projects ADD COLUMN IF NOT EXISTS manual_consumption_minutes INTEGER NOT NULL DEFAULT 0;"); await query("ALTER TABLE projects ADD COLUMN IF NOT EXISTS manual_consumption_minutes INTEGER NOT NULL DEFAULT 0;");
await query("ALTER TABLE projects ADD COLUMN IF NOT EXISTS zammad_owner_id BIGINT;");
await query("ALTER TABLE projects ALTER COLUMN user_id DROP NOT NULL;");
await query(`
UPDATE projects p
SET zammad_owner_id = t.zammad_owner_id
FROM tickets t
WHERE p.ticket_id = t.id
AND p.zammad_owner_id IS NULL
AND t.zammad_owner_id IS NOT NULL;
`);
await query(`
UPDATE projects p
SET user_id = t.user_id,
zammad_owner_id = t.zammad_owner_id
FROM tickets t
WHERE p.ticket_id = t.id
AND t.zammad_owner_id IS NOT NULL
AND (
p.user_id IS DISTINCT FROM t.user_id
OR p.zammad_owner_id IS DISTINCT FROM t.zammad_owner_id
)
AND NOT EXISTS (
SELECT 1
FROM projects existing
WHERE existing.ticket_id = p.ticket_id
AND existing.id <> p.id
AND (
(t.user_id IS NOT NULL AND existing.user_id = t.user_id)
OR (
t.user_id IS NULL
AND existing.user_id IS NULL
AND existing.zammad_owner_id IS NOT DISTINCT FROM t.zammad_owner_id
)
)
);
`);
await query("CREATE UNIQUE INDEX IF NOT EXISTS idx_projects_zammad_owner_ticket ON projects(zammad_owner_id, ticket_id) WHERE user_id IS NULL AND zammad_owner_id IS NOT NULL;");
await query(` await query(`
DO $$ DO $$
BEGIN BEGIN
@@ -799,13 +886,26 @@ export async function migrate() {
`); `);
await query(` await query(`
INSERT INTO projects (user_id, ticket_id, teamspace_project, description, goals) INSERT INTO projects (user_id, zammad_owner_id, ticket_id, teamspace_project, description, goals)
SELECT DISTINCT s.user_id, s.ticket_id, COALESCE(t.title, ''), '', '' SELECT DISTINCT
CASE
WHEN t.zammad_owner_id IS NOT NULL THEN zammad_owner_user.id
ELSE s.user_id
END,
t.zammad_owner_id,
s.ticket_id,
COALESCE(t.title, ''),
'',
''
FROM sessions s FROM sessions s
JOIN tickets t ON t.id = s.ticket_id JOIN tickets t ON t.id = s.ticket_id
LEFT JOIN users zammad_owner_user
ON zammad_owner_user.role = 'user'
AND zammad_owner_user.active
AND zammad_owner_user.zammad_user_id = t.zammad_owner_id
WHERE s.work_type = 'consulting' WHERE s.work_type = 'consulting'
AND s.user_id IS NOT NULL AND s.user_id IS NOT NULL
ON CONFLICT (user_id, ticket_id) DO NOTHING; ON CONFLICT DO NOTHING;
`); `);
await query(` await query(`
+1 -1
View File
@@ -1531,7 +1531,7 @@ export function App() {
<SidebarMenuItem> <SidebarMenuItem>
<SidebarMenuButton tooltip="Worklog"> <SidebarMenuButton tooltip="Worklog">
<FolderGit /> <FolderGit />
<span className="text-xs text-muted-foreground">v1.0.10-rc1</span> <span className="text-xs text-muted-foreground">v1.0.11-rc1</span>
</SidebarMenuButton> </SidebarMenuButton>
</SidebarMenuItem> </SidebarMenuItem>
</SidebarMenu> </SidebarMenu>
+16
View File
@@ -140,6 +140,11 @@ export type TicketMeta = {
zammad_priority_id?: string | null; zammad_priority_id?: string | null;
zammad_state_id?: string | null; zammad_state_id?: string | null;
zammad_owner_id?: string | null; zammad_owner_id?: string | null;
zammad_owner_user_id?: string | null;
zammad_owner_display_name?: string | null;
zammad_owner_username?: string | null;
is_foreign_zammad_owner?: boolean;
can_edit_ticket_data?: boolean;
project_id?: string | null; project_id?: string | null;
is_project?: boolean; is_project?: boolean;
project_name?: string | null; project_name?: string | null;
@@ -184,6 +189,9 @@ export type ProjectSummary = {
work_type: WorkType | null; work_type: WorkType | null;
zammad_priority_id?: string | null; zammad_priority_id?: string | null;
zammad_state_id?: string | null; zammad_state_id?: string | null;
zammad_ticket_id?: string | null;
zammad_owner_id?: string | null;
ticket_zammad_owner_id?: string | null;
zammad_pending_time?: string | null; zammad_pending_time?: string | null;
zammad_merged_from_ticket_id?: string | null; zammad_merged_from_ticket_id?: string | null;
zammad_merged_from_ticket_number?: string | null; zammad_merged_from_ticket_number?: string | null;
@@ -376,8 +384,16 @@ 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_ticket_id?: string | null;
zammad_group_id?: string | null;
zammad_priority_id?: string | null; zammad_priority_id?: string | null;
zammad_state_id?: string | null; zammad_state_id?: string | null;
zammad_owner_id?: string | null;
zammad_owner_user_id?: string | null;
zammad_owner_display_name?: string | null;
zammad_owner_username?: string | null;
is_foreign_zammad_owner?: boolean;
can_edit_ticket_data?: boolean;
closed_at: string | null; closed_at: string | null;
requires_closure: boolean; requires_closure: boolean;
recurring_session_count: number; recurring_session_count: number;
+2 -2
View File
@@ -1664,7 +1664,7 @@ export function ProjectsPage({ onNavigate, onStartTimer }: ProjectsPageProps) {
const remaining = projectRemaining(project, viewSettings.standardTeamspaceOnly); const remaining = projectRemaining(project, viewSettings.standardTeamspaceOnly);
const usedPercent = projectConsumptionPercent(project, viewSettings.standardTeamspaceOnly); const usedPercent = projectConsumptionPercent(project, viewSettings.standardTeamspaceOnly);
const showBudgetedTeamspaceEntries = !viewSettings.standardTeamspaceOnly && budgetedTeamspaceEntries.length > 1; const showBudgetedTeamspaceEntries = !viewSettings.standardTeamspaceOnly && budgetedTeamspaceEntries.length > 1;
const canEditProject = project.can_edit !== false; const canEditProject = project.can_edit === true;
return ( return (
<div key={project.id} className="border-b last:border-b-0"> <div key={project.id} className="border-b last:border-b-0">
@@ -1797,7 +1797,7 @@ export function ProjectsPage({ onNavigate, onStartTimer }: ProjectsPageProps) {
variant="outline" variant="outline"
size="sm" size="sm"
className="h-7 shrink-0 px-2 text-xs" className="h-7 shrink-0 px-2 text-xs"
disabled={savingProjectStatusId === project.id || project.can_edit === false} disabled={savingProjectStatusId === project.id || !canEditProject}
onClick={() => void acknowledgeProjectReviewCheck(project)} onClick={() => void acknowledgeProjectReviewCheck(project)}
> >
<CheckCircle2 className="mr-1.5 h-3.5 w-3.5" /> <CheckCircle2 className="mr-1.5 h-3.5 w-3.5" />
+46 -12
View File
@@ -912,8 +912,17 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onS
const projectBudgetMinutes = project?.hour_budget_minutes ?? null; const projectBudgetMinutes = project?.hour_budget_minutes ?? null;
const projectConsumptionUsagePercent = project ? projectConsumptionPercent(projectConsumptionMinutes, projectBudgetMinutes) : null; const projectConsumptionUsagePercent = project ? projectConsumptionPercent(projectConsumptionMinutes, projectBudgetMinutes) : null;
const projectRemainingMinutes = projectBudgetMinutes && projectBudgetMinutes > 0 ? projectBudgetMinutes - projectConsumptionMinutes : null; const projectRemainingMinutes = projectBudgetMinutes && projectBudgetMinutes > 0 ? projectBudgetMinutes - projectConsumptionMinutes : null;
const canEditProject = project?.can_edit !== false; const canEditProject = project?.can_edit === true;
const canEditTicketData = !project || canEditProject; const ticketManagedByZammad = Boolean(ticket?.zammad_ticket_id);
const canEditTicketData = ticket?.can_edit_ticket_data === true;
const zammadOwnerLabel = ticket?.zammad_owner_display_name
|| ticket?.zammad_owner_username
|| (ticket?.zammad_owner_id ? `Zammad-Owner ${ticket.zammad_owner_id}` : null);
const projectOwnerLabel = project?.owner_display_name
|| project?.owner_username
|| (project?.zammad_owner_id ? `Zammad-Owner ${project.zammad_owner_id}` : null)
|| zammadOwnerLabel;
const currentZammadUserLabel = currentUser.zammad_user_id ? `Zammad-User ${currentUser.zammad_user_id}` : "dein Zammad-User";
const isProject = Boolean(project); const isProject = Boolean(project);
const isGlobalTicket = periodType === "all"; const isGlobalTicket = periodType === "all";
const isPeriodClosed = Boolean(data?.closed); const isPeriodClosed = Boolean(data?.closed);
@@ -1045,6 +1054,7 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onS
const periodTeamspaceMinutes = (data?.dayBillings ?? []).reduce((sum, billing) => sum + Number(billing.billed_minutes ?? 0), 0); const periodTeamspaceMinutes = (data?.dayBillings ?? []).reduce((sum, billing) => sum + Number(billing.billed_minutes ?? 0), 0);
const ticketDataDirty = Boolean( const ticketDataDirty = Boolean(
ticket && ticket &&
canEditTicketData &&
(editTicketNumber !== ticket.ticket_number || (editTicketNumber !== ticket.ticket_number ||
editTicketTitle !== (ticket.title ?? "") || editTicketTitle !== (ticket.title ?? "") ||
editOrganizationId !== (ticket.organization_id ?? "") || editOrganizationId !== (ticket.organization_id ?? "") ||
@@ -1284,8 +1294,10 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onS
} }
if (!canEditTicketData) { if (!canEditTicketData) {
toast.error("Projekt ist geteilt", { toast.error(ticketManagedByZammad ? "Ticketdaten kommen aus Zammad" : "Ticket gehört einem anderen Owner", {
description: "Nur der Zammad-Owner kann Ticket- und Projektdaten ändern." description: ticketManagedByZammad
? "Nummer, Art, Titel und Organisation werden durch den Zammad-Sync verwaltet."
: "Nur der Owner kann Ticketdaten ändern."
}); });
return; return;
} }
@@ -1334,7 +1346,7 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onS
return; return;
} }
if (project.can_edit === false) { if (!canEditProject) {
toast.error("Projekt ist geteilt", { toast.error("Projekt ist geteilt", {
description: "Nur der Zammad-Owner kann die Projektdaten bearbeiten." description: "Nur der Zammad-Owner kann die Projektdaten bearbeiten."
}); });
@@ -1352,7 +1364,7 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onS
return; return;
} }
if (project.can_edit === false) { if (!canEditProject) {
toast.error("Projekt ist geteilt", { toast.error("Projekt ist geteilt", {
description: "Nur der Zammad-Owner kann die Projektdaten bearbeiten." description: "Nur der Zammad-Owner kann die Projektdaten bearbeiten."
}); });
@@ -1407,7 +1419,7 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onS
return; return;
} }
if (project.can_edit === false) { if (!canEditProject) {
toast.error("Projekt ist geteilt", { toast.error("Projekt ist geteilt", {
description: "Nur der Zammad-Owner kann den Projektstatus ändern." description: "Nur der Zammad-Owner kann den Projektstatus ändern."
}); });
@@ -1433,7 +1445,7 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onS
return; return;
} }
if (project.can_edit === false) { if (!canEditProject) {
toast.error("Projekt ist geteilt", { toast.error("Projekt ist geteilt", {
description: "Nur der Zammad-Owner kann die Prüfung bestätigen." description: "Nur der Zammad-Owner kann die Prüfung bestätigen."
}); });
@@ -1811,6 +1823,26 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onS
</Alert> </Alert>
) : null} ) : null}
{ticket?.is_foreign_zammad_owner ? (
<Alert className="py-3 text-sm">
<AlertCircle className="size-4" />
<AlertTitle>Anderer Zammad-Owner</AlertTitle>
<AlertDescription>
Dieses Ticket gehört in Zammad {zammadOwnerLabel ? `${zammadOwnerLabel}` : "einem anderen Owner"}; du bist {currentZammadUserLabel}. Du kannst deine Sessions sehen, Projekt- und Ticketdaten sind für dich geschützt.
</AlertDescription>
</Alert>
) : null}
{ticketManagedByZammad ? (
<Alert className="py-3 text-sm">
<AlertCircle className="size-4" />
<AlertTitle>Ticketdaten werden von Zammad verwaltet</AlertTitle>
<AlertDescription>
Nummer, Art, Titel und Organisation wurden durch den Zammad-Sync übernommen und können in TicketTracker nicht manuell geändert werden.
</AlertDescription>
</Alert>
) : null}
<div className="grid gap-4"> <div className="grid gap-4">
<Card> <Card>
<CardHeader className="p-4 pb-2"> <CardHeader className="p-4 pb-2">
@@ -1820,7 +1852,9 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onS
<CardDescription> <CardDescription>
{canEditTicketData {canEditTicketData
? "Änderungen an Organisation und Art werden auf alle vorhandenen Sessions dieses Tickets übernommen." ? "Änderungen an Organisation und Art werden auf alle vorhandenen Sessions dieses Tickets übernommen."
: "Dieses Projekt ist geteilt. Nur der Zammad-Owner kann Ticket- und Projektdaten ändern."} : ticketManagedByZammad
? "Nummer, Art, Titel und Organisation kommen aus Zammad und sind lokal gesperrt."
: "Nur der Owner kann Ticketdaten ändern."}
</CardDescription> </CardDescription>
</div> </div>
<HelpLink anchor="ticketdaten" label="Hilfe zu Ticketdaten" /> <HelpLink anchor="ticketdaten" label="Hilfe zu Ticketdaten" />
@@ -1891,8 +1925,8 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onS
{(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}
</CardTitle> </CardTitle>
<CardDescription> <CardDescription>
{!canEditProject && project.owner_display_name {!canEditProject && projectOwnerLabel
? `Eigentümer: ${project.owner_display_name}` ? `Eigentümer: ${projectOwnerLabel}`
: showBudgetedProjectEntries : showBudgetedProjectEntries
? `${budgetedProjectEntries.length} budgetierte Teamspace-Einträge` ? `${budgetedProjectEntries.length} budgetierte Teamspace-Einträge`
: budgetedProjectEntries[0] ? teamspaceEntryDisplayLabel(budgetedProjectEntries[0]) : project.teamspace_project || "Kein Teamspace-Projekt hinterlegt"} : budgetedProjectEntries[0] ? teamspaceEntryDisplayLabel(budgetedProjectEntries[0]) : project.teamspace_project || "Kein Teamspace-Projekt hinterlegt"}
@@ -1944,7 +1978,7 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onS
variant="outline" variant="outline"
size="sm" size="sm"
className="shrink-0" className="shrink-0"
disabled={savingProjectStatus || project.can_edit === false} disabled={savingProjectStatus || !canEditProject}
onClick={() => void acknowledgeProjectReviewCheck()} onClick={() => void acknowledgeProjectReviewCheck()}
> >
<CheckCircle2 className="mr-2 h-4 w-4" /> <CheckCircle2 className="mr-2 h-4 w-4" />
+20 -1
View File
@@ -659,6 +659,25 @@ function addCachedChange(changes: ZammadSyncChange[], field: string, from: unkno
} }
} }
const preservedExistingPreviewChangeFields = new Set([
"Fremdes Ticket",
"Fremder Zammad-Owner",
"Owner",
"Ticket-Zuordnung",
"Hinweis",
"Ticketnummer",
"TicketTracker-Zusammenführung"
]);
function preserveExistingPreviewChanges(item: ZammadSyncItem, changes: ZammadSyncChange[], fields: ZammadSyncExistingFields) {
const existingFields = new Set(changes.map((change) => change.field));
return [
...item.changes.filter((change) => preservedExistingPreviewChangeFields.has(change.field) && !existingFields.has(change.field) && changeAllowedByFields(item, change, fields)),
...changes
];
}
function changesFromExistingPreviewCache(item: ZammadSyncItem, fields: ZammadSyncExistingFields) { function changesFromExistingPreviewCache(item: ZammadSyncItem, fields: ZammadSyncExistingFields) {
if (!item.zammad) { if (!item.zammad) {
return item.changes.filter((change) => changeAllowedByFields(item, change, fields)); return item.changes.filter((change) => changeAllowedByFields(item, change, fields));
@@ -756,7 +775,7 @@ function changesFromExistingPreviewCache(item: ZammadSyncItem, fields: ZammadSyn
} }
} }
return changes; return preserveExistingPreviewChanges(item, changes, fields);
} }
function rebuildZammadSyncResultFromItems(mode: ZammadSyncResult["mode"], items: ZammadSyncItem[], checked = items.length): ZammadSyncResult { function rebuildZammadSyncResultFromItems(mode: ZammadSyncResult["mode"], items: ZammadSyncItem[], checked = items.length): ZammadSyncResult {