634 lines
26 KiB
TypeScript
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;
|
|
}
|
|
}
|