feat(i18n): localize pipelines module and components

- Added Pipelines namespace to messages/en.json encompassing page, board, card, form, settings, and analytics.
- Migrated PipelinesPage empty states, dropdowns, and pipeline creation dialogues.
- Localized pipeline-board and deal-card UI strings and badges (Won/Lost).
- Translated deal-form inputs, dynamic tooltips, select options, and error toasts.
- Localized pipeline-settings including the sortable stage management rows and delete confirmations.
- Migrated pipeline-analytics metrics and detailed statistical tooltips.
- Resolved typecheck dependencies to ensure the t function is correctly passed to nested sortable components.
This commit is contained in:
Ivan Mercedes 2026-07-07 17:14:48 -04:00
parent dcfd66792b
commit 8b1d75456f
7 changed files with 232 additions and 95 deletions

View File

@ -445,5 +445,118 @@
"toastDeleteFailed": "Could not delete field.", "toastDeleteFailed": "Could not delete field.",
"toastDeleted": "Deleted \"{name}\"." "toastDeleted": "Deleted \"{name}\"."
} }
},
"Pipelines": {
"page": {
"selectPipeline": "Select Pipeline",
"noPipelinesYet": "No pipelines yet",
"managePipelines": "Manage Pipelines",
"addPipeline": "Add Pipeline",
"addDeal": "Add Deal",
"createToStartTracking": "Create a pipeline to start tracking deals",
"createPipeline": "Create Pipeline",
"newPipeline": "New Pipeline",
"pipelineName": "Pipeline Name",
"pipelineNamePlaceholder": "e.g., Enterprise Sales",
"defaultStagesDesc": "Default stages (New Lead → Won) will be created automatically.",
"cancel": "Cancel",
"creating": "Creating...",
"createPipelineBtn": "Create Pipeline",
"toastFailedLoadPipelines": "Failed to load pipelines",
"toastFailedMoveDeal": "Failed to move deal",
"toastFailedCreatePipeline": "Failed to create pipeline",
"toastPipelineCreated": "Pipeline created",
"toastNotLinkedToAccount": "Your profile is not linked to an account."
},
"board": {
"dropDealHere": "Drop a deal here",
"addDeal": "Add Deal"
},
"card": {
"noContact": "No contact",
"won": "Won",
"lost": "Lost"
},
"form": {
"editDeal": "Edit Deal",
"newDeal": "New Deal",
"title": "Title",
"titlePlaceholder": "Deal title",
"contact": "Contact",
"selectContact": "Select a contact",
"linkToConversation": "Link to Conversation",
"value": "Value",
"currency": "Currency",
"expectedCloseDate": "Expected Close Date",
"stage": "Stage",
"assignedTo": "Assigned To",
"unassigned": "Unassigned",
"notes": "Notes",
"notesPlaceholder": "Add notes...",
"status": "Status",
"markAsWon": "Mark as Won",
"markAsLost": "Mark as Lost",
"reopenDeal": "Reopen deal",
"cancel": "Cancel",
"saving": "Saving...",
"saveChanges": "Save Changes",
"createDeal": "Create Deal",
"deletePrompt": "Delete this deal?",
"confirm": "Confirm",
"deleting": "Deleting...",
"deleteDeal": "Delete Deal",
"toastRequired": "Title, contact, and stage are required",
"toastFailedSave": "Failed to save deal",
"toastNotSignedIn": "Not signed in",
"toastNotLinked": "Your profile is not linked to an account.",
"toastFailedCreate": "Failed to create deal",
"toastUpdated": "Deal updated",
"toastCreated": "Deal created",
"toastFailedStatus": "Failed to update deal status",
"toastMarkedWon": "Marked as won",
"toastMarkedLost": "Marked as lost",
"toastReopened": "Deal reopened",
"toastFailedDelete": "Failed to delete deal",
"toastDeleted": "Deal deleted"
},
"settings": {
"managePipeline": "Manage Pipeline",
"deletePipeline": "Delete Pipeline",
"deletePipelineDesc": "This will archive all deals in this pipeline. This cannot be undone.",
"cancel": "Cancel",
"deleting": "Deleting...",
"deletePipelineBtn": "Delete Pipeline",
"pipelineName": "Pipeline Name",
"stages": "Stages",
"newStageNamePlaceholder": "New stage name",
"add": "Add",
"createNewPipeline": "Create a new pipeline",
"saving": "Saving...",
"saveChanges": "Save Changes",
"dragToReorder": "Drag to reorder",
"changeColor": "Change color",
"toastFailedSave": "Failed to save pipeline",
"toastSaved": "Pipeline saved",
"toastFailedAddStage": "Failed to add stage",
"toastMoveOrDeleteDeals": "Move or delete deals in this stage first",
"toastFailedDeleteStage": "Failed to delete stage",
"toastFailedDeletePipeline": "Failed to delete pipeline",
"toastDeleted": "Pipeline deleted"
},
"analytics": {
"totalDeals": "Total Deals",
"totalDealsTooltip": "Count of every deal in this pipeline that isn't marked as Lost. Won deals are still included.",
"pipelineValue": "Pipeline Value",
"pipelineValueTooltip": "Sum of the dollar values of all deals in this pipeline, excluding deals marked as Lost.",
"avgDealSize": "Avg Deal Size",
"avgDealSizeTooltip": "Pipeline Value divided by Total Deals — the average value of a single non-lost deal.",
"weightedValue": "Weighted Value",
"weightedValueTooltip": "Expected revenue: each open deal's value × its stage probability. First stage ≈ 10%, stages progress up to 90%, Won = 100%. Lost deals are excluded.",
"wonThisMonth": "Won This Month",
"wonThisMonthTooltip": "Deals marked as Won since the first day of the current month.",
"lostThisMonth": "Lost This Month",
"lostThisMonthTooltip": "Deals marked as Lost since the first day of the current month.",
"howCalculated": "How {label} is calculated"
}
} }
} }

View File

