Linting and Formatting

This commit is contained in:
Kartik Datrika
2026-03-23 16:41:58 +05:30
parent 727a0728ee
commit 2c87a39733
175 changed files with 16535 additions and 11532 deletions

View File

@@ -1,6 +1,6 @@
import { cx } from '@/utils/cx';
import { daysAgoFromNow } from '@/lib/format';
import type { Lead } from '@/types/entities';
import { daysAgoFromNow } from "@/lib/format";
import type { Lead } from "@/types/entities";
import { cx } from "@/utils/cx";
interface AgingWidgetProps {
leads: Lead[];
@@ -26,21 +26,21 @@ export const AgingWidget = ({ leads }: AgingWidgetProps) => {
const brackets: AgingBracket[] = [
{
label: 'Fresh (<2 days)',
color: 'text-success-primary',
barColor: 'bg-success-solid',
label: "Fresh (<2 days)",
color: "text-success-primary",
barColor: "bg-success-solid",
count: freshCount,
},
{
label: 'Warm (2-5 days)',
color: 'text-warning-primary',
barColor: 'bg-warning-solid',
label: "Warm (2-5 days)",
color: "text-warning-primary",
barColor: "bg-warning-solid",
count: warmCount,
},
{
label: 'Cold (>5 days)',
color: 'text-error-primary',
barColor: 'bg-error-solid',
label: "Cold (>5 days)",
color: "text-error-primary",
barColor: "bg-error-solid",
count: coldCount,
},
];
@@ -52,14 +52,12 @@ export const AgingWidget = ({ leads }: AgingWidgetProps) => {
{brackets.map((bracket) => (
<div key={bracket.label}>
<div className="mb-1 flex items-center justify-between">
<span className={cx('text-xs', bracket.color)}>{bracket.label}</span>
<span className={cx('text-sm font-bold', bracket.color)}>
{bracket.count}
</span>
<span className={cx("text-xs", bracket.color)}>{bracket.label}</span>
<span className={cx("text-sm font-bold", bracket.color)}>{bracket.count}</span>
</div>
<div className="h-2 overflow-hidden rounded-full bg-tertiary">
<div
className={cx('h-full rounded-full transition-all', bracket.barColor)}
className={cx("h-full rounded-full transition-all", bracket.barColor)}
style={{ width: `${(bracket.count / total) * 100}%` }}
/>
</div>

View File

@@ -1,30 +1,21 @@
import { daysAgoFromNow } from '@/lib/format';
import type { Lead } from '@/types/entities';
import { daysAgoFromNow } from "@/lib/format";
import type { Lead } from "@/types/entities";
interface AlertsWidgetProps {
leads: Lead[];
}
export const AlertsWidget = ({ leads }: AlertsWidgetProps) => {
const agingCount = leads.filter(
(l) =>
l.leadStatus === 'NEW' &&
l.createdAt !== null &&
daysAgoFromNow(l.createdAt) > 5,
).length;
const agingCount = leads.filter((l) => l.leadStatus === "NEW" && l.createdAt !== null && daysAgoFromNow(l.createdAt) > 5).length;
if (agingCount === 0) {
return null;
}
return (
<div className="rounded-xl border border-error-subtle bg-error-primary p-4">
<p className="text-xs font-bold text-error-primary">
{agingCount} leads aging &gt; 5 days
</p>
<p className="mt-1 text-xs text-error-primary opacity-80">
These leads haven&apos;t been contacted and are at risk of going cold.
</p>
<div className="border-error-subtle rounded-xl border bg-error-primary p-4">
<p className="text-xs font-bold text-error-primary">{agingCount} leads aging &gt; 5 days</p>
<p className="mt-1 text-xs text-error-primary opacity-80">These leads haven&apos;t been contacted and are at risk of going cold.</p>
</div>
);
};

View File

@@ -9,39 +9,23 @@ interface BulkActionBarProps {
export const BulkActionBar = ({ selectedCount, onAssign, onWhatsApp, onMarkSpam, onDeselect }: BulkActionBarProps) => {
if (selectedCount === 0) return null;
const buttonBase = 'rounded-lg px-3 py-1.5 text-xs font-semibold border-none cursor-pointer transition duration-100 ease-linear';
const buttonBase = "rounded-lg px-3 py-1.5 text-xs font-semibold border-none cursor-pointer transition duration-100 ease-linear";
return (
<div className="flex items-center gap-3 rounded-xl bg-brand-section p-3 text-white">
<span className="text-sm font-semibold">{selectedCount} leads selected</span>
<div className="ml-auto flex gap-2">
<button
type="button"
onClick={onAssign}
className={`${buttonBase} bg-white/20 text-white hover:bg-white/30`}
>
<button type="button" onClick={onAssign} className={`${buttonBase} bg-white/20 text-white hover:bg-white/30`}>
Assign to Call Center
</button>
<button
type="button"
onClick={onWhatsApp}
className={`${buttonBase} bg-success-solid text-white hover:bg-success-solid/90`}
>
<button type="button" onClick={onWhatsApp} className={`${buttonBase} bg-success-solid text-white hover:bg-success-solid/90`}>
Send WhatsApp
</button>
<button
type="button"
onClick={onMarkSpam}
className={`${buttonBase} bg-white/15 text-error-primary hover:bg-white/25`}
>
<button type="button" onClick={onMarkSpam} className={`${buttonBase} bg-white/15 text-error-primary hover:bg-white/25`}>
Mark Spam
</button>
<button
type="button"
onClick={onDeselect}
className={`${buttonBase} bg-white/10 text-white/70 hover:bg-white/20 hover:text-white`}
>
<button type="button" onClick={onDeselect} className={`${buttonBase} bg-white/10 text-white/70 hover:bg-white/20 hover:text-white`}>
Deselect
</button>
</div>

View File

@@ -1,6 +1,6 @@
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faXmark } from '@fortawesome/pro-duotone-svg-icons';
import { cx } from '@/utils/cx';
import { faXmark } from "@fortawesome/pro-duotone-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { cx } from "@/utils/cx";
type FilterPill = {
key: string;
@@ -23,7 +23,7 @@ export const FilterPills = ({ filters, onRemove, onClearAll }: FilterPillsProps)
<span
key={filter.key}
className={cx(
'flex items-center gap-1 rounded-full border border-brand bg-brand-primary px-3 py-1 text-xs font-medium text-brand-secondary',
"flex items-center gap-1 rounded-full border border-brand bg-brand-primary px-3 py-1 text-xs font-medium text-brand-secondary",
)}
>
{filter.label}: {filter.value}

View File

@@ -1,7 +1,7 @@
import { Button } from '@/components/base/buttons/button';
import { cx } from '@/utils/cx';
import { formatShortDate } from '@/lib/format';
import type { FollowUp } from '@/types/entities';
import { Button } from "@/components/base/buttons/button";
import { formatShortDate } from "@/lib/format";
import type { FollowUp } from "@/types/entities";
import { cx } from "@/utils/cx";
interface FollowupWidgetProps {
overdue: FollowUp[];
@@ -26,26 +26,15 @@ export const FollowupWidget = ({ overdue, upcoming }: FollowupWidgetProps) => {
<div
key={item.id}
className={cx(
'rounded-lg border-l-2 p-3',
isOverdue
? 'border-l-error-solid bg-error-primary'
: 'border-l-brand-solid bg-secondary',
"rounded-lg border-l-2 p-3",
isOverdue ? "border-l-error-solid bg-error-primary" : "border-l-brand-solid bg-secondary",
)}
>
<p
className={cx(
'text-xs font-semibold',
isOverdue ? 'text-error-primary' : 'text-brand-secondary',
)}
>
{item.scheduledAt
? formatShortDate(item.scheduledAt)
: 'No date'}
{isOverdue && ' — Overdue'}
</p>
<p className="mt-0.5 text-xs font-medium text-primary">
{item.description ?? item.followUpType ?? 'Follow-up'}
<p className={cx("text-xs font-semibold", isOverdue ? "text-error-primary" : "text-brand-secondary")}>
{item.scheduledAt ? formatShortDate(item.scheduledAt) : "No date"}
{isOverdue && " — Overdue"}
</p>
<p className="mt-0.5 text-xs font-medium text-primary">{item.description ?? item.followUpType ?? "Follow-up"}</p>
{(item.patientName || item.patientPhone) && (
<p className="mt-0.5 text-xs text-tertiary">
{item.patientName}

View File

@@ -1,5 +1,5 @@
import { cx } from '@/utils/cx';
import type { Lead } from '@/types/entities';
import type { Lead } from "@/types/entities";
import { cx } from "@/utils/cx";
interface KpiCardsProps {
leads: Lead[];
@@ -14,38 +14,38 @@ type KpiCard = {
};
export const KpiCards = ({ leads }: KpiCardsProps) => {
const newCount = leads.filter((l) => l.leadStatus === 'NEW').length;
const newCount = leads.filter((l) => l.leadStatus === "NEW").length;
const assignedCount = leads.filter((l) => l.assignedAgent !== null).length;
const contactedCount = leads.filter((l) => l.leadStatus === 'CONTACTED').length;
const convertedCount = leads.filter((l) => l.leadStatus === 'CONVERTED').length;
const contactedCount = leads.filter((l) => l.leadStatus === "CONTACTED").length;
const convertedCount = leads.filter((l) => l.leadStatus === "CONVERTED").length;
const cards: KpiCard[] = [
{
label: 'New Leads Today',
label: "New Leads Today",
value: newCount,
delta: '+12% vs yesterday',
deltaColor: 'text-success-primary',
delta: "+12% vs yesterday",
deltaColor: "text-success-primary",
isHero: true,
},
{
label: 'Assigned to CC',
label: "Assigned to CC",
value: assignedCount,
delta: '85% assigned',
deltaColor: 'text-brand-secondary',
delta: "85% assigned",
deltaColor: "text-brand-secondary",
isHero: false,
},
{
label: 'Contacted',
label: "Contacted",
value: contactedCount,
delta: '+8% vs yesterday',
deltaColor: 'text-success-primary',
delta: "+8% vs yesterday",
deltaColor: "text-success-primary",
isHero: false,
},
{
label: 'Converted',
label: "Converted",
value: convertedCount,
delta: '+3 this week',
deltaColor: 'text-warning-primary',
delta: "+3 this week",
deltaColor: "text-warning-primary",
isHero: false,
},
];
@@ -56,36 +56,13 @@ export const KpiCards = ({ leads }: KpiCardsProps) => {
<div
key={card.label}
className={cx(
'rounded-2xl p-5 transition hover:shadow-md',
card.isHero
? 'flex-[1.3] bg-brand-solid text-white'
: 'flex-1 border border-secondary bg-primary',
"rounded-2xl p-5 transition hover:shadow-md",
card.isHero ? "flex-[1.3] bg-brand-solid text-white" : "flex-1 border border-secondary bg-primary",
)}
>
<p
className={cx(
'text-xs font-medium uppercase tracking-wider',
card.isHero ? 'text-white/70' : 'text-quaternary',
)}
>
{card.label}
</p>
<p
className={cx(
'mt-1 text-display-sm font-bold',
card.isHero ? 'text-white' : 'text-primary',
)}
>
{card.value}
</p>
<p
className={cx(
'mt-1 text-xs',
card.isHero ? 'text-white/80' : card.deltaColor,
)}
>
{card.delta}
</p>
<p className={cx("text-xs font-medium tracking-wider uppercase", card.isHero ? "text-white/70" : "text-quaternary")}>{card.label}</p>
<p className={cx("mt-1 text-display-sm font-bold", card.isHero ? "text-white" : "text-primary")}>{card.value}</p>
<p className={cx("mt-1 text-xs", card.isHero ? "text-white/80" : card.deltaColor)}>{card.delta}</p>
</div>
))}
</div>

View File

@@ -1,7 +1,7 @@
import { SlideoutMenu } from '@/components/application/slideout-menus/slideout-menu';
import { formatPhone, formatShortDate } from '@/lib/format';
import type { Lead, LeadActivity, LeadActivityType } from '@/types/entities';
import { cx } from '@/utils/cx';
import { SlideoutMenu } from "@/components/application/slideout-menus/slideout-menu";
import { formatPhone, formatShortDate } from "@/lib/format";
import type { Lead, LeadActivity, LeadActivityType } from "@/types/entities";
import { cx } from "@/utils/cx";
type LeadActivitySlideoutProps = {
isOpen: boolean;
@@ -17,31 +17,29 @@ type ActivityConfig = {
};
const ACTIVITY_CONFIG: Record<LeadActivityType, ActivityConfig> = {
STATUS_CHANGE: { icon: '🔄', dotClass: 'bg-brand-secondary', label: 'Status Changed' },
CALL_MADE: { icon: '📞', dotClass: 'bg-brand-secondary', label: 'Call Made' },
CALL_RECEIVED: { icon: '📲', dotClass: 'bg-brand-secondary', label: 'Call Received' },
WHATSAPP_SENT: { icon: '💬', dotClass: 'bg-success-solid', label: 'WhatsApp Sent' },
WHATSAPP_RECEIVED: { icon: '💬', dotClass: 'bg-success-solid', label: 'WhatsApp Received' },
SMS_SENT: { icon: '✉️', dotClass: 'bg-brand-secondary', label: 'SMS Sent' },
EMAIL_SENT: { icon: '📧', dotClass: 'bg-brand-secondary', label: 'Email Sent' },
EMAIL_RECEIVED: { icon: '📧', dotClass: 'bg-brand-secondary', label: 'Email Received' },
NOTE_ADDED: { icon: '📝', dotClass: 'bg-warning-solid', label: 'Note Added' },
ASSIGNED: { icon: '📤', dotClass: 'bg-brand-secondary', label: 'Assigned' },
APPOINTMENT_BOOKED: { icon: '📅', dotClass: 'bg-brand-secondary', label: 'Appointment Booked' },
FOLLOW_UP_CREATED: { icon: '🔁', dotClass: 'bg-brand-secondary', label: 'Follow-up Created' },
CONVERTED: { icon: '✅', dotClass: 'bg-success-solid', label: 'Converted' },
MARKED_SPAM: { icon: '🚫', dotClass: 'bg-error-solid', label: 'Marked as Spam' },
DUPLICATE_DETECTED: { icon: '🔍', dotClass: 'bg-warning-solid', label: 'Duplicate Detected' },
STATUS_CHANGE: { icon: "🔄", dotClass: "bg-brand-secondary", label: "Status Changed" },
CALL_MADE: { icon: "📞", dotClass: "bg-brand-secondary", label: "Call Made" },
CALL_RECEIVED: { icon: "📲", dotClass: "bg-brand-secondary", label: "Call Received" },
WHATSAPP_SENT: { icon: "💬", dotClass: "bg-success-solid", label: "WhatsApp Sent" },
WHATSAPP_RECEIVED: { icon: "💬", dotClass: "bg-success-solid", label: "WhatsApp Received" },
SMS_SENT: { icon: "✉️", dotClass: "bg-brand-secondary", label: "SMS Sent" },
EMAIL_SENT: { icon: "📧", dotClass: "bg-brand-secondary", label: "Email Sent" },
EMAIL_RECEIVED: { icon: "📧", dotClass: "bg-brand-secondary", label: "Email Received" },
NOTE_ADDED: { icon: "📝", dotClass: "bg-warning-solid", label: "Note Added" },
ASSIGNED: { icon: "📤", dotClass: "bg-brand-secondary", label: "Assigned" },
APPOINTMENT_BOOKED: { icon: "📅", dotClass: "bg-brand-secondary", label: "Appointment Booked" },
FOLLOW_UP_CREATED: { icon: "🔁", dotClass: "bg-brand-secondary", label: "Follow-up Created" },
CONVERTED: { icon: "✅", dotClass: "bg-success-solid", label: "Converted" },
MARKED_SPAM: { icon: "🚫", dotClass: "bg-error-solid", label: "Marked as Spam" },
DUPLICATE_DETECTED: { icon: "🔍", dotClass: "bg-warning-solid", label: "Duplicate Detected" },
};
const DEFAULT_CONFIG: ActivityConfig = { icon: '📌', dotClass: 'bg-tertiary', label: 'Activity' };
const DEFAULT_CONFIG: ActivityConfig = { icon: "📌", dotClass: "bg-tertiary", label: "Activity" };
const StatusChangeContent = ({ previousValue, newValue }: { previousValue: string | null; newValue: string | null }) => (
<span className="text-sm text-secondary">
{previousValue && (
<span className="mr-1 text-sm line-through text-quaternary">{previousValue}</span>
)}
{previousValue && newValue && '→ '}
{previousValue && <span className="mr-1 text-sm text-quaternary line-through">{previousValue}</span>}
{previousValue && newValue && "→ "}
{newValue && <span className="font-medium text-brand-secondary">{newValue}</span>}
</span>
);
@@ -49,45 +47,33 @@ const StatusChangeContent = ({ previousValue, newValue }: { previousValue: strin
const ActivityItem = ({ activity, isLast }: { activity: LeadActivity; isLast: boolean }) => {
const type = activity.activityType;
const config = type ? (ACTIVITY_CONFIG[type] ?? DEFAULT_CONFIG) : DEFAULT_CONFIG;
const occurredAt = activity.occurredAt ? formatShortDate(activity.occurredAt) : '';
const occurredAt = activity.occurredAt ? formatShortDate(activity.occurredAt) : "";
return (
<div className="relative flex gap-3 pb-4">
{/* Vertical connecting line */}
{!isLast && (
<div className="absolute left-[15px] top-[36px] bottom-0 w-0.5 bg-tertiary" />
)}
{!isLast && <div className="absolute top-[36px] bottom-0 left-[15px] w-0.5 bg-tertiary" />}
{/* Dot */}
<div
className={cx(
'relative z-10 flex size-8 shrink-0 items-center justify-center rounded-full text-sm',
config.dotClass,
)}
aria-hidden="true"
>
<div className={cx("relative z-10 flex size-8 shrink-0 items-center justify-center rounded-full text-sm", config.dotClass)} aria-hidden="true">
{config.icon}
</div>
{/* Content */}
<div className="flex flex-1 flex-col gap-0.5 pt-1 min-w-0">
<div className="flex min-w-0 flex-1 flex-col gap-0.5 pt-1">
<div className="flex items-start gap-2">
<span className="flex-1 text-sm font-semibold text-primary">
{activity.summary ?? config.label}
</span>
<span className="flex-1 text-sm font-semibold text-primary">{activity.summary ?? config.label}</span>
</div>
{type === 'STATUS_CHANGE' && (activity.previousValue || activity.newValue) && (
{type === "STATUS_CHANGE" && (activity.previousValue || activity.newValue) && (
<StatusChangeContent previousValue={activity.previousValue} newValue={activity.newValue} />
)}
{type !== 'STATUS_CHANGE' && activity.newValue && (
<p className="text-xs text-tertiary">{activity.newValue}</p>
)}
{type !== "STATUS_CHANGE" && activity.newValue && <p className="text-xs text-tertiary">{activity.newValue}</p>}
<p className="text-xs text-quaternary">
{occurredAt}
{activity.performedBy ? ` · by ${activity.performedBy}` : ''}
{activity.performedBy ? ` · by ${activity.performedBy}` : ""}
</p>
</div>
</div>
@@ -95,9 +81,9 @@ const ActivityItem = ({ activity, isLast }: { activity: LeadActivity; isLast: bo
};
export const LeadActivitySlideout = ({ isOpen, onOpenChange, lead, activities }: LeadActivitySlideoutProps) => {
const firstName = lead.contactName?.firstName ?? '';
const lastName = lead.contactName?.lastName ?? '';
const fullName = `${firstName} ${lastName}`.trim() || 'Unknown Lead';
const firstName = lead.contactName?.firstName ?? "";
const lastName = lead.contactName?.lastName ?? "";
const fullName = `${firstName} ${lastName}`.trim() || "Unknown Lead";
const phone = lead.contactPhone?.[0] ? formatPhone(lead.contactPhone[0]) : null;
const email = lead.contactEmail?.[0]?.address ?? null;
@@ -116,11 +102,7 @@ export const LeadActivitySlideout = ({ isOpen, onOpenChange, lead, activities }:
<SlideoutMenu.Header onClose={close}>
<div className="flex flex-col gap-0.5 pr-8">
<h2 className="text-lg font-semibold text-primary">Lead Activity {fullName}</h2>
<p className="text-sm text-tertiary">
{[phone, email, lead.leadSource, lead.utmCampaign]
.filter(Boolean)
.join(' · ')}
</p>
<p className="text-sm text-tertiary">{[phone, email, lead.leadSource, lead.utmCampaign].filter(Boolean).join(" · ")}</p>
</div>
</SlideoutMenu.Header>
@@ -134,11 +116,7 @@ export const LeadActivitySlideout = ({ isOpen, onOpenChange, lead, activities }:
) : (
<div className="flex flex-col">
{filteredActivities.map((activity, idx) => (
<ActivityItem
key={activity.id}
activity={activity}
isLast={idx === filteredActivities.length - 1}
/>
<ActivityItem key={activity.id} activity={activity} isLast={idx === filteredActivities.length - 1} />
))}
</div>
)}

View File

@@ -1,12 +1,12 @@
import { Avatar } from '@/components/base/avatar/avatar';
import { Badge } from '@/components/base/badges/badges';
import { Button } from '@/components/base/buttons/button';
import { LeadStatusBadge } from '@/components/shared/status-badge';
import { SourceTag } from '@/components/shared/source-tag';
import { AgeIndicator } from '@/components/shared/age-indicator';
import { formatPhone, getInitials } from '@/lib/format';
import { cx } from '@/utils/cx';
import type { Lead } from '@/types/entities';
import { Avatar } from "@/components/base/avatar/avatar";
import { Badge } from "@/components/base/badges/badges";
import { Button } from "@/components/base/buttons/button";
import { AgeIndicator } from "@/components/shared/age-indicator";
import { SourceTag } from "@/components/shared/source-tag";
import { LeadStatusBadge } from "@/components/shared/status-badge";
import { formatPhone, getInitials } from "@/lib/format";
import type { Lead } from "@/types/entities";
import { cx } from "@/utils/cx";
interface LeadCardProps {
lead: Lead;
@@ -19,34 +19,34 @@ interface LeadCardProps {
}
const sourceLabelMap: Record<string, string> = {
FACEBOOK_AD: 'Facebook',
INSTAGRAM: 'Instagram',
GOOGLE_AD: 'Google',
GOOGLE_MY_BUSINESS: 'GMB',
WHATSAPP: 'WhatsApp',
WEBSITE: 'Website',
REFERRAL: 'Referral',
WALK_IN: 'Walk-in',
PHONE: 'Phone',
OTHER: 'Other',
FACEBOOK_AD: "Facebook",
INSTAGRAM: "Instagram",
GOOGLE_AD: "Google",
GOOGLE_MY_BUSINESS: "GMB",
WHATSAPP: "WhatsApp",
WEBSITE: "Website",
REFERRAL: "Referral",
WALK_IN: "Walk-in",
PHONE: "Phone",
OTHER: "Other",
};
export const LeadCard = ({ lead, onAssign, onMessage, onMarkSpam, onMerge, onLogCall, onUpdateStatus }: LeadCardProps) => {
const firstName = lead.contactName?.firstName ?? '';
const lastName = lead.contactName?.lastName ?? '';
const name = `${firstName} ${lastName}`.trim() || 'Unknown';
const initials = firstName && lastName ? getInitials(firstName, lastName) : '??';
const phone = lead.contactPhone?.[0] ? formatPhone(lead.contactPhone[0]) : '';
const sourceLabel = lead.leadSource ? sourceLabelMap[lead.leadSource] ?? lead.leadSource : '';
const firstName = lead.contactName?.firstName ?? "";
const lastName = lead.contactName?.lastName ?? "";
const name = `${firstName} ${lastName}`.trim() || "Unknown";
const initials = firstName && lastName ? getInitials(firstName, lastName) : "??";
const phone = lead.contactPhone?.[0] ? formatPhone(lead.contactPhone[0]) : "";
const sourceLabel = lead.leadSource ? (sourceLabelMap[lead.leadSource] ?? lead.leadSource) : "";
const isSpam = (lead.spamScore ?? 0) >= 60;
const isDuplicate = lead.isDuplicate === true;
const isAssigned = lead.assignedAgent !== null && lead.leadStatus !== 'NEW';
const isAssigned = lead.assignedAgent !== null && lead.leadStatus !== "NEW";
return (
<div
className={cx(
'flex items-center gap-4 rounded-2xl border border-secondary p-5 transition hover:shadow-md',
isSpam ? 'bg-warning-primary' : 'bg-primary',
"flex items-center gap-4 rounded-2xl border border-secondary p-5 transition hover:shadow-md",
isSpam ? "bg-warning-primary" : "bg-primary",
)}
>
{/* Avatar */}

View File

@@ -1,77 +1,64 @@
import type { FC } from 'react';
import { useMemo, useState } from 'react';
import { TableBody as AriaTableBody } from 'react-aria-components';
import type { SortDescriptor, Selection } from 'react-aria-components';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faEllipsisVertical } from '@fortawesome/pro-duotone-svg-icons';
import type { FC } from "react";
import { useMemo, useState } from "react";
import { faEllipsisVertical } from "@fortawesome/pro-duotone-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { TableBody as AriaTableBody } from "react-aria-components";
import type { Selection, SortDescriptor } from "react-aria-components";
import { Table } from "@/components/application/table/table";
import { Badge } from "@/components/base/badges/badges";
import { Button } from "@/components/base/buttons/button";
import { AgeIndicator } from "@/components/shared/age-indicator";
import { SourceTag } from "@/components/shared/source-tag";
import { LeadStatusBadge } from "@/components/shared/status-badge";
import { formatPhone, formatShortDate } from "@/lib/format";
import type { Lead } from "@/types/entities";
import { cx } from "@/utils/cx";
const DotsVertical: FC<{ className?: string }> = ({ className }) => <FontAwesomeIcon icon={faEllipsisVertical} className={className} />;
import { Badge } from '@/components/base/badges/badges';
import { Button } from '@/components/base/buttons/button';
import { Table } from '@/components/application/table/table';
import { LeadStatusBadge } from '@/components/shared/status-badge';
import { SourceTag } from '@/components/shared/source-tag';
import { AgeIndicator } from '@/components/shared/age-indicator';
import { formatPhone, formatShortDate } from '@/lib/format';
import { cx } from '@/utils/cx';
import type { Lead } from '@/types/entities';
type LeadTableProps = {
leads: Lead[];
onSelectionChange: (selectedIds: string[]) => void;
selectedIds: string[];
sortField: string;
sortDirection: 'asc' | 'desc';
sortDirection: "asc" | "desc";
onSort: (field: string) => void;
onViewActivity?: (lead: Lead) => void;
};
type TableRow = {
id: string;
type: 'lead' | 'dup-sub';
type: "lead" | "dup-sub";
lead: Lead;
};
const SpamDisplay = ({ score }: { score: number }) => {
const colorClass =
score < 30
? 'text-success-primary'
: score < 60
? 'text-warning-primary'
: 'text-error-primary';
const colorClass = score < 30 ? "text-success-primary" : score < 60 ? "text-warning-primary" : "text-error-primary";
const bgClass = score >= 60 ? 'rounded px-1.5 py-0.5 bg-error-primary' : '';
const bgClass = score >= 60 ? "rounded px-1.5 py-0.5 bg-error-primary" : "";
return <span className={cx('text-xs font-semibold', colorClass, bgClass)}>{score}%</span>;
return <span className={cx("text-xs font-semibold", colorClass, bgClass)}>{score}%</span>;
};
export const LeadTable = ({
leads,
onSelectionChange,
selectedIds,
sortField,
sortDirection,
onSort,
onViewActivity,
}: LeadTableProps) => {
export const LeadTable = ({ leads, onSelectionChange, selectedIds, sortField, sortDirection, onSort, onViewActivity }: LeadTableProps) => {
const [expandedDupId, setExpandedDupId] = useState<string | null>(null);
const selectedKeys: Selection = new Set(selectedIds);
const handleSelectionChange = (keys: Selection) => {
if (keys === 'all') {
if (keys === "all") {
// Only select actual lead rows, not dup sub-rows
onSelectionChange(leads.map((l) => l.id));
} else {
// Filter out dup sub-row IDs
const leadOnlyIds = [...keys].filter((k) => !String(k).endsWith('-dup')) as string[];
const leadOnlyIds = [...keys].filter((k) => !String(k).endsWith("-dup")) as string[];
onSelectionChange(leadOnlyIds);
}
};
const sortDescriptor: SortDescriptor = {
column: sortField,
direction: sortDirection === 'asc' ? 'ascending' : 'descending',
direction: sortDirection === "asc" ? "ascending" : "descending",
};
const handleSortChange = (descriptor: SortDescriptor) => {
@@ -84,28 +71,28 @@ export const LeadTable = ({
const tableRows = useMemo<TableRow[]>(() => {
const rows: TableRow[] = [];
for (const lead of leads) {
rows.push({ id: lead.id, type: 'lead', lead });
rows.push({ id: lead.id, type: "lead", lead });
if (lead.isDuplicate === true && expandedDupId === lead.id) {
rows.push({ id: `${lead.id}-dup`, type: 'dup-sub', lead });
rows.push({ id: `${lead.id}-dup`, type: "dup-sub", lead });
}
}
return rows;
}, [leads, expandedDupId]);
const columns = [
{ id: 'phone', label: 'Phone', allowsSorting: true },
{ id: 'name', label: 'Name', allowsSorting: true },
{ id: 'email', label: 'Email', allowsSorting: false },
{ id: 'campaign', label: 'Campaign', allowsSorting: false },
{ id: 'ad', label: 'Ad', allowsSorting: false },
{ id: 'source', label: 'Source', allowsSorting: true },
{ id: 'firstContactedAt', label: 'First Contact', allowsSorting: true },
{ id: 'lastContactedAt', label: 'Last Contact', allowsSorting: true },
{ id: 'status', label: 'Status', allowsSorting: true },
{ id: 'createdAt', label: 'Age', allowsSorting: true },
{ id: 'spamScore', label: 'Spam', allowsSorting: true },
{ id: 'dups', label: 'Dups', allowsSorting: false },
{ id: 'actions', label: '', allowsSorting: false },
{ id: "phone", label: "Phone", allowsSorting: true },
{ id: "name", label: "Name", allowsSorting: true },
{ id: "email", label: "Email", allowsSorting: false },
{ id: "campaign", label: "Campaign", allowsSorting: false },
{ id: "ad", label: "Ad", allowsSorting: false },
{ id: "source", label: "Source", allowsSorting: true },
{ id: "firstContactedAt", label: "First Contact", allowsSorting: true },
{ id: "lastContactedAt", label: "Last Contact", allowsSorting: true },
{ id: "status", label: "Status", allowsSorting: true },
{ id: "createdAt", label: "Age", allowsSorting: true },
{ id: "spamScore", label: "Spam", allowsSorting: true },
{ id: "dups", label: "Dups", allowsSorting: false },
{ id: "actions", label: "", allowsSorting: false },
];
return (
@@ -121,35 +108,22 @@ export const LeadTable = ({
size="sm"
>
<Table.Header columns={columns}>
{(column) => (
<Table.Head
key={column.id}
id={column.id}
label={column.label}
allowsSorting={column.allowsSorting}
/>
)}
{(column) => <Table.Head key={column.id} id={column.id} label={column.label} allowsSorting={column.allowsSorting} />}
</Table.Header>
<AriaTableBody items={tableRows}>
{(row) => {
const { lead } = row;
const firstName = lead.contactName?.firstName ?? '';
const lastName = lead.contactName?.lastName ?? '';
const name = `${firstName} ${lastName}`.trim() || '\u2014';
const phone = lead.contactPhone?.[0]
? formatPhone(lead.contactPhone[0])
: '\u2014';
const email = lead.contactEmail?.[0]?.address ?? '\u2014';
const firstName = lead.contactName?.firstName ?? "";
const lastName = lead.contactName?.lastName ?? "";
const name = `${firstName} ${lastName}`.trim() || "\u2014";
const phone = lead.contactPhone?.[0] ? formatPhone(lead.contactPhone[0]) : "\u2014";
const email = lead.contactEmail?.[0]?.address ?? "\u2014";
// Render duplicate sub-row
if (row.type === 'dup-sub') {
if (row.type === "dup-sub") {
return (
<Table.Row
key={row.id}
id={row.id}
className="bg-warning-primary"
>
<Table.Row key={row.id} id={row.id} className="bg-warning-primary">
<Table.Cell className="pl-10">
<span className="text-xs text-tertiary">{phone}</span>
</Table.Cell>
@@ -160,11 +134,7 @@ export const LeadTable = ({
<span className="text-xs text-tertiary">{email}</span>
</Table.Cell>
<Table.Cell>
{lead.leadSource ? (
<SourceTag source={lead.leadSource} size="sm" />
) : (
<span className="text-tertiary">{'\u2014'}</span>
)}
{lead.leadSource ? <SourceTag source={lead.leadSource} size="sm" /> : <span className="text-tertiary">{"\u2014"}</span>}
</Table.Cell>
<Table.Cell>
<Badge size="sm" type="pill-color" color="warning">
@@ -172,11 +142,7 @@ export const LeadTable = ({
</Badge>
</Table.Cell>
<Table.Cell>
<span className="text-xs text-tertiary">
{lead.createdAt
? formatShortDate(lead.createdAt)
: '\u2014'}
</span>
<span className="text-xs text-tertiary">{lead.createdAt ? formatShortDate(lead.createdAt) : "\u2014"}</span>
</Table.Cell>
<Table.Cell />
<Table.Cell />
@@ -208,10 +174,7 @@ export const LeadTable = ({
<Table.Row
key={row.id}
id={row.id}
className={cx(
isSpamRow && !isSelected && 'bg-warning-primary',
isSelected && 'bg-brand-primary',
)}
className={cx(isSpamRow && !isSelected && "bg-warning-primary", isSelected && "bg-brand-primary")}
>
<Table.Cell>
<span className="font-semibold text-primary">{phone}</span>
@@ -228,7 +191,7 @@ export const LeadTable = ({
{lead.utmCampaign}
</Badge>
) : (
<span className="text-tertiary">{'\u2014'}</span>
<span className="text-tertiary">{"\u2014"}</span>
)}
</Table.Cell>
<Table.Cell>
@@ -237,50 +200,26 @@ export const LeadTable = ({
Ad
</Badge>
) : (
<span className="text-tertiary">{'\u2014'}</span>
<span className="text-tertiary">{"\u2014"}</span>
)}
</Table.Cell>
<Table.Cell>
{lead.leadSource ? (
<SourceTag source={lead.leadSource} />
) : (
<span className="text-tertiary">{'\u2014'}</span>
)}
{lead.leadSource ? <SourceTag source={lead.leadSource} /> : <span className="text-tertiary">{"\u2014"}</span>}
</Table.Cell>
<Table.Cell>
<span className="text-tertiary">
{lead.firstContactedAt
? formatShortDate(lead.firstContactedAt)
: '\u2014'}
</span>
<span className="text-tertiary">{lead.firstContactedAt ? formatShortDate(lead.firstContactedAt) : "\u2014"}</span>
</Table.Cell>
<Table.Cell>
<span className="text-tertiary">
{lead.lastContactedAt
? formatShortDate(lead.lastContactedAt)
: '\u2014'}
</span>
<span className="text-tertiary">{lead.lastContactedAt ? formatShortDate(lead.lastContactedAt) : "\u2014"}</span>
</Table.Cell>
<Table.Cell>
{lead.leadStatus ? (
<LeadStatusBadge status={lead.leadStatus} />
) : (
<span className="text-tertiary">{'\u2014'}</span>
)}
{lead.leadStatus ? <LeadStatusBadge status={lead.leadStatus} /> : <span className="text-tertiary">{"\u2014"}</span>}
</Table.Cell>
<Table.Cell>
{lead.createdAt ? (
<AgeIndicator dateStr={lead.createdAt} />
) : (
<span className="text-tertiary">{'\u2014'}</span>
)}
{lead.createdAt ? <AgeIndicator dateStr={lead.createdAt} /> : <span className="text-tertiary">{"\u2014"}</span>}
</Table.Cell>
<Table.Cell>
{lead.spamScore != null ? (
<SpamDisplay score={lead.spamScore} />
) : (
<span className="text-tertiary">0%</span>
)}
{lead.spamScore != null ? <SpamDisplay score={lead.spamScore} /> : <span className="text-tertiary">0%</span>}
</Table.Cell>
<Table.Cell>
{isDup ? (
@@ -292,7 +231,7 @@ export const LeadTable = ({
}}
className="cursor-pointer border-none bg-transparent text-xs font-semibold text-warning-primary hover:text-warning-primary"
>
1 {isExpanded ? '\u25B4' : '\u25BE'}
1 {isExpanded ? "\u25B4" : "\u25BE"}
</button>
) : (
<span className="text-tertiary">0</span>

View File

@@ -1,5 +1,5 @@
import { cx } from '@/utils/cx';
import type { Lead, LeadSource } from '@/types/entities';
import type { Lead, LeadSource } from "@/types/entities";
import { cx } from "@/utils/cx";
interface SourceGridProps {
leads: Lead[];
@@ -19,64 +19,63 @@ type SourceConfig = {
const sourceConfigs: SourceConfig[] = [
{
source: 'FACEBOOK_AD',
label: 'Facebook Ads',
icon: 'f',
iconBg: 'bg-utility-blue-50',
iconText: 'text-utility-blue-700',
countColor: 'text-utility-blue-700',
delta: '+4',
source: "FACEBOOK_AD",
label: "Facebook Ads",
icon: "f",
iconBg: "bg-utility-blue-50",
iconText: "text-utility-blue-700",
countColor: "text-utility-blue-700",
delta: "+4",
},
{
source: 'GOOGLE_AD',
label: 'Google Ads',
icon: 'G',
iconBg: 'bg-utility-success-50',
iconText: 'text-utility-success-700',
countColor: 'text-utility-success-700',
delta: '+2',
source: "GOOGLE_AD",
label: "Google Ads",
icon: "G",
iconBg: "bg-utility-success-50",
iconText: "text-utility-success-700",
countColor: "text-utility-success-700",
delta: "+2",
},
{
source: 'INSTAGRAM',
label: 'Instagram',
icon: '@',
iconBg: 'bg-utility-pink-50',
iconText: 'text-utility-pink-700',
countColor: 'text-utility-pink-700',
delta: '+1',
source: "INSTAGRAM",
label: "Instagram",
icon: "@",
iconBg: "bg-utility-pink-50",
iconText: "text-utility-pink-700",
countColor: "text-utility-pink-700",
delta: "+1",
},
{
source: 'GOOGLE_MY_BUSINESS',
label: 'Google My Business',
icon: 'G',
iconBg: 'bg-utility-blue-light-50',
iconText: 'text-utility-blue-light-700',
countColor: 'text-utility-blue-light-700',
delta: '+3',
source: "GOOGLE_MY_BUSINESS",
label: "Google My Business",
icon: "G",
iconBg: "bg-utility-blue-light-50",
iconText: "text-utility-blue-light-700",
countColor: "text-utility-blue-light-700",
delta: "+3",
},
{
source: 'REFERRAL',
label: 'Referrals',
icon: 'R',
iconBg: 'bg-utility-purple-50',
iconText: 'text-utility-purple-700',
countColor: 'text-utility-purple-700',
delta: '+2',
source: "REFERRAL",
label: "Referrals",
icon: "R",
iconBg: "bg-utility-purple-50",
iconText: "text-utility-purple-700",
countColor: "text-utility-purple-700",
delta: "+2",
},
{
source: 'WALK_IN',
label: 'Walk-ins',
icon: 'W',
iconBg: 'bg-utility-orange-50',
iconText: 'text-utility-orange-700',
countColor: 'text-utility-orange-700',
delta: '0',
source: "WALK_IN",
label: "Walk-ins",
icon: "W",
iconBg: "bg-utility-orange-50",
iconText: "text-utility-orange-700",
countColor: "text-utility-orange-700",
delta: "0",
},
];
export const SourceGrid = ({ leads, onSourceFilter, activeSource }: SourceGridProps) => {
const countBySource = (source: LeadSource): number =>
leads.filter((l) => l.leadSource === source).length;
const countBySource = (source: LeadSource): number => leads.filter((l) => l.leadSource === source).length;
const handleClick = (source: LeadSource) => {
if (activeSource === source) {
@@ -98,37 +97,25 @@ export const SourceGrid = ({ leads, onSourceFilter, activeSource }: SourceGridPr
type="button"
onClick={() => handleClick(config.source)}
className={cx(
'cursor-pointer rounded-xl border border-secondary bg-primary p-4 text-left transition hover:shadow-md',
isActive && 'ring-2 ring-brand',
"cursor-pointer rounded-xl border border-secondary bg-primary p-4 text-left transition hover:shadow-md",
isActive && "ring-2 ring-brand",
)}
>
<div className="mb-2 flex items-center gap-2">
<span
className={cx(
'flex size-6 items-center justify-center rounded text-xs font-bold',
config.iconBg,
config.iconText,
)}
>
<span className={cx("flex size-6 items-center justify-center rounded text-xs font-bold", config.iconBg, config.iconText)}>
{config.icon}
</span>
<span className="text-xs text-quaternary">{config.label}</span>
</div>
<div className="flex items-baseline gap-2">
<span className={cx('text-xl font-bold', config.countColor)}>
{count}
</span>
<span className={cx("text-xl font-bold", config.countColor)}>{count}</span>
<span
className={cx(
'text-xs',
config.delta.startsWith('+')
? 'text-success-primary'
: config.delta === '0'
? 'text-quaternary'
: 'text-error-primary',
"text-xs",
config.delta.startsWith("+") ? "text-success-primary" : config.delta === "0" ? "text-quaternary" : "text-error-primary",
)}
>
{config.delta === '0' ? 'same' : config.delta}
{config.delta === "0" ? "same" : config.delta}
</span>
</div>
</button>