350 lines
14 KiB
TypeScript
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 };
|
|
}
|
|
}
|