|
|
@@ -8,21 +8,11 @@
|
|
|
UserRoundCog,
|
|
|
UserX
|
|
|
} from 'lucide-svelte';
|
|
|
- import { Chart, Svg, Axis, Bar, Line, Spline, Highlight, Group } from 'layerchart';
|
|
|
- import { scaleTime, scaleLinear, scaleBand } from 'd3-scale';
|
|
|
- import { format, addDays } from 'date-fns';
|
|
|
- import { ptBR } from 'date-fns/locale';
|
|
|
+ import { Chart, Svg, Axis, Spline, Highlight } from 'layerchart';
|
|
|
+ import { format } from 'date-fns';
|
|
|
import { goto } from '$app/navigation';
|
|
|
-
|
|
|
- import {
|
|
|
- mockKpis,
|
|
|
- mockPriorityQueue,
|
|
|
- mockRadarData,
|
|
|
- mockVolumeData,
|
|
|
- mockSentimentDistribution,
|
|
|
- mockAspectsData,
|
|
|
- mockAspectsDrilldown
|
|
|
- } from '$lib/core/models/mock-data.js';
|
|
|
+ import { onMount } from 'svelte';
|
|
|
+ import { api } from '$lib/core/api/client.js';
|
|
|
|
|
|
function getSentimentLabel(sentimentScore) {
|
|
|
if (sentimentScore >= 0.35) return 'Positivo';
|
|
|
@@ -31,39 +21,25 @@
|
|
|
}
|
|
|
|
|
|
function navigateToAnalytics(aspect, tone) {
|
|
|
- const aspectId = aspect.toLowerCase().normalize("NFD").replace(/[\u0300-\u036f]/g, "");
|
|
|
+ const aspectId = aspect
|
|
|
+ .toLowerCase()
|
|
|
+ .normalize('NFD')
|
|
|
+ .replace(/[̀-ͯ]/g, '');
|
|
|
goto(`/dashboard/analytics?aspect=${aspectId}&sentiment=${tone}#aspects-panel`);
|
|
|
}
|
|
|
|
|
|
- let selectedAspectDrilldown = $state(null);
|
|
|
-
|
|
|
+ // ── Filtros (enviados como query params ao backend) ────────────────────────
|
|
|
const periodOptions = [
|
|
|
- { id: 'today', label: 'Hoje (24h)', multiplier: 0.9 },
|
|
|
- { id: 'yesterday', label: 'Ontem', multiplier: 0.7 },
|
|
|
- { id: 'week', label: 'Últimos 7 dias', multiplier: 1.45 }
|
|
|
- ];
|
|
|
-
|
|
|
- const unitOptions = [
|
|
|
- { id: 'all', label: 'Todas as unidades', multiplier: 1 },
|
|
|
- { id: 'flagship', label: 'Loja Flagship', multiplier: 1.35 },
|
|
|
- { id: 'franquias', label: 'Franquias', multiplier: 0.75 },
|
|
|
- { id: 'pop-up', label: 'Pop-up Stores', multiplier: 1.1 },
|
|
|
- { id: 'digital', label: 'Operação Digital', multiplier: 1.6 }
|
|
|
- ];
|
|
|
-
|
|
|
- const areaOptions = [
|
|
|
- { id: 'all', label: 'Todas as áreas', multiplier: 1 },
|
|
|
- { id: 'atendimento', label: 'Atendimento', multiplier: 1.25 },
|
|
|
- { id: 'produto', label: 'Produto', multiplier: 0.8 },
|
|
|
- { id: 'logistica', label: 'Logística & Entrega', multiplier: 1.3 },
|
|
|
- { id: 'marketing', label: 'Marketing & Campanhas', multiplier: 1.4 }
|
|
|
+ { id: 'today', label: 'Hoje (24h)' },
|
|
|
+ { id: 'yesterday', label: 'Ontem' },
|
|
|
+ { id: 'week', label: 'Últimos 7 dias' }
|
|
|
];
|
|
|
|
|
|
const sentimentFilterOptions = [
|
|
|
- { id: 'all', label: 'Todos', multiplier: 1, weights: { positive: 1, neutral: 1, negative: 1 } },
|
|
|
- { id: 'positive', label: 'Positivo', multiplier: 1.35, weights: { positive: 1.5, neutral: 0.7, negative: 0.4 } },
|
|
|
- { id: 'neutral', label: 'Neutro', multiplier: 0.95, weights: { positive: 0.8, neutral: 1.3, negative: 0.8 } },
|
|
|
- { id: 'negative', label: 'Negativo', multiplier: 0.6, weights: { positive: 0.4, neutral: 0.7, negative: 1.7 } }
|
|
|
+ { id: 'all', label: 'Todos' },
|
|
|
+ { id: 'positive', label: 'Positivo' },
|
|
|
+ { id: 'neutral', label: 'Neutro' },
|
|
|
+ { id: 'negative', label: 'Negativo' }
|
|
|
];
|
|
|
|
|
|
const volumeViewOptions = [
|
|
|
@@ -71,230 +47,149 @@
|
|
|
{ id: 'day', label: 'Por Dia', format: 'dd/MM' }
|
|
|
];
|
|
|
|
|
|
- let selectedPeriod = $state(periodOptions[0].id);
|
|
|
- let selectedUnit = $state(unitOptions[0].id);
|
|
|
- let selectedArea = $state(areaOptions[0].id);
|
|
|
- let selectedSentimentFilter = $state(sentimentFilterOptions[0].id);
|
|
|
- let volumeView = $state(volumeViewOptions[0].id);
|
|
|
+ let selectedPeriod = $state('week');
|
|
|
+ let selectedSentimentFilter = $state('all');
|
|
|
+ let volumeView = $state('day');
|
|
|
let queueTab = $state('all');
|
|
|
|
|
|
- function openAspectDrilldown(aspect, tone) {
|
|
|
- selectedAspectDrilldown = { aspect, tone };
|
|
|
- }
|
|
|
-
|
|
|
- function clearAspectDrilldown() {
|
|
|
- selectedAspectDrilldown = null;
|
|
|
+ // ── Dados vindos da API ─────────────────────────────────────────────────────
|
|
|
+ let overview = $state(null);
|
|
|
+ let slaLive = $state([]);
|
|
|
+ let loading = $state(true);
|
|
|
+ let loadError = $state('');
|
|
|
+
|
|
|
+ async function loadDashboard() {
|
|
|
+ loading = true;
|
|
|
+ loadError = '';
|
|
|
+ try {
|
|
|
+ const [overviewData, slaData] = await Promise.all([
|
|
|
+ api.get('/v1/dashboard/overview', {
|
|
|
+ query: {
|
|
|
+ period: selectedPeriod,
|
|
|
+ sentiment: selectedSentimentFilter,
|
|
|
+ volume_view: volumeView
|
|
|
+ }
|
|
|
+ }),
|
|
|
+ api.get('/v1/sla/live-status').catch(() => ({ items: [] }))
|
|
|
+ ]);
|
|
|
+ overview = overviewData;
|
|
|
+ slaLive = slaData?.items ?? [];
|
|
|
+ } catch (err) {
|
|
|
+ loadError = err?.message ?? 'Falha ao carregar o dashboard.';
|
|
|
+ } finally {
|
|
|
+ loading = false;
|
|
|
+ }
|
|
|
}
|
|
|
|
|
|
- const drilldownItems = $derived(
|
|
|
- selectedAspectDrilldown
|
|
|
- ? (mockAspectsDrilldown[selectedAspectDrilldown.aspect]?.[selectedAspectDrilldown.tone] ?? []).slice(
|
|
|
- 0,
|
|
|
- 6
|
|
|
- )
|
|
|
- : []
|
|
|
- );
|
|
|
-
|
|
|
- const maxDrilldownValue = $derived(
|
|
|
- drilldownItems.length > 0 ? Math.max(1, ...drilldownItems.map((item) => item.value)) : 1
|
|
|
- );
|
|
|
-
|
|
|
- const drilldownTitle = $derived(
|
|
|
- selectedAspectDrilldown
|
|
|
- ? `${selectedAspectDrilldown.aspect} • ${
|
|
|
- selectedAspectDrilldown.tone === 'positive' ? 'Pontos Positivos' :
|
|
|
- selectedAspectDrilldown.tone === 'neutral' ? 'Pontos Neutros' : 'Pontos Negativos'
|
|
|
- }`
|
|
|
- : 'Distribuição de Aspectos'
|
|
|
- );
|
|
|
-
|
|
|
- const selectedFilterContext = $derived({
|
|
|
- period: periodOptions.find((opt) => opt.id === selectedPeriod) ?? periodOptions[0],
|
|
|
- unit: unitOptions.find((opt) => opt.id === selectedUnit) ?? unitOptions[0],
|
|
|
- area: areaOptions.find((opt) => opt.id === selectedArea) ?? areaOptions[0],
|
|
|
- sentiment: sentimentFilterOptions.find((opt) => opt.id === selectedSentimentFilter) ?? sentimentFilterOptions[0]
|
|
|
+ onMount(loadDashboard);
|
|
|
+
|
|
|
+ // Recarrega ao mudar filtros (a 1ª execução já é feita no onMount).
|
|
|
+ let firstRun = true;
|
|
|
+ $effect(() => {
|
|
|
+ // leitura para registrar dependências reativas
|
|
|
+ void selectedPeriod;
|
|
|
+ void selectedSentimentFilter;
|
|
|
+ void volumeView;
|
|
|
+ if (firstRun) {
|
|
|
+ firstRun = false;
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ loadDashboard();
|
|
|
});
|
|
|
|
|
|
- const combinedMultiplier = $derived(
|
|
|
- selectedFilterContext.period.multiplier *
|
|
|
- selectedFilterContext.unit.multiplier *
|
|
|
- selectedFilterContext.area.multiplier
|
|
|
- );
|
|
|
-
|
|
|
- const sentimentWeights = $derived(selectedFilterContext.sentiment.weights);
|
|
|
-
|
|
|
- const kpiDefinitions = [
|
|
|
+ // ── KPIs ────────────────────────────────────────────────────────────────────
|
|
|
+ const kpiCards = [
|
|
|
{
|
|
|
+ key: 'registeredUsers',
|
|
|
title: 'Usuários Cadastrados',
|
|
|
icon: Users,
|
|
|
color: 'text-emerald-600 dark:text-emerald-400',
|
|
|
bg: 'bg-emerald-50 dark:bg-emerald-400/10',
|
|
|
border: 'border-emerald-200 dark:border-emerald-400/20',
|
|
|
- compute: ({ multiplier }) => Math.max(5, Math.round(mockKpis.newPeople.new * multiplier)),
|
|
|
- format: (value) => value.toLocaleString('pt-BR')
|
|
|
+ format: (v) => Number(v ?? 0).toLocaleString('pt-BR')
|
|
|
},
|
|
|
{
|
|
|
+ key: 'activeAgents',
|
|
|
title: 'Atendentes Ativos',
|
|
|
icon: UserRoundCog,
|
|
|
color: 'text-purple-600 dark:text-purple-400',
|
|
|
bg: 'bg-purple-50 dark:bg-purple-400/10',
|
|
|
border: 'border-purple-200 dark:border-purple-400/20',
|
|
|
- compute: ({ multiplier }) => Math.max(1, Math.round(mockKpis.activeService.value * (0.6 + multiplier / 1.5))),
|
|
|
- format: (value) => value.toLocaleString('pt-BR')
|
|
|
+ format: (v) => Number(v ?? 0).toLocaleString('pt-BR')
|
|
|
},
|
|
|
{
|
|
|
+ key: 'totalConversations',
|
|
|
title: 'Total de conversas',
|
|
|
icon: MessageSquare,
|
|
|
color: 'text-sky-600 dark:text-sky-400',
|
|
|
bg: 'bg-sky-50 dark:bg-sky-400/10',
|
|
|
border: 'border-sky-200 dark:border-sky-400/20',
|
|
|
- compute: ({ multiplier }) => Math.max(0, Math.round(mockKpis.whatsappMessages.current * (multiplier * 0.9 + selectedFilterContext.sentiment.multiplier))),
|
|
|
- format: (value) => value.toLocaleString('pt-BR')
|
|
|
+ format: (v) => Number(v ?? 0).toLocaleString('pt-BR')
|
|
|
},
|
|
|
{
|
|
|
+ key: 'generalSentimentScore',
|
|
|
title: 'Sentimento Geral',
|
|
|
icon: ThumbsUp,
|
|
|
color: 'text-amber-600 dark:text-amber-400',
|
|
|
bg: 'bg-amber-50 dark:bg-amber-400/10',
|
|
|
border: 'border-amber-200 dark:border-amber-400/20',
|
|
|
- compute: ({ sentimentBias, multiplier }) => {
|
|
|
- const base = mockKpis.generalSentiment.value * sentimentBias + (multiplier - 1) * 0.4;
|
|
|
- const adjusted = Math.max(-1, Math.min(1, base));
|
|
|
- return adjusted;
|
|
|
- },
|
|
|
- format: (value) => getSentimentLabel(value)
|
|
|
+ format: (v) => getSentimentLabel(Number(v ?? 0))
|
|
|
},
|
|
|
{
|
|
|
+ key: 'unregisteredUsers',
|
|
|
title: 'Usuários Não Cadastrados',
|
|
|
icon: UserX,
|
|
|
color: 'text-indigo-600 dark:text-indigo-400',
|
|
|
bg: 'bg-indigo-50 dark:bg-indigo-400/10',
|
|
|
border: 'border-indigo-200 dark:border-indigo-400/20',
|
|
|
- compute: ({ multiplier }) => Math.max(1000, Math.round(mockKpis.crmLines.total * (1 + (1 - multiplier) / 2))),
|
|
|
- format: (value) => value.toLocaleString('pt-BR')
|
|
|
+ format: (v) => Number(v ?? 0).toLocaleString('pt-BR')
|
|
|
}
|
|
|
];
|
|
|
|
|
|
const kpis = $derived(
|
|
|
- kpiDefinitions.map((kpi) => ({
|
|
|
- ...kpi,
|
|
|
- value: kpi.format(
|
|
|
- kpi.compute({
|
|
|
- multiplier: combinedMultiplier,
|
|
|
- sentimentBias: selectedFilterContext.sentiment.multiplier
|
|
|
- })
|
|
|
- )
|
|
|
+ kpiCards.map((card) => ({
|
|
|
+ ...card,
|
|
|
+ value: card.format(overview?.kpis?.[card.key])
|
|
|
}))
|
|
|
);
|
|
|
|
|
|
- const aspectsData = $derived(
|
|
|
- mockAspectsData.map((aspect) => ({
|
|
|
- ...aspect,
|
|
|
- positive: Math.round(aspect.positive * sentimentWeights.positive * combinedMultiplier),
|
|
|
- neutral: Math.round(aspect.neutral * sentimentWeights.neutral * combinedMultiplier * 0.95),
|
|
|
- negative: Math.round(aspect.negative * sentimentWeights.negative * combinedMultiplier)
|
|
|
- }))
|
|
|
+ // ── Fila priorizada (overview.priorityQueue já traz lastMessage e slaStatus) ─
|
|
|
+ const queueAll = $derived(
|
|
|
+ (overview?.priorityQueue ?? []).map((item) => {
|
|
|
+ const isSeller = String(item.status ?? '')
|
|
|
+ .toLowerCase()
|
|
|
+ .includes('vendedora');
|
|
|
+ return { ...item, isSellerFault: isSeller, isClientFault: !isSeller };
|
|
|
+ })
|
|
|
);
|
|
|
|
|
|
- const baseDailyVolume = (() => {
|
|
|
- const grouped = new Map();
|
|
|
- for (const record of mockVolumeData) {
|
|
|
- const key = `${record.date.getFullYear()}-${record.date.getMonth()}-${record.date.getDate()}`;
|
|
|
- if (!grouped.has(key)) {
|
|
|
- grouped.set(key, { date: new Date(record.date.getFullYear(), record.date.getMonth(), record.date.getDate()), whatsapp: 0 });
|
|
|
- }
|
|
|
- grouped.get(key).whatsapp += record.whatsapp;
|
|
|
- }
|
|
|
-
|
|
|
- let series = Array.from(grouped.values()).sort((a, b) => a.date - b.date);
|
|
|
-
|
|
|
- if (series.length <= 1) {
|
|
|
- const seedDate = series[0]?.date ?? new Date();
|
|
|
- const seedValue = series[0]?.whatsapp ?? 120;
|
|
|
- series = Array.from({ length: 5 }, (_, idx) => {
|
|
|
- const offset = 4 - idx;
|
|
|
- return {
|
|
|
- date: addDays(seedDate, -offset),
|
|
|
- whatsapp: Math.max(5, Math.round(seedValue * (0.5 + idx * 0.2)))
|
|
|
- };
|
|
|
- });
|
|
|
- }
|
|
|
-
|
|
|
- return series;
|
|
|
- })();
|
|
|
-
|
|
|
- const selectedVolumeOption = $derived(volumeViewOptions.find((opt) => opt.id === volumeView) ?? volumeViewOptions[0]);
|
|
|
-
|
|
|
- const volumeData = $derived.by(() => {
|
|
|
- const base = volumeView === 'day' ? baseDailyVolume : mockVolumeData;
|
|
|
- return base.map((entry, idx) => ({
|
|
|
- date: entry.date,
|
|
|
- whatsapp: Math.max(0, Math.round(entry.whatsapp * (combinedMultiplier + idx * 0.02)))
|
|
|
- }));
|
|
|
- });
|
|
|
-
|
|
|
- const maxVolume = $derived(Math.max(60, ...volumeData.map((item) => item.whatsapp)));
|
|
|
-
|
|
|
- const filteredQueueItems = $derived.by(() => {
|
|
|
- const periodMult = selectedFilterContext.period.multiplier;
|
|
|
- const sentimentMult = selectedFilterContext.sentiment.multiplier;
|
|
|
- const areaMult = selectedFilterContext.area.multiplier;
|
|
|
-
|
|
|
- return mockPriorityQueue.map((item) => {
|
|
|
- const weight = periodMult * sentimentMult * areaMult;
|
|
|
- return {
|
|
|
- ...item,
|
|
|
- impact: Math.round(item.impact * weight),
|
|
|
- chance: Math.min(100, Math.max(10, Math.round(item.chance * sentimentMult))),
|
|
|
- isSellerFault: item.status.toLowerCase().includes('vendedora'),
|
|
|
- isClientFault: !item.status.toLowerCase().includes('vendedora')
|
|
|
- };
|
|
|
- });
|
|
|
- });
|
|
|
-
|
|
|
const queueItems = $derived.by(() => {
|
|
|
- if (queueTab === 'seller') return filteredQueueItems.filter(i => i.isSellerFault);
|
|
|
- if (queueTab === 'client') return filteredQueueItems.filter(i => i.isClientFault);
|
|
|
- return filteredQueueItems;
|
|
|
+ if (queueTab === 'seller') return queueAll.filter((i) => i.isSellerFault);
|
|
|
+ if (queueTab === 'client') return queueAll.filter((i) => i.isClientFault);
|
|
|
+ return queueAll;
|
|
|
});
|
|
|
|
|
|
const queueCounts = $derived({
|
|
|
- all: filteredQueueItems.length,
|
|
|
- seller: filteredQueueItems.filter(i => i.isSellerFault).length,
|
|
|
- client: filteredQueueItems.filter(i => i.isClientFault).length
|
|
|
+ all: queueAll.length,
|
|
|
+ seller: queueAll.filter((i) => i.isSellerFault).length,
|
|
|
+ client: queueAll.filter((i) => i.isClientFault).length
|
|
|
});
|
|
|
|
|
|
- const totalPotential = $derived(
|
|
|
- filteredQueueItems.reduce((sum, item) => sum + item.impact, 0)
|
|
|
- );
|
|
|
+ const totalPotential = $derived(queueAll.reduce((sum, item) => sum + (item.impact ?? 0), 0));
|
|
|
+
|
|
|
+ // SLA ao vivo: nº de departamentos com SLA estourado (de /v1/sla/live-status).
|
|
|
+ const slaBreachCount = $derived(slaLive.filter((s) => s.liveStatus === 'breach').length);
|
|
|
|
|
|
+ // ── Radar (humor da base) ────────────────────────────────────────────────────
|
|
|
const radarRadius = 80;
|
|
|
const radarCenter = 100;
|
|
|
-
|
|
|
- const radarData = $derived.by(() => {
|
|
|
- const periodBias = selectedFilterContext.period.multiplier;
|
|
|
- const unitBias = selectedFilterContext.unit.multiplier;
|
|
|
- const areaBias = selectedFilterContext.area.multiplier;
|
|
|
- const sentimentBias = selectedFilterContext.sentiment.multiplier;
|
|
|
- return mockRadarData.map((metric, idx) => {
|
|
|
- const wave = Math.sin((idx + 1) * periodBias) * 8;
|
|
|
- const adjusted = Math.max(
|
|
|
- 5,
|
|
|
- Math.min(
|
|
|
- 100,
|
|
|
- Math.round(metric.value * sentimentBias * (0.7 + unitBias / 3 + areaBias / 4) + wave)
|
|
|
- )
|
|
|
- );
|
|
|
- return { ...metric, value: adjusted };
|
|
|
- });
|
|
|
- });
|
|
|
-
|
|
|
- const angleStep = $derived((Math.PI * 2) / radarData.length);
|
|
|
+ const radarData = $derived(overview?.radarData ?? []);
|
|
|
+ const angleStep = $derived(radarData.length > 0 ? (Math.PI * 2) / radarData.length : 0);
|
|
|
|
|
|
const radarPoints = $derived(
|
|
|
radarData.map((d, i) => {
|
|
|
const angle = i * angleStep - Math.PI / 2;
|
|
|
- const r = (d.value / 100) * radarRadius;
|
|
|
+ const r = (Number(d.value ?? 0) / 100) * radarRadius;
|
|
|
return {
|
|
|
...d,
|
|
|
x: radarCenter + r * Math.cos(angle),
|
|
|
@@ -310,15 +205,66 @@
|
|
|
const radarPath = $derived(radarPoints.map((p) => `${p.x},${p.y}`).join(' '));
|
|
|
|
|
|
const gridLevels = $derived(
|
|
|
- [0.2, 0.4, 0.6, 0.8, 1].map((level) => {
|
|
|
- return radarData
|
|
|
+ [0.2, 0.4, 0.6, 0.8, 1].map((level) =>
|
|
|
+ radarData
|
|
|
.map((_, i) => {
|
|
|
const angle = i * angleStep - Math.PI / 2;
|
|
|
const r = level * radarRadius;
|
|
|
return `${radarCenter + r * Math.cos(angle)},${radarCenter + r * Math.sin(angle)}`;
|
|
|
})
|
|
|
- .join(' ');
|
|
|
- })
|
|
|
+ .join(' ')
|
|
|
+ )
|
|
|
+ );
|
|
|
+
|
|
|
+ // ── Volume por canal ──────────────────────────────────────────────────────────
|
|
|
+ const selectedVolumeOption = $derived(
|
|
|
+ volumeViewOptions.find((opt) => opt.id === volumeView) ?? volumeViewOptions[1]
|
|
|
+ );
|
|
|
+
|
|
|
+ const volumeData = $derived(
|
|
|
+ (overview?.volumeData ?? []).map((entry) => ({
|
|
|
+ date: new Date(entry.date),
|
|
|
+ whatsapp: Number(entry.whatsapp ?? 0)
|
|
|
+ }))
|
|
|
+ );
|
|
|
+
|
|
|
+ const maxVolume = $derived(Math.max(60, ...volumeData.map((item) => item.whatsapp), 0));
|
|
|
+
|
|
|
+ // ── Aspectos + drilldown ────────────────────────────────────────────────────
|
|
|
+ const aspectsData = $derived(overview?.aspectsData ?? []);
|
|
|
+
|
|
|
+ let selectedAspectDrilldown = $state(null);
|
|
|
+ function openAspectDrilldown(aspect, tone) {
|
|
|
+ selectedAspectDrilldown = { aspect, tone };
|
|
|
+ }
|
|
|
+ function clearAspectDrilldown() {
|
|
|
+ selectedAspectDrilldown = null;
|
|
|
+ }
|
|
|
+
|
|
|
+ const drilldownItems = $derived(
|
|
|
+ selectedAspectDrilldown
|
|
|
+ ? (
|
|
|
+ overview?.aspectsDrilldown?.[selectedAspectDrilldown.aspect]?.[
|
|
|
+ selectedAspectDrilldown.tone
|
|
|
+ ] ?? []
|
|
|
+ ).slice(0, 6)
|
|
|
+ : []
|
|
|
+ );
|
|
|
+
|
|
|
+ const maxDrilldownValue = $derived(
|
|
|
+ drilldownItems.length > 0 ? Math.max(1, ...drilldownItems.map((item) => item.value)) : 1
|
|
|
+ );
|
|
|
+
|
|
|
+ const drilldownTitle = $derived(
|
|
|
+ selectedAspectDrilldown
|
|
|
+ ? `${selectedAspectDrilldown.aspect} • ${
|
|
|
+ selectedAspectDrilldown.tone === 'positive'
|
|
|
+ ? 'Pontos Positivos'
|
|
|
+ : selectedAspectDrilldown.tone === 'neutral'
|
|
|
+ ? 'Pontos Neutros'
|
|
|
+ : 'Pontos Negativos'
|
|
|
+ }`
|
|
|
+ : 'Distribuição de Aspectos'
|
|
|
);
|
|
|
</script>
|
|
|
|
|
|
@@ -342,28 +288,6 @@
|
|
|
{/each}
|
|
|
</select>
|
|
|
</div>
|
|
|
- <div class="flex items-center gap-2">
|
|
|
- <span class="font-medium text-slate-600 dark:text-slate-400">Unidade:</span>
|
|
|
- <select
|
|
|
- class="rounded-lg border border-slate-300 bg-slate-50 px-3 py-1.5 text-slate-900 transition-colors focus:border-indigo-500 focus:ring-1 focus:ring-indigo-500 focus:outline-none dark:border-slate-700 dark:bg-slate-900 dark:text-slate-200"
|
|
|
- bind:value={selectedUnit}
|
|
|
- >
|
|
|
- {#each unitOptions as option}
|
|
|
- <option value={option.id}>{option.label}</option>
|
|
|
- {/each}
|
|
|
- </select>
|
|
|
- </div>
|
|
|
- <div class="flex items-center gap-2">
|
|
|
- <span class="font-medium text-slate-600 dark:text-slate-400">Área:</span>
|
|
|
- <select
|
|
|
- class="rounded-lg border border-slate-300 bg-slate-50 px-3 py-1.5 text-slate-900 transition-colors focus:border-indigo-500 focus:ring-1 focus:ring-indigo-500 focus:outline-none dark:border-slate-700 dark:bg-slate-900 dark:text-slate-200"
|
|
|
- bind:value={selectedArea}
|
|
|
- >
|
|
|
- {#each areaOptions as option}
|
|
|
- <option value={option.id}>{option.label}</option>
|
|
|
- {/each}
|
|
|
- </select>
|
|
|
- </div>
|
|
|
<div class="flex items-center gap-2">
|
|
|
<span class="font-medium text-slate-600 dark:text-slate-400">Sentimento:</span>
|
|
|
<select
|
|
|
@@ -375,8 +299,19 @@
|
|
|
{/each}
|
|
|
</select>
|
|
|
</div>
|
|
|
+ {#if loading}
|
|
|
+ <span class="text-xs text-slate-400">Carregando…</span>
|
|
|
+ {/if}
|
|
|
</div>
|
|
|
|
|
|
+ {#if loadError}
|
|
|
+ <div
|
|
|
+ class="rounded-xl border border-red-200 bg-red-50 p-4 text-sm text-red-700 dark:border-red-500/20 dark:bg-red-500/10 dark:text-red-400"
|
|
|
+ >
|
|
|
+ {loadError}
|
|
|
+ </div>
|
|
|
+ {/if}
|
|
|
+
|
|
|
<!-- KPIs -->
|
|
|
<div class="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-5">
|
|
|
{#each kpis as kpi}
|
|
|
@@ -391,7 +326,11 @@
|
|
|
<Icon size={20} strokeWidth={2.5} />
|
|
|
</div>
|
|
|
<div class="space-y-1">
|
|
|
- <div class="text-xs font-semibold uppercase tracking-wide text-slate-500 dark:text-slate-400">{kpi.title}</div>
|
|
|
+ <div
|
|
|
+ class="text-xs font-semibold uppercase tracking-wide text-slate-500 dark:text-slate-400"
|
|
|
+ >
|
|
|
+ {kpi.title}
|
|
|
+ </div>
|
|
|
<div class="text-2xl font-bold text-slate-900 dark:text-white">{kpi.value}</div>
|
|
|
</div>
|
|
|
</div>
|
|
|
@@ -419,6 +358,12 @@
|
|
|
class="rounded-md border border-red-200 bg-red-50 px-2.5 py-1 text-xs font-medium text-red-600 dark:border-red-500/20 dark:bg-red-500/10 dark:text-red-400"
|
|
|
>{queueCounts.all} fora da janela</span
|
|
|
>
|
|
|
+ {#if slaBreachCount > 0}
|
|
|
+ <span
|
|
|
+ class="rounded-md border border-orange-200 bg-orange-50 px-2.5 py-1 text-xs font-medium text-orange-700 dark:border-orange-500/20 dark:bg-orange-500/10 dark:text-orange-400"
|
|
|
+ >{slaBreachCount} SLA estourado</span
|
|
|
+ >
|
|
|
+ {/if}
|
|
|
<span
|
|
|
class="rounded-md border border-amber-200 bg-amber-50 px-2.5 py-1 text-xs font-medium text-amber-700 dark:border-amber-500/20 dark:bg-amber-500/10 dark:text-amber-400"
|
|
|
>Potencial: R$ {totalPotential.toLocaleString('pt-BR')}</span
|
|
|
@@ -431,19 +376,28 @@
|
|
|
>
|
|
|
<button
|
|
|
type="button"
|
|
|
- onclick={() => queueTab = 'all'}
|
|
|
- class="rounded-lg px-4 py-1.5 text-sm font-medium transition-colors {queueTab === 'all' ? 'border border-slate-300 bg-white text-slate-900 shadow-sm dark:border-slate-700 dark:bg-[#1e293b] dark:text-white' : 'border border-transparent bg-transparent text-slate-600 hover:border-slate-300 hover:text-slate-900 dark:text-slate-400 dark:hover:border-slate-700 dark:hover:text-white'}"
|
|
|
- >Todas ({queueCounts.all})</button>
|
|
|
+ onclick={() => (queueTab = 'all')}
|
|
|
+ class="rounded-lg px-4 py-1.5 text-sm font-medium transition-colors {queueTab === 'all'
|
|
|
+ ? 'border border-slate-300 bg-white text-slate-900 shadow-sm dark:border-slate-700 dark:bg-[#1e293b] dark:text-white'
|
|
|
+ : 'border border-transparent bg-transparent text-slate-600 hover:border-slate-300 hover:text-slate-900 dark:text-slate-400 dark:hover:border-slate-700 dark:hover:text-white'}"
|
|
|
+ >Todas ({queueCounts.all})</button
|
|
|
+ >
|
|
|
<button
|
|
|
type="button"
|
|
|
- onclick={() => queueTab = 'seller'}
|
|
|
- class="rounded-lg px-4 py-1.5 text-sm font-medium transition-colors {queueTab === 'seller' ? 'border border-slate-300 bg-white text-slate-900 shadow-sm dark:border-slate-700 dark:bg-[#1e293b] dark:text-white' : 'border border-transparent bg-transparent text-slate-600 hover:border-slate-300 hover:text-slate-900 dark:text-slate-400 dark:hover:border-slate-700 dark:hover:text-white'}"
|
|
|
- >Pela vendedora ({queueCounts.seller})</button>
|
|
|
+ onclick={() => (queueTab = 'seller')}
|
|
|
+ class="rounded-lg px-4 py-1.5 text-sm font-medium transition-colors {queueTab === 'seller'
|
|
|
+ ? 'border border-slate-300 bg-white text-slate-900 shadow-sm dark:border-slate-700 dark:bg-[#1e293b] dark:text-white'
|
|
|
+ : 'border border-transparent bg-transparent text-slate-600 hover:border-slate-300 hover:text-slate-900 dark:text-slate-400 dark:hover:border-slate-700 dark:hover:text-white'}"
|
|
|
+ >Pela vendedora ({queueCounts.seller})</button
|
|
|
+ >
|
|
|
<button
|
|
|
type="button"
|
|
|
- onclick={() => queueTab = 'client'}
|
|
|
- class="rounded-lg px-4 py-1.5 text-sm font-medium transition-colors {queueTab === 'client' ? 'border border-slate-300 bg-white text-slate-900 shadow-sm dark:border-slate-700 dark:bg-[#1e293b] dark:text-white' : 'border border-transparent bg-transparent text-slate-600 hover:border-slate-300 hover:text-slate-900 dark:text-slate-400 dark:hover:border-slate-700 dark:hover:text-white'}"
|
|
|
- >Pelo cliente ({queueCounts.client})</button>
|
|
|
+ onclick={() => (queueTab = 'client')}
|
|
|
+ class="rounded-lg px-4 py-1.5 text-sm font-medium transition-colors {queueTab === 'client'
|
|
|
+ ? 'border border-slate-300 bg-white text-slate-900 shadow-sm dark:border-slate-700 dark:bg-[#1e293b] dark:text-white'
|
|
|
+ : 'border border-transparent bg-transparent text-slate-600 hover:border-slate-300 hover:text-slate-900 dark:text-slate-400 dark:hover:border-slate-700 dark:hover:text-white'}"
|
|
|
+ >Pelo cliente ({queueCounts.client})</button
|
|
|
+ >
|
|
|
</div>
|
|
|
|
|
|
<div class="flex-1 space-y-4 overflow-y-auto p-4">
|
|
|
@@ -518,6 +472,11 @@
|
|
|
</div>
|
|
|
</div>
|
|
|
{/each}
|
|
|
+ {#if !loading && queueItems.length === 0}
|
|
|
+ <div class="py-10 text-center text-sm text-slate-400">
|
|
|
+ Nenhuma conversa na fila para os filtros atuais.
|
|
|
+ </div>
|
|
|
+ {/if}
|
|
|
</div>
|
|
|
</div>
|
|
|
|
|
|
@@ -635,34 +594,40 @@
|
|
|
</div>
|
|
|
|
|
|
<div class="h-full w-full flex-1">
|
|
|
- <Chart
|
|
|
- data={volumeData}
|
|
|
- x={d => d.date}
|
|
|
- y={d => d.whatsapp}
|
|
|
- yDomain={[0, maxVolume]}
|
|
|
- padding={{ top: 10, right: 10, bottom: 20, left: 30 }}
|
|
|
- >
|
|
|
- <Svg>
|
|
|
- <Axis
|
|
|
- placement="left"
|
|
|
- grid={{ class: 'stroke-slate-200 dark:stroke-slate-700', strokeDasharray: '2 2' }}
|
|
|
- class="fill-slate-500 text-xs dark:fill-slate-400"
|
|
|
- />
|
|
|
- <Axis
|
|
|
- placement="bottom"
|
|
|
- format={(d) => format(d, selectedVolumeOption.format)}
|
|
|
- class="fill-slate-500 text-xs dark:fill-slate-400"
|
|
|
- />
|
|
|
- <Spline stroke="#38bdf8" strokeWidth={2} />
|
|
|
- <Highlight
|
|
|
- points={{
|
|
|
- fill: '#38bdf8',
|
|
|
- class: 'stroke-white dark:stroke-slate-900',
|
|
|
- strokeWidth: 2
|
|
|
- }}
|
|
|
- />
|
|
|
- </Svg>
|
|
|
- </Chart>
|
|
|
+ {#if volumeData.length > 0}
|
|
|
+ <Chart
|
|
|
+ data={volumeData}
|
|
|
+ x={(d) => d.date}
|
|
|
+ y={(d) => d.whatsapp}
|
|
|
+ yDomain={[0, maxVolume]}
|
|
|
+ padding={{ top: 10, right: 10, bottom: 20, left: 30 }}
|
|
|
+ >
|
|
|
+ <Svg>
|
|
|
+ <Axis
|
|
|
+ placement="left"
|
|
|
+ grid={{ class: 'stroke-slate-200 dark:stroke-slate-700', strokeDasharray: '2 2' }}
|
|
|
+ class="fill-slate-500 text-xs dark:fill-slate-400"
|
|
|
+ />
|
|
|
+ <Axis
|
|
|
+ placement="bottom"
|
|
|
+ format={(d) => format(d, selectedVolumeOption.format)}
|
|
|
+ class="fill-slate-500 text-xs dark:fill-slate-400"
|
|
|
+ />
|
|
|
+ <Spline stroke="#38bdf8" strokeWidth={2} />
|
|
|
+ <Highlight
|
|
|
+ points={{
|
|
|
+ fill: '#38bdf8',
|
|
|
+ class: 'stroke-white dark:stroke-slate-900',
|
|
|
+ strokeWidth: 2
|
|
|
+ }}
|
|
|
+ />
|
|
|
+ </Svg>
|
|
|
+ </Chart>
|
|
|
+ {:else}
|
|
|
+ <div class="flex h-full items-center justify-center text-sm text-slate-400">
|
|
|
+ Sem dados de volume no período.
|
|
|
+ </div>
|
|
|
+ {/if}
|
|
|
</div>
|
|
|
</div>
|
|
|
|
|
|
@@ -689,10 +654,11 @@
|
|
|
{#if !selectedAspectDrilldown}
|
|
|
<div class="space-y-4">
|
|
|
{#each aspectsData as aspect}
|
|
|
+ {@const total = aspect.positive + aspect.neutral + aspect.negative}
|
|
|
<div>
|
|
|
<div class="mb-1 flex justify-between text-xs text-slate-600 dark:text-slate-300">
|
|
|
<span class="font-medium">{aspect.aspect}</span>
|
|
|
- <span>{aspect.positive + aspect.neutral + aspect.negative} interações</span>
|
|
|
+ <span>{total} interações</span>
|
|
|
</div>
|
|
|
<div
|
|
|
class="flex h-7 w-full overflow-hidden rounded bg-slate-100 shadow-inner dark:bg-slate-800"
|
|
|
@@ -700,31 +666,36 @@
|
|
|
<button
|
|
|
type="button"
|
|
|
class="h-full bg-emerald-500 transition-opacity hover:opacity-90"
|
|
|
- style="width: {(aspect.positive / (aspect.positive + aspect.neutral + aspect.negative)) * 100}%"
|
|
|
+ style="width: {total > 0 ? (aspect.positive / total) * 100 : 0}%"
|
|
|
onclick={() => openAspectDrilldown(aspect.aspect, 'positive')}
|
|
|
aria-label={`Ver pontos positivos de ${aspect.aspect}`}
|
|
|
></button>
|
|
|
<button
|
|
|
type="button"
|
|
|
class="h-full bg-slate-400 transition-opacity hover:opacity-90"
|
|
|
- style="width: {(aspect.neutral / (aspect.positive + aspect.neutral + aspect.negative)) * 100}%"
|
|
|
+ style="width: {total > 0 ? (aspect.neutral / total) * 100 : 0}%"
|
|
|
onclick={() => openAspectDrilldown(aspect.aspect, 'neutral')}
|
|
|
aria-label={`Ver pontos neutros de ${aspect.aspect}`}
|
|
|
></button>
|
|
|
<button
|
|
|
type="button"
|
|
|
class="h-full bg-red-500 transition-opacity hover:opacity-90"
|
|
|
- style="width: {(aspect.negative / (aspect.positive + aspect.neutral + aspect.negative)) * 100}%"
|
|
|
+ style="width: {total > 0 ? (aspect.negative / total) * 100 : 0}%"
|
|
|
onclick={() => openAspectDrilldown(aspect.aspect, 'negative')}
|
|
|
aria-label={`Ver pontos negativos de ${aspect.aspect}`}
|
|
|
></button>
|
|
|
</div>
|
|
|
</div>
|
|
|
{/each}
|
|
|
+ {#if !loading && aspectsData.length === 0}
|
|
|
+ <div class="py-10 text-center text-sm text-slate-400">
|
|
|
+ Sem aspectos para os filtros atuais.
|
|
|
+ </div>
|
|
|
+ {/if}
|
|
|
</div>
|
|
|
{:else}
|
|
|
- <div class="flex flex-col h-full">
|
|
|
- <div class="space-y-3 flex-1 overflow-y-auto custom-scrollbar pr-2 mb-4">
|
|
|
+ <div class="flex h-full flex-col">
|
|
|
+ <div class="custom-scrollbar mb-4 flex-1 space-y-3 overflow-y-auto pr-2">
|
|
|
{#each drilldownItems as item}
|
|
|
<div>
|
|
|
<div class="mb-1 flex justify-between text-xs text-slate-600 dark:text-slate-300">
|
|
|
@@ -736,8 +707,8 @@
|
|
|
class="h-full rounded {selectedAspectDrilldown.tone === 'positive'
|
|
|
? 'bg-emerald-500'
|
|
|
: selectedAspectDrilldown.tone === 'neutral'
|
|
|
- ? 'bg-slate-400'
|
|
|
- : 'bg-red-500'}"
|
|
|
+ ? 'bg-slate-400'
|
|
|
+ : 'bg-red-500'}"
|
|
|
style="width: {(item.value / maxDrilldownValue) * 100}%"
|
|
|
></div>
|
|
|
</div>
|
|
|
@@ -746,7 +717,8 @@
|
|
|
</div>
|
|
|
<button
|
|
|
type="button"
|
|
|
- onclick={() => navigateToAnalytics(selectedAspectDrilldown.aspect, selectedAspectDrilldown.tone)}
|
|
|
+ onclick={() =>
|
|
|
+ navigateToAnalytics(selectedAspectDrilldown.aspect, selectedAspectDrilldown.tone)}
|
|
|
class="w-full shrink-0 rounded-lg border border-indigo-200 bg-indigo-50 px-4 py-2.5 text-sm font-semibold text-indigo-700 transition-colors hover:bg-indigo-100 dark:border-indigo-500/30 dark:bg-indigo-500/10 dark:text-indigo-400 dark:hover:bg-indigo-500/20"
|
|
|
>
|
|
|
Saber mais detalhes
|
|
|
@@ -756,4 +728,4 @@
|
|
|
</div>
|
|
|
</div>
|
|
|
</div>
|
|
|
-</div>
|
|
|
+</div>
|