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

350 lines
14 KiB
TypeScript

// R-P8b (P8 configurable Kanban) Interim-UI (AD-28) — shared server helper for
// the board routes (`/projects` and `/projects/pipelines/:id`). Builds the
// KanbanBoardVM from projectService.loadProjectBoard + resolved owner labels +
// lost reasons, and handles the board actions (stage move, bulk move, undo,
// quick-create). Server-only (.server) — never imported by a component.
import { CatalogError } from "@hivegrid/foundation";
import { buildBoardFacetsVM, buildKanbanVM, type BoardFacetsVM, type KanbanBoardVM, type PipelineHandoverVM } from "@hivegrid/view-models";
import { isDemoDataUiEnabled } from "~/utils/services/.server/demoDataUi.server";
import { resolveUserLabels } from "~/utils/integrations/crm/crmService.server";
import {
archiveProject,
bulkMoveProjectStage,
createProject,
duplicateProject,
listLostReasons,
loadBoardAssigneeOptions,
loadProjectTagOptions,
loadProjectBoard,
moveProjectStage,
moveProjectToPipeline,
redoLastProjectStageChange,
undoLastProjectStageChange,
} from "./projectService.server";
import {
parseFacetList,
parseOwnerList,
parsePeriodList,
PROJECT_CUSTOMER_TYPES,
PROJECT_VALUE_BANDS,
type ProjectFacetFilters,
} from "./projectFilterFacets";
import { executeProjectHandover, parseHandoverSelection } from "./pipelineHandoverService.server";
import { loadHandoverVM } from "./pipelineHandoverRoute.server";
import { projectNotFoundResponse, type ProjectRouteContext } from "./projectAccess.server";
import { projectDetailPath, projectPipelineStreamPath, projectsExportPath, projectsListPath } from "./projectRoutePaths";
import { publishProjectBoardChange } from "./projectBoardEvents.server";
export type KanbanLoaderData = {
/** Pipeline name — shell breadcrumb title (Tenant > Boards > <Pipeline>). */
title: string;
tenantSlug: string;
vm: KanbanBoardVM;
pipelineId: string;
onlyMine: boolean;
q: string;
includeDemoData: boolean;
/** Whether the demo-data toggle may be shown at all (staging/dev only). */
demoDataUiEnabled: boolean;
/** CSV export of exactly what the board currently shows (same resource the list uses). */
exportHref: string;
/** List view of the same board and filters — the header view switch target. */
listHref: string;
/** WF-P8 S01 filter bar: facet dropdowns + active-filter chips. */
facets: BoardFacetsVM;
/** Raw facet selection, echoed back so the screen can toggle single values. */
facetSelection: {
owners: string[];
customerTypes: string[];
periods: string[];
periodFrom: string;
periodTo: string;
valueBands: string[];
};
};
export async function loadKanbanData({
tenantSlug,
tenantId,
ctx,
pipelineId,
onlyMine,
includeDemoData,
q,
facets,
locale,
}: {
tenantSlug: string;
tenantId: string;
ctx: ProjectRouteContext;
pipelineId?: string | null;
onlyMine: boolean;
includeDemoData: boolean;
q?: string | null;
facets?: ProjectFacetFilters;
locale?: string;
}): Promise<KanbanLoaderData> {
// Outside staging/dev the `demo=show` query param is ignored server-side —
// hiding the checkbox alone would leave demo rows reachable via URL.
const demoDataUiEnabled = isDemoDataUiEnabled(undefined, { tenantId, tenantSlug });
const withDemoData = demoDataUiEnabled && includeDemoData;
const [board, lostReasons] = await Promise.all([
// An unknown/unreadable pipelineId (e.g. a stale link that put a project id in
// the pipeline slot) must render 404, not surface the CatalogError as a 500.
loadProjectBoard({ tenantId, ctx, pipelineId, onlyMine, includeDemoData: withDemoData, q, facets }).catch((error) => {
if (error instanceof CatalogError) throw projectNotFoundResponse();
throw error;
}),
listLostReasons({ tenantId }),
]);
const ownerIds = board.columns.flatMap((column) => column.cards.map((card) => card.ownerUserId)).filter((id): id is string => Boolean(id));
const [ownerLabels, assignees, tagOptions] = await Promise.all([
resolveUserLabels(ownerIds),
loadBoardAssigneeOptions({ tenantId, ctx, pipelineId: board.pipeline.id, includeDemoData: withDemoData }),
loadProjectTagOptions(tenantId),
]);
const vm = buildKanbanVM({
locale,
board,
lostReasons: lostReasons.map((reason) => ({ id: reason.id, name: reason.name })),
ownerLabels,
buildDetailHref: (projectId) => projectDetailPath(tenantSlug, projectId),
sseHref: projectPipelineStreamPath(tenantSlug, board.pipeline.id),
perms: {
canWrite: ctx.permissions.includes("projects.write"),
canChangeStage: ctx.permissions.includes("projects.change_stage"),
canAdmin: ctx.permissions.includes("projects.admin"),
canDelete: ctx.permissions.includes("projects.delete"),
},
});
// The CSV resource is shared with the list view; the board contributes the
// filters it actually has (pipeline, search, only-mine, demo).
const exportParams = new URLSearchParams();
exportParams.set("pipelineId", board.pipeline.id);
if (q) exportParams.set("q", q);
if (onlyMine) exportParams.set("onlyMine", "1");
if (withDemoData) exportParams.set("demo", "show");
const facetsVM = buildBoardFacetsVM({
assignees: assignees.options,
hasUnassigned: assignees.hasUnassigned,
tags: tagOptions,
selected: {
owners: facets?.owners ?? [],
customerTypes: facets?.customerTypes ?? [],
tagIds: facets?.tagIds ?? [],
periods: facets?.periods ?? [],
valueBands: facets?.valueBands ?? [],
},
onlyMine,
q: q ?? "",
canFilterByValue: ctx.permissions.includes("deals.read_volume_all"),
});
return {
title: board.pipeline.name,
tenantSlug,
vm,
pipelineId: board.pipeline.id,
onlyMine,
q: q ?? "",
includeDemoData: withDemoData,
demoDataUiEnabled,
exportHref: `${projectsExportPath(tenantSlug)}?${exportParams.toString()}`,
// Switching to the list keeps the board and its filters — the list names the
// board in its headline, so losing the pipeline here would rename the page.
listHref: `${projectsListPath(tenantSlug)}?${exportParams.toString()}`,
facets: facetsVM,
facetSelection: {
owners: [...(facets?.owners ?? [])],
customerTypes: [...(facets?.customerTypes ?? [])],
periods: [...(facets?.periods ?? [])],
periodFrom: facets?.periodFrom ?? "",
periodTo: facets?.periodTo ?? "",
valueBands: [...(facets?.valueBands ?? [])],
},
};
}
/**
* WF-P8 S03: the board a list URL is bound to. The list shows one board (the
* flow index dropped the cross-board list), so an absent or stale pipelineId
* has to resolve rather than widen the result to every board. Same order the
* kanban board falls back in: default board first, then the first visible one.
*
* Pure, so the list screen and the CSV resource cannot drift apart on which
* board they mean.
*/
export function pickListPipeline<T extends { id: string; isDefault: boolean }>(
pipelines: readonly T[],
requestedPipelineId: string | null
): T | null {
return (
pipelines.find((pipeline) => pipeline.id === requestedPipelineId) ??
pipelines.find((pipeline) => pipeline.isDefault) ??
pipelines[0] ??
null
);
}
/** Parse the WF-P8 S01 facet query parameters off a board URL. */
export function parseBoardFacetParams(params: URLSearchParams): ProjectFacetFilters {
return {
owners: parseOwnerList(params.get("owner")),
customerTypes: parseFacetList(params.get("kundentyp"), PROJECT_CUSTOMER_TYPES),
// Tag ids are tenant data, so they parse like the owner list: unknown ids
// simply match nothing instead of rejecting the URL.
tagIds: parseOwnerList(params.get("tags")),
periods: parsePeriodList(params.get("zeitraum")),
periodFrom: params.get("von"),
periodTo: params.get("bis"),
valueBands: parseFacetList(params.get("wert"), PROJECT_VALUE_BANDS),
};
}
/**
* Permission required per board intent — used by the route actions so the
* pre-check stays in one place (create/duplicate = write, archive = delete,
* everything else = stage change).
*/
export function kanbanIntentPermission(intent: string | null): string {
switch (intent) {
case "createProject":
case "duplicateProject":
return "projects.write";
case "archiveProject":
return "projects.delete";
case "handoverProject":
return "projects.handover";
default:
return "projects.change_stage";
}
}
/** Handle a board intent. Returns the created project id for "createProject"
* (so the route can redirect), else null, plus the M04 payload when the move
* just put the project into a stage that offers a pipeline handover. Throws on
* failure. */
export async function handleKanbanIntent({
intent,
form,
tenantId,
ctx,
}: {
intent: string | null;
form: FormData;
tenantId: string;
ctx: ProjectRouteContext;
}): Promise<{ createdProjectId: string | null; handover?: PipelineHandoverVM | null }> {
const str = (name: string): string | null => {
const value = form.get(name);
return typeof value === "string" ? value : null;
};
switch (intent) {
case "moveStage": {
const moved = await moveProjectStage({
tenantId,
userId: ctx.userId,
ctx,
projectId: str("projectId") ?? "",
targetStageId: str("targetStageId") ?? "",
lostReasonId: str("lostReasonId") || null,
notiz: str("notiz") || null,
});
// Push a real live change-event so every connected board client for this
// pipeline revalidates immediately.
publishProjectBoardChange({ tenantId, pipelineId: moved.pipelineId, projectId: moved.id, type: "stage_changed" });
// M04 is triggered by the move, not by a button (D-P8: "Erreicht eine
// Karte die letzte Stage […], oeffnet sich M04 automatisch"). The board
// asks right here, so the dialog opens over the board the card was
// dropped on instead of waiting for someone to open the project.
return { createdProjectId: null, handover: await loadHandoverVM({ tenantId, projectId: moved.id, ctx }) };
}
case "bulkMoveStage": {
const ids = (str("projectIds") ?? "").split(",").map((id) => id.trim()).filter(Boolean);
const { pipelineIds } = await bulkMoveProjectStage({
tenantId,
userId: ctx.userId,
ctx,
projectIds: ids,
targetStageId: str("targetStageId") ?? "",
lostReasonId: str("lostReasonId") || null,
});
for (const pipelineId of pipelineIds) {
publishProjectBoardChange({ tenantId, pipelineId, type: "stage_changed" });
}
return { createdProjectId: null };
}
case "undoStage": {
const restored = await undoLastProjectStageChange({ tenantId, userId: ctx.userId, ctx, projectId: str("projectId") ?? "" });
publishProjectBoardChange({ tenantId, pipelineId: restored.pipelineId, projectId: restored.id, type: "stage_change_undone" });
return { createdProjectId: null };
}
case "moveToPipeline": {
const { project, previousPipelineId } = await moveProjectToPipeline({
tenantId,
userId: ctx.userId,
ctx,
projectId: str("projectId") ?? "",
targetPipelineId: str("targetPipelineId") ?? "",
});
// The card leaves one board and appears on the other, so both open
// boards have to redraw.
publishProjectBoardChange({ tenantId, pipelineId: previousPipelineId, projectId: project.id, type: "archived" });
publishProjectBoardChange({ tenantId, pipelineId: project.pipelineId, projectId: project.id, type: "created" });
return { createdProjectId: null };
}
case "redoStage": {
const reapplied = await redoLastProjectStageChange({ tenantId, userId: ctx.userId, ctx, projectId: str("projectId") ?? "" });
publishProjectBoardChange({ tenantId, pipelineId: reapplied.pipelineId, projectId: reapplied.id, type: "stage_changed" });
return { createdProjectId: null };
}
case "createProject": {
const project = await createProject({
tenantId,
userId: ctx.userId,
ctx,
projektname: str("projektname") ?? "",
potenziellerAuftragswert: str("potenziellerAuftragswert") || null,
// Quick-create lands on the board the user is currently viewing, not the
// default sales pipeline. Empty/absent -> service falls back to default.
pipelineId: str("pipelineId") || null,
zustaendigePersonId: ctx.userId,
});
publishProjectBoardChange({ tenantId, pipelineId: project.pipelineId, projectId: project.id, type: "created" });
return { createdProjectId: project.id };
}
case "duplicateProject": {
const copy = await duplicateProject({ tenantId, userId: ctx.userId, ctx, projectId: str("projectId") ?? "" });
publishProjectBoardChange({ tenantId, pipelineId: copy.pipelineId, projectId: copy.id, type: "created" });
return { createdProjectId: null };
}
case "archiveProject": {
const archived = await archiveProject({ tenantId, userId: ctx.userId, ctx, projectId: str("projectId") ?? "" });
publishProjectBoardChange({ tenantId, pipelineId: archived.pipelineId, projectId: archived.id, type: "archived" });
return { createdProjectId: null };
}
case "handoverProject": {
const projectId = str("projectId") ?? "";
const result = await executeProjectHandover({
tenantId,
userId: ctx.userId,
ctx,
projectId,
selection: parseHandoverSelection(str("selection")),
});
// The card leaves this board and appears on the successor's, so both
// redraw — same shape as a manual board change.
publishProjectBoardChange({ tenantId, pipelineId: str("fromPipelineId") ?? "", projectId, type: "archived" });
publishProjectBoardChange({ tenantId, pipelineId: result.toPipelineId, projectId, type: "created" });
return { createdProjectId: null, handover: null };
}
default:
return { createdProjectId: null };
}
}