Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion app/(app)/companies/[id]/deals/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ export default async function DealsTab({ params }: { params: Promise<{ id: strin
name: deals.name,
value: deals.value,
stageName: stages.name,
stageColor: stages.color,
stageEnteredAt: deals.stageEnteredAt,
expectedCloseDate: deals.expectedCloseDate,
})
Expand Down Expand Up @@ -50,7 +51,7 @@ export default async function DealsTab({ params }: { params: Promise<{ id: strin
>
<td className="px-3 py-2.5 font-medium text-text">{d.name}</td>
<td className="px-3 py-2.5">
<StagePill value={d.stageName} />
<StagePill value={d.stageName} color={d.stageColor} />
</td>
<td className="px-3 py-2.5 font-mono text-[11px] tabular-nums text-text">
{money(d.value)}
Expand Down
3 changes: 2 additions & 1 deletion app/(app)/companies/[id]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ export default async function OverviewTab({ params }: { params: Promise<{ id: st
name: deals.name,
value: deals.value,
stageName: stages.name,
stageColor: stages.color,
})
.from(deals)
.innerJoin(stages, eq(deals.stageId, stages.id))
Expand Down Expand Up @@ -169,7 +170,7 @@ export default async function OverviewTab({ params }: { params: Promise<{ id: st
<div>
<div className="font-medium text-text">{d.name}</div>
<div className="mt-0.5">
<StagePill value={d.stageName} />
<StagePill value={d.stageName} color={d.stageColor} />
</div>
</div>
<div className="font-mono text-[12px] tabular-nums text-text">
Expand Down
7 changes: 4 additions & 3 deletions app/(app)/deals/deals-view.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ export function DealsView({
}: {
pipelineName: string;
view: "list" | "board";
stages: (KanbanStage & { isWon: boolean; isLost: boolean })[];
stages: (KanbanStage & { color: string | null; isWon: boolean; isLost: boolean })[];
deals: DealRow[];
truncatedAt: number | null;
}) {
Expand All @@ -63,6 +63,7 @@ export function DealsView({
return {
...d,
stageName: stage?.name ?? "Unknown",
stageColor: stage?.color ?? null,
isWon: stage?.isWon ?? false,
isLost: stage?.isLost ?? false,
daysInStage: days,
Expand Down Expand Up @@ -226,7 +227,7 @@ export function DealsView({
{money(d.value)}
</td>
<td className="px-3.5 py-2.5 align-middle">
<StagePill value={d.stageName} />
<StagePill value={d.stageName} color={d.stageColor} />

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Kanban board never shows custom stage colors — color stripped in staging mapping

DealsView receives stages with color from the server component (added in this PR), but the mapping stages.map((s) => ({ id: s.id, name: s.name })) passed to KanbanBoard drops the color field. KanbanBoard now renders <StagePill value={stage.name} color={stage.color} /> where stage.color is always undefined, so the kanban headers fall through to the name-based STAGE_CONFIG lookup. Custom colors set in Settings → Pipelines are invisible in board view.

Fix: add color to the mapping: stages.map((s) => ({ id: s.id, name: s.name, color: s.color })).

</td>
<td className="px-3.5 py-2.5 align-middle text-[12px] text-text-muted">
{FORECAST[d.stageName] ?? "Pipeline"}
Expand Down Expand Up @@ -256,7 +257,7 @@ export function DealsView({
) : (
<div className="flex-1 overflow-hidden p-4">
<KanbanBoard
stages={stages.map((s) => ({ id: s.id, name: s.name }))}
stages={stages.map((s) => ({ id: s.id, name: s.name, color: s.color }))}
initialDeals={deals}
/>
</div>
Expand Down
5 changes: 3 additions & 2 deletions app/(app)/deals/kanban.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useState, type DragEvent } from "react";
import { TemperaturePill } from "@/components/pills";
import { StagePill, TemperaturePill } from "@/components/pills";
import { money, relativeTime } from "@/lib/format";
import { cn } from "@/lib/utils";
import { moveDealStageAction } from "./actions";
Expand All @@ -22,6 +22,7 @@ export type KanbanDeal = {
export type KanbanStage = {
id: string;
name: string;
color?: string | null;
};

export function KanbanBoard({
Expand Down Expand Up @@ -89,7 +90,7 @@ export function KanbanBoard({
onDrop={(e) => onDrop(e, stage.id)}
>
<div className="mb-2 flex items-center justify-between px-1">
<span className="text-sm font-medium">{stage.name}</span>
<StagePill value={stage.name} color={stage.color} />
<span className="text-xs text-muted-foreground">
{list.length} · {money(total)}
</span>
Expand Down
1 change: 1 addition & 0 deletions app/(app)/deals/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ export default async function DealsPage({
stages={stageRows.map((s) => ({
id: s.id,
name: s.name,
color: s.color,
isWon: s.isWon,
isLost: s.isLost,
}))}
Expand Down
3 changes: 2 additions & 1 deletion app/(app)/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ export default async function CockpitPage() {
value: deals.value,
stageEnteredAt: deals.stageEnteredAt,
stageName: stages.name,
stageColor: stages.color,
companyId: deals.companyId,
companyName: companies.name,
temperature: companies.temperature,
Expand Down Expand Up @@ -219,7 +220,7 @@ export default async function CockpitPage() {
{money(d.value)}
</td>
<td className="px-3 py-2.5">
<StagePill value={d.stageName} />
<StagePill value={d.stageName} color={d.stageColor} />
</td>
<td
className="px-3 py-2.5 font-mono text-[11px]"
Expand Down
186 changes: 186 additions & 0 deletions app/(app)/settings/pipelines/actions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,186 @@
"use server";

import { revalidatePath } from "next/cache";
import { z } from "zod";
import { db } from "@/db/client";
import { diffChangedFields, recordAudit, userActor } from "@/lib/audit";
import { requireOrgSession } from "@/lib/session";
import { isStageColor, type StageColor } from "@/lib/stage-colors";
import {
createStage,
deleteStage,
previewStageFlagToggle,
reorderStage,
StageOpError,
updateStage,
type ReorderDirection,
} from "@/lib/stages";

const NameSchema = z.string().trim().min(1).max(80);

function normalizeColor(value: FormDataEntryValue | null): StageColor | null | undefined {
if (value === null) return undefined;
const s = String(value);
if (s === "") return null;
if (!isStageColor(s)) return undefined;
return s;
}

function ok<T>(data: T): { ok: true; data: T } {
return { ok: true, data };
}
function err(code: StageOpError["code"], message: string): { ok: false; code: StageOpError["code"]; message: string } {
return { ok: false, code, message };
}
function mapError(e: unknown): { ok: false; code: StageOpError["code"]; message: string } {
if (e instanceof StageOpError) return err(e.code, e.message);
throw e;
}

function revalidateAll(): void {
revalidatePath("/settings/pipelines");
revalidatePath("/deals");
revalidatePath("/companies", "layout");
}

export async function addStageAction(formData: FormData) {
const session = await requireOrgSession();
const pipelineId = z.string().uuid().parse(formData.get("pipelineId"));
const name = NameSchema.parse(formData.get("name"));
const color = normalizeColor(formData.get("color"));
const isWon = formData.get("isWon") === "true";
const isLost = formData.get("isLost") === "true";

try {
const result = await createStage(db(), session.organizationId, {
pipelineId,
name,
color: color ?? null,
isWon,
isLost,
});
await recordAudit(db(), {
organizationId: session.organizationId,
actor: userActor(session.user.id, session.user.name ?? null),
entityType: "stage",
entityId: result.after.id,
action: "create",
changes: { after: result.after },
});
revalidateAll();
return ok(result.after);
} catch (e) {
return mapError(e);
}
}

export async function updateStageAction(formData: FormData) {
const session = await requireOrgSession();
const id = z.string().uuid().parse(formData.get("id"));
const nameRaw = formData.get("name");
const colorRaw = formData.get("color");
const isWonRaw = formData.get("isWon");
const isLostRaw = formData.get("isLost");

try {
const result = await updateStage(db(), session.organizationId, {
id,
name: nameRaw === null ? undefined : NameSchema.parse(nameRaw),
color: colorRaw === null ? undefined : normalizeColor(colorRaw),
isWon: isWonRaw === null ? undefined : isWonRaw === "true",
isLost: isLostRaw === null ? undefined : isLostRaw === "true",
});
if (!result) return err("not_found", "Stage not found");
await recordAudit(db(), {
organizationId: session.organizationId,
actor: userActor(session.user.id, session.user.name ?? null),
entityType: "stage",
entityId: result.after.id,
action: "update",
changes: diffChangedFields(result.before, result.after),
});
revalidateAll();
return ok(result.after);
} catch (e) {
return mapError(e);
}
}

const DirectionSchema = z.enum(["up", "down"]);

export async function reorderStageAction(input: {
stageId: string;
direction: ReorderDirection;
}) {
const session = await requireOrgSession();
const stageId = z.string().uuid().parse(input.stageId);
const direction = DirectionSchema.parse(input.direction);

try {
const result = await reorderStage(db(), session.organizationId, stageId, direction);
if (!result) return err("not_found", "Stage not found");
if (result.before.id === result.after.id && result.before.order === result.after.order) {
// boundary no-op; nothing to audit, nothing to revalidate
return ok(result.after);
}
await recordAudit(db(), {
organizationId: session.organizationId,
actor: userActor(session.user.id, session.user.name ?? null),
entityType: "stage",
entityId: result.after.id,
action: "update",
changes: { before: { order: result.before.order }, after: { order: result.after.order } },
});
revalidateAll();
return ok(result.after);
} catch (e) {
return mapError(e);
}
}

export async function deleteStageAction(input: {
stageId: string;
destinationStageId: string;
}) {
const session = await requireOrgSession();
const stageId = z.string().uuid().parse(input.stageId);
const destinationStageId = z.string().uuid().parse(input.destinationStageId);

try {
const result = await deleteStage(db(), session.organizationId, stageId, destinationStageId);
if (!result) return err("not_found", "Stage not found");

const actor = userActor(session.user.id, session.user.name ?? null);
for (const dealId of result.migratedDealIds) {
await recordAudit(db(), {
organizationId: session.organizationId,
actor,
entityType: "deal",
entityId: dealId,
action: "update",
changes: { before: { stageId }, after: { stageId: destinationStageId } },
});
}
await recordAudit(db(), {
organizationId: session.organizationId,
actor,
entityType: "stage",
entityId: stageId,
action: "delete",
changes: { before: result.before },
});
revalidateAll();
return ok({ migratedDealCount: result.migratedDealIds.length });
} catch (e) {
return mapError(e);
}
}

export async function previewStageFlagToggleAction(input: { stageId: string }) {
const session = await requireOrgSession();
const stageId = z.string().uuid().parse(input.stageId);
const result = await previewStageFlagToggle(db(), session.organizationId, stageId);
if (!result) return err("not_found", "Stage not found");
return ok(result);
}

Loading
Loading