mirror of
https://github.com/Luchexx/wacrm.git
synced 2026-11-02 17:29:45 +00:00
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>
This commit is contained in:
parent
11196b4728
commit
3abecb433b
@ -2,6 +2,8 @@
|
|||||||
|
|
||||||
import { useCallback, useEffect, useState } from 'react'
|
import { useCallback, useEffect, useState } from 'react'
|
||||||
import { createClient } from '@/lib/supabase/client'
|
import { createClient } from '@/lib/supabase/client'
|
||||||
|
import { useAuth } from '@/hooks/use-auth'
|
||||||
|
import { formatCurrency } from '@/lib/currency'
|
||||||
import {
|
import {
|
||||||
MessageSquare,
|
MessageSquare,
|
||||||
UserPlus,
|
UserPlus,
|
||||||
@ -35,6 +37,7 @@ import { ActivityFeed } from '@/components/dashboard/activity-feed'
|
|||||||
type RangeDays = 7 | 30 | 90
|
type RangeDays = 7 | 30 | 90
|
||||||
|
|
||||||
export default function DashboardPage() {
|
export default function DashboardPage() {
|
||||||
|
const { defaultCurrency } = useAuth()
|
||||||
const [metrics, setMetrics] = useState<MetricsBundle | null>(null)
|
const [metrics, setMetrics] = useState<MetricsBundle | null>(null)
|
||||||
const [metricsLoading, setMetricsLoading] = useState(true)
|
const [metricsLoading, setMetricsLoading] = useState(true)
|
||||||
|
|
||||||
@ -155,7 +158,7 @@ export default function DashboardPage() {
|
|||||||
/>
|
/>
|
||||||
<MetricCard
|
<MetricCard
|
||||||
title="Open Deals Value"
|
title="Open Deals Value"
|
||||||
value={formatCurrency(metrics.openDealsValue)}
|
value={formatCurrency(metrics.openDealsValue, defaultCurrency)}
|
||||||
icon={DollarSign}
|
icon={DollarSign}
|
||||||
subtitle={`${metrics.openDealsCount} open deal${metrics.openDealsCount === 1 ? '' : 's'}`}
|
subtitle={`${metrics.openDealsCount} open deal${metrics.openDealsCount === 1 ? '' : 's'}`}
|
||||||
/>
|
/>
|
||||||
@ -196,7 +199,11 @@ export default function DashboardPage() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="h-full lg:col-span-2">
|
<div className="h-full lg:col-span-2">
|
||||||
<PipelineDonut data={pipeline} loading={pipelineLoading} />
|
<PipelineDonut
|
||||||
|
data={pipeline}
|
||||||
|
loading={pipelineLoading}
|
||||||
|
currency={defaultCurrency}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -211,15 +218,6 @@ export default function DashboardPage() {
|
|||||||
|
|
||||||
// ------------------------------------------------------------
|
// ------------------------------------------------------------
|
||||||
|
|
||||||
function formatCurrency(v: number): string {
|
|
||||||
return new Intl.NumberFormat(undefined, {
|
|
||||||
style: 'currency',
|
|
||||||
currency: 'USD',
|
|
||||||
minimumFractionDigits: 0,
|
|
||||||
maximumFractionDigits: 0,
|
|
||||||
}).format(v)
|
|
||||||
}
|
|
||||||
|
|
||||||
function deltaLabel(delta: number, suffix: string): string {
|
function deltaLabel(delta: number, suffix: string): string {
|
||||||
if (delta === 0) return `No change ${suffix}`
|
if (delta === 0) return `No change ${suffix}`
|
||||||
const sign = delta > 0 ? '+' : ''
|
const sign = delta > 0 ? '+' : ''
|
||||||
|
|||||||
@ -8,6 +8,7 @@ import {
|
|||||||
User,
|
User,
|
||||||
Palette,
|
Palette,
|
||||||
UsersRound,
|
UsersRound,
|
||||||
|
Coins,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs';
|
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs';
|
||||||
import { WhatsAppConfig } from '@/components/settings/whatsapp-config';
|
import { WhatsAppConfig } from '@/components/settings/whatsapp-config';
|
||||||
@ -18,12 +19,14 @@ import { PasswordForm } from '@/components/settings/password-form';
|
|||||||
import { SessionsCard } from '@/components/settings/sessions-card';
|
import { SessionsCard } from '@/components/settings/sessions-card';
|
||||||
import { AppearancePanel } from '@/components/settings/appearance-panel';
|
import { AppearancePanel } from '@/components/settings/appearance-panel';
|
||||||
import { MembersTab } from '@/components/settings/members-tab';
|
import { MembersTab } from '@/components/settings/members-tab';
|
||||||
|
import { DealsSettings } from '@/components/settings/deals-settings';
|
||||||
|
|
||||||
const TAB_VALUES = [
|
const TAB_VALUES = [
|
||||||
'profile',
|
'profile',
|
||||||
'whatsapp',
|
'whatsapp',
|
||||||
'templates',
|
'templates',
|
||||||
'tags',
|
'tags',
|
||||||
|
'deals',
|
||||||
'appearance',
|
'appearance',
|
||||||
'members',
|
'members',
|
||||||
] as const;
|
] as const;
|
||||||
@ -90,6 +93,13 @@ export default function SettingsPage() {
|
|||||||
<Tag className="size-4" />
|
<Tag className="size-4" />
|
||||||
Tags
|
Tags
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
|
<TabsTrigger
|
||||||
|
value="deals"
|
||||||
|
className="data-active:bg-slate-800 data-active:text-primary text-slate-400"
|
||||||
|
>
|
||||||
|
<Coins className="size-4" />
|
||||||
|
Deals
|
||||||
|
</TabsTrigger>
|
||||||
<TabsTrigger
|
<TabsTrigger
|
||||||
value="appearance"
|
value="appearance"
|
||||||
className="data-active:bg-slate-800 data-active:text-primary text-slate-400"
|
className="data-active:bg-slate-800 data-active:text-primary text-slate-400"
|
||||||
@ -124,6 +134,10 @@ export default function SettingsPage() {
|
|||||||
<TagManager />
|
<TagManager />
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="deals">
|
||||||
|
<DealsSettings />
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
<TabsContent value="appearance">
|
<TabsContent value="appearance">
|
||||||
<AppearancePanel />
|
<AppearancePanel />
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|||||||
@ -3,6 +3,7 @@
|
|||||||
import { useState, useEffect, useCallback } from 'react';
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
import { createClient } from '@/lib/supabase/client';
|
import { createClient } from '@/lib/supabase/client';
|
||||||
import { useAuth } from '@/hooks/use-auth';
|
import { useAuth } from '@/hooks/use-auth';
|
||||||
|
import { formatCurrency } from '@/lib/currency';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import type { Contact, Tag, ContactTag, ContactNote, CustomField, ContactCustomValue, Deal } from '@/types';
|
import type { Contact, Tag, ContactTag, ContactNote, CustomField, ContactCustomValue, Deal } from '@/types';
|
||||||
import {
|
import {
|
||||||
@ -48,7 +49,7 @@ export function ContactDetailView({
|
|||||||
onUpdated,
|
onUpdated,
|
||||||
}: ContactDetailViewProps) {
|
}: ContactDetailViewProps) {
|
||||||
const supabase = createClient();
|
const supabase = createClient();
|
||||||
const { accountId } = useAuth();
|
const { accountId, defaultCurrency } = useAuth();
|
||||||
|
|
||||||
const [contact, setContact] = useState<Contact | null>(null);
|
const [contact, setContact] = useState<Contact | null>(null);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
@ -656,11 +657,10 @@ export function ContactDetailView({
|
|||||||
<div className="mt-1.5 flex items-center justify-between text-xs text-slate-400">
|
<div className="mt-1.5 flex items-center justify-between text-xs text-slate-400">
|
||||||
<span className="flex items-center gap-1">
|
<span className="flex items-center gap-1">
|
||||||
<DollarSign className="size-3" />
|
<DollarSign className="size-3" />
|
||||||
{new Intl.NumberFormat('en-US', {
|
{formatCurrency(
|
||||||
style: 'currency',
|
deal.value ?? 0,
|
||||||
currency: deal.currency || 'USD',
|
deal.currency || defaultCurrency,
|
||||||
maximumFractionDigits: 0,
|
)}
|
||||||
}).format(Number(deal.value || 0))}
|
|
||||||
</span>
|
</span>
|
||||||
{deal.status && deal.status !== 'open' && (
|
{deal.status && deal.status !== 'open' && (
|
||||||
<span
|
<span
|
||||||
|
|||||||
@ -2,15 +2,18 @@
|
|||||||
|
|
||||||
import { GitBranch } from 'lucide-react'
|
import { GitBranch } from 'lucide-react'
|
||||||
import type { PipelineDonutData } from '@/lib/dashboard/types'
|
import type { PipelineDonutData } from '@/lib/dashboard/types'
|
||||||
|
import { formatCurrencyShort } from '@/lib/currency'
|
||||||
import { EmptyState } from './empty-state'
|
import { EmptyState } from './empty-state'
|
||||||
import { Skeleton } from './skeleton'
|
import { Skeleton } from './skeleton'
|
||||||
|
|
||||||
interface PipelineDonutProps {
|
interface PipelineDonutProps {
|
||||||
data: PipelineDonutData | null
|
data: PipelineDonutData | null
|
||||||
loading: boolean
|
loading: boolean
|
||||||
|
/** Account default currency for the totals. */
|
||||||
|
currency: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export function PipelineDonut({ data, loading }: PipelineDonutProps) {
|
export function PipelineDonut({ data, loading, currency }: PipelineDonutProps) {
|
||||||
return (
|
return (
|
||||||
<section className="flex h-full flex-col rounded-xl border border-slate-800 bg-slate-900">
|
<section className="flex h-full flex-col rounded-xl border border-slate-800 bg-slate-900">
|
||||||
<header className="border-b border-slate-800 px-5 py-4">
|
<header className="border-b border-slate-800 px-5 py-4">
|
||||||
@ -31,7 +34,7 @@ export function PipelineDonut({ data, loading }: PipelineDonutProps) {
|
|||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<Donut data={data} />
|
<Donut data={data} currency={currency} />
|
||||||
<ul className="mt-5 space-y-2">
|
<ul className="mt-5 space-y-2">
|
||||||
{data.stages.map((s) => (
|
{data.stages.map((s) => (
|
||||||
<li key={s.id} className="flex items-center gap-3 text-xs">
|
<li key={s.id} className="flex items-center gap-3 text-xs">
|
||||||
@ -45,7 +48,7 @@ export function PipelineDonut({ data, loading }: PipelineDonutProps) {
|
|||||||
{s.dealCount} deal{s.dealCount === 1 ? '' : 's'}
|
{s.dealCount} deal{s.dealCount === 1 ? '' : 's'}
|
||||||
</span>
|
</span>
|
||||||
<span className="w-20 text-right text-slate-300 tabular-nums">
|
<span className="w-20 text-right text-slate-300 tabular-nums">
|
||||||
{formatCurrencyShort(s.totalValue)}
|
{formatCurrencyShort(s.totalValue, currency)}
|
||||||
</span>
|
</span>
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
@ -63,7 +66,7 @@ export function PipelineDonut({ data, loading }: PipelineDonutProps) {
|
|||||||
// between segments are implied by a thin slate-900 stroke between
|
// between segments are implied by a thin slate-900 stroke between
|
||||||
// them for a cleaner look.
|
// them for a cleaner look.
|
||||||
// ------------------------------------------------------------
|
// ------------------------------------------------------------
|
||||||
function Donut({ data }: { data: PipelineDonutData }) {
|
function Donut({ data, currency }: { data: PipelineDonutData; currency: string }) {
|
||||||
const size = 200
|
const size = 200
|
||||||
const r = 80
|
const r = 80
|
||||||
const ringWidth = 18
|
const ringWidth = 18
|
||||||
@ -121,7 +124,7 @@ function Donut({ data }: { data: PipelineDonutData }) {
|
|||||||
textAnchor="middle"
|
textAnchor="middle"
|
||||||
className="fill-white text-[18px] font-semibold tabular-nums"
|
className="fill-white text-[18px] font-semibold tabular-nums"
|
||||||
>
|
>
|
||||||
{formatCurrencyShort(data.totalValue)}
|
{formatCurrencyShort(data.totalValue, currency)}
|
||||||
</text>
|
</text>
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
@ -136,9 +139,3 @@ function arcPath(cx: number, cy: number, r: number, startRad: number, endRad: nu
|
|||||||
const largeArc = endRad - startRad > Math.PI ? 1 : 0
|
const largeArc = endRad - startRad > Math.PI ? 1 : 0
|
||||||
return `M ${x1} ${y1} A ${r} ${r} 0 ${largeArc} 1 ${x2} ${y2}`
|
return `M ${x1} ${y1} A ${r} ${r} 0 ${largeArc} 1 ${x2} ${y2}`
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatCurrencyShort(v: number): string {
|
|
||||||
if (v >= 1_000_000) return `$${(v / 1_000_000).toFixed(1)}M`
|
|
||||||
if (v >= 1_000) return `$${(v / 1_000).toFixed(1)}k`
|
|
||||||
return `$${v.toFixed(0)}`
|
|
||||||
}
|
|
||||||
|
|||||||
@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import type { Deal, PipelineStage } from "@/types";
|
import type { Deal, PipelineStage } from "@/types";
|
||||||
import { Calendar, Check, X } from "lucide-react";
|
import { Calendar, Check, X } from "lucide-react";
|
||||||
|
import { formatCurrency } from "@/lib/currency";
|
||||||
|
|
||||||
interface DealCardProps {
|
interface DealCardProps {
|
||||||
deal: Deal;
|
deal: Deal;
|
||||||
@ -10,15 +11,6 @@ interface DealCardProps {
|
|||||||
isOverlay?: boolean;
|
isOverlay?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatCurrency(value: number, currency?: string) {
|
|
||||||
return new Intl.NumberFormat("en-US", {
|
|
||||||
style: "currency",
|
|
||||||
currency: currency || "USD",
|
|
||||||
minimumFractionDigits: 0,
|
|
||||||
maximumFractionDigits: 0,
|
|
||||||
}).format(Number(value || 0));
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatDate(dateStr: string) {
|
function formatDate(dateStr: string) {
|
||||||
return new Date(dateStr).toLocaleDateString("en-US", {
|
return new Date(dateStr).toLocaleDateString("en-US", {
|
||||||
month: "short",
|
month: "short",
|
||||||
|
|||||||
@ -4,6 +4,7 @@ import { useState, useEffect } from "react";
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { createClient } from "@/lib/supabase/client";
|
import { createClient } from "@/lib/supabase/client";
|
||||||
import { useAuth } from "@/hooks/use-auth";
|
import { useAuth } from "@/hooks/use-auth";
|
||||||
|
import { CURRENCIES } from "@/lib/currency";
|
||||||
import type {
|
import type {
|
||||||
Contact,
|
Contact,
|
||||||
Conversation,
|
Conversation,
|
||||||
@ -52,11 +53,11 @@ export function DealForm({
|
|||||||
onSaved,
|
onSaved,
|
||||||
}: DealFormProps) {
|
}: DealFormProps) {
|
||||||
const supabase = createClient();
|
const supabase = createClient();
|
||||||
const { accountId } = useAuth();
|
const { accountId, defaultCurrency } = useAuth();
|
||||||
|
|
||||||
const [title, setTitle] = useState("");
|
const [title, setTitle] = useState("");
|
||||||
const [value, setValue] = useState("");
|
const [value, setValue] = useState("");
|
||||||
const [currency, setCurrency] = useState("USD");
|
const [currency, setCurrency] = useState(defaultCurrency);
|
||||||
const [contactId, setContactId] = useState("");
|
const [contactId, setContactId] = useState("");
|
||||||
const [stageId, setStageId] = useState("");
|
const [stageId, setStageId] = useState("");
|
||||||
const [assignedTo, setAssignedTo] = useState("");
|
const [assignedTo, setAssignedTo] = useState("");
|
||||||
@ -83,7 +84,7 @@ export function DealForm({
|
|||||||
if (deal) {
|
if (deal) {
|
||||||
setTitle(deal.title);
|
setTitle(deal.title);
|
||||||
setValue(String(deal.value ?? ""));
|
setValue(String(deal.value ?? ""));
|
||||||
setCurrency(deal.currency || "USD");
|
setCurrency(deal.currency || defaultCurrency);
|
||||||
// contact_id is nullable when the contact has been deleted
|
// contact_id is nullable when the contact has been deleted
|
||||||
// (migration 004: ON DELETE SET NULL). "" means "no selection".
|
// (migration 004: ON DELETE SET NULL). "" means "no selection".
|
||||||
setContactId(deal.contact_id ?? "");
|
setContactId(deal.contact_id ?? "");
|
||||||
@ -94,14 +95,14 @@ export function DealForm({
|
|||||||
} else {
|
} else {
|
||||||
setTitle("");
|
setTitle("");
|
||||||
setValue("");
|
setValue("");
|
||||||
setCurrency("USD");
|
setCurrency(defaultCurrency);
|
||||||
setContactId("");
|
setContactId("");
|
||||||
setStageId(defaultStageId || stages[0]?.id || "");
|
setStageId(defaultStageId || stages[0]?.id || "");
|
||||||
setAssignedTo("");
|
setAssignedTo("");
|
||||||
setExpectedCloseDate("");
|
setExpectedCloseDate("");
|
||||||
setNotes("");
|
setNotes("");
|
||||||
}
|
}
|
||||||
}, [open, deal, defaultStageId, stages]);
|
}, [open, deal, defaultStageId, stages, defaultCurrency]);
|
||||||
/* eslint-enable react-hooks/set-state-in-effect */
|
/* eslint-enable react-hooks/set-state-in-effect */
|
||||||
|
|
||||||
// Load supporting data once the sheet is open
|
// Load supporting data once the sheet is open
|
||||||
@ -313,9 +314,11 @@ export function DealForm({
|
|||||||
onChange={(e) => setCurrency(e.target.value)}
|
onChange={(e) => setCurrency(e.target.value)}
|
||||||
className="h-9 w-full rounded-lg border border-slate-700 bg-slate-800 px-2.5 text-sm text-white outline-none focus:border-primary"
|
className="h-9 w-full rounded-lg border border-slate-700 bg-slate-800 px-2.5 text-sm text-white outline-none focus:border-primary"
|
||||||
>
|
>
|
||||||
<option value="USD">USD</option>
|
{CURRENCIES.map((c) => (
|
||||||
<option value="EUR">EUR</option>
|
<option key={c.code} value={c.code}>
|
||||||
<option value="GBP">GBP</option>
|
{c.code}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -17,21 +17,14 @@ import {
|
|||||||
TooltipProvider,
|
TooltipProvider,
|
||||||
TooltipTrigger,
|
TooltipTrigger,
|
||||||
} from "@/components/ui/tooltip";
|
} from "@/components/ui/tooltip";
|
||||||
|
import { useAuth } from "@/hooks/use-auth";
|
||||||
|
import { formatCurrency } from "@/lib/currency";
|
||||||
|
|
||||||
interface PipelineAnalyticsProps {
|
interface PipelineAnalyticsProps {
|
||||||
stages: PipelineStage[];
|
stages: PipelineStage[];
|
||||||
deals: Deal[];
|
deals: Deal[];
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatCurrency(value: number) {
|
|
||||||
return new Intl.NumberFormat("en-US", {
|
|
||||||
style: "currency",
|
|
||||||
currency: "USD",
|
|
||||||
minimumFractionDigits: 0,
|
|
||||||
maximumFractionDigits: 0,
|
|
||||||
}).format(value);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Weighted pipeline value: value × per-stage probability.
|
* Weighted pipeline value: value × per-stage probability.
|
||||||
* First stage ≈ 10%, stages interpolate up to 90% before the final stage,
|
* First stage ≈ 10%, stages interpolate up to 90% before the final stage,
|
||||||
@ -53,6 +46,7 @@ function computeStageProbability(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function PipelineAnalytics({ stages, deals }: PipelineAnalyticsProps) {
|
export function PipelineAnalytics({ stages, deals }: PipelineAnalyticsProps) {
|
||||||
|
const { defaultCurrency } = useAuth();
|
||||||
const sortedStages = useMemo(
|
const sortedStages = useMemo(
|
||||||
() => [...stages].sort((a, b) => a.position - b.position),
|
() => [...stages].sort((a, b) => a.position - b.position),
|
||||||
[stages],
|
[stages],
|
||||||
@ -109,19 +103,19 @@ export function PipelineAnalytics({ stages, deals }: PipelineAnalyticsProps) {
|
|||||||
<Metric
|
<Metric
|
||||||
icon={<DollarSign className="h-4 w-4 text-primary" />}
|
icon={<DollarSign className="h-4 w-4 text-primary" />}
|
||||||
label="Pipeline Value"
|
label="Pipeline Value"
|
||||||
value={formatCurrency(stats.totalValue)}
|
value={formatCurrency(stats.totalValue, defaultCurrency)}
|
||||||
tooltip="Sum of the dollar values of all deals in this pipeline, excluding deals marked as Lost."
|
tooltip="Sum of the dollar values of all deals in this pipeline, excluding deals marked as Lost."
|
||||||
/>
|
/>
|
||||||
<Metric
|
<Metric
|
||||||
icon={<Target className="h-4 w-4 text-blue-400" />}
|
icon={<Target className="h-4 w-4 text-blue-400" />}
|
||||||
label="Avg Deal Size"
|
label="Avg Deal Size"
|
||||||
value={formatCurrency(stats.avgValue)}
|
value={formatCurrency(stats.avgValue, defaultCurrency)}
|
||||||
tooltip="Pipeline Value divided by Total Deals — the average value of a single non-lost deal."
|
tooltip="Pipeline Value divided by Total Deals — the average value of a single non-lost deal."
|
||||||
/>
|
/>
|
||||||
<Metric
|
<Metric
|
||||||
icon={<TrendingUp className="h-4 w-4 text-purple-400" />}
|
icon={<TrendingUp className="h-4 w-4 text-purple-400" />}
|
||||||
label="Weighted Value"
|
label="Weighted Value"
|
||||||
value={formatCurrency(stats.weightedValue)}
|
value={formatCurrency(stats.weightedValue, defaultCurrency)}
|
||||||
tooltip="Expected revenue: each open deal's value × its stage probability. First stage ≈ 10%, stages progress up to 90%, Won = 100%. Lost deals are excluded."
|
tooltip="Expected revenue: each open deal's value × its stage probability. First stage ≈ 10%, stages progress up to 90%, Won = 100%. Lost deals are excluded."
|
||||||
/>
|
/>
|
||||||
<Metric
|
<Metric
|
||||||
|
|||||||
@ -18,6 +18,8 @@ import type { Deal, PipelineStage } from "@/types";
|
|||||||
import { DealCard } from "./deal-card";
|
import { DealCard } from "./deal-card";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Plus } from "lucide-react";
|
import { Plus } from "lucide-react";
|
||||||
|
import { useAuth } from "@/hooks/use-auth";
|
||||||
|
import { formatCurrency } from "@/lib/currency";
|
||||||
|
|
||||||
interface PipelineBoardProps {
|
interface PipelineBoardProps {
|
||||||
stages: PipelineStage[];
|
stages: PipelineStage[];
|
||||||
@ -27,15 +29,6 @@ interface PipelineBoardProps {
|
|||||||
onEditDeal: (deal: Deal) => void;
|
onEditDeal: (deal: Deal) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatCurrency(value: number) {
|
|
||||||
return new Intl.NumberFormat("en-US", {
|
|
||||||
style: "currency",
|
|
||||||
currency: "USD",
|
|
||||||
minimumFractionDigits: 0,
|
|
||||||
maximumFractionDigits: 0,
|
|
||||||
}).format(value);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function PipelineBoard({
|
export function PipelineBoard({
|
||||||
stages,
|
stages,
|
||||||
deals,
|
deals,
|
||||||
@ -43,6 +36,7 @@ export function PipelineBoard({
|
|||||||
onAddDeal,
|
onAddDeal,
|
||||||
onEditDeal,
|
onEditDeal,
|
||||||
}: PipelineBoardProps) {
|
}: PipelineBoardProps) {
|
||||||
|
const { defaultCurrency } = useAuth();
|
||||||
const [activeDealId, setActiveDealId] = useState<string | null>(null);
|
const [activeDealId, setActiveDealId] = useState<string | null>(null);
|
||||||
|
|
||||||
const sortedStages = useMemo(
|
const sortedStages = useMemo(
|
||||||
@ -121,6 +115,7 @@ export function PipelineBoard({
|
|||||||
stage={stage}
|
stage={stage}
|
||||||
deals={stageDeals}
|
deals={stageDeals}
|
||||||
totalValue={totalValue}
|
totalValue={totalValue}
|
||||||
|
currency={defaultCurrency}
|
||||||
onAddDeal={onAddDeal}
|
onAddDeal={onAddDeal}
|
||||||
onEditDeal={onEditDeal}
|
onEditDeal={onEditDeal}
|
||||||
/>
|
/>
|
||||||
@ -194,12 +189,14 @@ function StageColumn({
|
|||||||
stage,
|
stage,
|
||||||
deals,
|
deals,
|
||||||
totalValue,
|
totalValue,
|
||||||
|
currency,
|
||||||
onAddDeal,
|
onAddDeal,
|
||||||
onEditDeal,
|
onEditDeal,
|
||||||
}: {
|
}: {
|
||||||
stage: PipelineStage;
|
stage: PipelineStage;
|
||||||
deals: Deal[];
|
deals: Deal[];
|
||||||
totalValue: number;
|
totalValue: number;
|
||||||
|
currency: string;
|
||||||
onAddDeal: (stageId: string) => void;
|
onAddDeal: (stageId: string) => void;
|
||||||
onEditDeal: (deal: Deal) => void;
|
onEditDeal: (deal: Deal) => void;
|
||||||
}) {
|
}) {
|
||||||
@ -226,7 +223,9 @@ function StageColumn({
|
|||||||
{deals.length}
|
{deals.length}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-slate-400">{formatCurrency(totalValue)}</p>
|
<p className="text-xs text-slate-400">
|
||||||
|
{formatCurrency(totalValue, currency)}
|
||||||
|
</p>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
ref={setNodeRef}
|
ref={setNodeRef}
|
||||||
|
|||||||
125
src/components/settings/deals-settings.tsx
Normal file
125
src/components/settings/deals-settings.tsx
Normal file
@ -0,0 +1,125 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { Coins, Loader2 } from "lucide-react";
|
||||||
|
|
||||||
|
import { createClient } from "@/lib/supabase/client";
|
||||||
|
import { useAuth } from "@/hooks/use-auth";
|
||||||
|
import { CURRENCIES } from "@/lib/currency";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
CardDescription,
|
||||||
|
} from "@/components/ui/card";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deals settings — account-wide default currency.
|
||||||
|
*
|
||||||
|
* One currency per account (issue #218): the chosen code seeds new
|
||||||
|
* deals and formats every aggregated total. Existing deals keep their
|
||||||
|
* own saved currency. Writes go straight to `accounts.default_currency`;
|
||||||
|
* the `accounts_update` RLS policy (017) already restricts that to
|
||||||
|
* admins+, so non-admins see a disabled, read-only control.
|
||||||
|
*/
|
||||||
|
export function DealsSettings() {
|
||||||
|
const supabase = createClient();
|
||||||
|
const {
|
||||||
|
accountId,
|
||||||
|
defaultCurrency,
|
||||||
|
canEditSettings,
|
||||||
|
profileLoading,
|
||||||
|
refreshProfile,
|
||||||
|
} = useAuth();
|
||||||
|
|
||||||
|
const [selected, setSelected] = useState(defaultCurrency);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
|
||||||
|
// Keep the select in sync once the profile (and its account default)
|
||||||
|
// resolves, and after a save round-trips through refreshProfile.
|
||||||
|
useEffect(() => {
|
||||||
|
setSelected(defaultCurrency);
|
||||||
|
}, [defaultCurrency]);
|
||||||
|
|
||||||
|
const dirty = selected !== defaultCurrency;
|
||||||
|
|
||||||
|
async function handleSave() {
|
||||||
|
if (!accountId || !dirty) return;
|
||||||
|
setSaving(true);
|
||||||
|
const { error } = await supabase
|
||||||
|
.from("accounts")
|
||||||
|
.update({ default_currency: selected })
|
||||||
|
.eq("id", accountId);
|
||||||
|
if (error) {
|
||||||
|
toast.error("Failed to save default currency");
|
||||||
|
setSaving(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Pull the new value back into the auth context so the deal form
|
||||||
|
// and every total pick it up without a full reload.
|
||||||
|
await refreshProfile();
|
||||||
|
setSaving(false);
|
||||||
|
toast.success("Default currency updated");
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="mt-4 space-y-4">
|
||||||
|
<Card className="bg-slate-900 border-slate-700 ring-0 ring-transparent">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2 text-white">
|
||||||
|
<Coins className="size-4 text-primary" />
|
||||||
|
Default currency
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription className="text-slate-400">
|
||||||
|
New deals default to this currency, and pipeline and
|
||||||
|
dashboard totals are shown in it. Existing deals keep the
|
||||||
|
currency they were saved with.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<div className="grid gap-2 sm:max-w-xs">
|
||||||
|
<Label className="text-slate-300">Currency</Label>
|
||||||
|
<select
|
||||||
|
value={selected}
|
||||||
|
onChange={(e) => setSelected(e.target.value)}
|
||||||
|
disabled={!canEditSettings || profileLoading}
|
||||||
|
className="h-9 w-full rounded-lg border border-slate-700 bg-slate-800 px-2.5 text-sm text-white outline-none focus:border-primary focus:ring-1 focus:ring-primary disabled:cursor-not-allowed disabled:opacity-60"
|
||||||
|
>
|
||||||
|
{CURRENCIES.map((c) => (
|
||||||
|
<option key={c.code} value={c.code}>
|
||||||
|
{c.code} — {c.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
{!canEditSettings && (
|
||||||
|
<p className="text-xs text-slate-500">
|
||||||
|
Only account admins can change the default currency.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{canEditSettings && (
|
||||||
|
<Button
|
||||||
|
onClick={handleSave}
|
||||||
|
disabled={saving || !dirty}
|
||||||
|
className="bg-primary text-primary-foreground hover:bg-primary/90"
|
||||||
|
>
|
||||||
|
{saving ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="size-4 animate-spin" />
|
||||||
|
Saving...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
"Save"
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -11,6 +11,7 @@ import {
|
|||||||
} from "react";
|
} from "react";
|
||||||
import { createClient } from "@/lib/supabase/client";
|
import { createClient } from "@/lib/supabase/client";
|
||||||
import type { User } from "@supabase/supabase-js";
|
import type { User } from "@supabase/supabase-js";
|
||||||
|
import { DEFAULT_CURRENCY } from "@/lib/currency";
|
||||||
import {
|
import {
|
||||||
canEditSettings as canEditSettingsFor,
|
canEditSettings as canEditSettingsFor,
|
||||||
canManageMembers as canManageMembersFor,
|
canManageMembers as canManageMembersFor,
|
||||||
@ -38,6 +39,9 @@ interface Profile {
|
|||||||
interface AccountSummary {
|
interface AccountSummary {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
|
/** Default deal currency (ISO-4217). NOT NULL DEFAULT 'USD' in the
|
||||||
|
* DB (migration 021); narrowed to DEFAULT_CURRENCY when absent. */
|
||||||
|
default_currency: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface AuthContextValue {
|
interface AuthContextValue {
|
||||||
@ -77,8 +81,12 @@ interface AuthContextValue {
|
|||||||
accountId: string | null;
|
accountId: string | null;
|
||||||
/** Role within that account. Null while loading. */
|
/** Role within that account. Null while loading. */
|
||||||
accountRole: AccountRole | null;
|
accountRole: AccountRole | null;
|
||||||
/** Lightweight account meta — id + name. Null while loading. */
|
/** Lightweight account meta — id + name + default_currency. Null while loading. */
|
||||||
account: AccountSummary | null;
|
account: AccountSummary | null;
|
||||||
|
/** Account default deal currency. Falls back to DEFAULT_CURRENCY
|
||||||
|
* while loading or when no account is resolved, so callers can use
|
||||||
|
* it unconditionally. */
|
||||||
|
defaultCurrency: string;
|
||||||
/** True if `accountRole === 'owner'`. */
|
/** True if `accountRole === 'owner'`. */
|
||||||
isOwner: boolean;
|
isOwner: boolean;
|
||||||
/** True if `accountRole === 'admin'` (does NOT include owner — use canManageMembers for "admin or above"). */
|
/** True if `accountRole === 'admin'` (does NOT include owner — use canManageMembers for "admin or above"). */
|
||||||
@ -128,7 +136,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
|||||||
// missing account collapses to null rather than a half-
|
// missing account collapses to null rather than a half-
|
||||||
// populated row (shouldn't happen post-017 NOT NULL, but
|
// populated row (shouldn't happen post-017 NOT NULL, but
|
||||||
// belt-and-braces against forks running older schemas).
|
// belt-and-braces against forks running older schemas).
|
||||||
"id, full_name, email, avatar_url, role, beta_features, account_id, account_role, account:accounts!inner(id, name)",
|
"id, full_name, email, avatar_url, role, beta_features, account_id, account_role, account:accounts!inner(id, name, default_currency)",
|
||||||
)
|
)
|
||||||
.eq("user_id", userId)
|
.eq("user_id", userId)
|
||||||
.maybeSingle();
|
.maybeSingle();
|
||||||
@ -148,9 +156,23 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
|||||||
// as either an object or a single-element array depending on
|
// as either an object or a single-element array depending on
|
||||||
// the schema's inferred cardinality — normalise to the object
|
// the schema's inferred cardinality — normalise to the object
|
||||||
// form before reading.
|
// form before reading.
|
||||||
const accountRow = Array.isArray(data.account)
|
const accountRaw = Array.isArray(data.account)
|
||||||
? data.account[0] ?? null
|
? data.account[0] ?? null
|
||||||
: (data.account as { id: string; name: string } | null);
|
: (data.account as {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
default_currency: string | null;
|
||||||
|
} | null);
|
||||||
|
// Narrow default_currency defensively: forks running pre-021
|
||||||
|
// schemas won't have the column, so a missing/null value reads
|
||||||
|
// as the safe USD fallback rather than crashing the picker.
|
||||||
|
const accountRow: AccountSummary | null = accountRaw
|
||||||
|
? {
|
||||||
|
id: accountRaw.id,
|
||||||
|
name: accountRaw.name,
|
||||||
|
default_currency: accountRaw.default_currency ?? DEFAULT_CURRENCY,
|
||||||
|
}
|
||||||
|
: null;
|
||||||
|
|
||||||
// Narrow the DB enum into our AccountRole union. The DB
|
// Narrow the DB enum into our AccountRole union. The DB
|
||||||
// constraint should make this unconditional, but a future
|
// constraint should make this unconditional, but a future
|
||||||
@ -299,6 +321,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
|||||||
signOut,
|
signOut,
|
||||||
refreshProfile,
|
refreshProfile,
|
||||||
account,
|
account,
|
||||||
|
defaultCurrency: account?.default_currency ?? DEFAULT_CURRENCY,
|
||||||
...derived,
|
...derived,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@ -328,6 +351,7 @@ export function useAuth(): AuthContextValue {
|
|||||||
},
|
},
|
||||||
refreshProfile: async () => {},
|
refreshProfile: async () => {},
|
||||||
account: null,
|
account: null,
|
||||||
|
defaultCurrency: DEFAULT_CURRENCY,
|
||||||
accountId: null,
|
accountId: null,
|
||||||
accountRole: null,
|
accountRole: null,
|
||||||
isOwner: false,
|
isOwner: false,
|
||||||
|
|||||||
@ -465,6 +465,16 @@ async function runStep(step: AutomationStep, args: ExecuteArgs): Promise<string>
|
|||||||
case 'create_deal': {
|
case 'create_deal': {
|
||||||
const cfg = step.step_config as CreateDealStepConfig
|
const cfg = step.step_config as CreateDealStepConfig
|
||||||
if (!cfg.pipeline_id || !cfg.stage_id) throw new Error('create_deal needs pipeline + stage')
|
if (!cfg.pipeline_id || !cfg.stage_id) throw new Error('create_deal needs pipeline + stage')
|
||||||
|
// Match the account's configured default currency rather than
|
||||||
|
// the static `deals.currency` DB default — keeps automation-
|
||||||
|
// created deals consistent with the one-currency-per-account
|
||||||
|
// rule (issue #218). Fall back to USD if the row is somehow
|
||||||
|
// missing the value (pre-021 forks).
|
||||||
|
const { data: acct } = await db
|
||||||
|
.from('accounts')
|
||||||
|
.select('default_currency')
|
||||||
|
.eq('id', args.automation.account_id)
|
||||||
|
.maybeSingle()
|
||||||
await db.from('deals').insert({
|
await db.from('deals').insert({
|
||||||
// Tenancy + audit, same split as automation_logs above.
|
// Tenancy + audit, same split as automation_logs above.
|
||||||
account_id: args.automation.account_id,
|
account_id: args.automation.account_id,
|
||||||
@ -474,6 +484,7 @@ async function runStep(step: AutomationStep, args: ExecuteArgs): Promise<string>
|
|||||||
contact_id: args.contactId,
|
contact_id: args.contactId,
|
||||||
title: interpolate(cfg.title, args),
|
title: interpolate(cfg.title, args),
|
||||||
value: cfg.value ?? 0,
|
value: cfg.value ?? 0,
|
||||||
|
currency: acct?.default_currency ?? 'USD',
|
||||||
status: 'open',
|
status: 'open',
|
||||||
})
|
})
|
||||||
return 'deal created'
|
return 'deal created'
|
||||||
|
|||||||
80
src/lib/currency.ts
Normal file
80
src/lib/currency.ts
Normal file
@ -0,0 +1,80 @@
|
|||||||
|
/**
|
||||||
|
* Currency — single source of truth for deal-value formatting and
|
||||||
|
* the currency picker options.
|
||||||
|
*
|
||||||
|
* Before this module, ~6 components each defined their own
|
||||||
|
* `Intl.NumberFormat(..., { currency: "USD" })` helper with USD
|
||||||
|
* baked in. The default currency is now configurable per account
|
||||||
|
* (accounts.default_currency, migration 021), so every formatter
|
||||||
|
* takes a currency and falls back to DEFAULT_CURRENCY only when
|
||||||
|
* nothing is known.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** App-wide fallback when no account/deal currency is available. */
|
||||||
|
export const DEFAULT_CURRENCY = "USD";
|
||||||
|
|
||||||
|
export interface CurrencyOption {
|
||||||
|
/** ISO-4217 code, e.g. "USD". Stored verbatim in the DB. */
|
||||||
|
code: string;
|
||||||
|
/** Human label for the dropdown, e.g. "US Dollar". */
|
||||||
|
label: string;
|
||||||
|
/** Symbol for compact display, e.g. "$". */
|
||||||
|
symbol: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The currencies offered in pickers. Codes must be valid ISO-4217 so
|
||||||
|
* `Intl.NumberFormat` renders the right symbol/grouping. Extend this
|
||||||
|
* list to offer more — nothing else needs to change.
|
||||||
|
*/
|
||||||
|
export const CURRENCIES: CurrencyOption[] = [
|
||||||
|
{ code: "USD", label: "US Dollar", symbol: "$" },
|
||||||
|
{ code: "EUR", label: "Euro", symbol: "€" },
|
||||||
|
{ code: "GBP", label: "British Pound", symbol: "£" },
|
||||||
|
{ code: "INR", label: "Indian Rupee", symbol: "₹" },
|
||||||
|
{ code: "AUD", label: "Australian Dollar", symbol: "A$" },
|
||||||
|
{ code: "CAD", label: "Canadian Dollar", symbol: "C$" },
|
||||||
|
{ code: "BRL", label: "Brazilian Real", symbol: "R$" },
|
||||||
|
{ code: "JPY", label: "Japanese Yen", symbol: "¥" },
|
||||||
|
{ code: "CNY", label: "Chinese Yuan", symbol: "¥" },
|
||||||
|
{ code: "AED", label: "UAE Dirham", symbol: "د.إ" },
|
||||||
|
{ code: "ZAR", label: "South African Rand", symbol: "R" },
|
||||||
|
{ code: "NGN", label: "Nigerian Naira", symbol: "₦" },
|
||||||
|
{ code: "SGD", label: "Singapore Dollar", symbol: "S$" },
|
||||||
|
{ code: "MXN", label: "Mexican Peso", symbol: "$" },
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format a deal value as a currency string. Whole-number output
|
||||||
|
* (no minor units) — deal values are tracked to the dollar across
|
||||||
|
* the app. `currency` defaults to USD so callers with nothing better
|
||||||
|
* stay safe, but pass the account/deal currency wherever known.
|
||||||
|
*/
|
||||||
|
export function formatCurrency(
|
||||||
|
value: number,
|
||||||
|
currency: string = DEFAULT_CURRENCY,
|
||||||
|
): string {
|
||||||
|
return new Intl.NumberFormat(undefined, {
|
||||||
|
style: "currency",
|
||||||
|
currency: currency || DEFAULT_CURRENCY,
|
||||||
|
minimumFractionDigits: 0,
|
||||||
|
maximumFractionDigits: 0,
|
||||||
|
}).format(Number(value || 0));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compact currency for tight spaces (donut center, legend rows):
|
||||||
|
* "$1.2M" / "€34.5k" / "₹900". Uses the currency's symbol from
|
||||||
|
* CURRENCIES, falling back to the code when we don't carry a symbol.
|
||||||
|
*/
|
||||||
|
export function formatCurrencyShort(
|
||||||
|
value: number,
|
||||||
|
currency: string = DEFAULT_CURRENCY,
|
||||||
|
): string {
|
||||||
|
const code = currency || DEFAULT_CURRENCY;
|
||||||
|
const symbol = CURRENCIES.find((c) => c.code === code)?.symbol ?? `${code} `;
|
||||||
|
const v = Number(value || 0);
|
||||||
|
if (v >= 1_000_000) return `${symbol}${(v / 1_000_000).toFixed(1)}M`;
|
||||||
|
if (v >= 1_000) return `${symbol}${(v / 1_000).toFixed(1)}k`;
|
||||||
|
return `${symbol}${v.toFixed(0)}`;
|
||||||
|
}
|
||||||
32
supabase/migrations/021_account_default_currency.sql
Normal file
32
supabase/migrations/021_account_default_currency.sql
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
-- ============================================================
|
||||||
|
-- 021_account_default_currency
|
||||||
|
--
|
||||||
|
-- Make the default deal currency configurable per account.
|
||||||
|
--
|
||||||
|
-- Before this, the app hardcoded USD everywhere — deal-value
|
||||||
|
-- formatters, the new-deal form, and automation-created deals all
|
||||||
|
-- assumed USD. wacrm is self-hostable and used globally, so a fixed
|
||||||
|
-- USD default made deal tracking unhelpful for non-US businesses
|
||||||
|
-- (issue #218).
|
||||||
|
--
|
||||||
|
-- We add a single `default_currency` column to `accounts`. New deals
|
||||||
|
-- and all aggregated totals (pipeline/dashboard) format in this
|
||||||
|
-- currency; existing deals keep their own saved `deals.currency`.
|
||||||
|
-- We enforce one currency per account (no FX conversion) — the
|
||||||
|
-- issue's recommended first pass.
|
||||||
|
--
|
||||||
|
-- RLS: no change needed. The existing `accounts_update` policy
|
||||||
|
-- (017) already restricts writes to admins+, which is exactly who
|
||||||
|
-- should change an account-wide setting.
|
||||||
|
-- ============================================================
|
||||||
|
|
||||||
|
ALTER TABLE accounts
|
||||||
|
ADD COLUMN IF NOT EXISTS default_currency TEXT NOT NULL DEFAULT 'USD';
|
||||||
|
|
||||||
|
-- Keep the value an ISO-4217-shaped 3-letter uppercase code without
|
||||||
|
-- pinning to a fixed enum — forks can use any currency Intl supports.
|
||||||
|
ALTER TABLE accounts
|
||||||
|
DROP CONSTRAINT IF EXISTS accounts_default_currency_format;
|
||||||
|
ALTER TABLE accounts
|
||||||
|
ADD CONSTRAINT accounts_default_currency_format
|
||||||
|
CHECK (default_currency ~ '^[A-Z]{3}$');
|
||||||
Loading…
Reference in New Issue
Block a user