mirror of
https://github.com/Luchexx/wacrm.git
synced 2026-11-02 09:19:51 +00:00
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>
291 lines
8.6 KiB
TypeScript
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>
|
|
);
|
|
}
|