mirror of
https://github.com/Luchexx/wacrm.git
synced 2026-11-02 09:19:51 +00:00
Merge pull request #226 from ArnasDon/feat/configurable-default-currency
feat(deals): make default deal currency configurable per account (#218)
This commit is contained in:
commit
e354bedd42
@ -2,6 +2,8 @@
|
||||
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { createClient } from '@/lib/supabase/client'
|
||||
import { useAuth } from '@/hooks/use-auth'
|
||||
import { formatCurrency } from '@/lib/currency'
|
||||
import {
|
||||
MessageSquare,
|
||||
UserPlus,
|
||||
@ -35,6 +37,7 @@ import { ActivityFeed } from '@/components/dashboard/activity-feed'
|
||||
type RangeDays = 7 | 30 | 90
|
||||
|
||||
export default function DashboardPage() {
|
||||
const { defaultCurrency } = useAuth()
|
||||
const [metrics, setMetrics] = useState<MetricsBundle | null>(null)
|
||||
const [metricsLoading, setMetricsLoading] = useState(true)
|
||||
|
||||
@ -155,7 +158,7 @@ export default function DashboardPage() {
|
||||
/>
|
||||
<MetricCard
|
||||
title="Open Deals Value"
|
||||
value={formatCurrency(metrics.openDealsValue)}
|
||||
value={formatCurrency(metrics.openDealsValue, defaultCurrency)}
|
||||
icon={DollarSign}
|
||||
subtitle={`${metrics.openDealsCount} open deal${metrics.openDealsCount === 1 ? '' : 's'}`}
|
||||
/>
|
||||
@ -196,7 +199,11 @@ export default function DashboardPage() {
|
||||
/>
|
||||
</div>
|
||||
<div className="h-full lg:col-span-2">
|
||||
<PipelineDonut data={pipeline} loading={pipelineLoading} />
|
||||
<PipelineDonut
|
||||
data={pipeline}
|
||||
loading={pipelineLoading}
|
||||
currency={defaultCurrency}
|
||||
/>
|
||||
</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 {
|
||||
if (delta === 0) return `No change ${suffix}`
|
||||
const sign = delta > 0 ? '+' : ''
|
||||
|
||||
@ -8,6 +8,7 @@ import {
|
||||
User,
|
||||
Palette,
|
||||
UsersRound,
|
||||
Coins,
|
||||
} from 'lucide-react';
|
||||
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs';
|
||||
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 { AppearancePanel } from '@/components/settings/appearance-panel';
|
||||
import { MembersTab } from '@/components/settings/members-tab';
|
||||
import { DealsSettings } from '@/components/settings/deals-settings';
|
||||
|
||||
const TAB_VALUES = [
|
||||
'profile',
|
||||
'whatsapp',
|
||||
'templates',
|
||||
'tags',
|
||||
'deals',
|
||||
'appearance',
|
||||
'members',
|
||||
] as const;
|
||||
@ -90,6 +93,13 @@ export default function SettingsPage() {
|
||||
<Tag className="size-4" />
|
||||
Tags
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="deals"
|
||||
className="data-active:bg-slate-800 data-active:text-primary text-slate-400"
|
||||
>
|
||||
<Coins className="size-4" />
|
||||
Deals
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="appearance"
|
||||
className="data-active:bg-slate-800 data-active:text-primary text-slate-400"
|
||||
@ -124,6 +134,10 @@ export default function SettingsPage() {
|
||||
<TagManager />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="deals">
|
||||
<DealsSettings />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="appearance">
|
||||
<AppearancePanel />
|
||||
</TabsContent>
|
||||
|
||||
@ -3,6 +3,7 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { createClient } from '@/lib/supabase/client';
|
||||
import { useAuth } from '@/hooks/use-auth';
|
||||
import { formatCurrency } from '@/lib/currency';
|
||||
import { toast } from 'sonner';
|
||||
import type { Contact, Tag, ContactTag, ContactNote, CustomField, ContactCustomValue, Deal } from '@/types';
|
||||
import {
|
||||
@ -48,7 +49,7 @@ export function ContactDetailView({
|
||||
onUpdated,
|
||||
}: ContactDetailViewProps) {
|
||||
const supabase = createClient();
|
||||
const { accountId } = useAuth();
|
||||
const { accountId, defaultCurrency } = useAuth();
|
||||
|
||||
const [contact, setContact] = useState<Contact | null>(null);
|
||||
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">
|
||||
<span className="flex items-center gap-1">
|
||||
<DollarSign className="size-3" />
|
||||
{new Intl.NumberFormat('en-US', {
|
||||
style: 'currency',
|
||||
currency: deal.currency || 'USD',
|
||||
maximumFractionDigits: 0,
|
||||
}).format(Number(deal.value || 0))}
|
||||
{formatCurrency(
|
||||
deal.value ?? 0,
|
||||
deal.currency || defaultCurrency,
|
||||
)}
|
||||
</span>
|
||||
{deal.status && deal.status !== 'open' && (
|
||||
<span
|
||||
|
||||
@ -2,15 +2,18 @@
|
||||
|
||||
import { GitBranch } from 'lucide-react'
|
||||
import type { PipelineDonutData } from '@/lib/dashboard/types'
|
||||
import { formatCurrencyShort } from '@/lib/currency'
|
||||
import { EmptyState } from './empty-state'
|
||||
import { Skeleton } from './skeleton'
|
||||
|
||||
interface PipelineDonutProps {
|
||||
data: PipelineDonutData | null
|
||||
loading: boolean
|
||||
/** Account default currency for the totals. */
|
||||
currency: string
|
||||
}
|
||||
|
||||
export function PipelineDonut({ data, loading }: PipelineDonutProps) {
|
||||
export function PipelineDonut({ data, loading, currency }: PipelineDonutProps) {
|
||||
return (
|
||||
<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">
|
||||
@ -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">
|
||||
{data.stages.map((s) => (
|
||||
<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'}
|
||||
</span>
|
||||
<span className="w-20 text-right text-slate-300 tabular-nums">
|
||||
{formatCurrencyShort(s.totalValue)}
|
||||
{formatCurrencyShort(s.totalValue, currency)}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
@ -63,7 +66,7 @@ export function PipelineDonut({ data, loading }: PipelineDonutProps) {
|
||||
// between segments are implied by a thin slate-900 stroke between
|
||||
// them for a cleaner look.
|
||||
// ------------------------------------------------------------
|
||||
function Donut({ data }: { data: PipelineDonutData }) {
|
||||
function Donut({ data, currency }: { data: PipelineDonutData; currency: string }) {
|
||||
const size = 200
|
||||
const r = 80
|
||||
const ringWidth = 18
|
||||
@ -121,7 +124,7 @@ function Donut({ data }: { data: PipelineDonutData }) {
|
||||
textAnchor="middle"
|
||||
className="fill-white text-[18px] font-semibold tabular-nums"
|
||||
>
|
||||
{formatCurrencyShort(data.totalValue)}
|
||||
{formatCurrencyShort(data.totalValue, currency)}
|
||||
</text>
|
||||
</svg>
|
||||
</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
|
||||
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 { Calendar, Check, X } from "lucide-react";
|
||||
import { formatCurrency } from "@/lib/currency";
|
||||
|
||||
interface DealCardProps {
|
||||
deal: Deal;
|
||||
@ -10,15 +11,6 @@ interface DealCardProps {
|
||||
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) {
|
||||
return new Date(dateStr).toLocaleDateString("en-US", {
|
||||
month: "short",
|
||||
|
||||
@ -4,6 +4,7 @@ import { useState, useEffect } from "react";
|
||||
import Link from "next/link";
|
||||
import { createClient } from "@/lib/supabase/client";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { CURRENCIES } from "@/lib/currency";
|
||||
import type {
|
||||
Contact,
|
||||
Conversation,
|
||||
@ -52,11 +53,11 @@ export function DealForm({
|
||||
onSaved,
|
||||
}: DealFormProps) {
|
||||
const supabase = createClient();
|
||||
const { accountId } = useAuth();
|
||||
const { accountId, defaultCurrency } = useAuth();
|
||||
|
||||
const [title, setTitle] = useState("");
|
||||
const [value, setValue] = useState("");
|
||||
const [currency, setCurrency] = useState("USD");
|
||||
const [currency, setCurrency] = useState(defaultCurrency);
|
||||
const [contactId, setContactId] = useState("");
|
||||
const [stageId, setStageId] = useState("");
|
||||
const [assignedTo, setAssignedTo] = useState("");
|
||||
@ -83,7 +84,7 @@ export function DealForm({
|
||||
if (deal) {
|
||||
setTitle(deal.title);
|
||||
setValue(String(deal.value ?? ""));
|
||||
setCurrency(deal.currency || "USD");
|
||||
setCurrency(deal.currency || defaultCurrency);
|
||||
// contact_id is nullable when the contact has been deleted
|
||||
// (migration 004: ON DELETE SET NULL). "" means "no selection".
|
||||
setContactId(deal.contact_id ?? "");
|
||||
@ -94,14 +95,14 @@ export function DealForm({
|
||||
} else {
|
||||
setTitle("");
|
||||
setValue("");
|
||||
setCurrency("USD");
|
||||
setCurrency(defaultCurrency);
|
||||
setContactId("");
|
||||
setStageId(defaultStageId || stages[0]?.id || "");
|
||||
setAssignedTo("");
|
||||
setExpectedCloseDate("");
|
||||
setNotes("");
|
||||
}
|
||||
}, [open, deal, defaultStageId, stages]);
|
||||
}, [open, deal, defaultStageId, stages, defaultCurrency]);
|
||||
/* eslint-enable react-hooks/set-state-in-effect */
|
||||
|
||||
// Load supporting data once the sheet is open
|
||||
@ -313,9 +314,11 @@ export function DealForm({
|
||||
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"
|
||||
>
|
||||
<option value="USD">USD</option>
|
||||
<option value="EUR">EUR</option>
|
||||
<option value="GBP">GBP</option>
|
||||
{CURRENCIES.map((c) => (
|
||||
<option key={c.code} value={c.code}>
|
||||
{c.code}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -17,21 +17,14 @@ import {
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { formatCurrency } from "@/lib/currency";
|
||||
|
||||
interface PipelineAnalyticsProps {
|
||||
stages: PipelineStage[];
|
||||
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.
|
||||
* First stage ≈ 10%, stages interpolate up to 90% before the final stage,
|
||||
@ -53,6 +46,7 @@ function computeStageProbability(
|
||||
}
|
||||
|
||||
export function PipelineAnalytics({ stages, deals }: PipelineAnalyticsProps) {
|
||||
const { defaultCurrency } = useAuth();
|
||||
const sortedStages = useMemo(
|
||||
() => [...stages].sort((a, b) => a.position - b.position),
|
||||
[stages],
|
||||
@ -109,19 +103,19 @@ export function PipelineAnalytics({ stages, deals }: PipelineAnalyticsProps) {
|
||||
<Metric
|
||||
icon={<DollarSign className="h-4 w-4 text-primary" />}
|
||||
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."
|
||||
/>
|
||||
<Metric
|
||||
icon={<Target className="h-4 w-4 text-blue-400" />}
|
||||
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."
|
||||
/>
|
||||
<Metric
|
||||
icon={<TrendingUp className="h-4 w-4 text-purple-400" />}
|
||||
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."
|
||||
/>
|
||||
<Metric
|
||||
|
||||
@ -18,6 +18,8 @@ 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[];
|
||||
@ -27,15 +29,6 @@ interface PipelineBoardProps {
|
||||
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({
|
||||
stages,
|
||||
deals,
|
||||
@ -43,6 +36,7 @@ export function PipelineBoard({
|
||||
onAddDeal,
|
||||
onEditDeal,
|
||||
}: PipelineBoardProps) {
|
||||
const { defaultCurrency } = useAuth();
|
||||
const [activeDealId, setActiveDealId] = useState<string | null>(null);
|
||||
|
||||
const sortedStages = useMemo(
|
||||
@ -121,6 +115,7 @@ export function PipelineBoard({
|
||||
stage={stage}
|
||||
deals={stageDeals}
|
||||
totalValue={totalValue}
|
||||
currency={defaultCurrency}
|
||||
onAddDeal={onAddDeal}
|
||||
onEditDeal={onEditDeal}
|
||||
/>
|
||||
@ -194,12 +189,14 @@ function StageColumn({
|
||||
stage,
|
||||
deals,
|
||||
totalValue,
|
||||
currency,
|
||||
onAddDeal,
|
||||
onEditDeal,
|
||||
}: {
|
||||
stage: PipelineStage;
|
||||
deals: Deal[];
|
||||
totalValue: number;
|
||||
currency: string;
|
||||
onAddDeal: (stageId: string) => void;
|
||||
onEditDeal: (deal: Deal) => void;
|
||||
}) {
|
||||
@ -226,7 +223,9 @@ function StageColumn({
|
||||
{deals.length}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xs text-slate-400">{formatCurrency(totalValue)}</p>
|
||||
<p className="text-xs text-slate-400">
|
||||
{formatCurrency(totalValue, currency)}
|
||||
</p>
|
||||
|
||||
<div
|
||||
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";
|
||||
import { createClient } from "@/lib/supabase/client";
|
||||
import type { User } from "@supabase/supabase-js";
|
||||
import { DEFAULT_CURRENCY } from "@/lib/currency";
|
||||
import {
|
||||
canEditSettings as canEditSettingsFor,
|
||||
canManageMembers as canManageMembersFor,
|
||||
@ -38,6 +39,9 @@ interface Profile {
|
||||
interface AccountSummary {
|
||||
id: 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 {
|
||||
@ -77,8 +81,12 @@ interface AuthContextValue {
|
||||
accountId: string | null;
|
||||
/** Role within that account. Null while loading. */
|
||||
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 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'`. */
|
||||
isOwner: boolean;
|
||||
/** 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-
|
||||
// populated row (shouldn't happen post-017 NOT NULL, but
|
||||
// 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)
|
||||
.maybeSingle();
|
||||
@ -148,9 +156,23 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
// as either an object or a single-element array depending on
|
||||
// the schema's inferred cardinality — normalise to the object
|
||||
// form before reading.
|
||||
const accountRow = Array.isArray(data.account)
|
||||
const accountRaw = Array.isArray(data.account)
|
||||
? 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
|
||||
// constraint should make this unconditional, but a future
|
||||
@ -299,6 +321,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
signOut,
|
||||
refreshProfile,
|
||||
account,
|
||||
defaultCurrency: account?.default_currency ?? DEFAULT_CURRENCY,
|
||||
...derived,
|
||||
}}
|
||||
>
|
||||
@ -328,6 +351,7 @@ export function useAuth(): AuthContextValue {
|
||||
},
|
||||
refreshProfile: async () => {},
|
||||
account: null,
|
||||
defaultCurrency: DEFAULT_CURRENCY,
|
||||
accountId: null,
|
||||
accountRole: null,
|
||||
isOwner: false,
|
||||
|
||||
@ -465,6 +465,16 @@ async function runStep(step: AutomationStep, args: ExecuteArgs): Promise<string>
|
||||
case 'create_deal': {
|
||||
const cfg = step.step_config as CreateDealStepConfig
|
||||
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({
|
||||
// Tenancy + audit, same split as automation_logs above.
|
||||
account_id: args.automation.account_id,
|
||||
@ -474,6 +484,7 @@ async function runStep(step: AutomationStep, args: ExecuteArgs): Promise<string>
|
||||
contact_id: args.contactId,
|
||||
title: interpolate(cfg.title, args),
|
||||
value: cfg.value ?? 0,
|
||||
currency: acct?.default_currency ?? 'USD',
|
||||
status: 'open',
|
||||
})
|
||||
return 'deal created'
|
||||
|
||||
65
src/lib/currency.test.ts
Normal file
65
src/lib/currency.test.ts
Normal file
@ -0,0 +1,65 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
CURRENCIES,
|
||||
DEFAULT_CURRENCY,
|
||||
formatCurrency,
|
||||
formatCurrencyShort,
|
||||
} from "./currency";
|
||||
|
||||
describe("formatCurrency", () => {
|
||||
it("formats whole amounts with no minor units", () => {
|
||||
// Use a non-breaking-space-tolerant check: Intl may insert NBSP.
|
||||
const out = formatCurrency(1234, "USD");
|
||||
expect(out).toContain("1,234");
|
||||
expect(out).not.toContain(".00");
|
||||
});
|
||||
|
||||
it("defaults to USD when no currency is given", () => {
|
||||
expect(formatCurrency(10)).toBe(formatCurrency(10, DEFAULT_CURRENCY));
|
||||
});
|
||||
|
||||
it("treats an empty-string currency as the default", () => {
|
||||
expect(formatCurrency(10, "")).toBe(formatCurrency(10, DEFAULT_CURRENCY));
|
||||
});
|
||||
|
||||
it("coerces non-finite values to 0", () => {
|
||||
expect(formatCurrency(Number.NaN, "USD")).toContain("0");
|
||||
});
|
||||
|
||||
it("renders a well-formed but unknown ISO code without throwing", () => {
|
||||
// Intl is lenient here — it uses the code as the symbol.
|
||||
const out = formatCurrency(1234, "ZZZ");
|
||||
expect(out).toContain("ZZZ");
|
||||
expect(out).toContain("1,234");
|
||||
});
|
||||
|
||||
it("never throws on a structurally invalid code (no DB CHECK on deals.currency)", () => {
|
||||
for (const bad of ["United States", "US", "USDD", "12", "u$d"]) {
|
||||
expect(() => formatCurrency(1234, bad)).not.toThrow();
|
||||
expect(formatCurrency(1234, bad)).toContain("1,234");
|
||||
}
|
||||
});
|
||||
|
||||
it("formats every offered currency without throwing", () => {
|
||||
for (const c of CURRENCIES) {
|
||||
expect(() => formatCurrency(1000, c.code)).not.toThrow();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatCurrencyShort", () => {
|
||||
it("abbreviates millions and thousands with the currency symbol", () => {
|
||||
expect(formatCurrencyShort(2_500_000, "USD")).toBe("$2.5M");
|
||||
expect(formatCurrencyShort(3_400, "USD")).toBe("$3.4k");
|
||||
expect(formatCurrencyShort(900, "USD")).toBe("$900");
|
||||
});
|
||||
|
||||
it("uses the matching symbol for non-USD currencies", () => {
|
||||
expect(formatCurrencyShort(1_000, "EUR")).toBe("€1.0k");
|
||||
expect(formatCurrencyShort(1_000, "INR")).toBe("₹1.0k");
|
||||
});
|
||||
|
||||
it("falls back to the code prefix for unknown currencies (no throw)", () => {
|
||||
expect(formatCurrencyShort(1_000, "ZZZ")).toBe("ZZZ 1.0k");
|
||||
});
|
||||
});
|
||||
97
src/lib/currency.ts
Normal file
97
src/lib/currency.ts
Normal file
@ -0,0 +1,97 @@
|
||||
/**
|
||||
* 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.
|
||||
*
|
||||
* Total by design: `Intl.NumberFormat` throws a RangeError on a
|
||||
* structurally invalid currency code, and `deals.currency` carries
|
||||
* NO DB CHECK (only `accounts.default_currency` does), so legacy
|
||||
* rows, imports, or hand-edited data can hold malformed values like
|
||||
* "United States". We never let that crash a render — on a bad code
|
||||
* we fall back to "CODE 1,234".
|
||||
*/
|
||||
export function formatCurrency(
|
||||
value: number,
|
||||
currency: string = DEFAULT_CURRENCY,
|
||||
): string {
|
||||
const code = (currency || DEFAULT_CURRENCY).trim();
|
||||
const amount = Number(value) || 0;
|
||||
try {
|
||||
return new Intl.NumberFormat(undefined, {
|
||||
style: "currency",
|
||||
currency: code,
|
||||
minimumFractionDigits: 0,
|
||||
maximumFractionDigits: 0,
|
||||
}).format(amount);
|
||||
} catch {
|
||||
// Invalid ISO code — show the raw code + grouped number so the
|
||||
// value is still legible instead of throwing.
|
||||
return `${code} ${new Intl.NumberFormat(undefined, {
|
||||
maximumFractionDigits: 0,
|
||||
}).format(amount)}`;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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