Files
HiveGrid-Wireframes/03 Quellen und Referenzen/hivegrid-cloud/apps/web/app/utils/integrations/projects/projectDetailRoute.server.ts
T

634 lines
26 KiB
TypeScript

// R-P8a (P8 Projekt core) Interim-UI (AD-28) — server helper for the project
// detail (`/projects/:id`). Builds the P8 ProjektDetailVM + ActivityStreamVM
// from projectService + the tenant-scoped Activity timeline and handles the P8
// action intents (stammdaten inline-edit, stage move, 24h-undo). Server-only
// (.server) — never imported by a component. The P9 (auftrag/signature) parts
// of the route stay untouched; this only augments the P8 core section.
import {
buildActivityStreamVM,
buildProjektDetailVM,
datentypLabelKey,
type ActivityStreamVM,
type ProjectMemberRole,
type ProjektDetailVM,
} from "@hivegrid/view-models";
import { loadKwpProjectPanel } from "@hivegrid/integrations/kwp-project-domain.server";
import { canUsePortalInvitationEntrypoint } from "~/services/customer-portal/portal-invitation-entrypoint.server";
import { withTenantRLS } from "~/utils/db/.server/tenantRls.server";
import { companyDetailPath, contactDetailPath } from "~/utils/integrations/crm/crmRoutePaths";
import { createKontakt, listAssignableUsers, resolveUserLabels } from "~/utils/integrations/crm/crmService.server";
import { getCustomFieldValues, listCustomFieldDefinitions } from "~/utils/integrations/crm/customFieldService.server";
import type { ProjectRouteContext } from "./projectAccess.server";
import { publishProjectBoardChange } from "./projectBoardEvents.server";
import { listProjectNotes, toProjektNotizContext } from "./projektNotizService.server";
import {
linkProjektKontakt,
unlinkProjektKontakt,
loadProjectDetail,
moveProjectStage,
PROJECT_ADDRESS_SLOTS,
setProjectCustomFieldValue,
undoLastProjectStageChange,
updateProjectAddresses,
updateProjectStammdaten,
type ProjectAddressInput,
type ProjectAddressSlot,
type ProjektKontaktDetail,
} from "./projectService.server";
const ACTIVITY_LIMIT = 50;
/** Rows per rail tile: the rail is a glance, the modules below are the full list. */
const RAIL_LIMIT = 8;
/** Polymorphic CustomFieldValue entity type for the project core (ENTITY-MODEL Domäne 5). */
const PROJEKT_CUSTOM_FIELD_ENTITY = "projekt" as const;
type ActivityDbRow = {
id: string;
createdAt: Date;
actorUserId: string | null;
actorKind: string;
activityType: string;
summaryText: string;
};
function decimalToDisplay(value: unknown): string | null {
if (value == null) return null;
const num = typeof value === "number" ? value : Number(String(value));
if (!Number.isFinite(num)) return null;
return num.toFixed(2);
}
type MemberDbRow = { id: string; userId: string; rolle: ProjectMemberRole };
/**
* WF-P8 S02 "Planung und Verantwortliche" needs the member rows with their raw
* user ids to drive the two people pickers, which the P9 operations VM drops in
* favour of a display label. Group principals stay out: the section offers
* pickers over named persons, groups remain editable in the operations panel.
*/
async function loadProjectUserMembers(tenantId: string, projectId: string): Promise<MemberDbRow[]> {
const rows = await withTenantRLS(tenantId, (tx) =>
tx.projektMitglied.findMany({
where: { tenantId, projektId: projectId, archivedAt: null, userId: { not: null } },
orderBy: { createdAt: "asc" },
select: { id: true, userId: true, rolle: true },
})
);
return rows.flatMap((row) => (row.userId ? [{ id: row.id, userId: row.userId, rolle: row.rolle as ProjectMemberRole }] : []));
}
/**
* WF-P8 S02 "Letzter Kontakt" on the contact cards. Derived, never stored: the
* timeline already holds every touch (mail, call, note), and a stored column
* would be a second truth that drifts the moment one writer forgets it.
*
* Contacts and companies use different subject types in the timeline, so both
* are queried and the newest entry per contact id wins.
*/
async function loadLastContactTouch(tenantId: string, kontaktIds: readonly string[]): Promise<Map<string, string>> {
if (kontaktIds.length === 0) return new Map();
const rows = await withTenantRLS(tenantId, (tx) =>
tx.activity.findMany({
where: { tenantId, subjectType: { in: ["contact", "company"] }, subjectId: { in: [...kontaktIds] } },
orderBy: { createdAt: "desc" },
select: { subjectId: true, createdAt: true },
})
);
const newest = new Map<string, string>();
for (const row of rows) {
if (!newest.has(row.subjectId)) newest.set(row.subjectId, row.createdAt.toISOString());
}
return newest;
}
/**
* WF-P8 S02 rail: the project's stage history. `ProjektStageChange` already keeps
* every move, so the rail reads it instead of a second log — and it reads it
* read-only: moving a stage stays the board's and the header's job.
*/
async function loadStageHistory(
tenantId: string,
projectId: string,
stageNameById: Map<string, string>
): Promise<{ id: string; label: string; atIso: string; actorUserId: string | null }[]> {
const rows = await withTenantRLS(tenantId, (tx) =>
tx.projektStageChange.findMany({
where: { tenantId, projektId: projectId, undoneAt: null },
orderBy: { createdAt: "desc" },
take: RAIL_LIMIT,
select: { id: true, fromStageId: true, toStageId: true, createdAt: true, changedByUserId: true },
})
);
return rows.map((row) => ({
id: row.id,
label: `${stageNameById.get(row.fromStageId) ?? row.fromStageId} → ${stageNameById.get(row.toStageId) ?? row.toStageId}`,
atIso: row.createdAt.toISOString(),
actorUserId: row.changedByUserId,
}));
}
/**
* WF-P8 S02 rail: the next appointments and the open tasks of this project.
*
* Read here rather than reused from the P9 operations view model, because that
* one formats an assignee as the bare user id (`principalLabel`) — fine for a
* panel that shows it next to a picker, useless in a rail line. These rows carry
* the ids so the caller can resolve them with the labels it already fetches.
*/
async function loadRailAgenda(
tenantId: string,
projectId: string
): Promise<{
appointments: { id: string; titel: string; startAt: Date; userId: string | null }[];
tasks: { id: string; title: string; deadline: Date | null; userId: string | null }[];
}> {
return withTenantRLS(tenantId, async (tx) => {
const [appointments, tasks] = await Promise.all([
tx.projektTermin.findMany({
where: { tenantId, projektId: projectId, archivedAt: null },
orderBy: { startAt: "asc" },
take: RAIL_LIMIT,
select: { id: true, titel: true, startAt: true, assignedToUserId: true },
}),
tx.task.findMany({
where: { tenantId, linkedEntityType: "project", linkedEntityId: projectId, archivedAt: null, status: { not: "done" } },
orderBy: [{ deadline: "asc" }, { createdAt: "desc" }],
take: RAIL_LIMIT,
select: { id: true, title: true, deadline: true, assignedToUserId: true },
}),
]);
return {
appointments: appointments.map((row) => ({ id: row.id, titel: row.titel, startAt: row.startAt, userId: row.assignedToUserId })),
tasks: tasks.map((row) => ({ id: row.id, title: row.title, deadline: row.deadline, userId: row.assignedToUserId })),
};
});
}
/** S02 KWP block. The module owns the binding, so it owns the read as well. */
async function loadKwpPanel(tenantId: string, projectId: string) {
return withTenantRLS(tenantId, (tx) => loadKwpProjectPanel(tx as never, tenantId, projectId));
}
async function loadProjectActivities(tenantId: string, projectId: string): Promise<ActivityDbRow[]> {
const rows = await withTenantRLS(tenantId, (tx) =>
tx.activity.findMany({
where: { tenantId, subjectType: "project", subjectId: projectId },
orderBy: { createdAt: "desc" },
take: ACTIVITY_LIMIT + 1,
select: { id: true, createdAt: true, actorUserId: true, actorKind: true, activityType: true, summaryText: true },
})
);
return rows as ActivityDbRow[];
}
export type ProjektCustomFieldDef = { id: string; key: string; label: string; datentyp: string; datentypLabelKey: string };
/** One rail entry: a line plus its timestamp and, where known, its author. */
export type ProjektRailEntry = { id: string; label: string; meta: string | null; atIso: string | null };
/**
* The read-only content of the S02 rail tiles the screen cannot derive from the
* detail VM. Read-only on purpose: the editors for appointments, tasks and notes
* live in the operations panel further down the same route, and a second editor
* over the same rows is how the two drift apart. A second *view* does not have
* that problem.
*/
export type ProjektDetailRailAreas = {
history: ProjektRailEntry[];
notes: ProjektRailEntry[];
appointments: ProjektRailEntry[];
tasks: ProjektRailEntry[];
};
export type ProjektDetailP8Data = {
detailVM: ProjektDetailVM;
activityVM: ActivityStreamVM;
ownerOptions: { id: string; label: string }[];
customFieldDefs: ProjektCustomFieldDef[];
railAreas: ProjektDetailRailAreas;
};
export async function loadProjektDetailP8({
tenantId,
tenantSlug,
projectId,
ctx,
nowIso,
locale,
}: {
tenantId: string;
/** Needed for the S02 contact cards, which link into the CRM detail screens. */
tenantSlug: string;
projectId: string;
ctx: ProjectRouteContext;
nowIso: string;
locale?: string;
}): Promise<ProjektDetailP8Data> {
const detail = await loadProjectDetail({ tenantId, projectId, ctx });
const canBrowseTenantMembers = ctx.permissions.includes("projects.write") || ctx.permissions.includes("app.settings.members.view");
const canReadCrm = ctx.permissions.includes("crm.read_all") || ctx.permissions.includes("crm.read_own");
const [activityRows, memberRows, kwpPanel, ownerOptions, customFieldValues, customFieldDefinitions, canInviteToPortal] = await Promise.all([
loadProjectActivities(tenantId, projectId),
loadProjectUserMembers(tenantId, projectId),
loadKwpPanel(tenantId, projectId),
canBrowseTenantMembers ? listAssignableUsers(tenantId) : Promise.resolve([]),
getCustomFieldValues({ tenantId, entityType: PROJEKT_CUSTOM_FIELD_ENTITY, entityId: projectId }),
listCustomFieldDefinitions({ tenantId, entityType: PROJEKT_CUSTOM_FIELD_ENTITY }),
canUsePortalInvitationEntrypoint(ctx),
]);
const stageNameById = new Map(detail.stages.map((stage) => [stage.id, stage.name]));
const [lastTouchByKontaktId, stageHistory, noteRows, agenda] = await Promise.all([
loadLastContactTouch(
tenantId,
detail.kontakte.map((kontakt) => kontakt.kontaktId)
),
loadStageHistory(tenantId, projectId, stageNameById),
// The note reader owns the visibility scope, so an internal note stays out of
// the rail for somebody who may not read it.
listProjectNotes({ tenantId, projectId, ctx: toProjektNotizContext(ctx) }).catch(() => []),
loadRailAgenda(tenantId, projectId),
]);
const actorIds = activityRows.map((row) => row.actorUserId).filter((id): id is string => Boolean(id));
const userLabels = await resolveUserLabels(
[
detail.project.zustaendigePersonId,
detail.project.projektleiterId,
...memberRows.map((row) => row.userId),
...actorIds,
...stageHistory.map((entry) => entry.actorUserId),
...noteRows.map((note) => note.autorUserId),
...agenda.appointments.map((appointment) => appointment.userId),
...agenda.tasks.map((task) => task.userId),
].filter((id): id is string => Boolean(id))
);
const detailVM = buildProjektDetailVM({
locale,
project: {
id: detail.project.id,
projektNummer: detail.project.projektNummer,
projektname: detail.project.projektname,
beschreibung: detail.project.beschreibung,
prioritaet: detail.project.prioritaet,
stageId: detail.project.stageId,
startDatumIso: detail.project.startDatum?.toISOString() ?? null,
projekttyp: detail.project.projekttyp,
quelle: detail.project.quelle,
promotion: detail.project.promotion,
email: detail.project.email,
telefon: detail.project.telefon,
fax: detail.project.fax,
deadlineIso: detail.project.deadline?.toISOString() ?? null,
zustaendigePersonId: detail.project.zustaendigePersonId,
projektleiterId: detail.project.projektleiterId,
konvertiertAusLeadId: detail.project.konvertiertAusLeadId,
wonAtIso: detail.project.wonAt?.toISOString() ?? null,
lostAtIso: detail.project.lostAt?.toISOString() ?? null,
undoStageChangeUntilIso: detail.project.undoStageChangeUntil?.toISOString() ?? null,
lostReasonId: detail.project.lostReasonId,
},
pipelineName: detail.pipeline.name,
pipelineTypHint: detail.pipeline.typHint,
stages: detail.stages.map((stage) => ({
id: stage.id,
name: stage.name,
stageKind: stage.stageKind,
winProbability: stage.winProbability,
color: stage.color,
})),
kontakte: detail.kontakte.map((kontakt) => ({
id: kontakt.id,
kontaktId: kontakt.kontaktId,
kontaktTyp: kontakt.kontaktTyp,
rolle: kontakt.rolle,
primaer: kontakt.primaer,
label: kontakt.label,
email: kontakt.email,
telefon: kontakt.telefon,
// Only offered with a CRM read permission. `crm.read_own` can still land
// on a 404 for someone else's contact — that is the module's own owner
// gate, and hiding the link entirely would cost the common case.
stammdaten: contactStammdatenRows(kontakt),
notizen: kontakt.notizen,
durchwahl: kontakt.durchwahl,
mobil: kontakt.mobil,
fax: kontakt.fax,
adresseLabel: kontakt.adresseLabel,
lastContactAtIso: lastTouchByKontaktId.get(kontakt.kontaktId) ?? null,
detailHref: canReadCrm
? kontakt.kontaktTyp === "organisation"
? companyDetailPath(tenantSlug, kontakt.kontaktId)
: contactDetailPath(tenantSlug, kontakt.kontaktId)
: null,
portalInviteEligible: kontakt.portalInviteEligible,
})),
lostReasons: detail.lostReasons.map((reason) => ({ id: reason.id, name: reason.name })),
adressen: detail.adressen,
kwp: kwpPanel,
customFields: customFieldValues.map((cf) => ({
definitionId: cf.definition.id,
key: cf.definition.key,
label: cf.definition.label,
datentyp: cf.definition.datentyp,
wert: cf.wert,
optionen: cf.definition.optionen,
})),
value: detail.canViewVolume ? decimalToDisplay(detail.project.potenziellerAuftragswert) : null,
orderVolume: detail.canViewVolume ? decimalToDisplay(detail.project.auftragsvolumen) : null,
valueVisible: detail.canViewVolume,
ownerLabel: detail.project.zustaendigePersonId ? (userLabels.get(detail.project.zustaendigePersonId) ?? null) : null,
projektleiterLabel: detail.project.projektleiterId ? (userLabels.get(detail.project.projektleiterId) ?? null) : null,
mitglieder: memberRows.map((row) => ({
id: row.id,
userId: row.userId,
rolle: row.rolle,
label: userLabels.get(row.userId) ?? row.userId,
})),
perms: {
canWrite: hasPerm(ctx.permissions, "projects.write"),
canChangeStage: hasPerm(ctx.permissions, "projects.change_stage"),
canInviteToPortal,
canManageMembers: hasPerm(ctx.permissions, "projects.manage_members"),
},
nowIso,
});
const activityVM = buildActivityStreamVM({
rows: activityRows.slice(0, ACTIVITY_LIMIT).map((row) => ({
id: row.id,
createdAt: row.createdAt,
actorUserId: row.actorUserId,
actorKind: row.actorKind as never,
activityType: row.activityType as never,
summaryText: row.summaryText,
})),
userLabels,
hasMore: activityRows.length > ACTIVITY_LIMIT,
});
const customFieldDefs: ProjektCustomFieldDef[] = customFieldDefinitions.map((def) => ({
id: def.id,
key: def.key,
label: def.label,
datentyp: def.datentyp,
datentypLabelKey: datentypLabelKey(def.datentyp),
}));
const railAreas: ProjektDetailRailAreas = {
history: stageHistory.map((entry) => ({
id: entry.id,
label: entry.label,
meta: entry.actorUserId ? (userLabels.get(entry.actorUserId) ?? null) : null,
atIso: entry.atIso,
})),
appointments: agenda.appointments.map((appointment) => ({
id: appointment.id,
label: appointment.titel,
meta: appointment.userId ? (userLabels.get(appointment.userId) ?? null) : null,
atIso: appointment.startAt.toISOString(),
})),
tasks: agenda.tasks.map((task) => ({
id: task.id,
label: task.title,
meta: task.userId ? (userLabels.get(task.userId) ?? null) : null,
atIso: task.deadline?.toISOString() ?? null,
})),
notes: noteRows.slice(0, RAIL_LIMIT).map((note) => ({
id: note.id,
label: note.inhalt,
// The visibility is part of the line: "nur ich" next to a note is what
// tells the author that nobody else is reading it.
meta: [note.autorUserId ? userLabels.get(note.autorUserId) : null, note.sichtbarkeit].filter(Boolean).join(" · ") || null,
atIso: note.createdAt.toISOString(),
})),
};
return { detailVM, activityVM, ownerOptions, customFieldDefs, railAreas };
}
function hasPerm(permissions: readonly string[], permission: string): boolean {
return permissions.includes(permission);
}
/**
* WF-P8 S02 contact cards: the master-data rows below the channels. Only fields
* that are actually set become rows — an empty card of "—" labels tells nobody
* anything. Company fields go on the organisation card, person fields on the
* Ansprechpartner card, so a card never shows the other kind's empty slots.
*/
export function contactStammdatenRows(kontakt: ProjektKontaktDetail): { labelKey: string; value: string }[] {
const candidates: [string, string | null][] =
kontakt.kontaktTyp === "organisation"
? [
["projects.detail.contacts.website", kontakt.website],
["projects.detail.contacts.vatId", kontakt.ustIdNr],
["projects.detail.contacts.tradeRegister", kontakt.handelsregister],
["projects.detail.contacts.customerNumber", kontakt.kundennummer],
["projects.detail.contacts.customerSince", kontakt.kundeSeit ? kontakt.kundeSeit.toISOString().slice(0, 10) : null],
["projects.detail.contacts.industry", kontakt.branche],
["projects.detail.contacts.headcount", kontakt.mitarbeiterzahl === null ? null : String(kontakt.mitarbeiterzahl)],
]
: [
["projects.detail.contacts.position", kontakt.position],
["projects.detail.contacts.availability", kontakt.erreichbarkeit],
];
return candidates.flatMap(([labelKey, value]) => {
const trimmed = value?.trim();
return trimmed ? [{ labelKey, value: trimmed }] : [];
});
}
/**
* Parse the S02 address payload. Unknown slots and non-string members are
* dropped; `null` for a slot is meaningful (clear it), so it survives. A
* malformed payload writes nothing rather than clearing every slot.
*/
export function parseAddressSlots(raw: string | null): Partial<Record<ProjectAddressSlot, ProjectAddressInput | null>> {
if (!raw) return {};
let parsed: unknown;
try {
parsed = JSON.parse(raw);
} catch {
return {};
}
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return {};
const out: Partial<Record<ProjectAddressSlot, ProjectAddressInput | null>> = {};
for (const slot of PROJECT_ADDRESS_SLOTS) {
if (!(slot in parsed)) continue;
const value = (parsed as Record<string, unknown>)[slot];
if (value === null) {
out[slot] = null;
continue;
}
if (typeof value !== "object" || Array.isArray(value)) continue;
const record = value as Record<string, unknown>;
const pick = (key: string): string | undefined => (typeof record[key] === "string" ? (record[key] as string) : undefined);
out[slot] = {
strasse: pick("strasse"),
hausnummer: pick("hausnummer"),
plz: pick("plz"),
ort: pick("ort"),
land: pick("land"),
// Slot extras (ProjektAdressDetail). Absent members stay undefined so a
// slot that never shows a field cannot clear it by omission.
bauabschnitt: pick("bauabschnitt"),
zufahrt: pick("zufahrt"),
koordinaten: pick("koordinaten"),
empfaenger: pick("empfaenger"),
zahlungsziel: pick("zahlungsziel"),
rechnungsEmail: pick("rechnungsEmail"),
postErwuenscht: typeof record.postErwuenscht === "boolean" ? record.postErwuenscht : undefined,
telefon: pick("telefon"),
ansprechpartnerVorOrt: pick("ansprechpartnerVorOrt"),
notizen: pick("notizen"),
};
}
return out;
}
/** Handle a P8 detail intent. Returns true if the intent was a P8 one (handled),
* false if it should fall through to the existing P9 action switch. Throws
* ProjectServiceError/CatalogError on failure (caller maps to a Response). */
export async function handleProjektDetailP8Intent({
intent,
form,
tenantId,
projectId,
ctx,
}: {
intent: string | null;
form: FormData;
tenantId: string;
projectId: string;
ctx: ProjectRouteContext;
}): Promise<boolean> {
const str = (name: string): string | null => {
const value = form.get(name);
return typeof value === "string" ? value : null;
};
switch (intent) {
case "updateStammdaten": {
const updated = await updateProjectStammdaten({
tenantId,
projectId,
userId: ctx.userId,
ctx,
projektname: str("projektname") ?? undefined,
beschreibung: form.has("beschreibung") ? str("beschreibung") : undefined,
prioritaet: (str("prioritaet") as never) ?? undefined,
potenziellerAuftragswert: form.has("potenziellerAuftragswert") ? str("potenziellerAuftragswert") : undefined,
auftragsvolumen: form.has("auftragsvolumen") ? str("auftragsvolumen") : undefined,
startDatum: form.has("startDatum") ? str("startDatum") : undefined,
deadline: form.has("deadline") ? str("deadline") : undefined,
zustaendigePersonId: form.has("zustaendigePersonId") ? str("zustaendigePersonId") : undefined,
projektleiterId: form.has("projektleiterId") ? str("projektleiterId") : undefined,
projekttyp: form.has("projekttyp") ? str("projekttyp") : undefined,
quelle: form.has("quelle") ? str("quelle") : undefined,
promotion: form.has("promotion") ? str("promotion") : undefined,
email: form.has("email") ? str("email") : undefined,
telefon: form.has("telefon") ? str("telefon") : undefined,
fax: form.has("fax") ? str("fax") : undefined,
});
// A detail-edit changes a board card (owner/value/priority) — publish a live
// board change so any open pipeline board revalidates.
publishProjectBoardChange({ tenantId, pipelineId: updated.pipelineId, projectId: updated.id, type: "updated" });
return true;
}
case "updateAddresses": {
// One JSON field carries all three slots: the section saves them together
// and a slot is a small record, not a flat list of form fields.
const updated = await updateProjectAddresses({
tenantId,
projectId,
userId: ctx.userId,
ctx,
slots: parseAddressSlots(str("slots")),
});
publishProjectBoardChange({ tenantId, pipelineId: updated.pipelineId, projectId: updated.id, type: "updated" });
return true;
}
case "linkKontakt": {
// WF-P8 S02 "Bestehende Firma / Kontakt durchsuchen": the picked contact.
await linkProjektKontakt({
tenantId,
projectId,
kontaktId: str("kontaktId") ?? "",
rolle: (str("rolle") as never) ?? undefined,
userId: ctx.userId,
ctx,
});
return true;
}
case "unlinkKontakt": {
// WF-P8 S02: the contact leaves this project, not the CRM.
await unlinkProjektKontakt({
tenantId,
projectId,
projektKontaktId: str("projektKontaktId") ?? "",
userId: ctx.userId,
ctx,
});
return true;
}
case "createKontakt": {
// "Neuen Kontakt erstellen" / "Neue Firma erstellen" — one intent, because
// the only difference is the contact type and which name field is filled.
// The contact itself is created through the CRM service, so a project never
// becomes a second place where contacts are born.
const kontaktTyp = str("kontaktTyp") === "organisation" ? "organisation" : "privatperson";
const email = str("email")?.trim() ?? "";
const kontakt = await createKontakt({
tenantId,
userId: ctx.userId ?? "",
kontaktTyp,
vorname: kontaktTyp === "privatperson" ? str("vorname") : null,
nachname: kontaktTyp === "privatperson" ? str("nachname") : null,
orgName: kontaktTyp === "organisation" ? str("orgName") : null,
emails: email ? [{ email, primaer: true }] : [],
});
await linkProjektKontakt({
tenantId,
projectId,
kontaktId: kontakt.id,
rolle: (str("rolle") as never) ?? undefined,
userId: ctx.userId,
ctx,
});
return true;
}
case "moveStage": {
const moved = await moveProjectStage({
tenantId,
userId: ctx.userId,
ctx,
projectId,
targetStageId: str("targetStageId") ?? "",
lostReasonId: str("lostReasonId") || null,
notiz: str("notiz") || null,
});
publishProjectBoardChange({ tenantId, pipelineId: moved.pipelineId, projectId: moved.id, type: "stage_changed" });
return true;
}
case "undoStage": {
const restored = await undoLastProjectStageChange({ tenantId, userId: ctx.userId, ctx, projectId });
publishProjectBoardChange({ tenantId, pipelineId: restored.pipelineId, projectId: restored.id, type: "stage_change_undone" });
return true;
}
case "setCustomField": {
await setProjectCustomFieldValue({
tenantId,
userId: ctx.userId,
ctx,
projectId,
definitionId: str("definitionId") ?? "",
wert: str("wert"),
});
return true;
}
default:
return false;
}
}