Files
mira/frontend/src/views/ClientDashboardView.tsx
Cauê Faleiros 8c7e5fbbe4
All checks were successful
CI / backend (push) Successful in 13m19s
CI / frontend (push) Successful in 11m3s
CI / docker (push) Successful in 1m58s
first commit
2026-06-03 16:31:42 -03:00

250 lines
11 KiB
TypeScript

import { useEffect, useMemo, useState } from "react";
import { AlertCircle, CalendarDays, CheckCircle2, Clock, ExternalLink, FileText, Plus } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { getStoredToken } from "@/lib/auth";
import {
listCalendarItemsByRange,
listCustomDates,
listHolidays,
type CalendarItem,
type CustomDate,
type Holiday,
} from "@/lib/calendar";
import type { Client } from "@/lib/clients";
type ClientDashboardViewProps = {
client: Client | null;
selectedClientId: string;
onViewChange: (view: string) => void;
onPostSelect: (postId: string) => void;
};
const statusLabels: Record<CalendarItem["status"], string> = {
rascunho: "Rascunho",
planejado: "Planejado",
em_producao: "Em produção",
em_revisao: "Em revisão",
aprovado: "Aprovado",
publicado: "Publicado",
cancelado: "Cancelado",
};
const shortDateFormatter = new Intl.DateTimeFormat("pt-BR", { day: "2-digit", month: "short" });
const longDateFormatter = new Intl.DateTimeFormat("pt-BR", { day: "2-digit", month: "long", year: "numeric" });
export function ClientDashboardView({ client, selectedClientId, onViewChange, onPostSelect }: ClientDashboardViewProps) {
const today = useMemo(() => new Date(), []);
const monthStart = useMemo(() => new Date(today.getFullYear(), today.getMonth(), 1), [today]);
const monthEnd = useMemo(() => new Date(today.getFullYear(), today.getMonth() + 1, 0), [today]);
const [items, setItems] = useState<CalendarItem[]>([]);
const [holidays, setHolidays] = useState<Holiday[]>([]);
const [customDates, setCustomDates] = useState<CustomDate[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState("");
const monthStartValue = formatDateValue(monthStart);
const monthEndValue = formatDateValue(monthEnd);
const todayValue = formatDateValue(today);
const year = today.getFullYear();
useEffect(() => {
const token = getStoredToken();
if (!token) return;
setIsLoading(true);
setError("");
Promise.all([
listCalendarItemsByRange(token, monthStartValue, monthEndValue, selectedClientId),
listHolidays(token, year),
listCustomDates(token, year, selectedClientId),
])
.then(([loadedItems, loadedHolidays, loadedCustomDates]) => {
setItems(loadedItems);
setHolidays(loadedHolidays);
setCustomDates(loadedCustomDates);
})
.catch((err) => {
setError(err instanceof Error ? err.message : "Não foi possível carregar o dashboard do cliente.");
})
.finally(() => setIsLoading(false));
}, [monthEndValue, monthStartValue, selectedClientId, year]);
const visibleItems = items
.filter((item) => item.status !== "cancelado")
.sort((a, b) => a.scheduled_date.localeCompare(b.scheduled_date));
const upcomingItems = visibleItems.filter((item) => item.scheduled_date >= todayValue);
const reviewItems = visibleItems.filter((item) => item.status === "em_revisao");
const approvedItems = visibleItems.filter((item) => item.status === "aprovado" || item.status === "publicado");
const dates = [...holidays, ...customDates]
.filter((date) => date.date >= monthStartValue && date.date <= monthEndValue)
.sort((a, b) => a.date.localeCompare(b.date));
if (!client) {
return (
<div className="rounded-lg border border-destructive/30 bg-destructive/10 px-3 py-2 text-sm text-destructive">
Cliente não encontrado.
</div>
);
}
return (
<div className="space-y-6">
<div className="flex flex-col sm:flex-row sm:items-end justify-between gap-4">
<div>
<div className="flex flex-wrap items-center gap-2">
<h1 className="text-2xl font-semibold tracking-tight">Dashboard de {client.name}</h1>
<Badge variant="outline">{client.status === "archived" ? "Arquivado" : "Ativo"}</Badge>
</div>
<p className="text-sm text-muted-foreground mt-1">
Visão do cliente entre {shortDateFormatter.format(monthStart)} e {shortDateFormatter.format(monthEnd)}.
</p>
</div>
<div className="flex flex-wrap gap-2">
<Button variant="outline" size="sm" className="h-9" onClick={() => onViewChange("calendar")}>
<CalendarDays className="mr-2 h-4 w-4" />
Calendário
</Button>
<Button size="sm" className="h-9" onClick={() => onViewChange("calendar")}>
<Plus className="mr-2 h-4 w-4" />
Novo Post
</Button>
</div>
</div>
{error && (
<div className="rounded-lg border border-destructive/30 bg-destructive/10 px-3 py-2 text-sm text-destructive">
{error}
</div>
)}
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
<MetricCard title="Posts do mês" value={visibleItems.length} detail={`${upcomingItems.length} próximos`} />
<MetricCard title="Em aprovação" value={reviewItems.length} detail="Aguardando retorno" />
<MetricCard title="Aprovados" value={approvedItems.length} detail="Prontos ou publicados" emphasize />
<MetricCard title="Datas do mês" value={dates.length} detail="Feriados e datas úteis" />
</div>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
<Card className="lg:col-span-2 shadow-sm">
<CardHeader className="p-4 pb-2">
<CardTitle className="text-base flex items-center gap-2">
<FileText className="h-4 w-4 text-muted-foreground" />
Linha do tempo de posts
</CardTitle>
</CardHeader>
<CardContent className="p-4 pt-2 space-y-3">
{isLoading && <p className="text-sm text-muted-foreground py-6">Carregando posts...</p>}
{!isLoading && visibleItems.length === 0 && (
<p className="text-sm text-muted-foreground py-6">Nenhum post previsto para este mês.</p>
)}
{visibleItems.map((item) => (
<button key={item.id} type="button" className="w-full rounded-lg border border-border/60 bg-card p-4 text-left hover:bg-muted/30" onClick={() => onPostSelect(item.id)}>
<div className="flex flex-col sm:flex-row sm:items-start justify-between gap-3">
<div className="min-w-0 space-y-1">
<div className="flex flex-wrap items-center gap-2">
<Badge variant="secondary" className="text-[10px] bg-orange-500/10 text-orange-700 dark:text-orange-300">
{statusLabels[item.status]}
</Badge>
<span className="text-xs text-muted-foreground flex items-center gap-1">
<Clock className="h-3.5 w-3.5" />
{longDateFormatter.format(parseDateValue(item.scheduled_date))}
</span>
</div>
<h2 className="font-medium text-sm text-foreground">{item.title}</h2>
{item.description && <p className="text-sm text-muted-foreground">{item.description}</p>}
{item.client_notes && <p className="text-sm text-muted-foreground">{item.client_notes}</p>}
</div>
{item.status === "aprovado" || item.status === "publicado" ? (
<CheckCircle2 className="h-5 w-5 shrink-0 text-primary" />
) : (
<AlertCircle className="h-5 w-5 shrink-0 text-orange-500" />
)}
</div>
</button>
))}
</CardContent>
</Card>
<div className="space-y-6">
<Card className="shadow-sm">
<CardHeader className="p-4 pb-2">
<CardTitle className="text-base flex items-center gap-2">
<CalendarDays className="h-4 w-4 text-muted-foreground" />
Datas relevantes
</CardTitle>
</CardHeader>
<CardContent className="p-4 pt-2 space-y-3">
{dates.map((date) => (
<div key={`${date.date}-${date.name}`} className="flex items-center justify-between gap-3 text-sm">
<div className="min-w-0">
<p className="font-medium truncate">{date.name}</p>
<p className="text-xs text-muted-foreground">
{"source" in date ? "Feriado nacional" : date.category === "feriados-brasil" ? "Data comemorativa" : "Data da agência"}
</p>
</div>
<Badge variant="outline" className="font-mono bg-muted/40">
{shortDateFormatter.format(parseDateValue(date.date))}
</Badge>
</div>
))}
{dates.length === 0 && (
<p className="text-sm text-muted-foreground py-4">Nenhuma data relevante neste mês.</p>
)}
</CardContent>
</Card>
{(client.website_url || client.instagram_url || client.facebook_url || client.linkedin_url) && (
<Card className="shadow-sm">
<CardHeader className="p-4 pb-2">
<CardTitle className="text-base">Links do cliente</CardTitle>
</CardHeader>
<CardContent className="p-4 pt-2 space-y-2">
<ClientLink label="Website" url={client.website_url} />
<ClientLink label="Instagram" url={client.instagram_url} />
<ClientLink label="Facebook" url={client.facebook_url} />
<ClientLink label="LinkedIn" url={client.linkedin_url} />
</CardContent>
</Card>
)}
</div>
</div>
</div>
);
}
function MetricCard({ title, value, detail, emphasize = false }: { title: string; value: number; detail: string; emphasize?: boolean }) {
return (
<Card className="shadow-sm">
<CardHeader className="p-4 pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">{title}</CardTitle>
</CardHeader>
<CardContent className="p-4 pt-0">
<div className={`text-2xl font-semibold ${emphasize ? "text-primary" : ""}`}>{value}</div>
<p className="text-xs text-muted-foreground mt-1">{detail}</p>
</CardContent>
</Card>
);
}
function ClientLink({ label, url }: { label: string; url: string }) {
if (!url) return null;
return (
<a className="flex items-center justify-between gap-3 rounded-lg border border-border/60 px-3 py-2 text-sm hover:bg-muted/30" href={url} target="_blank" rel="noreferrer">
<span>{label}</span>
<ExternalLink className="h-4 w-4 text-muted-foreground" />
</a>
);
}
function formatDateValue(date: Date) {
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}-${String(date.getDate()).padStart(2, "0")}`;
}
function parseDateValue(value: string) {
const [year, month, day] = value.split("-").map(Number);
return new Date(year, month - 1, day);
}