Files
LynkedUpPro_CRM/src/modules/socialAdsEngine/screens/ComplianceCenter.jsx
T
2026-06-12 13:22:03 +05:30

133 lines
7.8 KiB
React

/**
* ComplianceCenter — pending creative/consent/campaign approvals, blocked claims,
* review-disclosure checks, and an AI audit + conversion-sync log (spec §8, §10.1).
* An unapproved campaign cannot publish and a risky message cannot send.
*/
import React from 'react';
import {
ShieldCheck, AlertTriangle, Ban, CheckCircle2, Clock, RefreshCw,
FileWarning, Wand2, ArrowUpRight,
} from 'lucide-react';
import { useSocialAds } from '../SocialAdsContext.jsx';
import { PlatformBadge, Card, SectionHeader, StatusChip, Btn } from '../components/ui.jsx';
const TYPE_LABEL = {
creative: 'Creative', consent_text: 'Consent text',
review_disclosure: 'Review disclosure', campaign: 'Campaign publish',
};
const SYNC_ICON = {
synced: { icon: CheckCircle2, color: 'text-emerald-500' },
queued: { icon: Clock, color: 'text-amber-500' },
failed: { icon: AlertTriangle, color: 'text-red-500' },
};
export default function ComplianceCenter() {
const { approvals, resolveApproval, conversionEvents, retryConversion } = useSocialAds();
const pending = approvals.filter(a => a.status === 'pending');
const blocked = approvals.filter(a => a.status === 'blocked');
const resolved = approvals.filter(a => a.status === 'approved');
return (
<div className="space-y-6">
<SectionHeader icon={ShieldCheck} title="Compliance Center" description="Approval workflow, blocked claims, consent versions, and the AI / conversion audit log. Nothing risky publishes or sends until resolved.">
<div className="flex items-center gap-2">
<StatusChip status="pending" label={`${pending.length} pending`} />
{blocked.length > 0 && <StatusChip status="blocked" label={`${blocked.length} blocked`} />}
</div>
</SectionHeader>
{/* Blocked first — must resolve */}
{blocked.length > 0 && (
<Card title="Blocked — resolve before publish" icon={Ban} subtitle="Risky claims detected by the compliance reviewer.">
<div className="space-y-2">
{blocked.map(a => <ApprovalRow key={a.id} item={a} onResolve={resolveApproval} />)}
</div>
</Card>
)}
{/* Pending queue */}
<Card title="Approval queue" icon={Clock} subtitle="Creative, consent text, review disclosure, and campaign publish approvals.">
{pending.length === 0
? <p className="text-sm text-zinc-500 py-4 text-center">Nothing pending all clear.</p>
: <div className="space-y-2">{pending.map(a => <ApprovalRow key={a.id} item={a} onResolve={resolveApproval} />)}</div>}
</Card>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
{/* Conversion sync log */}
<Card title="Conversion sync log" icon={ArrowUpRight} subtitle="Events sent back to ad platforms (deduped).">
<div className="space-y-1.5">
{conversionEvents.map(e => {
const meta = SYNC_ICON[e.syncStatus];
const Icon = meta.icon;
return (
<div key={e.id} className="flex items-center gap-2 px-2.5 py-2 rounded-lg bg-zinc-50 dark:bg-white/5">
<Icon size={14} className={meta.color} />
<PlatformBadge platformId={e.platform} size="sm" showName={false} />
<div className="min-w-0 flex-1">
<p className="text-xs font-semibold text-zinc-700 dark:text-zinc-200 truncate">{e.eventName}</p>
<p className="text-[10px] text-zinc-400 font-mono truncate">dedup: {e.dedupKey}</p>
</div>
{e.value > 0 && <span className="text-[11px] font-mono text-emerald-600 dark:text-emerald-400">${e.value.toLocaleString()}</span>}
{e.syncStatus === 'failed'
? <Btn size="sm" variant="danger" icon={RefreshCw} onClick={() => retryConversion(e.id)}>Retry</Btn>
: <StatusChip status={e.syncStatus} />}
</div>
);
})}
</div>
</Card>
{/* Recently resolved + AI audit */}
<Card title="AI audit & recent approvals" icon={Wand2} subtitle="Human corrections and resolved items.">
<div className="space-y-1.5">
{resolved.map(a => (
<div key={a.id} className="flex items-center gap-2 px-2.5 py-2 rounded-lg bg-zinc-50 dark:bg-white/5">
<CheckCircle2 size={14} className="text-emerald-500" />
<div className="min-w-0 flex-1">
<p className="text-xs font-semibold text-zinc-700 dark:text-zinc-200 truncate">{a.title}</p>
<p className="text-[10px] text-zinc-400">{TYPE_LABEL[a.type]} · approved by {a.reviewer}</p>
</div>
<StatusChip status="approved" />
</div>
))}
<div className="flex items-start gap-2 px-2.5 py-2 rounded-lg bg-purple-50 dark:bg-purple-500/10 mt-2">
<Wand2 size={14} className="text-purple-500 mt-0.5" />
<p className="text-[11px] text-zinc-600 dark:text-zinc-300">
AI guardrail summary: <b>2</b> creatives auto-flagged this week (1 scarcity, 1 price claim), <b>0</b> auto-published. All AI copy required human approval.
</p>
</div>
</div>
</Card>
</div>
</div>
);
}
function ApprovalRow({ item, onResolve }) {
const isBlocked = item.status === 'blocked';
return (
<div className={`flex flex-col sm:flex-row sm:items-center gap-3 p-3 rounded-xl ${isBlocked ? 'bg-red-50 dark:bg-red-500/10 border border-red-200 dark:border-red-500/20' : 'bg-zinc-50 dark:bg-white/5'}`}>
<div className="flex items-start gap-2 min-w-0 flex-1">
<FileWarning size={16} className={isBlocked ? 'text-red-500 mt-0.5' : 'text-amber-500 mt-0.5'} />
<div className="min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<span className="text-[10px] font-bold uppercase tracking-wide px-1.5 py-0.5 rounded bg-zinc-200 dark:bg-white/10 text-zinc-500">{TYPE_LABEL[item.type]}</span>
<h4 className="font-semibold text-sm text-zinc-800 dark:text-zinc-100">{item.title}</h4>
</div>
{item.flags?.length > 0 && (
<ul className="mt-1 space-y-0.5">
{item.flags.map((f, i) => <li key={i} className="text-[11px] text-amber-600 dark:text-amber-400/80 flex items-start gap-1"><AlertTriangle size={11} className="mt-0.5 shrink-0" />{f}</li>)}
</ul>
)}
</div>
</div>
<div className="flex items-center gap-2 shrink-0">
<Btn size="sm" variant="success" icon={CheckCircle2} disabled={isBlocked} onClick={() => onResolve(item.id, 'approved')}>Approve</Btn>
<Btn size="sm" variant="danger" icon={Ban} onClick={() => onResolve(item.id, 'blocked')}>{isBlocked ? 'Keep blocked' : 'Block'}</Btn>
</div>
</div>
);
}