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.",
|
"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"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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"
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -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 && (
|
||||||
<>
|
<>
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user