first commit
This commit is contained in:
249
frontend/src/views/ClientDashboardView.tsx
Normal file
249
frontend/src/views/ClientDashboardView.tsx
Normal file
@@ -0,0 +1,249 @@
|
||||
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);
|
||||
}
|
||||
Reference in New Issue
Block a user