// 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 > ). */ 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 { // 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( 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 }; } }