@ -29,6 +29,7 @@ import { toast } from "sonner";
import { useCan } from "@/hooks/use-can"; import { useCan } from "@/hooks/use-can";
import { useAuth } from "@/hooks/use-auth"; import { useAuth } from "@/hooks/use-auth";
import { GatedButton } from "@/components/ui/gated-button"; import { GatedButton } from "@/components/ui/gated-button";
import { useTranslations } from "next-intl";
// Pipeline creation is admin-class (settings-tier write under // Pipeline creation is admin-class (settings-tier write under
// the new RLS); deal creation is operational and only requires // the new RLS); deal creation is operational and only requires
@ -45,6 +46,7 @@ const SPEC_DEFAULT_STAGES = [
]; ];
export default function PipelinesPage() { export default function PipelinesPage() {
const t = useTranslations("Pipelines.page");
const supabase = createClient(); const supabase = createClient();
const canEditSettings = useCan("edit-settings"); const canEditSettings = useCan("edit-settings");
const canCreateDeals = useCan("send-messages"); const canCreateDeals = useCan("send-messages");
@ -223,11 +225,11 @@ export default function PipelinesPage() {
.update({ stage_id: newStageId }) .update({ stage_id: newStageId })
.eq("id", dealId); .eq("id", dealId);
if (error) { if (error) {
toast.error("Failed to move deal"); toast.error(t("toastFailedMoveDeal"));
refreshDeals(); refreshDeals();
} }
}, },
[supabase, refreshDeals], [supabase, refreshDeals, t],
); );
const handleAddDeal = useCallback( const handleAddDeal = useCallback(
@ -260,7 +262,7 @@ export default function PipelinesPage() {
} }
// pipelines.account_id is NOT NULL post-017 with no DB default. // pipelines.account_id is NOT NULL post-017 with no DB default.
if (!accountId) { if (!accountId) {
toast.error("Your profile is not linked to an account."); toast.error(t("toastNotLinkedToAccount"));
setCreating(false); setCreating(false);
return; return;
} }
@ -272,7 +274,7 @@ export default function PipelinesPage() {
.single(); .single();
if (error || !pipeline) { if (error || !pipeline) {
toast.error("Failed to create pipeline"); toast.error(t("toastFailedCreatePipeline"));
setCreating(false); setCreating(false);
return; return;
} }
@ -290,7 +292,7 @@ export default function PipelinesPage() {
setSelectedPipelineId(pipeline.id); setSelectedPipelineId(pipeline.id);
await refreshPipelines(); await refreshPipelines();
setCreating(false); setCreating(false);
toast.success("Pipeline created"); toast.success(t("toastPipelineCreated"));
} }
const selectedPipeline = pipelines.find((p) => p.id === selectedPipelineId); const selectedPipeline = pipelines.find((p) => p.id === selectedPipelineId);
@ -323,7 +325,7 @@ export default function PipelinesPage() {
> >
<GitBranch className="h-4 w-4 text-primary" /> <GitBranch className="h-4 w-4 text-primary" />
<span className="font-semibold"> <span className="font-semibold">
{selectedPipeline?.name ?? "Select Pipeline"} {selectedPipeline?.name ?? t("selectPipeline")}
</span> </span>
<ChevronDown className="h-4 w-4 text-muted-foreground" /> <ChevronDown className="h-4 w-4 text-muted-foreground" />
</DropdownMenuTrigger> </DropdownMenuTrigger>
@ -333,7 +335,7 @@ export default function PipelinesPage() {
> >
{pipelines.length === 0 && ( {pipelines.length === 0 && (
<DropdownMenuItem disabled className="text-muted-foreground"> <DropdownMenuItem disabled className="text-muted-foreground">
No pipelines yet {t("noPipelinesYet")}
</DropdownMenuItem> </DropdownMenuItem>
)} )}
{pipelines.map((p) => ( {pipelines.map((p) => (
@ -357,7 +359,7 @@ export default function PipelinesPage() {
className="text-popover-foreground" className="text-popover-foreground"
> >
<Settings className="mr-2 h-3.5 w-3.5" /> <Settings className="mr-2 h-3.5 w-3.5" />
Manage Pipelines {t("managePipelines")}
</DropdownMenuItem> </DropdownMenuItem>
)} )}
</DropdownMenuContent> </DropdownMenuContent>
@ -373,7 +375,7 @@ export default function PipelinesPage() {
className="border-border bg-card text-foreground hover:bg-muted" className="border-border bg-card text-foreground hover:bg-muted"
> >
<Plus className="mr-1 h-4 w-4" /> <Plus className="mr-1 h-4 w-4" />
Add Pipeline {t("addPipeline")}
</GatedButton> </GatedButton>
<GatedButton <GatedButton
canAct={canCreateDeals} canAct={canCreateDeals}
@ -383,7 +385,7 @@ export default function PipelinesPage() {
className="bg-primary text-primary-foreground hover:bg-primary/90" className="bg-primary text-primary-foreground hover:bg-primary/90"
> >
<Plus className="mr-1 h-4 w-4" /> <Plus className="mr-1 h-4 w-4" />
Add Deal {t("addDeal")}
</GatedButton> </GatedButton>
</div> </div>
</div> </div>
@ -393,10 +395,10 @@ export default function PipelinesPage() {
<div className="flex flex-col items-center justify-center rounded-xl border border-dashed border-border py-20"> <div className="flex flex-col items-center justify-center rounded-xl border border-dashed border-border py-20">
<GitBranch className="h-12 w-12 text-muted-foreground" /> <GitBranch className="h-12 w-12 text-muted-foreground" />
<h3 className="mt-4 text-lg font-medium text-foreground"> <h3 className="mt-4 text-lg font-medium text-foreground">
No pipelines yet {t("noPipelinesYet")}
</h3> </h3>
<p className="mt-2 text-sm text-muted-foreground"> <p className="mt-2 text-sm text-muted-foreground">
Create a pipeline to start tracking deals {t("createToStartTracking")}
</p> </p>
<GatedButton <GatedButton
canAct={canEditSettings} canAct={canEditSettings}
@ -405,7 +407,7 @@ export default function PipelinesPage() {
className="mt-4 bg-primary text-primary-foreground hover:bg-primary/90" className="mt-4 bg-primary text-primary-foreground hover:bg-primary/90"
> >
<Plus className="mr-1 h-4 w-4" /> <Plus className="mr-1 h-4 w-4" />
Create Pipeline {t("createPipeline")}
</GatedButton> </GatedButton>
</div> </div>
) : ( ) : (
@ -425,21 +427,21 @@ export default function PipelinesPage() {
<Dialog open={newPipelineOpen} onOpenChange={setNewPipelineOpen}> <Dialog open={newPipelineOpen} onOpenChange={setNewPipelineOpen}>
<DialogContent className="sm:max-w-sm bg-popover border-border"> <DialogContent className="sm:max-w-sm bg-popover border-border">
<DialogHeader> <DialogHeader>
<DialogTitle className="text-popover-foreground">New Pipeline</DialogTitle> <DialogTitle className="text-popover-foreground">{t("newPipeline")}</DialogTitle>
</DialogHeader> </DialogHeader>
<div className="py-2"> <div className="py-2">
<Label className="text-muted-foreground">Pipeline Name</Label> <Label className="text-muted-foreground">{t("pipelineName")}</Label>
<Input <Input
value={newPipelineName} value={newPipelineName}
onChange={(e) => setNewPipelineName(e.target.value)} onChange={(e) => setNewPipelineName(e.target.value)}
placeholder="e.g., Enterprise Sales" placeholder={t("pipelineNamePlaceholder")}
className="mt-2 bg-muted border-border text-foreground" className="mt-2 bg-muted border-border text-foreground"
onKeyDown={(e) => { onKeyDown={(e) => {
if (e.key === "Enter") handleCreatePipeline(); if (e.key === "Enter") handleCreatePipeline();
}} }}
/> />
<p className="mt-2 text-xs text-muted-foreground"> <p className="mt-2 text-xs text-muted-foreground">
Default stages (New Lead → Won) will be created automatically. {t("defaultStagesDesc")}
</p> </p>
</div> </div>
<DialogFooter className="bg-popover/50 border-border"> <DialogFooter className="bg-popover/50 border-border">
@ -448,14 +450,14 @@ export default function PipelinesPage() {
onClick={() => setNewPipelineOpen(false)} onClick={() => setNewPipelineOpen(false)}
className="border-border text-muted-foreground hover:bg-muted" className="border-border text-muted-foreground hover:bg-muted"
> >
Cancel {t("cancel")}
</Button> </Button>
<Button <Button
onClick={handleCreatePipeline} onClick={handleCreatePipeline}
disabled={creating || !newPipelineName.trim()} disabled={creating || !newPipelineName.trim()}
className="bg-primary text-primary-foreground hover:bg-primary/90" className="bg-primary text-primary-foreground hover:bg-primary/90"
> >
{creating ? "Creating..." : "Create Pipeline"} {creating ? t("creating") : t("createPipelineBtn")}
</Button> </Button>
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>

View File

@ -3,6 +3,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"; import { formatCurrency } from "@/lib/currency";
import { useTranslations } from "next-intl";
interface DealCardProps { interface DealCardProps {
deal: Deal; deal: Deal;
@ -26,7 +27,8 @@ function initials(name?: string, fallback?: string) {
} }
export function DealCard({ deal, stage, onEdit, isOverlay }: DealCardProps) { export function DealCard({ deal, stage, onEdit, isOverlay }: DealCardProps) {
const contactLabel = deal.contact?.name || deal.contact?.phone || "No contact"; const t = useTranslations("Pipelines.card");
const contactLabel = deal.contact?.name || deal.contact?.phone || t("noContact");
const assigneeLabel = deal.assignee?.full_name || null; const assigneeLabel = deal.assignee?.full_name || null;
return ( return (
@ -59,13 +61,13 @@ export function DealCard({ deal, stage, onEdit, isOverlay }: DealCardProps) {
{deal.status === "won" && ( {deal.status === "won" && (
<span className="inline-flex shrink-0 items-center gap-1 rounded-full bg-primary/15 px-2 py-0.5 text-[10px] font-semibold text-primary"> <span className="inline-flex shrink-0 items-center gap-1 rounded-full bg-primary/15 px-2 py-0.5 text-[10px] font-semibold text-primary">
<Check className="h-3 w-3" /> <Check className="h-3 w-3" />
Won {t("won")}
</span> </span>
)} )}
{deal.status === "lost" && ( {deal.status === "lost" && (
<span className="inline-flex shrink-0 items-center gap-1 rounded-full bg-red-500/15 px-2 py-0.5 text-[10px] font-semibold text-red-400"> <span className="inline-flex shrink-0 items-center gap-1 rounded-full bg-red-500/15 px-2 py-0.5 text-[10px] font-semibold text-red-400">
<X className="h-3 w-3" /> <X className="h-3 w-3" />
Lost {t("lost")}
</span> </span>
)} )}
</div> </div>

View File

@ -32,6 +32,7 @@ import {
Loader2, Loader2,
} from "lucide-react"; } from "lucide-react";
import { toast } from "sonner"; import { toast } from "sonner";
import { useTranslations } from "next-intl";
interface DealFormProps { interface DealFormProps {
open: boolean; open: boolean;
@ -52,6 +53,7 @@ export function DealForm({
defaultStageId, defaultStageId,
onSaved, onSaved,
}: DealFormProps) { }: DealFormProps) {
const t = useTranslations("Pipelines.form");
const supabase = createClient(); const supabase = createClient();
const { accountId, defaultCurrency } = useAuth(); const { accountId, defaultCurrency } = useAuth();
@ -151,7 +153,7 @@ export function DealForm({
async function handleSave() { async function handleSave() {
if (!title.trim() || !contactId || !stageId) { if (!title.trim() || !contactId || !stageId) {
toast.error("Title, contact, and stage are required"); toast.error(t("toastRequired"));
return; return;
} }
setSaving(true); setSaving(true);
@ -174,7 +176,7 @@ export function DealForm({
.update(payload) .update(payload)
.eq("id", deal.id); .eq("id", deal.id);
if (error) { if (error) {
toast.error("Failed to save deal"); toast.error(t("toastFailedSave"));
setSaving(false); setSaving(false);
return; return;
} }
@ -184,12 +186,12 @@ export function DealForm({
} = await supabase.auth.getSession(); } = await supabase.auth.getSession();
const user = session?.user; const user = session?.user;
if (!user) { if (!user) {
toast.error("Not signed in"); toast.error(t("toastNotSignedIn"));
setSaving(false); setSaving(false);
return; return;
} }
if (!accountId) { if (!accountId) {
toast.error("Your profile is not linked to an account."); toast.error(t("toastNotLinked"));
setSaving(false); setSaving(false);
return; return;
} }
@ -197,14 +199,14 @@ export function DealForm({
.from("deals") .from("deals")
.insert({ ...payload, user_id: user.id, account_id: accountId, status: "open" }); .insert({ ...payload, user_id: user.id, account_id: accountId, status: "open" });
if (error) { if (error) {
toast.error("Failed to create deal"); toast.error(t("toastFailedCreate"));
setSaving(false); setSaving(false);
return; return;
} }
} }
setSaving(false); setSaving(false);
toast.success(deal ? "Deal updated" : "Deal created"); toast.success(deal ? t("toastUpdated") : t("toastCreated"));
onOpenChange(false); onOpenChange(false);
onSaved(); onSaved();
} }
@ -218,11 +220,11 @@ export function DealForm({
.eq("id", deal.id); .eq("id", deal.id);
setStatusAction(null); setStatusAction(null);
if (error) { if (error) {
toast.error("Failed to update deal status"); toast.error(t("toastFailedStatus"));
return; return;
} }
toast.success( toast.success(
status === "won" ? "Marked as won" : status === "lost" ? "Marked as lost" : "Deal reopened", status === "won" ? t("toastMarkedWon") : status === "lost" ? t("toastMarkedLost") : t("toastReopened"),
); );
onOpenChange(false); onOpenChange(false);
onSaved(); onSaved();
@ -234,10 +236,10 @@ export function DealForm({
const { error } = await supabase.from("deals").delete().eq("id", deal.id); const { error } = await supabase.from("deals").delete().eq("id", deal.id);
setDeleting(false); setDeleting(false);
if (error) { if (error) {
toast.error("Failed to delete deal"); toast.error(t("toastFailedDelete"));
return; return;
} }
toast.success("Deal deleted"); toast.success(t("toastDeleted"));
setConfirmDelete(false); setConfirmDelete(false);
onOpenChange(false); onOpenChange(false);
onSaved(); onSaved();
@ -252,29 +254,29 @@ export function DealForm({
<div className="flex h-full flex-col"> <div className="flex h-full flex-col">
<SheetHeader className="border-b border-border/50 p-4"> <SheetHeader className="border-b border-border/50 p-4">
<SheetTitle className="text-popover-foreground"> <SheetTitle className="text-popover-foreground">
{deal ? "Edit Deal" : "New Deal"} {deal ? t("editDeal") : t("newDeal")}
</SheetTitle> </SheetTitle>
</SheetHeader> </SheetHeader>
<div className="flex-1 overflow-y-auto p-4 space-y-4"> <div className="flex-1 overflow-y-auto p-4 space-y-4">
<div className="grid gap-2"> <div className="grid gap-2">
<Label className="text-muted-foreground">Title</Label> <Label className="text-muted-foreground">{t("title")}</Label>
<Input <Input
value={title} value={title}
onChange={(e) => setTitle(e.target.value)} onChange={(e) => setTitle(e.target.value)}
placeholder="Deal title" placeholder={t("titlePlaceholder")}
className="border-border bg-muted text-foreground" className="border-border bg-muted text-foreground"
/> />
</div> </div>
<div className="grid gap-2"> <div className="grid gap-2">
<Label className="text-muted-foreground">Contact</Label> <Label className="text-muted-foreground">{t("contact")}</Label>
<select <select
value={contactId} value={contactId}
onChange={(e) => setContactId(e.target.value)} onChange={(e) => setContactId(e.target.value)}
className="h-9 w-full rounded-lg border border-border bg-muted px-2.5 text-sm text-foreground outline-none focus:border-primary focus:ring-1 focus:ring-primary" className="h-9 w-full rounded-lg border border-border bg-muted px-2.5 text-sm text-foreground outline-none focus:border-primary focus:ring-1 focus:ring-primary"
> >
<option value="">Select a contact</option> <option value="">{t("selectContact")}</option>
{contacts.map((c) => ( {contacts.map((c) => (
<option key={c.id} value={c.id}> <option key={c.id} value={c.id}>
{c.name || c.phone} {c.name || c.phone}
@ -288,14 +290,14 @@ export function DealForm({
className="mt-1 inline-flex items-center gap-1.5 self-start rounded-md bg-primary/10 px-2 py-1 text-xs text-primary hover:bg-primary/20" className="mt-1 inline-flex items-center gap-1.5 self-start rounded-md bg-primary/10 px-2 py-1 text-xs text-primary hover:bg-primary/20"
> >
<MessageSquare className="h-3 w-3" /> <MessageSquare className="h-3 w-3" />
Link to Conversation {t("linkToConversation")}
</Link> </Link>
)} )}
</div> </div>
<div className="grid grid-cols-[1fr_110px] gap-3"> <div className="grid grid-cols-[1fr_110px] gap-3">
<div className="grid gap-2"> <div className="grid gap-2">
<Label className="text-muted-foreground">Value</Label> <Label className="text-muted-foreground">{t("value")}</Label>
<div className="relative"> <div className="relative">
<DollarSign className="absolute left-2 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground" /> <DollarSign className="absolute left-2 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground" />
<Input <Input
@ -308,7 +310,7 @@ export function DealForm({
</div> </div>
</div> </div>
<div className="grid gap-2"> <div className="grid gap-2">
<Label className="text-muted-foreground">Currency</Label> <Label className="text-muted-foreground">{t("currency")}</Label>
<select <select
value={currency} value={currency}
onChange={(e) => setCurrency(e.target.value)} onChange={(e) => setCurrency(e.target.value)}
@ -324,7 +326,7 @@ export function DealForm({
</div> </div>
<div className="grid gap-2"> <div className="grid gap-2">
<Label className="text-muted-foreground">Expected Close Date</Label> <Label className="text-muted-foreground">{t("expectedCloseDate")}</Label>
<Input <Input
type="date" type="date"
value={expectedCloseDate} value={expectedCloseDate}
@ -334,7 +336,7 @@ export function DealForm({
</div> </div>
<div className="grid gap-2"> <div className="grid gap-2">
<Label className="text-muted-foreground">Stage</Label> <Label className="text-muted-foreground">{t("stage")}</Label>
<select <select
value={stageId} value={stageId}
onChange={(e) => setStageId(e.target.value)} onChange={(e) => setStageId(e.target.value)}
@ -349,13 +351,13 @@ export function DealForm({
</div> </div>
<div className="grid gap-2"> <div className="grid gap-2">
<Label className="text-muted-foreground">Assigned To</Label> <Label className="text-muted-foreground">{t("assignedTo")}</Label>
<select <select
value={assignedTo} value={assignedTo}
onChange={(e) => setAssignedTo(e.target.value)} onChange={(e) => setAssignedTo(e.target.value)}
className="h-9 w-full rounded-lg border border-border bg-muted px-2.5 text-sm text-foreground outline-none focus:border-primary" className="h-9 w-full rounded-lg border border-border bg-muted px-2.5 text-sm text-foreground outline-none focus:border-primary"
> >
<option value="">Unassigned</option> <option value="">{t("unassigned")}</option>
{profiles.map((p) => ( {profiles.map((p) => (
<option key={p.id} value={p.id}> <option key={p.id} value={p.id}>
{p.full_name || p.email} {p.full_name || p.email}
@ -365,11 +367,11 @@ export function DealForm({
</div> </div>
<div className="grid gap-2"> <div className="grid gap-2">
<Label className="text-muted-foreground">Notes</Label> <Label className="text-muted-foreground">{t("notes")}</Label>
<Textarea <Textarea
value={notes} value={notes}
onChange={(e) => setNotes(e.target.value)} onChange={(e) => setNotes(e.target.value)}
placeholder="Add notes..." placeholder={t("notesPlaceholder")}
className="min-h-[100px] border-border bg-muted text-foreground" className="min-h-[100px] border-border bg-muted text-foreground"
/> />
</div> </div>
@ -377,7 +379,7 @@ export function DealForm({
{deal && ( {deal && (
<div className="space-y-2 rounded-lg border border-border bg-muted/50 p-3"> <div className="space-y-2 rounded-lg border border-border bg-muted/50 p-3">
<p className="text-xs font-medium uppercase tracking-wider text-muted-foreground"> <p className="text-xs font-medium uppercase tracking-wider text-muted-foreground">
Status {t("status")}
</p> </p>
<div className="flex gap-2"> <div className="flex gap-2">
<Button <Button
@ -391,7 +393,7 @@ export function DealForm({
) : ( ) : (
<> <>
<Check className="mr-1 h-4 w-4" /> <Check className="mr-1 h-4 w-4" />
Mark as Won {t("markAsWon")}
</> </>
)} )}
</Button> </Button>
@ -406,7 +408,7 @@ export function DealForm({
) : ( ) : (
<> <>
<X className="mr-1 h-4 w-4" /> <X className="mr-1 h-4 w-4" />
Mark as Lost {t("markAsLost")}
</> </>
)} )}
</Button> </Button>
@ -419,7 +421,7 @@ export function DealForm({
disabled={!!statusAction} disabled={!!statusAction}
className="w-full text-muted-foreground hover:text-foreground" className="w-full text-muted-foreground hover:text-foreground"
> >
Reopen deal {t("reopenDeal")}
</Button> </Button>
)} )}
</div> </div>
@ -433,21 +435,21 @@ export function DealForm({
onClick={() => onOpenChange(false)} onClick={() => onOpenChange(false)}
className="flex-1 border-border bg-transparent text-muted-foreground hover:bg-muted" className="flex-1 border-border bg-transparent text-muted-foreground hover:bg-muted"
> >
Cancel {t("cancel")}
</Button> </Button>
<Button <Button
onClick={handleSave} onClick={handleSave}
disabled={saving || !title.trim() || !contactId || !stageId} disabled={saving || !title.trim() || !contactId || !stageId}
className="flex-1 bg-primary text-primary-foreground hover:bg-primary/90" className="flex-1 bg-primary text-primary-foreground hover:bg-primary/90"
> >
{saving ? "Saving..." : deal ? "Save Changes" : "Create Deal"} {saving ? t("saving") : deal ? t("saveChanges") : t("createDeal")}
</Button> </Button>
</div> </div>
{deal && {deal &&
(confirmDelete ? ( (confirmDelete ? (
<div className="mt-3 flex items-center justify-between gap-2 rounded-md border border-red-500/30 bg-red-500/10 px-3 py-2 text-xs"> <div className="mt-3 flex items-center justify-between gap-2 rounded-md border border-red-500/30 bg-red-500/10 px-3 py-2 text-xs">
<span className="text-red-300">Delete this deal?</span> <span className="text-red-300">{t("deletePrompt")}</span>
<div className="flex gap-1"> <div className="flex gap-1">
<button <button
type="button" type="button"
@ -455,7 +457,7 @@ export function DealForm({
disabled={deleting} disabled={deleting}
className="rounded px-2 py-1 text-muted-foreground hover:bg-muted" className="rounded px-2 py-1 text-muted-foreground hover:bg-muted"
> >
Cancel {t("cancel")}
</button> </button>
<button <button
type="button" type="button"
@ -463,7 +465,7 @@ export function DealForm({
disabled={deleting} disabled={deleting}
className="rounded bg-red-600 px-2 py-1 font-medium text-white hover:bg-red-700 disabled:opacity-50" className="rounded bg-red-600 px-2 py-1 font-medium text-white hover:bg-red-700 disabled:opacity-50"
> >
{deleting ? "Deleting..." : "Confirm"} {deleting ? t("deleting") : t("confirm")}
</button> </button>
</div> </div>
</div> </div>
@ -474,7 +476,7 @@ export function DealForm({
className="mt-3 flex w-full items-center justify-center gap-1 text-xs text-red-400 hover:text-red-300" className="mt-3 flex w-full items-center justify-center gap-1 text-xs text-red-400 hover:text-red-300"
> >
<Trash2 className="h-3 w-3" /> <Trash2 className="h-3 w-3" />
Delete Deal {t("deleteDeal")}
</button> </button>
))} ))}
</div> </div>

View File

@ -19,6 +19,7 @@ import {
} from "@/components/ui/tooltip"; } from "@/components/ui/tooltip";
import { useAuth } from "@/hooks/use-auth"; import { useAuth } from "@/hooks/use-auth";
import { formatCurrency } from "@/lib/currency"; import { formatCurrency } from "@/lib/currency";
import { useTranslations } from "next-intl";
interface PipelineAnalyticsProps { interface PipelineAnalyticsProps {
stages: PipelineStage[]; stages: PipelineStage[];
@ -46,6 +47,7 @@ function computeStageProbability(
} }
export function PipelineAnalytics({ stages, deals }: PipelineAnalyticsProps) { export function PipelineAnalytics({ stages, deals }: PipelineAnalyticsProps) {
const t = useTranslations("Pipelines.analytics");
const { defaultCurrency } = useAuth(); 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),
@ -96,39 +98,45 @@ export function PipelineAnalytics({ stages, deals }: PipelineAnalyticsProps) {
<div className="grid grid-cols-2 gap-3 rounded-xl border border-border bg-card/60 p-4 sm:grid-cols-3 xl:grid-cols-6"> <div className="grid grid-cols-2 gap-3 rounded-xl border border-border bg-card/60 p-4 sm:grid-cols-3 xl:grid-cols-6">
<Metric <Metric
icon={<BarChart3 className="h-4 w-4 text-muted-foreground" />} icon={<BarChart3 className="h-4 w-4 text-muted-foreground" />}
label="Total Deals" label={t("totalDeals")}
value={String(stats.totalCount)} value={String(stats.totalCount)}
tooltip="Count of every deal in this pipeline that isn't marked as Lost. Won deals are still included." tooltip={t("totalDealsTooltip")}
t={t}
/> />
<Metric <Metric
icon={<DollarSign className="h-4 w-4 text-primary" />} icon={<DollarSign className="h-4 w-4 text-primary" />}
label="Pipeline Value" label={t("pipelineValue")}
value={formatCurrency(stats.totalValue, defaultCurrency)} value={formatCurrency(stats.totalValue, defaultCurrency)}
tooltip="Sum of the dollar values of all deals in this pipeline, excluding deals marked as Lost." tooltip={t("pipelineValueTooltip")}
t={t}
/> />
<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={t("avgDealSize")}
value={formatCurrency(stats.avgValue, defaultCurrency)} value={formatCurrency(stats.avgValue, defaultCurrency)}
tooltip="Pipeline Value divided by Total Deals — the average value of a single non-lost deal." tooltip={t("avgDealSizeTooltip")}
t={t}
/> />
<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={t("weightedValue")}
value={formatCurrency(stats.weightedValue, defaultCurrency)} 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={t("weightedValueTooltip")}
t={t}
/> />
<Metric <Metric
icon={<Trophy className="h-4 w-4 text-primary" />} icon={<Trophy className="h-4 w-4 text-primary" />}
label="Won This Month" label={t("wonThisMonth")}
value={String(stats.wonThisMonth)} value={String(stats.wonThisMonth)}
tooltip="Deals marked as Won since the first day of the current month." tooltip={t("wonThisMonthTooltip")}
t={t}
/> />
<Metric <Metric
icon={<XCircle className="h-4 w-4 text-red-400" />} icon={<XCircle className="h-4 w-4 text-red-400" />}
label="Lost This Month" label={t("lostThisMonth")}
value={String(stats.lostThisMonth)} value={String(stats.lostThisMonth)}
tooltip="Deals marked as Lost since the first day of the current month." tooltip={t("lostThisMonthTooltip")}
t={t}
/> />
</div> </div>
</TooltipProvider> </TooltipProvider>
@ -140,11 +148,13 @@ function Metric({
label, label,
value, value,
tooltip, tooltip,
t,
}: { }: {
icon: React.ReactNode; icon: React.ReactNode;
label: string; label: string;
value: string; value: string;
tooltip: string; tooltip: string;
t: any;
}) { }) {
return ( return (
<div className="rounded-lg bg-muted/50 p-3"> <div className="rounded-lg bg-muted/50 p-3">
@ -156,7 +166,7 @@ function Metric({
render={ render={
<button <button
type="button" type="button"
aria-label={`How ${label} is calculated`} aria-label={t("howCalculated", { label })}
className="ml-auto text-muted-foreground hover:text-foreground focus:outline-none" className="ml-auto text-muted-foreground hover:text-foreground focus:outline-none"
/> />
} }

View File

@ -20,6 +20,7 @@ import { Button } from "@/components/ui/button";
import { Plus } from "lucide-react"; import { Plus } from "lucide-react";
import { useAuth } from "@/hooks/use-auth"; import { useAuth } from "@/hooks/use-auth";
import { formatCurrency } from "@/lib/currency"; import { formatCurrency } from "@/lib/currency";
import { useTranslations } from "next-intl";
interface PipelineBoardProps { interface PipelineBoardProps {
stages: PipelineStage[]; stages: PipelineStage[];
@ -200,6 +201,7 @@ function StageColumn({
onAddDeal: (stageId: string) => void; onAddDeal: (stageId: string) => void;
onEditDeal: (deal: Deal) => void; onEditDeal: (deal: Deal) => void;
}) { }) {
const t = useTranslations("Pipelines.board");
const { setNodeRef, isOver } = useDroppable({ id: stage.id }); const { setNodeRef, isOver } = useDroppable({ id: stage.id });
return ( return (
@ -237,7 +239,7 @@ function StageColumn({
> >
{deals.length === 0 ? ( {deals.length === 0 ? (
<div className="flex flex-1 items-center justify-center rounded-lg border-2 border-dashed border-border py-10 text-xs text-muted-foreground"> <div className="flex flex-1 items-center justify-center rounded-lg border-2 border-dashed border-border py-10 text-xs text-muted-foreground">
Drop a deal here {t("dropDealHere")}
</div> </div>
) : ( ) : (
deals.map((deal) => ( deals.map((deal) => (
@ -258,7 +260,7 @@ function StageColumn({
className="mt-3 w-full justify-start border border-dashed border-border bg-transparent text-muted-foreground hover:border-border hover:bg-muted hover:text-foreground" className="mt-3 w-full justify-start border border-dashed border-border bg-transparent text-muted-foreground hover:border-border hover:bg-muted hover:text-foreground"
> >
<Plus className="mr-1 h-3 w-3" /> <Plus className="mr-1 h-3 w-3" />
Add Deal {t("addDeal")}
</Button> </Button>
</div> </div>
); );

View File

@ -35,6 +35,7 @@ import {
AlertTriangle, AlertTriangle,
} from "lucide-react"; } from "lucide-react";
import { toast } from "sonner"; import { toast } from "sonner";
import { useTranslations } from "next-intl";
const STAGE_COLORS = [ const STAGE_COLORS = [
"#3b82f6", "#3b82f6",
@ -68,6 +69,7 @@ export function PipelineSettings({
onStagesChanged, onStagesChanged,
onCreateNewPipeline, onCreateNewPipeline,
}: PipelineSettingsProps) { }: PipelineSettingsProps) {
const t = useTranslations("Pipelines.settings");
const supabase = createClient(); const supabase = createClient();
const [name, setName] = useState(pipeline.name); const [name, setName] = useState(pipeline.name);
@ -127,14 +129,14 @@ export function PipelineSettings({
setSaving(false); setSaving(false);
if (renameRes.error || stagesRes.error) { if (renameRes.error || stagesRes.error) {
toast.error("Failed to save pipeline"); toast.error(t("toastFailedSave"));
return; return;
} }
onOpenChange(false); onOpenChange(false);
onPipelinesChanged(); onPipelinesChanged();
onStagesChanged(); onStagesChanged();
toast.success("Pipeline saved"); toast.success(t("toastSaved"));
} }
async function handleAddStage() { async function handleAddStage() {
@ -151,7 +153,7 @@ export function PipelineSettings({
.select() .select()
.single(); .single();
if (error || !data) { if (error || !data) {
toast.error("Failed to add stage"); toast.error(t("toastFailedAddStage"));
return; return;
} }
setLocalStages([...localStages, data as PipelineStage]); setLocalStages([...localStages, data as PipelineStage]);
@ -166,7 +168,7 @@ export function PipelineSettings({
.select("id", { count: "exact", head: true }) .select("id", { count: "exact", head: true })
.eq("stage_id", stageId); .eq("stage_id", stageId);
if (count && count > 0) { if (count && count > 0) {
toast.error("Move or delete deals in this stage first"); toast.error(t("toastMoveOrDeleteDeals"));
return; return;
} }
const { error } = await supabase const { error } = await supabase
@ -174,7 +176,7 @@ export function PipelineSettings({
.delete() .delete()
.eq("id", stageId); .eq("id", stageId);
if (error) { if (error) {
toast.error("Failed to delete stage"); toast.error(t("toastFailedDeleteStage"));
return; return;
} }
setLocalStages(localStages.filter((s) => s.id !== stageId)); setLocalStages(localStages.filter((s) => s.id !== stageId));
@ -189,19 +191,19 @@ export function PipelineSettings({
.eq("id", pipeline.id); .eq("id", pipeline.id);
setDeleting(false); setDeleting(false);
if (error) { if (error) {
toast.error("Failed to delete pipeline"); toast.error(t("toastFailedDeletePipeline"));
return; return;
} }
onOpenChange(false); onOpenChange(false);
onPipelinesChanged(); onPipelinesChanged();
toast.success("Pipeline deleted"); toast.success(t("toastDeleted"));
} }
return ( return (
<Dialog open={open} onOpenChange={onOpenChange}> <Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-md bg-popover border-border max-h-[85vh] overflow-y-auto"> <DialogContent className="sm:max-w-md bg-popover border-border max-h-[85vh] overflow-y-auto">
<DialogHeader> <DialogHeader>
<DialogTitle className="text-popover-foreground">Manage Pipeline</DialogTitle> <DialogTitle className="text-popover-foreground">{t("managePipeline")}</DialogTitle>
</DialogHeader> </DialogHeader>
{showDeleteConfirm ? ( {showDeleteConfirm ? (
@ -210,11 +212,10 @@ export function PipelineSettings({
<AlertTriangle className="h-5 w-5 shrink-0 text-red-400" /> <AlertTriangle className="h-5 w-5 shrink-0 text-red-400" />
<div> <div>
<p className="text-sm font-medium text-red-400"> <p className="text-sm font-medium text-red-400">
Delete Pipeline {t("deletePipeline")}
</p> </p>
<p className="mt-1 text-xs text-muted-foreground"> <p className="mt-1 text-xs text-muted-foreground">
This will archive all deals in this pipeline. This cannot be {t("deletePipelineDesc")}
undone.
</p> </p>
</div> </div>
</div> </div>
@ -224,14 +225,14 @@ export function PipelineSettings({
onClick={() => setShowDeleteConfirm(false)} onClick={() => setShowDeleteConfirm(false)}
className="border-border bg-transparent text-muted-foreground hover:bg-muted" className="border-border bg-transparent text-muted-foreground hover:bg-muted"
> >
Cancel {t("cancel")}
</Button> </Button>
<Button <Button
onClick={handleDeletePipeline} onClick={handleDeletePipeline}
disabled={deleting} disabled={deleting}
className="bg-red-600 text-white hover:bg-red-700" className="bg-red-600 text-white hover:bg-red-700"
> >
{deleting ? "Deleting..." : "Delete Pipeline"} {deleting ? t("deleting") : t("deletePipelineBtn")}
</Button> </Button>
</div> </div>
</div> </div>
@ -239,7 +240,7 @@ export function PipelineSettings({
<> <>
<div className="grid gap-4 py-2"> <div className="grid gap-4 py-2">
<div className="grid gap-2"> <div className="grid gap-2">
<Label className="text-muted-foreground">Pipeline Name</Label> <Label className="text-muted-foreground">{t("pipelineName")}</Label>
<Input <Input
value={name} value={name}
onChange={(e) => setName(e.target.value)} onChange={(e) => setName(e.target.value)}
@ -248,7 +249,7 @@ export function PipelineSettings({
</div> </div>
<div className="grid gap-2"> <div className="grid gap-2">
<Label className="text-muted-foreground">Stages</Label> <Label className="text-muted-foreground">{t("stages")}</Label>
<DndContext <DndContext
sensors={sensors} sensors={sensors}
collisionDetection={closestCenter} collisionDetection={closestCenter}
@ -275,6 +276,7 @@ export function PipelineSettings({
}} }}
onRemove={() => handleRemoveStage(stage.id)} onRemove={() => handleRemoveStage(stage.id)}
colors={STAGE_COLORS} colors={STAGE_COLORS}
t={t}
/> />
))} ))}
</div> </div>
@ -304,7 +306,7 @@ export function PipelineSettings({
<Input <Input
value={newStageName} value={newStageName}
onChange={(e) => setNewStageName(e.target.value)} onChange={(e) => setNewStageName(e.target.value)}
placeholder="New stage name" placeholder={t("newStageNamePlaceholder")}
className="border-border bg-muted text-sm text-foreground" className="border-border bg-muted text-sm text-foreground"
onKeyDown={(e) => { onKeyDown={(e) => {
if (e.key === "Enter") handleAddStage(); if (e.key === "Enter") handleAddStage();
@ -318,7 +320,7 @@ export function PipelineSettings({
className="shrink-0 border-border bg-transparent text-muted-foreground hover:bg-muted" className="shrink-0 border-border bg-transparent text-muted-foreground hover:bg-muted"
> >
<Plus className="mr-1 h-3 w-3" /> <Plus className="mr-1 h-3 w-3" />
Add {t("add")}
</Button> </Button>
</div> </div>
</div> </div>
@ -329,7 +331,7 @@ export function PipelineSettings({
className="w-full border-border bg-transparent text-muted-foreground hover:bg-muted" className="w-full border-border bg-transparent text-muted-foreground hover:bg-muted"
> >
<Plus className="mr-1 h-3 w-3" /> <Plus className="mr-1 h-3 w-3" />
Create a new pipeline {t("createNewPipeline")}
</Button> </Button>
</div> </div>
@ -339,21 +341,21 @@ export function PipelineSettings({
onClick={() => setShowDeleteConfirm(true)} onClick={() => setShowDeleteConfirm(true)}
className="mr-auto bg-red-600 hover:bg-red-700" className="mr-auto bg-red-600 hover:bg-red-700"
> >
Delete Pipeline {t("deletePipeline")}
</Button> </Button>
<Button <Button
variant="outline" variant="outline"
onClick={() => onOpenChange(false)} onClick={() => onOpenChange(false)}
className="border-border bg-transparent text-muted-foreground hover:bg-muted" className="border-border bg-transparent text-muted-foreground hover:bg-muted"
> >
Cancel {t("cancel")}
</Button> </Button>
<Button <Button
onClick={handleSave} onClick={handleSave}
disabled={saving || !name.trim()} disabled={saving || !name.trim()}
className="bg-primary text-primary-foreground hover:bg-primary/90" className="bg-primary text-primary-foreground hover:bg-primary/90"
> >
{saving ? "Saving..." : "Save Changes"} {saving ? t("saving") : t("saveChanges")}
</Button> </Button>
</DialogFooter> </DialogFooter>
</> </>
@ -369,12 +371,14 @@ function SortableStageRow({
onColorChange, onColorChange,
onRemove, onRemove,
colors, colors,
t,
}: { }: {
stage: PipelineStage; stage: PipelineStage;
onNameChange: (v: string) => void; onNameChange: (v: string) => void;
onColorChange: (v: string) => void; onColorChange: (v: string) => void;
onRemove: () => void; onRemove: () => void;
colors: string[]; colors: string[];
t: any;
}) { }) {
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = const { attributes, listeners, setNodeRef, transform, transition, isDragging } =
useSortable({ id: stage.id }); useSortable({ id: stage.id });
@ -396,11 +400,11 @@ function SortableStageRow({
{...attributes} {...attributes}
{...listeners} {...listeners}
className="cursor-grab touch-none text-muted-foreground hover:text-foreground active:cursor-grabbing" className="cursor-grab touch-none text-muted-foreground hover:text-foreground active:cursor-grabbing"
aria-label="Drag to reorder" aria-label={t("dragToReorder")}
> >
<GripVertical className="h-4 w-4" /> <GripVertical className="h-4 w-4" />
</button> </button>
<ColorSwatch value={stage.color} onChange={onColorChange} colors={colors} /> <ColorSwatch value={stage.color} onChange={onColorChange} colors={colors} t={t} />
<Input <Input
value={stage.name} value={stage.name}
onChange={(e) => onNameChange(e.target.value)} onChange={(e) => onNameChange(e.target.value)}
@ -422,10 +426,12 @@ function ColorSwatch({
value, value,
onChange, onChange,
colors, colors,
t,
}: { }: {
value: string; value: string;
onChange: (v: string) => void; onChange: (v: string) => void;
colors: string[]; colors: string[];
t: any;
}) { }) {
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
return ( return (
@ -435,7 +441,7 @@ function ColorSwatch({
onClick={() => setOpen((v) => !v)} onClick={() => setOpen((v) => !v)}
className="h-4 w-4 rounded-full border border-border" className="h-4 w-4 rounded-full border border-border"
style={{ backgroundColor: value }} style={{ backgroundColor: value }}
aria-label="Change color" aria-label={t("changeColor")}
/> />
{open && ( {open && (
<> <>