wacrm/src/components/pipelines/pipeline-board.tsx
Arnas Donauskas 3abecb433b
feat(deals): make default deal currency configurable per account (#218)
wacrm hardcoded USD across every deal-value formatter, the new-deal
form, and automation-created deals, making deal tracking unhelpful for
non-US self-hosted users. Add a per-account default currency.

- Migration 021: accounts.default_currency TEXT NOT NULL DEFAULT 'USD'
  with a 3-letter-code CHECK. The existing admin-only accounts_update
  RLS policy already gates writes.
- New src/lib/currency.ts: single source of truth (CURRENCIES list,
  formatCurrency, formatCurrencyShort), replacing ~6 duplicated
  USD-hardcoded helpers.
- useAuth exposes defaultCurrency (joined from accounts), falling back
  to USD while loading / on pre-021 schemas.
- Settings → Deals tab to pick the currency (admin-only, read-only for
  others to match RLS).
- Deal form defaults new deals to the account currency and sources its
  dropdown from CURRENCIES; existing deals keep their saved currency.
- Pipeline board/analytics, dashboard "Open Deals Value", and the
  pipeline donut format totals in the account currency. Individual
  deal cards still show each deal's own currency.
- Automation create_deal sets the new deal's currency from the
  account default instead of the static DB default.

One currency per account (no FX conversion) per the issue's
recommended first pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 13:59:48 +03:00

291 lines
8.6 KiB
TypeScript

"use client";
import { useMemo, useState } from "react";
import {
DndContext,
DragOverlay,
KeyboardSensor,
PointerSensor,
useSensor,
useSensors,
useDroppable,
useDraggable,
closestCorners,
type DragEndEvent,
type DragStartEvent,
} from "@dnd-kit/core";
import type { Deal, PipelineStage } from "@/types";
import { DealCard } from "./deal-card";
import { Button } from "@/components/ui/button";
import { Plus } from "lucide-react";
import { useAuth } from "@/hooks/use-auth";
import { formatCurrency } from "@/lib/currency";
interface PipelineBoardProps {
stages: PipelineStage[];
deals: Deal[];
onDealMoved: (dealId: string, newStageId: string) => void;
onAddDeal: (stageId: string) => void;
onEditDeal: (deal: Deal) => void;
}
export function PipelineBoard({
stages,
deals,
onDealMoved,
onAddDeal,
onEditDeal,
}: PipelineBoardProps) {
const { defaultCurrency } = useAuth();
const [activeDealId, setActiveDealId] = useState<string | null>(null);
const sortedStages = useMemo(
() => [...stages].sort((a, b) => a.position - b.position),
[stages],
);
const dealsByStage = useMemo(() => {
const map = new Map<string, Deal[]>();
for (const stage of sortedStages) map.set(stage.id, []);
for (const deal of deals) {
const bucket = map.get(deal.stage_id);
if (bucket) bucket.push(deal);
}
return map;
}, [sortedStages, deals]);
const sensors = useSensors(
// 5px activation distance avoids clicks being interpreted as drags.
useSensor(PointerSensor, { activationConstraint: { distance: 5 } }),
// Keyboard drag support: focus a card, Space to pick up, arrows to move,
// Space to drop, Escape to cancel.
useSensor(KeyboardSensor),
);
const activeDeal = activeDealId
? deals.find((d) => d.id === activeDealId) ?? null
: null;
function handleDragStart(event: DragStartEvent) {
setActiveDealId(String(event.active.id));
}
function handleDragEnd(event: DragEndEvent) {
setActiveDealId(null);
const { active, over } = event;
if (!over) return;
const dealId = String(active.id);
const targetStageId = String(over.id);
const deal = deals.find((d) => d.id === dealId);
if (!deal || deal.stage_id === targetStageId) return;
if (!sortedStages.some((s) => s.id === targetStageId)) return;
onDealMoved(dealId, targetStageId);
}
function handleDragCancel() {
setActiveDealId(null);
}
return (
<DndContext
sensors={sensors}
collisionDetection={closestCorners}
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
onDragCancel={handleDragCancel}
>
{/* snap-x + snap-mandatory on mobile so swipes land the next
stage cleanly at the viewport edge instead of mid-column.
Disabled on lg+ where snapping would interfere with the
natural layout. The board can still overflow horizontally on
lg+ once a pipeline has many stages (columns keep a 260px
min-width), so a thin scrollbar stays visible on desktop. */}
<div className="pipeline-scroll flex snap-x snap-mandatory gap-3 overflow-x-auto pb-4 lg:snap-none">
{sortedStages.map((stage) => {
const stageDeals = dealsByStage.get(stage.id) ?? [];
const totalValue = stageDeals.reduce(
(s, d) => s + Number(d.value || 0),
0,
);
return (
<StageColumn
key={stage.id}
stage={stage}
deals={stageDeals}
totalValue={totalValue}
currency={defaultCurrency}
onAddDeal={onAddDeal}
onEditDeal={onEditDeal}
/>
);
})}
</div>
<DragOverlay
dropAnimation={{
duration: 200,
easing: "cubic-bezier(0.2, 0, 0, 1)",
}}
>
{activeDeal ? (
<div className="opacity-90">
<DealCard
deal={activeDeal}
stage={
sortedStages.find((s) => s.id === activeDeal.stage_id) ?? null
}
onEdit={() => {}}
isOverlay
/>
</div>
) : null}
</DragOverlay>
<style jsx>{`
.pipeline-scroll {
scroll-behavior: smooth;
}
/* On touch devices the peek/snap layout already signals there's
more to swipe, so the scrollbar is hidden for a clean look.
On desktop (mouse) the board can overflow with many stages
and there is no peek hint, so keep a thin, themed scrollbar
visible to make the overflow discoverable and usable. */
@media (hover: none), (pointer: coarse) {
.pipeline-scroll::-webkit-scrollbar {
height: 0;
display: none;
}
.pipeline-scroll {
scrollbar-width: none;
}
}
@media (hover: hover) and (pointer: fine) {
.pipeline-scroll {
scrollbar-width: thin;
scrollbar-color: rgb(51 65 85) transparent;
}
.pipeline-scroll::-webkit-scrollbar {
height: 8px;
}
.pipeline-scroll::-webkit-scrollbar-track {
background: transparent;
}
.pipeline-scroll::-webkit-scrollbar-thumb {
background-color: rgb(51 65 85);
border-radius: 9999px;
}
.pipeline-scroll::-webkit-scrollbar-thumb:hover {
background-color: rgb(71 85 105);
}
}
`}</style>
</DndContext>
);
}
function StageColumn({
stage,
deals,
totalValue,
currency,
onAddDeal,
onEditDeal,
}: {
stage: PipelineStage;
deals: Deal[];
totalValue: number;
currency: string;
onAddDeal: (stageId: string) => void;
onEditDeal: (deal: Deal) => void;
}) {
const { setNodeRef, isOver } = useDroppable({ id: stage.id });
return (
// On mobile each column is `w-[85vw]` (with a reasonable min/max)
// so the next column's edge peeks in — a "there's more here" hint.
// snap-start lands each column cleanly when swiping. On lg+ we
// restore the flex-1 share-the-row behavior. The droppable ref is
// on the inner messages region below — intentionally NOT here, so
// a drag over the column header doesn't highlight the whole column.
<div className="flex w-[85vw] min-w-[260px] max-w-[320px] shrink-0 snap-start flex-col rounded-xl border border-slate-800 bg-slate-900/60 p-4 lg:w-auto lg:max-w-none lg:flex-1 lg:basis-[260px] lg:shrink lg:snap-none">
{/* 3px colored top border — sits above the column's padding */}
<div
className="-mx-4 -mt-4 h-[3px] rounded-t-xl"
style={{ backgroundColor: stage.color }}
/>
<div className="flex items-center justify-between pt-3">
<h3 className="truncate text-sm font-semibold text-white">
{stage.name}
</h3>
<span className="shrink-0 rounded-full bg-slate-800 px-2 py-0.5 text-[11px] font-medium text-slate-300">
{deals.length}
</span>
</div>
<p className="text-xs text-slate-400">
{formatCurrency(totalValue, currency)}
</p>
<div
ref={setNodeRef}
className={`mt-3 flex flex-1 flex-col gap-2 rounded-lg transition-all ${
isOver
? "bg-primary/5 outline outline-2 outline-dashed outline-primary outline-offset-2"
: ""
}`}
>
{deals.length === 0 ? (
<div className="flex flex-1 items-center justify-center rounded-lg border-2 border-dashed border-slate-700 py-10 text-xs text-slate-500">
Drop a deal here
</div>
) : (
deals.map((deal) => (
<DraggableDealCard
key={deal.id}
deal={deal}
stage={stage}
onEdit={onEditDeal}
/>
))
)}
</div>
<Button
variant="ghost"
size="sm"
onClick={() => onAddDeal(stage.id)}
className="mt-3 w-full justify-start border border-dashed border-slate-700 bg-transparent text-slate-400 hover:border-slate-600 hover:bg-slate-800 hover:text-white"
>
<Plus className="mr-1 h-3 w-3" />
Add Deal
</Button>
</div>
);
}
function DraggableDealCard({
deal,
stage,
onEdit,
}: {
deal: Deal;
stage: PipelineStage;
onEdit: (deal: Deal) => void;
}) {
const { attributes, listeners, setNodeRef, isDragging } = useDraggable({
id: deal.id,
});
return (
<div
ref={setNodeRef}
{...listeners}
{...attributes}
style={{ opacity: isDragging ? 0.3 : 1, touchAction: "none" }}
>
<DealCard deal={deal} stage={stage} onEdit={onEdit} />
</div>
);
}