This commit is contained in:
michael committed 2026-09-30 12:43:01 +02:00
1 parent 39690b1d2b
commit 35cb578a96
8 files changed
+1091 -46

No files matched your search

+7
View File
@@ -672,6 +672,13 @@ export function updateProjectStatus(projectId: string, closed: boolean) {
});
}
export function acknowledgeProjectReview(projectId: string) {
return request<{ project: { id: string; ticket_id: string; close_review_required: boolean } }>(`/api/projects/${projectId}/review`, {
method: "PATCH",
body: JSON.stringify({ acknowledged: true })
});
}
export function deleteProject(projectId: string) {
return request<{ deleted: { id: string; ticket_id: string } }>(`/api/projects/${projectId}`, {
method: "DELETE"
+2
View File
@@ -180,6 +180,8 @@ export type ProjectSummary = {
zammad_priority_id?: string | null;
zammad_state_id?: string | null;
zammad_pending_time?: string | null;
zammad_merged_from_ticket_id?: string | null;
zammad_merged_from_ticket_number?: string | null;
teamspace_project: string | null;
description: string;
goals: string;
+10 -8
View File
@@ -1859,14 +1859,16 @@ function DayControlWidget({ stats, projects, todos, onNavigate }: WidgetContentP
empty="Keine Budget-Hinweise."
items={budgetAttention.slice(0, 4).map((project) => {
const percent = projectConsumptionPercent(project);
const hasUnassignedSessions = Number(project.unassigned_teamspace_session_count ?? 0) > 0;
const detail = project.close_review_required
? "Abschluss prüfen"
: hasUnassignedSessions
? `${project.unassigned_teamspace_session_count} Session(s) ohne TS-Zuordnung`
: `${percent ?? 0}% Kontingent verbraucht`;
const note = project.close_review_required
? "Abschluss in TT/Teamspace prüfen und ggf. Umbuchungen klären."
const hasUnassignedSessions = Number(project.unassigned_teamspace_session_count ?? 0) > 0;
const detail = project.close_review_required
? project.zammad_merged_from_ticket_id || project.zammad_state_id !== "4" ? "Merge prüfen" : "Abschluss prüfen"
: hasUnassignedSessions
? `${project.unassigned_teamspace_session_count} Session(s) ohne TS-Zuordnung`
: `${percent ?? 0}% Kontingent verbraucht`;
const note = project.close_review_required
? project.zammad_merged_from_ticket_id || project.zammad_state_id !== "4"
? "Lokale Projektdaten aus dem alten Ticket prüfen und in Zammad nachtragen."
: "Abschluss in TT/Teamspace prüfen und ggf. Umbuchungen klären."
: hasUnassignedSessions
? "Sessions einem Teamspace-Projekt zuordnen."
: "Kontingent kritisch: Budget, Verbrauch und weitere Arbeit prüfen.";
+36 -4
View File
@@ -15,7 +15,7 @@ import { HelpLink } from "@/components/HelpLink";
import { MultiFilterDropdown, type MultiFilterOption } from "@/components/MultiFilterDropdown";
import { OrganizationSelect } from "@/components/OrganizationSelect";
import { cn } from "@/lib/utils";
import { addFavoriteTicket, createProject, getProjects, hideProject, lookupTicket, unhideProject, updateProject, updateProjectStatus } from "../api";
import { acknowledgeProjectReview, addFavoriteTicket, createProject, getProjects, hideProject, lookupTicket, unhideProject, updateProject, updateProjectStatus } from "../api";
import { currentMonth, formatMinutes } from "../format";
import { projectHasRecurringBilling } from "../recurring-billings";
import { ticketPattern } from "../timers";
@@ -1359,6 +1359,21 @@ export function ProjectsPage({ onNavigate, onStartTimer }: ProjectsPageProps) {
}
}
async function acknowledgeProjectReviewCheck(project: ProjectSummary) {
setSavingProjectStatusId(project.id);
try {
await acknowledgeProjectReview(project.id);
toast.success("Prüfung bestätigt");
await load();
} catch (error) {
toast.error("Prüfung konnte nicht bestätigt werden", {
description: error instanceof Error ? error.message : "Unbekannter Fehler"
});
} finally {
setSavingProjectStatusId(null);
}
}
async function saveProjectFavorite(project: ProjectSummary) {
try {
await addFavoriteTicket(project.ticket_number);
@@ -1769,9 +1784,26 @@ export function ProjectsPage({ onNavigate, onStartTimer }: ProjectsPageProps) {
</p>
) : null}
{project.close_review_required ? (
<p className="rounded-md border border-amber-500/30 bg-amber-500/10 px-2 py-1 text-xs text-amber-700 dark:text-amber-300">
Zammad meldet das Consulting-Ticket als geschlossen. Bitte Zeiten in Teamspace und TicketTracker prüfen und das Projekt danach schließen.
</p>
<div className="flex flex-col gap-2 rounded-md border border-amber-500/30 bg-amber-500/10 px-2 py-1 text-xs text-amber-700 dark:text-amber-300 sm:flex-row sm:items-center sm:justify-between">
<span>
{project.zammad_merged_from_ticket_id || project.zammad_state_id !== "4"
? project.zammad_merged_from_ticket_id
? `Dieses Projekt wurde aus ${project.zammad_merged_from_ticket_number ?? `Zammad-ID ${project.zammad_merged_from_ticket_id}`} übernommen. Teamspace-Projekte und Budget bleiben lokal erhalten; bitte in Zammad prüfen und nachtragen.`
: "Dieses Projekt steht zur TS/TT-Prüfung, obwohl das Zammad-Ticket offen ist. Teamspace-Projekte und Budget bleiben lokal erhalten; bitte in Zammad prüfen und nachtragen."
: "Zammad meldet das Consulting-Ticket als geschlossen. Bitte Zeiten in Teamspace und TicketTracker prüfen und das Projekt danach schließen."}
</span>
<Button
type="button"
variant="outline"
size="sm"
className="h-7 shrink-0 px-2 text-xs"
disabled={savingProjectStatusId === project.id || project.can_edit === false}
onClick={() => void acknowledgeProjectReviewCheck(project)}
>
<CheckCircle2 className="mr-1.5 h-3.5 w-3.5" />
Geprüft
</Button>
</div>
) : null}
{showBudgetedTeamspaceEntries ? (
<div className="grid gap-1.5 md:grid-cols-2">
+49 -3
View File
@@ -15,6 +15,7 @@ import { OrganizationSelect } from "@/components/OrganizationSelect";
import { readRichActivityEditorValue, RichActivityEditor } from "@/components/RichActivityEditor";
import { TodoPanel } from "@/components/TodoPanel";
import {
acknowledgeProjectReview,
createSession,
deleteProject,
deleteSession,
@@ -1406,6 +1407,32 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onS
}
}
async function acknowledgeProjectReviewCheck() {
if (!project) {
return;
}
if (project.can_edit === false) {
toast.error("Projekt ist geteilt", {
description: "Nur der Zammad-Owner kann die Prüfung bestätigen."
});
return;
}
setSavingProjectStatus(true);
try {
await acknowledgeProjectReview(project.id);
toast.success("Prüfung bestätigt");
await load();
} catch (error) {
toast.error("Prüfung konnte nicht bestätigt werden", {
description: error instanceof Error ? error.message : "Unbekannter Fehler"
});
} finally {
setSavingProjectStatus(false);
}
}
async function submitTicketSession(event: FormEvent) {
event.preventDefault();
@@ -1879,9 +1906,28 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onS
) : null}
{project.close_review_required ? (
<Alert variant="warning">
<div className="font-medium">Zeiten prüfen</div>
<div className="text-sm text-muted-foreground">
Zammad meldet dieses Consulting-Ticket als geschlossen. Bitte Zeiten in Teamspace und TicketTracker prüfen und das Projekt danach schließen.
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div className="space-y-1">
<div className="font-medium">{project.zammad_merged_from_ticket_id || project.zammad_state_id !== "4" ? "Merge prüfen" : "Zeiten prüfen"}</div>
<div className="text-sm text-muted-foreground">
{project.zammad_merged_from_ticket_id || project.zammad_state_id !== "4"
? project.zammad_merged_from_ticket_id
? `Dieses Projekt wurde aus ${project.zammad_merged_from_ticket_number ?? `Zammad-ID ${project.zammad_merged_from_ticket_id}`} übernommen. Teamspace-Projekte, Budget und Zuordnungen bleiben lokal erhalten; bitte in Zammad prüfen und nachtragen.`
: "Dieses Projekt steht zur TS/TT-Prüfung, obwohl das Zammad-Ticket offen ist. Teamspace-Projekte, Budget und Zuordnungen bleiben lokal erhalten; bitte in Zammad prüfen und nachtragen."
: "Zammad meldet dieses Consulting-Ticket als geschlossen. Bitte Zeiten in Teamspace und TicketTracker prüfen und das Projekt danach schließen."}
</div>
</div>
<Button
type="button"
variant="outline"
size="sm"
className="shrink-0"
disabled={savingProjectStatus || project.can_edit === false}
onClick={() => void acknowledgeProjectReviewCheck()}
>
<CheckCircle2 className="mr-2 h-4 w-4" />
Geprüft
</Button>
</div>
</Alert>
) : null}
+38 -4
View File
@@ -433,7 +433,7 @@ function formatChangeValue(field: string, value: unknown) {
return formatMinutes(value);
}
if (field === "Teamspace-Projekte" || field === "Teamspace-Prüfung") {
if (field === "Teamspace-Projekte" || field === "Teamspace-Prüfung" || field === "Merge-Projektdaten") {
return formatTeamspaceProjectList(value) || "-";
}
@@ -493,6 +493,10 @@ function changeAllowedByFields(item: ZammadSyncItem, change: ZammadSyncChange, f
return fields.projectName && consultingProjectCanBeTouched;
}
if (change.field === "Merge-Projektdaten" || change.field === "Merge-Prüfung") {
return true;
}
if (change.field === "Teamspace-Budget") {
return fields.budget && consultingProjectCanBeTouched;
}
@@ -578,6 +582,10 @@ function addCachedTeamspaceProjectChanges(changes: ZammadSyncChange[], localProj
}
}
function mergeReviewMatchesCachedZammad(localTeamspaceProjects: unknown, zammadTeamspaceProjects: unknown) {
return !cachedValueChanged("Teamspace-Projekte", localTeamspaceProjects, zammadTeamspaceProjects);
}
function zammadPendingTodoPreview(zammad: Record<string, unknown>) {
const stateId = numericValue(cachedValue(zammad, "stateId"));
const pendingTime = cachedValue(zammad, "pendingTime");
@@ -666,6 +674,11 @@ function changesFromExistingPreviewCache(item: ZammadSyncItem, fields: ZammadSyn
const localWorkType = cachedValue(local, "workType");
const zammadWorkType = cachedValue(zammad, "workType");
const zammadStateId = numericValue(cachedValue(zammad, "stateId"));
const mergeProtected = Boolean(
cachedValue(local, "zammadMergedFromTicketId")
|| cachedValue(zammad, "mergedFromTicketId")
|| (cachedValue(project, "closeReviewRequired") === true && zammadStateId !== 4)
);
const pendingTodoExists = cachedValue(local, "zammadPendingTodoExists");
const localTeamspaceProjects = cachedStringList(project, "teamspaceProjects");
const zammadTeamspaceProjects = cachedStringList(zammad, "teamspaceProjects");
@@ -694,15 +707,15 @@ function changesFromExistingPreviewCache(item: ZammadSyncItem, fields: ZammadSyn
addCachedChange(changes, "Organisation", cachedValue(local, "organizationId"), cachedValue(zammad, "organizationId"));
}
if (fields.type) {
if (fields.type && !mergeProtected) {
addCachedChange(changes, "Typ", localWorkType, zammadWorkType);
}
if (fields.type && zammadWorkType === "support" && project) {
if (!mergeProtected && fields.type && zammadWorkType === "support" && project) {
changes.push({ field: "Projekt/Teamspace-Daten", from: "vorhanden", to: "werden gelöscht" });
}
const consultingProjectCanBeTouched = zammadWorkType === "consulting" && (fields.type || localWorkType === "consulting");
const consultingProjectCanBeTouched = !mergeProtected && zammadWorkType === "consulting" && (fields.type || localWorkType === "consulting");
if (consultingProjectCanBeTouched) {
if (fields.type && !project) {
@@ -722,6 +735,27 @@ function changesFromExistingPreviewCache(item: ZammadSyncItem, fields: ZammadSyn
}
}
if (mergeProtected && project && cachedValue(project, "closeReviewRequired") === true && zammadStateId !== 4) {
if (mergeReviewMatchesCachedZammad(localTeamspaceProjectList, zammadTeamspaceProjectList)) {
changes.push({ field: "Merge-Prüfung", from: true, to: false });
} else {
if (localTeamspaceProjectList.length > 0 && zammadTeamspaceProjectList.length === 0) {
changes.push({
field: "Merge-Projektdaten",
from: normalizedTeamspaceProjectList(localTeamspaceProjectList),
to: "in Zammad fehlen; lokale Projektdaten bleiben erhalten und muessen dort nachgetragen werden"
});
} else if (cachedValueChanged("Teamspace-Projekte", localTeamspaceProjectList, zammadTeamspaceProjectList)) {
changes.push({
field: "Merge-Projektdaten",
from: normalizedTeamspaceProjectList(localTeamspaceProjectList),
to: normalizedTeamspaceProjectList(zammadTeamspaceProjectList)
});
}
}
}
return changes;
}