mirror of
https://github.com/Luchexx/wacrm.git
synced 2026-11-02 17:29:45 +00:00
The legal brief on third-party trademark use says: avoid the WhatsApp teal/green palette and any visual treatment that could read as a WhatsApp/Meta product. Emerald-500 was used as the primary brand colour across hero, CTAs, sidebar, charts, badges, and the favicon — close enough in hue to WhatsApp's signature green to muddy the line. Swapping to Tailwind violet (which matches the Hostinger brand purple #7c3aed) makes the visual identity unambiguously ours. Scope: - Mechanical Tailwind class swap: emerald-* → violet-* across src/. - src/app/globals.css: --primary, --ring, --sidebar-primary, --sidebar-ring, --chart-1 oklch tokens swapped from emerald (oklch 0.696 0.17 162) to Hostinger-aligned violet (oklch 0.526 0.247 293). - src/app/icon.tsx: favicon background #10b981 → #7c3aed. - src/app/opengraph-image.tsx: badge bg, accent text, gradient. - src/components/landing/hero.tsx + cta-banner.tsx: radial-gradient hex. - src/components/landing/mock/analytics-mock.tsx: marketing chart line. - src/components/dashboard/conversations-chart.tsx + response-time-chart.tsx: "Outgoing" series colour. Distinguishable from "Incoming" blue at a glance; we can revisit the chart pair later for stronger colourblind contrast (separate concern). - README.md: license badge colour parameter. Out of scope: - src/components/settings/tag-manager.tsx still offers "Emerald" as one of the user-pickable contact-tag colours alongside Blue / Yellow / Red / etc. — that's a user data choice, not a brand mark. Contrast check: white text on #7c3aed measures ~4.78:1 (WCAG AA passing for normal body text). Violet-400 (#a78bfa) on slate-950 measures ~10:1 for accent text, well clear of AA. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
476 lines
16 KiB
TypeScript
476 lines
16 KiB
TypeScript
"use client";
|
|
|
|
import { useState, useEffect } from "react";
|
|
import Link from "next/link";
|
|
import { createClient } from "@/lib/supabase/client";
|
|
import type {
|
|
Contact,
|
|
Conversation,
|
|
Deal,
|
|
DealStatus,
|
|
PipelineStage,
|
|
Profile,
|
|
} from "@/types";
|
|
import {
|
|
Sheet,
|
|
SheetContent,
|
|
SheetHeader,
|
|
SheetTitle,
|
|
} from "@/components/ui/sheet";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Input } from "@/components/ui/input";
|
|
import { Label } from "@/components/ui/label";
|
|
import { Textarea } from "@/components/ui/textarea";
|
|
import {
|
|
Check,
|
|
X,
|
|
Trash2,
|
|
MessageSquare,
|
|
DollarSign,
|
|
Loader2,
|
|
} from "lucide-react";
|
|
import { toast } from "sonner";
|
|
|
|
interface DealFormProps {
|
|
open: boolean;
|
|
onOpenChange: (open: boolean) => void;
|
|
deal?: Deal | null;
|
|
pipelineId: string;
|
|
stages: PipelineStage[];
|
|
defaultStageId?: string;
|
|
onSaved: () => void;
|
|
}
|
|
|
|
export function DealForm({
|
|
open,
|
|
onOpenChange,
|
|
deal,
|
|
pipelineId,
|
|
stages,
|
|
defaultStageId,
|
|
onSaved,
|
|
}: DealFormProps) {
|
|
const supabase = createClient();
|
|
|
|
const [title, setTitle] = useState("");
|
|
const [value, setValue] = useState("");
|
|
const [currency, setCurrency] = useState("USD");
|
|
const [contactId, setContactId] = useState("");
|
|
const [stageId, setStageId] = useState("");
|
|
const [assignedTo, setAssignedTo] = useState("");
|
|
const [expectedCloseDate, setExpectedCloseDate] = useState("");
|
|
const [notes, setNotes] = useState("");
|
|
|
|
const [contacts, setContacts] = useState<Contact[]>([]);
|
|
const [profiles, setProfiles] = useState<Profile[]>([]);
|
|
const [linkedConversation, setLinkedConversation] =
|
|
useState<Conversation | null>(null);
|
|
|
|
const [saving, setSaving] = useState(false);
|
|
const [statusAction, setStatusAction] = useState<DealStatus | null>(null);
|
|
const [deleting, setDeleting] = useState(false);
|
|
const [confirmDelete, setConfirmDelete] = useState(false);
|
|
|
|
// Reset the form fields every time the sheet opens or its input
|
|
// props change. This is a legitimate prop-driven sync; the rule is
|
|
// over-cautious here, hence the block-level disable.
|
|
/* eslint-disable react-hooks/set-state-in-effect */
|
|
useEffect(() => {
|
|
if (!open) return;
|
|
setConfirmDelete(false);
|
|
if (deal) {
|
|
setTitle(deal.title);
|
|
setValue(String(deal.value ?? ""));
|
|
setCurrency(deal.currency || "USD");
|
|
// contact_id is nullable when the contact has been deleted
|
|
// (migration 004: ON DELETE SET NULL). "" means "no selection".
|
|
setContactId(deal.contact_id ?? "");
|
|
setStageId(deal.stage_id);
|
|
setAssignedTo(deal.assigned_to ?? "");
|
|
setExpectedCloseDate(deal.expected_close_date ?? "");
|
|
setNotes(deal.notes ?? "");
|
|
} else {
|
|
setTitle("");
|
|
setValue("");
|
|
setCurrency("USD");
|
|
setContactId("");
|
|
setStageId(defaultStageId || stages[0]?.id || "");
|
|
setAssignedTo("");
|
|
setExpectedCloseDate("");
|
|
setNotes("");
|
|
}
|
|
}, [open, deal, defaultStageId, stages]);
|
|
/* eslint-enable react-hooks/set-state-in-effect */
|
|
|
|
// Load supporting data once the sheet is open
|
|
useEffect(() => {
|
|
if (!open) return;
|
|
let cancelled = false;
|
|
(async () => {
|
|
const [c, p] = await Promise.all([
|
|
supabase.from("contacts").select("*").order("name"),
|
|
supabase.from("profiles").select("*").order("full_name"),
|
|
]);
|
|
if (cancelled) return;
|
|
setContacts((c.data ?? []) as Contact[]);
|
|
setProfiles((p.data ?? []) as Profile[]);
|
|
})();
|
|
return () => {
|
|
cancelled = true;
|
|
};
|
|
}, [open, supabase]);
|
|
|
|
// Fetch linked conversation for the selected contact (newest open one).
|
|
// Clearing on no-selection is sync with prop state; the populated
|
|
// case runs setLinkedConversation inside the async fetch callback.
|
|
useEffect(() => {
|
|
if (!open || !contactId) {
|
|
// eslint-disable-next-line react-hooks/set-state-in-effect
|
|
setLinkedConversation(null);
|
|
return;
|
|
}
|
|
let cancelled = false;
|
|
(async () => {
|
|
const { data } = await supabase
|
|
.from("conversations")
|
|
.select("*")
|
|
.eq("contact_id", contactId)
|
|
.order("last_message_at", { ascending: false })
|
|
.limit(1)
|
|
.maybeSingle();
|
|
if (cancelled) return;
|
|
setLinkedConversation((data as Conversation | null) ?? null);
|
|
})();
|
|
return () => {
|
|
cancelled = true;
|
|
};
|
|
}, [open, contactId, supabase]);
|
|
|
|
async function handleSave() {
|
|
if (!title.trim() || !contactId || !stageId) {
|
|
toast.error("Title, contact, and stage are required");
|
|
return;
|
|
}
|
|
setSaving(true);
|
|
|
|
const payload = {
|
|
title: title.trim(),
|
|
value: parseFloat(value) || 0,
|
|
currency,
|
|
contact_id: contactId,
|
|
pipeline_id: pipelineId,
|
|
stage_id: stageId,
|
|
assigned_to: assignedTo || null,
|
|
notes: notes.trim() || null,
|
|
expected_close_date: expectedCloseDate || null,
|
|
};
|
|
|
|
if (deal) {
|
|
const { error } = await supabase
|
|
.from("deals")
|
|
.update(payload)
|
|
.eq("id", deal.id);
|
|
if (error) {
|
|
toast.error("Failed to save deal");
|
|
setSaving(false);
|
|
return;
|
|
}
|
|
} else {
|
|
const {
|
|
data: { session },
|
|
} = await supabase.auth.getSession();
|
|
const user = session?.user;
|
|
if (!user) {
|
|
toast.error("Not signed in");
|
|
setSaving(false);
|
|
return;
|
|
}
|
|
const { error } = await supabase
|
|
.from("deals")
|
|
.insert({ ...payload, user_id: user.id, status: "open" });
|
|
if (error) {
|
|
toast.error("Failed to create deal");
|
|
setSaving(false);
|
|
return;
|
|
}
|
|
}
|
|
|
|
setSaving(false);
|
|
toast.success(deal ? "Deal updated" : "Deal created");
|
|
onOpenChange(false);
|
|
onSaved();
|
|
}
|
|
|
|
async function handleStatusChange(status: DealStatus) {
|
|
if (!deal) return;
|
|
setStatusAction(status);
|
|
const { error } = await supabase
|
|
.from("deals")
|
|
.update({ status })
|
|
.eq("id", deal.id);
|
|
setStatusAction(null);
|
|
if (error) {
|
|
toast.error("Failed to update deal status");
|
|
return;
|
|
}
|
|
toast.success(
|
|
status === "won" ? "Marked as won" : status === "lost" ? "Marked as lost" : "Deal reopened",
|
|
);
|
|
onOpenChange(false);
|
|
onSaved();
|
|
}
|
|
|
|
async function handleDelete() {
|
|
if (!deal) return;
|
|
setDeleting(true);
|
|
const { error } = await supabase.from("deals").delete().eq("id", deal.id);
|
|
setDeleting(false);
|
|
if (error) {
|
|
toast.error("Failed to delete deal");
|
|
return;
|
|
}
|
|
toast.success("Deal deleted");
|
|
setConfirmDelete(false);
|
|
onOpenChange(false);
|
|
onSaved();
|
|
}
|
|
|
|
return (
|
|
<Sheet open={open} onOpenChange={onOpenChange}>
|
|
<SheetContent
|
|
side="right"
|
|
className="bg-slate-900 border-slate-700 text-slate-200 sm:max-w-lg w-full p-0"
|
|
>
|
|
<div className="flex h-full flex-col">
|
|
<SheetHeader className="border-b border-slate-700/50 p-4">
|
|
<SheetTitle className="text-white">
|
|
{deal ? "Edit Deal" : "New Deal"}
|
|
</SheetTitle>
|
|
</SheetHeader>
|
|
|
|
<div className="flex-1 overflow-y-auto p-4 space-y-4">
|
|
<div className="grid gap-2">
|
|
<Label className="text-slate-300">Title</Label>
|
|
<Input
|
|
value={title}
|
|
onChange={(e) => setTitle(e.target.value)}
|
|
placeholder="Deal title"
|
|
className="border-slate-700 bg-slate-800 text-white"
|
|
/>
|
|
</div>
|
|
|
|
<div className="grid gap-2">
|
|
<Label className="text-slate-300">Contact</Label>
|
|
<select
|
|
value={contactId}
|
|
onChange={(e) => setContactId(e.target.value)}
|
|
className="h-9 w-full rounded-lg border border-slate-700 bg-slate-800 px-2.5 text-sm text-white outline-none focus:border-violet-500 focus:ring-1 focus:ring-violet-500"
|
|
>
|
|
<option value="">Select a contact</option>
|
|
{contacts.map((c) => (
|
|
<option key={c.id} value={c.id}>
|
|
{c.name || c.phone}
|
|
</option>
|
|
))}
|
|
</select>
|
|
|
|
{linkedConversation && (
|
|
<Link
|
|
href="/inbox"
|
|
className="mt-1 inline-flex items-center gap-1.5 self-start rounded-md bg-violet-500/10 px-2 py-1 text-xs text-violet-400 hover:bg-violet-500/20"
|
|
>
|
|
<MessageSquare className="h-3 w-3" />
|
|
Link to Conversation
|
|
</Link>
|
|
)}
|
|
</div>
|
|
|
|
<div className="grid grid-cols-[1fr_110px] gap-3">
|
|
<div className="grid gap-2">
|
|
<Label className="text-slate-300">Value</Label>
|
|
<div className="relative">
|
|
<DollarSign className="absolute left-2 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-slate-500" />
|
|
<Input
|
|
type="number"
|
|
value={value}
|
|
onChange={(e) => setValue(e.target.value)}
|
|
placeholder="0"
|
|
className="border-slate-700 bg-slate-800 pl-7 text-white"
|
|
/>
|
|
</div>
|
|
</div>
|
|
<div className="grid gap-2">
|
|
<Label className="text-slate-300">Currency</Label>
|
|
<select
|
|
value={currency}
|
|
onChange={(e) => setCurrency(e.target.value)}
|
|
className="h-9 w-full rounded-lg border border-slate-700 bg-slate-800 px-2.5 text-sm text-white outline-none focus:border-violet-500"
|
|
>
|
|
<option value="USD">USD</option>
|
|
<option value="EUR">EUR</option>
|
|
<option value="GBP">GBP</option>
|
|
</select>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="grid gap-2">
|
|
<Label className="text-slate-300">Expected Close Date</Label>
|
|
<Input
|
|
type="date"
|
|
value={expectedCloseDate}
|
|
onChange={(e) => setExpectedCloseDate(e.target.value)}
|
|
className="border-slate-700 bg-slate-800 text-white"
|
|
/>
|
|
</div>
|
|
|
|
<div className="grid gap-2">
|
|
<Label className="text-slate-300">Stage</Label>
|
|
<select
|
|
value={stageId}
|
|
onChange={(e) => setStageId(e.target.value)}
|
|
className="h-9 w-full rounded-lg border border-slate-700 bg-slate-800 px-2.5 text-sm text-white outline-none focus:border-violet-500"
|
|
>
|
|
{stages.map((s) => (
|
|
<option key={s.id} value={s.id}>
|
|
{s.name}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
|
|
<div className="grid gap-2">
|
|
<Label className="text-slate-300">Assigned To</Label>
|
|
<select
|
|
value={assignedTo}
|
|
onChange={(e) => setAssignedTo(e.target.value)}
|
|
className="h-9 w-full rounded-lg border border-slate-700 bg-slate-800 px-2.5 text-sm text-white outline-none focus:border-violet-500"
|
|
>
|
|
<option value="">Unassigned</option>
|
|
{profiles.map((p) => (
|
|
<option key={p.id} value={p.id}>
|
|
{p.full_name || p.email}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
|
|
<div className="grid gap-2">
|
|
<Label className="text-slate-300">Notes</Label>
|
|
<Textarea
|
|
value={notes}
|
|
onChange={(e) => setNotes(e.target.value)}
|
|
placeholder="Add notes..."
|
|
className="min-h-[100px] border-slate-700 bg-slate-800 text-white"
|
|
/>
|
|
</div>
|
|
|
|
{deal && (
|
|
<div className="space-y-2 rounded-lg border border-slate-700 bg-slate-900/50 p-3">
|
|
<p className="text-xs font-medium uppercase tracking-wider text-slate-400">
|
|
Status
|
|
</p>
|
|
<div className="flex gap-2">
|
|
<Button
|
|
type="button"
|
|
onClick={() => handleStatusChange("won")}
|
|
disabled={!!statusAction || deal.status === "won"}
|
|
className="flex-1 bg-violet-600 text-white hover:bg-violet-700 disabled:opacity-50"
|
|
>
|
|
{statusAction === "won" ? (
|
|
<Loader2 className="h-4 w-4 animate-spin" />
|
|
) : (
|
|
<>
|
|
<Check className="mr-1 h-4 w-4" />
|
|
Mark as Won
|
|
</>
|
|
)}
|
|
</Button>
|
|
<Button
|
|
type="button"
|
|
onClick={() => handleStatusChange("lost")}
|
|
disabled={!!statusAction || deal.status === "lost"}
|
|
className="flex-1 bg-red-600 text-white hover:bg-red-700 disabled:opacity-50"
|
|
>
|
|
{statusAction === "lost" ? (
|
|
<Loader2 className="h-4 w-4 animate-spin" />
|
|
) : (
|
|
<>
|
|
<X className="mr-1 h-4 w-4" />
|
|
Mark as Lost
|
|
</>
|
|
)}
|
|
</Button>
|
|
</div>
|
|
{deal.status && deal.status !== "open" && (
|
|
<Button
|
|
type="button"
|
|
variant="ghost"
|
|
onClick={() => handleStatusChange("open")}
|
|
disabled={!!statusAction}
|
|
className="w-full text-slate-400 hover:text-white"
|
|
>
|
|
Reopen deal
|
|
</Button>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<div className="border-t border-slate-700/50 bg-slate-900/80 p-4">
|
|
<div className="flex gap-2">
|
|
<Button
|
|
variant="outline"
|
|
onClick={() => onOpenChange(false)}
|
|
className="flex-1 border-slate-700 bg-transparent text-slate-300 hover:bg-slate-800"
|
|
>
|
|
Cancel
|
|
</Button>
|
|
<Button
|
|
onClick={handleSave}
|
|
disabled={saving || !title.trim() || !contactId || !stageId}
|
|
className="flex-1 bg-violet-600 text-white hover:bg-violet-700"
|
|
>
|
|
{saving ? "Saving..." : deal ? "Save Changes" : "Create Deal"}
|
|
</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>
|
|
<div className="flex gap-1">
|
|
<button
|
|
type="button"
|
|
onClick={() => setConfirmDelete(false)}
|
|
disabled={deleting}
|
|
className="rounded px-2 py-1 text-slate-300 hover:bg-slate-800"
|
|
>
|
|
Cancel
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={handleDelete}
|
|
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"}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<button
|
|
type="button"
|
|
onClick={() => setConfirmDelete(true)}
|
|
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
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</SheetContent>
|
|
</Sheet>
|
|
);
|
|
}
|