mirror of
https://github.com/Luchexx/wacrm.git
synced 2026-11-02 09:19:51 +00:00
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:
parent
dcfd66792b
commit
8b1d75456f
113
messages/en.json
113
messages/en.json
@ -445,5 +445,118 @@
|
||||
"toastDeleteFailed": "Could not delete field.",
|
||||
"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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -29,6 +29,7 @@ import { toast } from "sonner";
|
||||
import { useCan } from "@/hooks/use-can";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { GatedButton } from "@/components/ui/gated-button";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
// Pipeline creation is admin-class (settings-tier write under
|
||||
// the new RLS); deal creation is operational and only requires
|
||||
@ -45,6 +46,7 @@ const SPEC_DEFAULT_STAGES = [
|
||||
];
|
||||
|
||||
export default function PipelinesPage() {
|
||||
const t = useTranslations("Pipelines.page");
|
||||
const supabase = createClient();
|
||||
const canEditSettings = useCan("edit-settings");
|
||||
const canCreateDeals = useCan("send-messages");
|
||||
@ -223,11 +225,11 @@ export default function PipelinesPage() {
|
||||
.update({ stage_id: newStageId })
|
||||
.eq("id", dealId);
|
||||
if (error) {
|
||||
toast.error("Failed to move deal");
|
||||
toast.error(t("toastFailedMoveDeal"));
|
||||
refreshDeals();
|
||||
}
|
||||
},
|
||||
[supabase, refreshDeals],
|
||||
[supabase, refreshDeals, t],
|
||||
);
|
||||
|
||||
const handleAddDeal = useCallback(
|
||||
@ -260,7 +262,7 @@ export default function PipelinesPage() {
|
||||
}
|
||||
// pipelines.account_id is NOT NULL post-017 with no DB default.
|
||||
if (!accountId) {
|
||||
toast.error("Your profile is not linked to an account.");
|
||||
toast.error(t("toastNotLinkedToAccount"));
|
||||
setCreating(false);
|
||||
return;
|
||||
}
|
||||
@ -272,7 +274,7 @@ export default function PipelinesPage() {
|
||||
.single();
|
||||
|
||||
if (error || !pipeline) {
|
||||
toast.error("Failed to create pipeline");
|
||||
toast.error(t("toastFailedCreatePipeline"));
|
||||
setCreating(false);
|
||||
return;
|
||||
}
|
||||
@ -290,7 +292,7 @@ export default function PipelinesPage() {
|
||||
setSelectedPipelineId(pipeline.id);
|
||||
await refreshPipelines();
|
||||
setCreating(false);
|
||||
toast.success("Pipeline created");
|
||||
toast.success(t("toastPipelineCreated"));
|
||||
}
|
||||
|
||||
const selectedPipeline = pipelines.find((p) => p.id === selectedPipelineId);
|
||||
@ -323,7 +325,7 @@ export default function PipelinesPage() {
|
||||
>
|
||||
<GitBranch className="h-4 w-4 text-primary" />
|
||||
<span className="font-semibold">
|
||||
{selectedPipeline?.name ?? "Select Pipeline"}
|
||||
{selectedPipeline?.name ?? t("selectPipeline")}
|
||||
</span>
|
||||
<ChevronDown className="h-4 w-4 text-muted-foreground" />
|
||||
</DropdownMenuTrigger>
|
||||
@ -333,7 +335,7 @@ export default function PipelinesPage() {
|
||||
>
|
||||
{pipelines.length === 0 && (
|
||||
<DropdownMenuItem disabled className="text-muted-foreground">
|
||||
No pipelines yet
|
||||
{t("noPipelinesYet")}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{pipelines.map((p) => (
|
||||
@ -357,7 +359,7 @@ export default function PipelinesPage() {
|
||||
className="text-popover-foreground"
|
||||
>
|
||||
<Settings className="mr-2 h-3.5 w-3.5" />
|
||||
Manage Pipelines
|
||||
{t("managePipelines")}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
@ -373,7 +375,7 @@ export default function PipelinesPage() {
|
||||
className="border-border bg-card text-foreground hover:bg-muted"
|
||||
>
|
||||
<Plus className="mr-1 h-4 w-4" />
|
||||
Add Pipeline
|
||||
{t("addPipeline")}
|
||||
</GatedButton>
|
||||
<GatedButton
|
||||
canAct={canCreateDeals}
|
||||
@ -383,7 +385,7 @@ export default function PipelinesPage() {
|
||||
className="bg-primary text-primary-foreground hover:bg-primary/90"
|
||||
>
|
||||
<Plus className="mr-1 h-4 w-4" />
|
||||
Add Deal
|
||||
{t("addDeal")}
|
||||
</GatedButton>
|
||||
</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">
|
||||
<GitBranch className="h-12 w-12 text-muted-foreground" />
|
||||
<h3 className="mt-4 text-lg font-medium text-foreground">
|
||||
No pipelines yet
|
||||
{t("noPipelinesYet")}
|
||||
</h3>
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
Create a pipeline to start tracking deals
|
||||
{t("createToStartTracking")}
|
||||
</p>
|
||||
<GatedButton
|
||||
canAct={canEditSettings}
|
||||
@ -405,7 +407,7 @@ export default function PipelinesPage() {
|
||||
className="mt-4 bg-primary text-primary-foreground hover:bg-primary/90"
|
||||
>
|
||||
<Plus className="mr-1 h-4 w-4" />
|
||||
Create Pipeline
|
||||
{t("createPipeline")}
|
||||
</GatedButton>
|
||||
</div>
|
||||
) : (
|
||||
@ -425,21 +427,21 @@ export default function PipelinesPage() {
|
||||
<Dialog open={newPipelineOpen} onOpenChange={setNewPipelineOpen}>
|
||||
<DialogContent className="sm:max-w-sm bg-popover border-border">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-popover-foreground">New Pipeline</DialogTitle>
|
||||
<DialogTitle className="text-popover-foreground">{t("newPipeline")}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="py-2">
|
||||
<Label className="text-muted-foreground">Pipeline Name</Label>
|
||||
<Label className="text-muted-foreground">{t("pipelineName")}</Label>
|
||||
<Input
|
||||
value={newPipelineName}
|
||||
onChange={(e) => setNewPipelineName(e.target.value)}
|
||||
placeholder="e.g., Enterprise Sales"
|
||||
placeholder={t("pipelineNamePlaceholder")}
|
||||
className="mt-2 bg-muted border-border text-foreground"
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") handleCreatePipeline();
|
||||
}}
|
||||
/>
|
||||
<p className="mt-2 text-xs text-muted-foreground">
|
||||
Default stages (New Lead → Won) will be created automatically.
|
||||
{t("defaultStagesDesc")}
|
||||
</p>
|
||||
</div>
|
||||
<DialogFooter className="bg-popover/50 border-border">
|
||||
@ -448,14 +450,14 @@ export default function PipelinesPage() {
|
||||
onClick={() => setNewPipelineOpen(false)}
|
||||
className="border-border text-muted-foreground hover:bg-muted"
|
||||
>
|
||||
Cancel
|
||||
{t("cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleCreatePipeline}
|
||||
disabled={creating || !newPipelineName.trim()}
|
||||
className="bg-primary text-primary-foreground hover:bg-primary/90"
|
||||
>
|
||||
{creating ? "Creating..." : "Create Pipeline"}
|
||||
{creating ? t("creating") : t("createPipelineBtn")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
|
||||
@ -3,6 +3,7 @@
|
||||
import type { Deal, PipelineStage } from "@/types";
|
||||
import { Calendar, Check, X } from "lucide-react";
|
||||
import { formatCurrency } from "@/lib/currency";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
interface DealCardProps {
|
||||
deal: Deal;
|
||||
@ -26,7 +27,8 @@ function initials(name?: string, fallback?: string) {
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
return (
|
||||
@ -59,13 +61,13 @@ export function DealCard({ deal, stage, onEdit, isOverlay }: DealCardProps) {
|
||||
{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">
|
||||
<Check className="h-3 w-3" />
|
||||
Won
|
||||
{t("won")}
|
||||
</span>
|
||||
)}
|
||||
{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">
|
||||
<X className="h-3 w-3" />
|
||||
Lost
|
||||
{t("lost")}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@ -32,6 +32,7 @@ import {
|
||||
Loader2,
|
||||
} from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
interface DealFormProps {
|
||||
open: boolean;
|
||||
@ -52,6 +53,7 @@ export function DealForm({
|
||||
defaultStageId,
|
||||
onSaved,
|
||||
}: DealFormProps) {
|
||||
const t = useTranslations("Pipelines.form");
|
||||
const supabase = createClient();
|
||||
const { accountId, defaultCurrency } = useAuth();
|
||||
|
||||
@ -151,7 +153,7 @@ export function DealForm({
|
||||
|
||||
async function handleSave() {
|
||||
if (!title.trim() || !contactId || !stageId) {
|
||||
toast.error("Title, contact, and stage are required");
|
||||
toast.error(t("toastRequired"));
|
||||
return;
|
||||
}
|
||||
setSaving(true);
|
||||
@ -174,7 +176,7 @@ export function DealForm({
|
||||
.update(payload)
|
||||
.eq("id", deal.id);
|
||||
if (error) {
|
||||
toast.error("Failed to save deal");
|
||||
toast.error(t("toastFailedSave"));
|
||||
setSaving(false);
|
||||
return;
|
||||
}
|
||||
@ -184,12 +186,12 @@ export function DealForm({
|
||||
} = await supabase.auth.getSession();
|
||||
const user = session?.user;
|
||||
if (!user) {
|
||||
toast.error("Not signed in");
|
||||
toast.error(t("toastNotSignedIn"));
|
||||
setSaving(false);
|
||||
return;
|
||||
}
|
||||
if (!accountId) {
|
||||
toast.error("Your profile is not linked to an account.");
|
||||
toast.error(t("toastNotLinked"));
|
||||
setSaving(false);
|
||||
return;
|
||||
}
|
||||
@ -197,14 +199,14 @@ export function DealForm({
|
||||
.from("deals")
|
||||
.insert({ ...payload, user_id: user.id, account_id: accountId, status: "open" });
|
||||
if (error) {
|
||||
toast.error("Failed to create deal");
|
||||
toast.error(t("toastFailedCreate"));
|
||||
setSaving(false);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
setSaving(false);
|
||||
toast.success(deal ? "Deal updated" : "Deal created");
|
||||
toast.success(deal ? t("toastUpdated") : t("toastCreated"));
|
||||
onOpenChange(false);
|
||||
onSaved();
|
||||
}
|
||||
@ -218,11 +220,11 @@ export function DealForm({
|
||||
.eq("id", deal.id);
|
||||
setStatusAction(null);
|
||||
if (error) {
|
||||
toast.error("Failed to update deal status");
|
||||
toast.error(t("toastFailedStatus"));
|
||||
return;
|
||||
}
|
||||
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);
|
||||
onSaved();
|
||||
@ -234,10 +236,10 @@ export function DealForm({
|
||||
const { error } = await supabase.from("deals").delete().eq("id", deal.id);
|
||||
setDeleting(false);
|
||||
if (error) {
|
||||
toast.error("Failed to delete deal");
|
||||
toast.error(t("toastFailedDelete"));
|
||||
return;
|
||||
}
|
||||
toast.success("Deal deleted");
|
||||
toast.success(t("toastDeleted"));
|
||||
setConfirmDelete(false);
|
||||
onOpenChange(false);
|
||||
onSaved();
|
||||
@ -252,29 +254,29 @@ export function DealForm({
|
||||
<div className="flex h-full flex-col">
|
||||
<SheetHeader className="border-b border-border/50 p-4">
|
||||
<SheetTitle className="text-popover-foreground">
|
||||
{deal ? "Edit Deal" : "New Deal"}
|
||||
{deal ? t("editDeal") : t("newDeal")}
|
||||
</SheetTitle>
|
||||
</SheetHeader>
|
||||
|
||||
<div className="flex-1 overflow-y-auto p-4 space-y-4">
|
||||
<div className="grid gap-2">
|
||||
<Label className="text-muted-foreground">Title</Label>
|
||||
<Label className="text-muted-foreground">{t("title")}</Label>
|
||||
<Input
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
placeholder="Deal title"
|
||||
placeholder={t("titlePlaceholder")}
|
||||
className="border-border bg-muted text-foreground"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label className="text-muted-foreground">Contact</Label>
|
||||
<Label className="text-muted-foreground">{t("contact")}</Label>
|
||||
<select
|
||||
value={contactId}
|
||||
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"
|
||||
>
|
||||
<option value="">Select a contact</option>
|
||||
<option value="">{t("selectContact")}</option>
|
||||
{contacts.map((c) => (
|
||||
<option key={c.id} value={c.id}>
|
||||
{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"
|
||||
>
|
||||
<MessageSquare className="h-3 w-3" />
|
||||
Link to Conversation
|
||||
{t("linkToConversation")}
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-[1fr_110px] gap-3">
|
||||
<div className="grid gap-2">
|
||||
<Label className="text-muted-foreground">Value</Label>
|
||||
<Label className="text-muted-foreground">{t("value")}</Label>
|
||||
<div className="relative">
|
||||
<DollarSign className="absolute left-2 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
@ -308,7 +310,7 @@ export function DealForm({
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label className="text-muted-foreground">Currency</Label>
|
||||
<Label className="text-muted-foreground">{t("currency")}</Label>
|
||||
<select
|
||||
value={currency}
|
||||
onChange={(e) => setCurrency(e.target.value)}
|
||||
@ -324,7 +326,7 @@ export function DealForm({
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label className="text-muted-foreground">Expected Close Date</Label>
|
||||
<Label className="text-muted-foreground">{t("expectedCloseDate")}</Label>
|
||||
<Input
|
||||
type="date"
|
||||
value={expectedCloseDate}
|
||||
@ -334,7 +336,7 @@ export function DealForm({
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label className="text-muted-foreground">Stage</Label>
|
||||
<Label className="text-muted-foreground">{t("stage")}</Label>
|
||||
<select
|
||||
value={stageId}
|
||||
onChange={(e) => setStageId(e.target.value)}
|
||||
@ -349,13 +351,13 @@ export function DealForm({
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label className="text-muted-foreground">Assigned To</Label>
|
||||
<Label className="text-muted-foreground">{t("assignedTo")}</Label>
|
||||
<select
|
||||
value={assignedTo}
|
||||
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"
|
||||
>
|
||||
<option value="">Unassigned</option>
|
||||
<option value="">{t("unassigned")}</option>
|
||||
{profiles.map((p) => (
|
||||
<option key={p.id} value={p.id}>
|
||||
{p.full_name || p.email}
|
||||
@ -365,11 +367,11 @@ export function DealForm({
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label className="text-muted-foreground">Notes</Label>
|
||||
<Label className="text-muted-foreground">{t("notes")}</Label>
|
||||
<Textarea
|
||||
value={notes}
|
||||
onChange={(e) => setNotes(e.target.value)}
|
||||
placeholder="Add notes..."
|
||||
placeholder={t("notesPlaceholder")}
|
||||
className="min-h-[100px] border-border bg-muted text-foreground"
|
||||
/>
|
||||
</div>
|
||||
@ -377,7 +379,7 @@ export function DealForm({
|
||||
{deal && (
|
||||
<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">
|
||||
Status
|
||||
{t("status")}
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
@ -391,7 +393,7 @@ export function DealForm({
|
||||
) : (
|
||||
<>
|
||||
<Check className="mr-1 h-4 w-4" />
|
||||
Mark as Won
|
||||
{t("markAsWon")}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
@ -406,7 +408,7 @@ export function DealForm({
|
||||
) : (
|
||||
<>
|
||||
<X className="mr-1 h-4 w-4" />
|
||||
Mark as Lost
|
||||
{t("markAsLost")}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
@ -419,7 +421,7 @@ export function DealForm({
|
||||
disabled={!!statusAction}
|
||||
className="w-full text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
Reopen deal
|
||||
{t("reopenDeal")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
@ -433,21 +435,21 @@ export function DealForm({
|
||||
onClick={() => onOpenChange(false)}
|
||||
className="flex-1 border-border bg-transparent text-muted-foreground hover:bg-muted"
|
||||
>
|
||||
Cancel
|
||||
{t("cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleSave}
|
||||
disabled={saving || !title.trim() || !contactId || !stageId}
|
||||
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>
|
||||
</div>
|
||||
|
||||
{deal &&
|
||||
(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">
|
||||
<span className="text-red-300">Delete this deal?</span>
|
||||
<span className="text-red-300">{t("deletePrompt")}</span>
|
||||
<div className="flex gap-1">
|
||||
<button
|
||||
type="button"
|
||||
@ -455,7 +457,7 @@ export function DealForm({
|
||||
disabled={deleting}
|
||||
className="rounded px-2 py-1 text-muted-foreground hover:bg-muted"
|
||||
>
|
||||
Cancel
|
||||
{t("cancel")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@ -463,7 +465,7 @@ export function DealForm({
|
||||
disabled={deleting}
|
||||
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>
|
||||
</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"
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
Delete Deal
|
||||
{t("deleteDeal")}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@ -19,6 +19,7 @@ import {
|
||||
} from "@/components/ui/tooltip";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { formatCurrency } from "@/lib/currency";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
interface PipelineAnalyticsProps {
|
||||
stages: PipelineStage[];
|
||||
@ -46,6 +47,7 @@ function computeStageProbability(
|
||||
}
|
||||
|
||||
export function PipelineAnalytics({ stages, deals }: PipelineAnalyticsProps) {
|
||||
const t = useTranslations("Pipelines.analytics");
|
||||
const { defaultCurrency } = useAuth();
|
||||
const sortedStages = useMemo(
|
||||
() => [...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">
|
||||
<Metric
|
||||
icon={<BarChart3 className="h-4 w-4 text-muted-foreground" />}
|
||||
label="Total Deals"
|
||||
label={t("totalDeals")}
|
||||
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
|
||||
icon={<DollarSign className="h-4 w-4 text-primary" />}
|
||||
label="Pipeline Value"
|
||||
label={t("pipelineValue")}
|
||||
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
|
||||
icon={<Target className="h-4 w-4 text-blue-400" />}
|
||||
label="Avg Deal Size"
|
||||
label={t("avgDealSize")}
|
||||
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
|
||||
icon={<TrendingUp className="h-4 w-4 text-purple-400" />}
|
||||
label="Weighted Value"
|
||||
label={t("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={t("weightedValueTooltip")}
|
||||
t={t}
|
||||
/>
|
||||
<Metric
|
||||
icon={<Trophy className="h-4 w-4 text-primary" />}
|
||||
label="Won This Month"
|
||||
label={t("wonThisMonth")}
|
||||
value={String(stats.wonThisMonth)}
|
||||
tooltip="Deals marked as Won since the first day of the current month."
|
||||
tooltip={t("wonThisMonthTooltip")}
|
||||
t={t}
|
||||
/>
|
||||
<Metric
|
||||
icon={<XCircle className="h-4 w-4 text-red-400" />}
|
||||
label="Lost This Month"
|
||||
label={t("lostThisMonth")}
|
||||
value={String(stats.lostThisMonth)}
|
||||
tooltip="Deals marked as Lost since the first day of the current month."
|
||||
tooltip={t("lostThisMonthTooltip")}
|
||||
t={t}
|
||||
/>
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
@ -140,11 +148,13 @@ function Metric({
|
||||
label,
|
||||
value,
|
||||
tooltip,
|
||||
t,
|
||||
}: {
|
||||
icon: React.ReactNode;
|
||||
label: string;
|
||||
value: string;
|
||||
tooltip: string;
|
||||
t: any;
|
||||
}) {
|
||||
return (
|
||||
<div className="rounded-lg bg-muted/50 p-3">
|
||||
@ -156,7 +166,7 @@ function Metric({
|
||||
render={
|
||||
<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"
|
||||
/>
|
||||
}
|
||||
|
||||
@ -20,6 +20,7 @@ import { Button } from "@/components/ui/button";
|
||||
import { Plus } from "lucide-react";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { formatCurrency } from "@/lib/currency";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
interface PipelineBoardProps {
|
||||
stages: PipelineStage[];
|
||||
@ -200,6 +201,7 @@ function StageColumn({
|
||||
onAddDeal: (stageId: string) => void;
|
||||
onEditDeal: (deal: Deal) => void;
|
||||
}) {
|
||||
const t = useTranslations("Pipelines.board");
|
||||
const { setNodeRef, isOver } = useDroppable({ id: stage.id });
|
||||
|
||||
return (
|
||||
@ -237,7 +239,7 @@ function StageColumn({
|
||||
>
|
||||
{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">
|
||||
Drop a deal here
|
||||
{t("dropDealHere")}
|
||||
</div>
|
||||
) : (
|
||||
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"
|
||||
>
|
||||
<Plus className="mr-1 h-3 w-3" />
|
||||
Add Deal
|
||||
{t("addDeal")}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
|
||||
@ -35,6 +35,7 @@ import {
|
||||
AlertTriangle,
|
||||
} from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
const STAGE_COLORS = [
|
||||
"#3b82f6",
|
||||
@ -68,6 +69,7 @@ export function PipelineSettings({
|
||||
onStagesChanged,
|
||||
onCreateNewPipeline,
|
||||
}: PipelineSettingsProps) {
|
||||
const t = useTranslations("Pipelines.settings");
|
||||
const supabase = createClient();
|
||||
|
||||
const [name, setName] = useState(pipeline.name);
|
||||
@ -127,14 +129,14 @@ export function PipelineSettings({
|
||||
setSaving(false);
|
||||
|
||||
if (renameRes.error || stagesRes.error) {
|
||||
toast.error("Failed to save pipeline");
|
||||
toast.error(t("toastFailedSave"));
|
||||
return;
|
||||
}
|
||||
|
||||
onOpenChange(false);
|
||||
onPipelinesChanged();
|
||||
onStagesChanged();
|
||||
toast.success("Pipeline saved");
|
||||
toast.success(t("toastSaved"));
|
||||
}
|
||||
|
||||
async function handleAddStage() {
|
||||
@ -151,7 +153,7 @@ export function PipelineSettings({
|
||||
.select()
|
||||
.single();
|
||||
if (error || !data) {
|
||||
toast.error("Failed to add stage");
|
||||
toast.error(t("toastFailedAddStage"));
|
||||
return;
|
||||
}
|
||||
setLocalStages([...localStages, data as PipelineStage]);
|
||||
@ -166,7 +168,7 @@ export function PipelineSettings({
|
||||
.select("id", { count: "exact", head: true })
|
||||
.eq("stage_id", stageId);
|
||||
if (count && count > 0) {
|
||||
toast.error("Move or delete deals in this stage first");
|
||||
toast.error(t("toastMoveOrDeleteDeals"));
|
||||
return;
|
||||
}
|
||||
const { error } = await supabase
|
||||
@ -174,7 +176,7 @@ export function PipelineSettings({
|
||||
.delete()
|
||||
.eq("id", stageId);
|
||||
if (error) {
|
||||
toast.error("Failed to delete stage");
|
||||
toast.error(t("toastFailedDeleteStage"));
|
||||
return;
|
||||
}
|
||||
setLocalStages(localStages.filter((s) => s.id !== stageId));
|
||||
@ -189,19 +191,19 @@ export function PipelineSettings({
|
||||
.eq("id", pipeline.id);
|
||||
setDeleting(false);
|
||||
if (error) {
|
||||
toast.error("Failed to delete pipeline");
|
||||
toast.error(t("toastFailedDeletePipeline"));
|
||||
return;
|
||||
}
|
||||
onOpenChange(false);
|
||||
onPipelinesChanged();
|
||||
toast.success("Pipeline deleted");
|
||||
toast.success(t("toastDeleted"));
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-md bg-popover border-border max-h-[85vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-popover-foreground">Manage Pipeline</DialogTitle>
|
||||
<DialogTitle className="text-popover-foreground">{t("managePipeline")}</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
{showDeleteConfirm ? (
|
||||
@ -210,11 +212,10 @@ export function PipelineSettings({
|
||||
<AlertTriangle className="h-5 w-5 shrink-0 text-red-400" />
|
||||
<div>
|
||||
<p className="text-sm font-medium text-red-400">
|
||||
Delete Pipeline
|
||||
{t("deletePipeline")}
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
This will archive all deals in this pipeline. This cannot be
|
||||
undone.
|
||||
{t("deletePipelineDesc")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@ -224,14 +225,14 @@ export function PipelineSettings({
|
||||
onClick={() => setShowDeleteConfirm(false)}
|
||||
className="border-border bg-transparent text-muted-foreground hover:bg-muted"
|
||||
>
|
||||
Cancel
|
||||
{t("cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleDeletePipeline}
|
||||
disabled={deleting}
|
||||
className="bg-red-600 text-white hover:bg-red-700"
|
||||
>
|
||||
{deleting ? "Deleting..." : "Delete Pipeline"}
|
||||
{deleting ? t("deleting") : t("deletePipelineBtn")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@ -239,7 +240,7 @@ export function PipelineSettings({
|
||||
<>
|
||||
<div className="grid gap-4 py-2">
|
||||
<div className="grid gap-2">
|
||||
<Label className="text-muted-foreground">Pipeline Name</Label>
|
||||
<Label className="text-muted-foreground">{t("pipelineName")}</Label>
|
||||
<Input
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
@ -248,7 +249,7 @@ export function PipelineSettings({
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label className="text-muted-foreground">Stages</Label>
|
||||
<Label className="text-muted-foreground">{t("stages")}</Label>
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={closestCenter}
|
||||
@ -275,6 +276,7 @@ export function PipelineSettings({
|
||||
}}
|
||||
onRemove={() => handleRemoveStage(stage.id)}
|
||||
colors={STAGE_COLORS}
|
||||
t={t}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@ -304,7 +306,7 @@ export function PipelineSettings({
|
||||
<Input
|
||||
value={newStageName}
|
||||
onChange={(e) => setNewStageName(e.target.value)}
|
||||
placeholder="New stage name"
|
||||
placeholder={t("newStageNamePlaceholder")}
|
||||
className="border-border bg-muted text-sm text-foreground"
|
||||
onKeyDown={(e) => {
|
||||
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"
|
||||
>
|
||||
<Plus className="mr-1 h-3 w-3" />
|
||||
Add
|
||||
{t("add")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@ -329,7 +331,7 @@ export function PipelineSettings({
|
||||
className="w-full border-border bg-transparent text-muted-foreground hover:bg-muted"
|
||||
>
|
||||
<Plus className="mr-1 h-3 w-3" />
|
||||
Create a new pipeline
|
||||
{t("createNewPipeline")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@ -339,21 +341,21 @@ export function PipelineSettings({
|
||||
onClick={() => setShowDeleteConfirm(true)}
|
||||
className="mr-auto bg-red-600 hover:bg-red-700"
|
||||
>
|
||||
Delete Pipeline
|
||||
{t("deletePipeline")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => onOpenChange(false)}
|
||||
className="border-border bg-transparent text-muted-foreground hover:bg-muted"
|
||||
>
|
||||
Cancel
|
||||
{t("cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleSave}
|
||||
disabled={saving || !name.trim()}
|
||||
className="bg-primary text-primary-foreground hover:bg-primary/90"
|
||||
>
|
||||
{saving ? "Saving..." : "Save Changes"}
|
||||
{saving ? t("saving") : t("saveChanges")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</>
|
||||
@ -369,12 +371,14 @@ function SortableStageRow({
|
||||
onColorChange,
|
||||
onRemove,
|
||||
colors,
|
||||
t,
|
||||
}: {
|
||||
stage: PipelineStage;
|
||||
onNameChange: (v: string) => void;
|
||||
onColorChange: (v: string) => void;
|
||||
onRemove: () => void;
|
||||
colors: string[];
|
||||
t: any;
|
||||
}) {
|
||||
const { attributes, listeners, setNodeRef, transform, transition, isDragging } =
|
||||
useSortable({ id: stage.id });
|
||||
@ -396,11 +400,11 @@ function SortableStageRow({
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
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" />
|
||||
</button>
|
||||
<ColorSwatch value={stage.color} onChange={onColorChange} colors={colors} />
|
||||
<ColorSwatch value={stage.color} onChange={onColorChange} colors={colors} t={t} />
|
||||
<Input
|
||||
value={stage.name}
|
||||
onChange={(e) => onNameChange(e.target.value)}
|
||||
@ -422,10 +426,12 @@ function ColorSwatch({
|
||||
value,
|
||||
onChange,
|
||||
colors,
|
||||
t,
|
||||
}: {
|
||||
value: string;
|
||||
onChange: (v: string) => void;
|
||||
colors: string[];
|
||||
t: any;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
return (
|
||||
@ -435,7 +441,7 @@ function ColorSwatch({
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
className="h-4 w-4 rounded-full border border-border"
|
||||
style={{ backgroundColor: value }}
|
||||
aria-label="Change color"
|
||||
aria-label={t("changeColor")}
|
||||
/>
|
||||
{open && (
|
||||
<>
|
||||
|
||||
Loading…
Reference in New Issue
Block a user