250 lines
11 KiB
TypeScript
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);
|
|
}
|