Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions eslint.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,7 @@ export default tseslint.config(
],
rules: {
"no-console": "off",
"react-hooks/rules-of-hooks": "off",
},
},
);
6 changes: 4 additions & 2 deletions src/components/contacts/ContactQuickView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,11 +31,13 @@ interface ContactQuickViewProps {
export const ContactQuickView: React.FC<ContactQuickViewProps> = ({
contact, isOpen, onClose, onEdit, onDelete, onOpenChat
}) => {
// Hooks ANTES do early return (Rules of Hooks)
const health = useMemo(() => contact ? calculateContactHealth(contact) : 0, [contact]);

if (!contact) return null;

const typeCfg = contact.contact_type ? CONTACT_TYPE_CONFIG[contact.contact_type] : null;
const initials = contact.name.split(' ').map(n => n[0]).join('').toUpperCase().slice(0, 2);
const health = useMemo(() => contact ? calculateContactHealth(contact) : 0, [contact]);
const healthColor = getHealthColor(health);

return (
Expand Down Expand Up @@ -282,4 +284,4 @@ export const ContactQuickView: React.FC<ContactQuickViewProps> = ({
</SheetContent>
</Sheet>
);
};
};
3 changes: 2 additions & 1 deletion src/components/ui/mobile-components.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -191,6 +191,7 @@ export function PullToRefresh({ onRefresh, children }: PullToRefreshProps) {
const [isRefreshing, setIsRefreshing] = React.useState(false);
const pullY = useMotionValue(0);
const pullProgress = useTransform(pullY, [0, 80], [0, 1]);
const pullRotate = useTransform(pullProgress, [0, 1], [0, 180]);

const handleDragEnd = async () => {
if (pullY.get() > 80) {
Expand Down Expand Up @@ -226,7 +227,7 @@ export function PullToRefresh({ onRefresh, children }: PullToRefreshProps) {
/>
) : (
<motion.div
style={{ rotate: useTransform(pullProgress, [0, 1], [0, 180]) }}
style={{ rotate: pullRotate }}
className="w-6 h-6"
>
Expand Down
11 changes: 11 additions & 0 deletions src/features/inbox/components/SLAIndicatorForContact.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -92,10 +92,21 @@ function SLATooltipContent({ applicable, isLoading, fallbackFr, fallbackRes, pri
/**
* Resolves the applicable SLA for the contact (hierarchy: contact > company > job_title > contact_type > queue > agent)
* and renders the SLAIndicator with those minutes. Tooltip explains the matched rule and fallback reason.
*
* Wrapper externo: faz early return se não há contact. O Inner é onde os hooks rodam.
*/
export function SLAIndicatorForContact({ conversation, compact, className }: SLAIndicatorForContactProps) {
const contact = conversation.contact;
if (!contact) return null;
return <SLAIndicatorForContactInner conversation={conversation} compact={compact} className={className} />;
}

/**
* Inner component — assume que contact existe (garantido pelo wrapper acima).
* Todos os hooks ficam aqui, sem early returns possíveis ANTES deles.
*/
function SLAIndicatorForContactInner({ conversation, compact, className }: SLAIndicatorForContactProps) {
const contact = conversation.contact;

const { data: applicable, isLoading } = useApplicableSLA({
contactId: contact.id,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -219,8 +219,20 @@ const SCOPE_LABELS: Record<SLAScope, string> = {
none: 'Sem SLA',
};

// Tipos derivados do hook (timeline é NonNullable porque o wrapper já fez o early return)
type TimelineData = NonNullable<ReturnType<typeof useConversationSLATimeline>['data']>;
type ApplicableSLAData = ReturnType<typeof useApplicableSLA>['data'];

/**
* Wrapper externo: roda hooks de estado/fetch + early returns (skeleton/empty state).
* Quando timeline está pronto e tem dados, delega ao Inner que tem o useMemo + useSLAAlerts.
*
* Esse split é necessário pra cumprir Rules of Hooks (hooks após early return = bug):
* - Wrapper: useMemo, useState (×3), useEffect, useConversationSLATimeline, useApplicableSLA
* - Inner: useMemo (handleOpenConversation), useSLAAlerts
*/
export function SLATimelineSection({ conversation }: SLATimelineSectionProps) {
const { contact, queue, assignedTo } = conversation;
const { contact } = conversation;
const remoteJid = useMemo(
() => (contact.phone ? `${contact.phone}@s.whatsapp.net` : null),
[contact.phone]
Expand All @@ -242,8 +254,8 @@ export function SLATimelineSection({ conversation }: SLATimelineSectionProps) {

const { data: timeline, isLoading } = useConversationSLATimeline(remoteJid, contact.id);

const slaQueueId = scope === 'current' || scope === 'queue' ? (queue?.id ?? null) : null;
const slaAgentId = scope === 'current' || scope === 'agent' ? (assignedTo?.id ?? null) : null;
const slaQueueId = scope === 'current' || scope === 'queue' ? (conversation.queue?.id ?? null) : null;
const slaAgentId = scope === 'current' || scope === 'agent' ? (conversation.assignedTo?.id ?? null) : null;
const { data: sla } = useApplicableSLA({
contactId: scope === 'none' ? undefined : contact.id,
company: scope === 'none' ? null : (contact.company ?? null),
Expand Down Expand Up @@ -280,6 +292,46 @@ export function SLATimelineSection({ conversation }: SLATimelineSectionProps) {
);
}

return (
<SLATimelineSectionInner
conversation={conversation}
timeline={timeline}
sla={sla}
statusFilter={statusFilter}
setStatusFilter={setStatusFilter}
periodFilter={periodFilter}
setPeriodFilter={setPeriodFilter}
scope={scope}
setScope={setScope}
remoteJid={remoteJid}
/>
);
}

interface SLATimelineSectionInnerProps {
conversation: Conversation;
timeline: TimelineData;
sla: ApplicableSLAData;
statusFilter: SLAStatus[];
setStatusFilter: (filter: SLAStatus[]) => void;
periodFilter: PeriodFilter;
setPeriodFilter: (filter: PeriodFilter) => void;
scope: SLAScope;
setScope: (scope: SLAScope) => void;
remoteJid: string | null;
}

/**
* Inner component: assume timeline existe e tem dados (garantido pelo wrapper).
* Hooks aqui (useMemo + useSLAAlerts) rodam SEMPRE — sem early returns possíveis ANTES.
*/
function SLATimelineSectionInner({
conversation, timeline, sla,
statusFilter, setStatusFilter, periodFilter, setPeriodFilter, scope, setScope,
remoteJid,
}: SLATimelineSectionInnerProps) {
const { contact, queue, assignedTo } = conversation;

const firstResponseLimit = sla?.firstResponseMinutes ?? 5;
const resolutionLimit = sla?.resolutionMinutes ?? 60;

Expand Down
Loading