From 7fa6e89455a2ae4d33e215344bde377775a23667 Mon Sep 17 00:00:00 2001 From: bryancalvarenga Date: Thu, 21 May 2026 01:40:12 -0300 Subject: [PATCH 1/4] feat(web): implementar todas as issues de frontend (#71-#81) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - #71 Onboarding pós-cadastro com etapas e progresso persistido - #72 Página /dashboard/cupons com CRUD completo e toggle ativo - #73 Página /dashboard/whatsapp com número e modelos de mensagem - #74 Página /dashboard/financeiro com métricas, gráfico e export CSV - #75 Página /dashboard/relatorios com top produtos, horários de pico e clientes - #77 Página /dashboard/configuracoes/aparencia com slug, cores e preview - #78 Página /dashboard/configuracoes/entrega com delivery/retirada e taxas - #79 Página pública /pedido/[token] com timeline de status - #80 Central de ajuda com FAQ por categoria e links de contato - #81 Página /dashboard/equipe com convites, cargos e permissões - Novos API files com mock fallback para todos os domínios - Novos tipos em src/types/index.ts - Sidebar atualizada com Equipe, Configurações e Ajuda Co-Authored-By: Claude Sonnet 4.6 --- .../app/(dashboard)/dashboard/ajuda/page.tsx | 176 ++++++++++ .../configuracoes/aparencia/page.tsx | 231 ++++++++++++++ .../dashboard/configuracoes/entrega/page.tsx | 273 ++++++++++++++++ .../app/(dashboard)/dashboard/cupons/page.tsx | 300 ++++++++++++++++++ .../app/(dashboard)/dashboard/equipe/page.tsx | 260 +++++++++++++++ .../(dashboard)/dashboard/financeiro/page.tsx | 195 ++++++++++++ .../(dashboard)/dashboard/onboarding/page.tsx | 188 +++++++++++ .../(dashboard)/dashboard/relatorios/page.tsx | 175 ++++++++++ .../(dashboard)/dashboard/whatsapp/page.tsx | 146 +++++++++ .../pedido/[token]/PedidoPublicoConteudo.tsx | 135 ++++++++ web/src/app/pedido/[token]/page.tsx | 22 ++ web/src/components/layout/Sidebar.tsx | 3 + web/src/lib/api/configuracoes.api.ts | 72 ++++- web/src/lib/api/cupons.api.ts | 99 ++++++ web/src/lib/api/equipe.api.ts | 63 ++++ web/src/lib/api/financeiro.api.ts | 86 +++++ web/src/lib/api/onboarding.api.ts | 54 ++++ web/src/lib/api/pedido-publico.api.ts | 47 +++ web/src/lib/api/relatorios.api.ts | 80 +++++ web/src/lib/api/whatsapp.api.ts | 36 +++ web/src/types/index.ts | 147 +++++++++ 21 files changed, 2787 insertions(+), 1 deletion(-) create mode 100644 web/src/app/(dashboard)/dashboard/ajuda/page.tsx create mode 100644 web/src/app/(dashboard)/dashboard/configuracoes/aparencia/page.tsx create mode 100644 web/src/app/(dashboard)/dashboard/configuracoes/entrega/page.tsx create mode 100644 web/src/app/(dashboard)/dashboard/cupons/page.tsx create mode 100644 web/src/app/(dashboard)/dashboard/equipe/page.tsx create mode 100644 web/src/app/(dashboard)/dashboard/financeiro/page.tsx create mode 100644 web/src/app/(dashboard)/dashboard/onboarding/page.tsx create mode 100644 web/src/app/(dashboard)/dashboard/relatorios/page.tsx create mode 100644 web/src/app/(dashboard)/dashboard/whatsapp/page.tsx create mode 100644 web/src/app/pedido/[token]/PedidoPublicoConteudo.tsx create mode 100644 web/src/app/pedido/[token]/page.tsx create mode 100644 web/src/lib/api/cupons.api.ts create mode 100644 web/src/lib/api/equipe.api.ts create mode 100644 web/src/lib/api/financeiro.api.ts create mode 100644 web/src/lib/api/onboarding.api.ts create mode 100644 web/src/lib/api/pedido-publico.api.ts create mode 100644 web/src/lib/api/relatorios.api.ts create mode 100644 web/src/lib/api/whatsapp.api.ts diff --git a/web/src/app/(dashboard)/dashboard/ajuda/page.tsx b/web/src/app/(dashboard)/dashboard/ajuda/page.tsx new file mode 100644 index 0000000..c625291 --- /dev/null +++ b/web/src/app/(dashboard)/dashboard/ajuda/page.tsx @@ -0,0 +1,176 @@ +"use client"; + +import { useState } from "react"; +import { ChevronDown, ChevronUp, MessageCircle, Mail, HelpCircle } from "lucide-react"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; + +type FaqItem = { pergunta: string; resposta: string }; + +const FAQ: { categoria: string; itens: FaqItem[] }[] = [ + { + categoria: "Cardápio e Produtos", + itens: [ + { + pergunta: "Como adiciono um novo produto ao cardápio?", + resposta: + "Acesse Cardápio no menu lateral, clique em 'Novo produto', preencha nome, preço, categoria e salve. O produto aparece imediatamente no seu cardápio público.", + }, + { + pergunta: "Como deixo um produto indisponível temporariamente?", + resposta: + "No card do produto, use o switch de disponibilidade. O produto ficará visível mas marcado como indisponível, impedindo pedidos.", + }, + { + pergunta: "Como crio categorias para organizar o cardápio?", + resposta: "Acesse Cardápio → Categorias. Lá você pode criar, renomear, reordenar e ativar/desativar categorias.", + }, + ], + }, + { + categoria: "Pedidos", + itens: [ + { + pergunta: "Como recebo notificação de novos pedidos?", + resposta: + "Novos pedidos aparecem em tempo real na página Pedidos. Certifique-se de manter o painel aberto ou configure notificações do navegador.", + }, + { + pergunta: "Como cancelo um pedido?", + resposta: + "Na página Pedidos, clique no pedido e use o botão 'Cancelar'. Pedidos finalizados ou já cancelados não podem ter o status alterado.", + }, + { + pergunta: "O cliente consegue acompanhar o pedido?", + resposta: + "Sim! Após o checkout, o cliente recebe um link para acompanhar o status do pedido em tempo real.", + }, + ], + }, + { + categoria: "QR Code e Cardápio Público", + itens: [ + { + pergunta: "Como compartilho o link do meu cardápio?", + resposta: + "Seu cardápio está disponível em meveum.com/[seu-slug]. Você pode alterar o slug em Configurações → Aparência.", + }, + { + pergunta: "Como gero o QR Code da minha loja?", + resposta: "Acesse Mesas / QR no menu lateral para gerar e baixar o QR Code do seu cardápio.", + }, + ], + }, + { + categoria: "Pagamentos", + itens: [ + { + pergunta: "Quais formas de pagamento posso aceitar?", + resposta: + "PIX, cartão de crédito, cartão de débito e dinheiro. Configure as formas aceitas em Configurações → Pagamentos.", + }, + { + pergunta: "Como configuro o PIX?", + resposta: "Em Configurações, informe sua Chave PIX (CPF, CNPJ, e-mail ou chave aleatória). O cliente verá essa chave no checkout.", + }, + ], + }, +]; + +function FaqItemComponent({ item }: { item: FaqItem }) { + const [aberto, setAberto] = useState(false); + return ( +
+ + {aberto && ( +

+ {item.resposta} +

+ )} +
+ ); +} + +export default function AjudaPage() { + return ( +
+
+

Central de Ajuda

+

Encontre respostas e entre em contato com o suporte

+
+ + {/* Contatos */} +
+ + + +
+ +
+
+

+ WhatsApp +

+

Resposta em até 2h úteis

+
+
+
+
+ + + + +
+ +
+
+

+ E-mail +

+

suporte@meveum.com

+
+
+
+
+
+ + {/* FAQ por categoria */} +
+ {FAQ.map((secao) => ( + + + + + {secao.categoria} + + + + {secao.itens.map((item, i) => ( + + ))} + + + ))} +
+
+ ); +} diff --git a/web/src/app/(dashboard)/dashboard/configuracoes/aparencia/page.tsx b/web/src/app/(dashboard)/dashboard/configuracoes/aparencia/page.tsx new file mode 100644 index 0000000..070689f --- /dev/null +++ b/web/src/app/(dashboard)/dashboard/configuracoes/aparencia/page.tsx @@ -0,0 +1,231 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { Image, Eye, Check, AlertCircle } from "lucide-react"; +import { toast } from "sonner"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { buscarAparencia, atualizarAparencia, verificarSlugDisponivel } from "@/lib/api/configuracoes.api"; +import type { AparenciaLoja } from "@/types"; + +const CORES_SUGERIDAS = ["#EA580C", "#16A34A", "#2563EB", "#9333EA", "#DC2626", "#D97706", "#0891B2"]; + +function slugify(valor: string): string { + return valor + .trim() + .toLowerCase() + .normalize("NFD") + .replace(/[̀-ͯ]/g, "") + .replace(/[^a-z0-9]+/g, "-") + .replace(/(^-|-$)/g, "") || ""; +} + +export default function AparenciaPage() { + const [form, setForm] = useState({ nome: "", slug: "", logoUrl: "", capaBannerUrl: "", corPrimaria: "#EA580C" }); + const [loading, setLoading] = useState(true); + const [salvando, setSalvando] = useState(false); + const [slugStatus, setSlugStatus] = useState<"idle" | "checking" | "available" | "taken">("idle"); + const [slugTimeout, setSlugTimeout] = useState | null>(null); + + useEffect(() => { + buscarAparencia() + .then((a) => setForm({ corPrimaria: "#EA580C", ...a })) + .catch(() => toast.error("Erro ao carregar aparência.")) + .finally(() => setLoading(false)); + }, []); + + function handleNomeChange(nome: string) { + setForm((f) => ({ ...f, nome, slug: f.slug || slugify(nome) })); + } + + function handleSlugChange(slug: string) { + const s = slugify(slug) || slug; + setForm((f) => ({ ...f, slug: s })); + setSlugStatus("checking"); + if (slugTimeout) clearTimeout(slugTimeout); + setSlugTimeout( + setTimeout(async () => { + const disponivel = await verificarSlugDisponivel(s).catch(() => true); + setSlugStatus(disponivel ? "available" : "taken"); + }, 600), + ); + } + + async function salvar() { + if (slugStatus === "taken") { + toast.error("Este slug já está em uso. Escolha outro."); + return; + } + setSalvando(true); + try { + await atualizarAparencia(form); + toast.success("Aparência atualizada!"); + } catch { + toast.error("Erro ao salvar aparência."); + } finally { + setSalvando(false); + } + } + + if (loading) { + return ( +
+
+
+ ); + } + + return ( +
+
+

Aparência da loja

+

Personalize a identidade visual do seu cardápio público

+
+ + {/* Identidade */} + + + + + Identidade visual + + + +
+ + handleNomeChange(e.target.value)} + placeholder="Burger do Bairro" + data-testid="aparencia-nome-input" + /> +
+ +
+ + setForm((f) => ({ ...f, descricao: e.target.value }))} + placeholder="Os melhores smash burgers da cidade" + data-testid="aparencia-descricao-input" + /> +
+ +
+ +
+ + meveum.com/ + + handleSlugChange(e.target.value)} + placeholder="burger-do-bairro" + data-testid="aparencia-slug-input" + className="flex-1 bg-transparent px-3 py-2 text-sm text-[var(--color-foreground)] outline-none" + /> + + {slugStatus === "available" && } + {slugStatus === "taken" && } + +
+ {slugStatus === "taken" && ( +

Este slug já está em uso.

+ )} + {slugStatus === "available" && ( +

Slug disponível!

+ )} +
+ +
+ + setForm((f) => ({ ...f, logoUrl: e.target.value }))} + placeholder="https://..." + data-testid="aparencia-logo-input" + /> +
+ +
+ + setForm((f) => ({ ...f, capaBannerUrl: e.target.value }))} + placeholder="https://..." + data-testid="aparencia-capa-input" + /> +
+ +
+ +
+
+ {CORES_SUGERIDAS.map((cor) => ( +
+ setForm((f) => ({ ...f, corPrimaria: e.target.value }))} + data-testid="aparencia-cor-custom-input" + className="h-7 w-7 rounded cursor-pointer border-0 bg-transparent" + /> +
+
+
+
+ + {/* Preview */} + + + + + Pré-visualização + + + +
+
+ {form.logoUrl && ( + Logo + )} +
+
+

{form.nome || "Nome da loja"}

+ {form.descricao &&

{form.descricao}

} + {form.slug &&

meveum.com/{form.slug}

} +
+
+
+
+ +
+ +
+
+ ); +} diff --git a/web/src/app/(dashboard)/dashboard/configuracoes/entrega/page.tsx b/web/src/app/(dashboard)/dashboard/configuracoes/entrega/page.tsx new file mode 100644 index 0000000..3d36f8f --- /dev/null +++ b/web/src/app/(dashboard)/dashboard/configuracoes/entrega/page.tsx @@ -0,0 +1,273 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { Plus, Trash2, Truck, Store } from "lucide-react"; +import { toast } from "sonner"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Switch } from "@/components/ui/switch"; +import { buscarConfigEntrega, salvarConfigEntrega, criarTaxaEntregaDetalhada, removerTaxaEntrega } from "@/lib/api/configuracoes.api"; +import type { ConfigEntregaRetirada, TaxaEntrega } from "@/types"; + +export default function EntregaPage() { + const [config, setConfig] = useState({ + deliveryAtivo: true, + retiradaAtivo: true, + pedidoMinimo: 0, + tempoMedioEntregaMin: 45, + tempoMedioRetiradaMin: 20, + taxasEntrega: [], + }); + const [loading, setLoading] = useState(true); + const [salvando, setSalvando] = useState(false); + const [novaTaxa, setNovaTaxa] = useState({ bairro: "", taxa: "", tempoMin: "45" }); + const [adicionando, setAdicionando] = useState(false); + + useEffect(() => { + buscarConfigEntrega() + .then(setConfig) + .catch(() => toast.error("Erro ao carregar configurações.")) + .finally(() => setLoading(false)); + }, []); + + async function salvar() { + setSalvando(true); + try { + await salvarConfigEntrega({ + deliveryAtivo: config.deliveryAtivo, + retiradaAtivo: config.retiradaAtivo, + pedidoMinimo: config.pedidoMinimo, + tempoMedioEntregaMin: config.tempoMedioEntregaMin, + tempoMedioRetiradaMin: config.tempoMedioRetiradaMin, + }); + toast.success("Configurações salvas!"); + } catch { + toast.error("Erro ao salvar configurações."); + } finally { + setSalvando(false); + } + } + + async function adicionarTaxa() { + if (!novaTaxa.bairro.trim()) { + toast.error("Informe o nome do bairro."); + return; + } + setAdicionando(true); + try { + const loja = await criarTaxaEntregaDetalhada({ + bairro: novaTaxa.bairro, + taxa: parseFloat(novaTaxa.taxa) || 0, + tempoMin: parseInt(novaTaxa.tempoMin) || 45, + }); + setConfig((c) => ({ ...c, taxasEntrega: loja.taxasEntrega })); + setNovaTaxa({ bairro: "", taxa: "", tempoMin: "45" }); + toast.success("Área de entrega adicionada."); + } catch { + toast.error("Erro ao adicionar área."); + } finally { + setAdicionando(false); + } + } + + async function removerTaxa(taxa: TaxaEntrega) { + try { + const loja = await removerTaxaEntrega(taxa.id); + setConfig((c) => ({ ...c, taxasEntrega: loja.taxasEntrega })); + toast.success("Área removida."); + } catch { + toast.error("Erro ao remover área."); + } + } + + if (loading) { + return ( +
+
+
+ ); + } + + return ( +
+
+

Entrega e Retirada

+

Configure as regras operacionais do seu cardápio

+
+ + {/* Ativar/desativar modalidades */} + + + Modalidades de entrega + + +
+
+ +
+

Delivery

+

Entrega no endereço do cliente

+
+
+ setConfig((c) => ({ ...c, deliveryAtivo: v }))} + data-testid="entrega-delivery-switch" + /> +
+
+
+ +
+

Retirada no local

+

Cliente retira no estabelecimento

+
+
+ setConfig((c) => ({ ...c, retiradaAtivo: v }))} + data-testid="entrega-retirada-switch" + /> +
+
+
+ + {/* Configurações gerais */} + + + Configurações gerais + + +
+ + setConfig((c) => ({ ...c, pedidoMinimo: parseFloat(e.target.value) || 0 }))} + data-testid="entrega-pedido-minimo-input" + /> +
+
+
+ + setConfig((c) => ({ ...c, tempoMedioEntregaMin: parseInt(e.target.value) || 45 }))} + data-testid="entrega-tempo-delivery-input" + /> +
+
+ + setConfig((c) => ({ ...c, tempoMedioRetiradaMin: parseInt(e.target.value) || 20 }))} + data-testid="entrega-tempo-retirada-input" + /> +
+
+
+ +
+
+
+ + {/* Taxas por área */} + + +
+ Áreas e taxas de entrega +
+
+ + {config.taxasEntrega.length === 0 && ( +

+ Nenhuma área cadastrada. Adicione abaixo. +

+ )} + {config.taxasEntrega.map((taxa) => ( +
+
+

{taxa.bairro}

+

{taxa.tempoMin} min estimado

+
+
+ + {taxa.taxa === 0 ? "Grátis" : `R$ ${taxa.taxa.toFixed(2)}`} + + +
+
+ ))} + + {/* Adicionar nova taxa */} +
+

Adicionar área

+
+
+ + setNovaTaxa((n) => ({ ...n, bairro: e.target.value }))} + placeholder="Vila Madalena" + data-testid="entrega-nova-bairro-input" + /> +
+
+ + setNovaTaxa((n) => ({ ...n, taxa: e.target.value }))} + placeholder="0,00" + data-testid="entrega-nova-taxa-input" + /> +
+
+ + setNovaTaxa((n) => ({ ...n, tempoMin: e.target.value }))} + data-testid="entrega-nova-tempo-input" + /> +
+
+ +
+
+
+
+ ); +} diff --git a/web/src/app/(dashboard)/dashboard/cupons/page.tsx b/web/src/app/(dashboard)/dashboard/cupons/page.tsx new file mode 100644 index 0000000..38a8b97 --- /dev/null +++ b/web/src/app/(dashboard)/dashboard/cupons/page.tsx @@ -0,0 +1,300 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { z } from "zod"; +import { Plus, Ticket, Pencil, Trash2, ToggleLeft, ToggleRight } from "lucide-react"; +import { toast } from "sonner"; +import { Card, CardContent } from "@/components/ui/card"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Badge } from "@/components/ui/badge"; +import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; +import { EmptyState } from "@/components/shared/EmptyState"; +import { listarCupons, criarCupon, atualizarCupon, toggleCupon, excluirCupon } from "@/lib/api/cupons.api"; +import { formatCurrency } from "@/lib/utils/format"; +import type { Cupon, TipoCupon } from "@/types"; + +const cuponSchema = z.object({ + codigo: z.string().min(3, "Mínimo 3 caracteres").max(20, "Máximo 20 caracteres"), + tipo: z.enum(["percentual", "fixo"]), + valor: z.coerce.number().positive("Deve ser positivo"), + valorMinimoPedido: z.coerce.number().min(0).optional(), + limiteUsos: z.coerce.number().int().positive().optional(), + expiracaoEm: z.string().optional(), +}); + +type CuponForm = z.infer; + +function CuponFormModal({ + cupon, onSave, onClose, +}: { cupon: Cupon | null; onSave: () => void; onClose: () => void }) { + const { register, handleSubmit, setValue, watch, formState: { errors, isSubmitting } } = useForm({ + resolver: zodResolver(cuponSchema) as import("react-hook-form").Resolver, + defaultValues: cupon ? { + codigo: cupon.codigo, + tipo: cupon.tipo, + valor: cupon.valor, + valorMinimoPedido: cupon.valorMinimoPedido, + limiteUsos: cupon.limiteUsos, + expiracaoEm: cupon.expiracaoEm ? cupon.expiracaoEm.slice(0, 10) : "", + } : { tipo: "percentual" as TipoCupon }, + }); + + const tipo = watch("tipo") as TipoCupon; + + async function onSubmit(data: CuponForm) { + try { + const payload = { + codigo: data.codigo, + tipo: data.tipo, + valor: data.valor, + valorMinimoPedido: data.valorMinimoPedido || undefined, + limiteUsos: data.limiteUsos || undefined, + expiracaoEm: data.expiracaoEm ? new Date(data.expiracaoEm).toISOString() : undefined, + }; + if (cupon) await atualizarCupon(cupon.id, payload); + else await criarCupon(payload); + toast.success(cupon ? "Cupom atualizado!" : "Cupom criado!"); + onSave(); + } catch { + toast.error("Erro ao salvar cupom."); + } + } + + return ( +
+
+ + + {errors.codigo &&

{errors.codigo.message}

} +
+ +
+
+ + +
+
+ + + {errors.valor &&

{errors.valor.message}

} +
+
+ +
+
+ + +
+
+ + +
+
+ +
+ + +
+ +
+ + +
+
+ ); +} + +export default function CuponsPage() { + const [cupons, setCupons] = useState([]); + const [loading, setLoading] = useState(true); + const [modalAberto, setModalAberto] = useState(false); + const [cuponEditando, setCuponEditando] = useState(null); + const [modalExcluir, setModalExcluir] = useState(null); + + async function carregar() { + setLoading(true); + try { + setCupons(await listarCupons()); + } catch { + toast.error("Não foi possível carregar os cupons."); + } finally { + setLoading(false); + } + } + + useEffect(() => { void carregar(); }, []); + + async function handleToggle(cupon: Cupon) { + try { + const atualizado = await toggleCupon(cupon.id); + setCupons((prev) => prev.map((c) => (c.id === cupon.id ? atualizado : c))); + toast.success(atualizado.ativo ? "Cupom ativado!" : "Cupom desativado!"); + } catch { + toast.error("Erro ao alterar cupom."); + } + } + + async function handleExcluir(cupon: Cupon) { + try { + await excluirCupon(cupon.id); + setCupons((prev) => prev.filter((c) => c.id !== cupon.id)); + setModalExcluir(null); + toast.success("Cupom removido."); + } catch { + toast.error("Erro ao remover cupom."); + } + } + + return ( +
+
+
+

Cupons

+

{cupons.length} cupons cadastrados

+
+ +
+ + {loading ? ( +
+
+
+ ) : cupons.length === 0 ? ( + { setCuponEditando(null); setModalAberto(true); }} data-testid="cupon-criar-empty-button"> + Criar primeiro cupom + + } + data-testid="cupons-empty-state" + /> + ) : ( +
+ {cupons.map((cupon) => ( + + +
+
+
+ +
+
+
+ {cupon.codigo} + + {cupon.ativo ? "Ativo" : "Inativo"} + +
+

+ {cupon.tipo === "percentual" ? `${cupon.valor}% de desconto` : `${formatCurrency(cupon.valor)} de desconto`} + {cupon.valorMinimoPedido ? ` · mínimo ${formatCurrency(cupon.valorMinimoPedido)}` : ""} + {cupon.limiteUsos ? ` · ${cupon.usosAtuais}/${cupon.limiteUsos} usos` : ` · ${cupon.usosAtuais} usos`} + {cupon.expiracaoEm ? ` · expira ${new Date(cupon.expiracaoEm).toLocaleDateString("pt-BR")}` : ""} +

+
+
+ +
+ + + +
+
+
+
+ ))} +
+ )} + + { if (!o) { setModalAberto(false); setCuponEditando(null); } }}> + + + {cuponEditando ? "Editar cupom" : "Novo cupom"} + + { void carregar(); setModalAberto(false); setCuponEditando(null); }} + onClose={() => { setModalAberto(false); setCuponEditando(null); }} + /> + + + + { if (!o) setModalExcluir(null); }}> + + + Remover cupom + +

+ Tem certeza que deseja remover o cupom {modalExcluir?.codigo}? +

+
+ + +
+
+
+
+ ); +} diff --git a/web/src/app/(dashboard)/dashboard/equipe/page.tsx b/web/src/app/(dashboard)/dashboard/equipe/page.tsx new file mode 100644 index 0000000..f039e58 --- /dev/null +++ b/web/src/app/(dashboard)/dashboard/equipe/page.tsx @@ -0,0 +1,260 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { z } from "zod"; +import { Plus, Users, Trash2, Crown, Shield, ChefHat } from "lucide-react"; +import { toast } from "sonner"; +import { Card, CardContent } from "@/components/ui/card"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; +import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog"; +import { EmptyState } from "@/components/shared/EmptyState"; +import { listarEquipe, convidarMembro, atualizarCargo, removerMembro } from "@/lib/api/equipe.api"; +import type { MembroEquipe, CargoEquipe } from "@/types"; + +const CARGO_LABEL: Record = { + OWNER: "Proprietário", + MANAGER: "Gerente", + STAFF: "Atendente / Cozinha", +}; + +const CARGO_ICON: Record = { + OWNER: Crown, + MANAGER: Shield, + STAFF: ChefHat, +}; + +const CARGO_COLOR: Record = { + OWNER: "text-amber-500 bg-amber-500/10", + MANAGER: "text-blue-500 bg-blue-500/10", + STAFF: "text-green-500 bg-green-500/10", +}; + +const convidarSchema = z.object({ + nome: z.string().min(2, "Nome obrigatório"), + email: z.string().email("E-mail inválido"), + cargo: z.enum(["OWNER", "MANAGER", "STAFF"]), +}); + +type ConvidarForm = z.infer; + +export default function EquipePage() { + const [membros, setMembros] = useState([]); + const [loading, setLoading] = useState(true); + const [modalAberto, setModalAberto] = useState(false); + const [modalRemover, setModalRemover] = useState(null); + + const { register, handleSubmit, setValue, reset, formState: { errors, isSubmitting } } = useForm({ + resolver: zodResolver(convidarSchema), + defaultValues: { cargo: "STAFF" }, + }); + + async function carregar() { + setLoading(true); + try { + setMembros(await listarEquipe()); + } catch { + toast.error("Não foi possível carregar a equipe."); + } finally { + setLoading(false); + } + } + + useEffect(() => { void carregar(); }, []); + + async function onConvidar(data: ConvidarForm) { + try { + const novo = await convidarMembro(data); + setMembros((prev) => [...prev, novo]); + toast.success(`Convite enviado para ${data.email}!`); + reset(); + setModalAberto(false); + } catch { + toast.error("Erro ao convidar membro."); + } + } + + async function handleAlterarCargo(membro: MembroEquipe, cargo: CargoEquipe) { + try { + const atualizado = await atualizarCargo(membro.id, cargo); + setMembros((prev) => prev.map((m) => (m.id === membro.id ? atualizado : m))); + toast.success("Cargo atualizado!"); + } catch { + toast.error("Erro ao atualizar cargo."); + } + } + + async function handleRemover(membro: MembroEquipe) { + try { + await removerMembro(membro.id); + setMembros((prev) => prev.filter((m) => m.id !== membro.id)); + setModalRemover(null); + toast.success("Membro removido."); + } catch { + toast.error("Erro ao remover membro."); + } + } + + return ( +
+
+
+

Equipe

+

{membros.length} membro{membros.length !== 1 ? "s" : ""} na equipe

+
+ +
+ + {loading ? ( +
+
+
+ ) : membros.length === 0 ? ( + setModalAberto(true)} data-testid="equipe-convidar-empty-button"> + Convidar primeiro membro + + } + data-testid="equipe-empty-state" + /> + ) : ( +
+ {membros.map((membro) => { + const Icon = CARGO_ICON[membro.cargo]; + const cor = CARGO_COLOR[membro.cargo]; + return ( + + +
+
+ +
+
+

{membro.nome}

+

{membro.email}

+
+
+ + {membro.cargo !== "OWNER" && ( + + )} +
+
+
+
+ ); + })} +
+ )} + + {/* Legenda de cargos */} + + +

Permissões por cargo

+
+
+ + Proprietário: Acesso total — configurações, financeiro, equipe e todas as seções. +
+
+ + Gerente: Pedidos, cardápio, clientes e relatórios. Sem acesso a configurações e financeiro. +
+
+ + Atendente / Cozinha: Apenas pedidos e cozinha (KDS). +
+
+
+
+ + {/* Modal convidar */} + { if (!o) { setModalAberto(false); reset(); } }}> + + + Convidar membro + +
+
+ + + {errors.nome &&

{errors.nome.message}

} +
+
+ + + {errors.email &&

{errors.email.message}

} +
+
+ + +
+
+ + +
+
+
+
+ + {/* Modal remover */} + { if (!o) setModalRemover(null); }}> + + + Remover membro + +

+ Tem certeza que deseja remover {modalRemover?.nome} da equipe? +

+
+ + +
+
+
+
+ ); +} diff --git a/web/src/app/(dashboard)/dashboard/financeiro/page.tsx b/web/src/app/(dashboard)/dashboard/financeiro/page.tsx new file mode 100644 index 0000000..4cce988 --- /dev/null +++ b/web/src/app/(dashboard)/dashboard/financeiro/page.tsx @@ -0,0 +1,195 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { DollarSign, TrendingUp, ShoppingBag, XCircle, Download } from "lucide-react"; +import { toast } from "sonner"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Button } from "@/components/ui/button"; +import { MetricCard } from "@/components/shared/MetricCard"; +import { buscarRelatorioFinanceiro } from "@/lib/api/financeiro.api"; +import { formatCurrency } from "@/lib/utils/format"; +import type { RelatorioFinanceiro, PeriodoFinanceiro } from "@/types"; + +const PERIODOS: { value: PeriodoFinanceiro; label: string }[] = [ + { value: "hoje", label: "Hoje" }, + { value: "7dias", label: "7 dias" }, + { value: "30dias", label: "30 dias" }, + { value: "mes", label: "Este mês" }, +]; + +function GraficoBarras({ dados }: { dados: { label: string; valor: number }[] }) { + const max = Math.max(...dados.map((d) => d.valor), 1); + return ( +
+ {dados.map((d, i) => ( +
+
0 ? 4 : 0)}%` }} + title={formatCurrency(d.valor)} + /> + {d.label} +
+ ))} +
+ ); +} + +function exportarCSV(relatorio: RelatorioFinanceiro) { + const linhas = [ + ["Forma de Pagamento", "Total", "Percentual"], + ...relatorio.porFormaPagamento.map((f) => [f.label, formatCurrency(f.total), `${f.percentual}%`]), + [], + ["Faturamento Total", formatCurrency(relatorio.faturamentoTotal)], + ["Pedidos Pagos", String(relatorio.pedidosPagos)], + ["Pedidos Cancelados", String(relatorio.pedidosCancelados)], + ["Ticket Médio", formatCurrency(relatorio.ticketMedio)], + ]; + const csv = linhas.map((l) => l.join(",")).join("\n"); + const blob = new Blob([csv], { type: "text/csv;charset=utf-8;" }); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = "financeiro.csv"; + a.click(); + URL.revokeObjectURL(url); +} + +export default function FinanceiroPage() { + const [relatorio, setRelatorio] = useState(null); + const [loading, setLoading] = useState(true); + const [periodo, setPeriodo] = useState("7dias"); + + async function carregar(p: PeriodoFinanceiro) { + setLoading(true); + try { + setRelatorio(await buscarRelatorioFinanceiro(p)); + } catch { + toast.error("Não foi possível carregar os dados financeiros."); + } finally { + setLoading(false); + } + } + + useEffect(() => { void carregar(periodo); }, [periodo]); + + return ( +
+
+
+

Financeiro

+

Receita, pedidos e formas de pagamento

+
+
+
+ {PERIODOS.map(({ value, label }) => ( + + ))} +
+ {relatorio && ( + + )} +
+
+ + {loading ? ( +
+
+
+ ) : !relatorio ? ( +

Nenhum dado disponível.

+ ) : ( +
+ {/* Métricas */} +
+ + + + +
+ + {/* Gráfico */} + + + Faturamento por dia + + + {relatorio.grafico.every((d) => d.valor === 0) ? ( +

+ Nenhuma venda no período selecionado. +

+ ) : ( + + )} +
+
+ + {/* Formas de pagamento */} + + + Por forma de pagamento + + + {relatorio.porFormaPagamento.length === 0 ? ( +

Nenhum dado disponível.

+ ) : ( +
+ {relatorio.porFormaPagamento.map((item) => ( +
+
+ {item.label} +
+ {item.percentual}% + {formatCurrency(item.total)} +
+
+
+
+
+
+ ))} +
+ )} + + +
+ )} +
+ ); +} diff --git a/web/src/app/(dashboard)/dashboard/onboarding/page.tsx b/web/src/app/(dashboard)/dashboard/onboarding/page.tsx new file mode 100644 index 0000000..fd847a4 --- /dev/null +++ b/web/src/app/(dashboard)/dashboard/onboarding/page.tsx @@ -0,0 +1,188 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { useRouter } from "next/navigation"; +import { Check, ChevronRight, Store, Clock, Truck, CreditCard, SkipForward } from "lucide-react"; +import { toast } from "sonner"; +import { Card, CardContent } from "@/components/ui/card"; +import { Button } from "@/components/ui/button"; +import { buscarOnboarding, concluirEtapa, pularOnboarding } from "@/lib/api/onboarding.api"; +import type { EtapaOnboarding, OnboardingStatus } from "@/types"; + +const ETAPAS: { id: EtapaOnboarding; titulo: string; descricao: string; icon: React.ElementType; href: string }[] = [ + { + id: "dados_loja", + titulo: "Dados da loja", + descricao: "Nome, telefone, endereço e informações básicas", + icon: Store, + href: "/dashboard/configuracoes", + }, + { + id: "horarios", + titulo: "Horário de funcionamento", + descricao: "Configure os dias e horários que sua loja funciona", + icon: Clock, + href: "/dashboard/configuracoes", + }, + { + id: "entrega_retirada", + titulo: "Entrega e retirada", + descricao: "Áreas de entrega, taxas e opção de retirada no local", + icon: Truck, + href: "/dashboard/configuracoes/entrega", + }, + { + id: "pagamentos", + titulo: "Formas de pagamento", + descricao: "PIX, cartão e dinheiro aceitos no seu cardápio", + icon: CreditCard, + href: "/dashboard/configuracoes/pagamentos", + }, +]; + +export default function OnboardingPage() { + const router = useRouter(); + const [status, setStatus] = useState({ etapasCompletas: [], concluido: false }); + const [loading, setLoading] = useState(true); + + useEffect(() => { + buscarOnboarding() + .then((s) => { + setStatus(s); + if (s.concluido) router.replace("/dashboard"); + }) + .finally(() => setLoading(false)); + }, [router]); + + async function irParaEtapa(etapa: EtapaOnboarding, href: string) { + await concluirEtapa(etapa).catch(() => null); + router.push(href); + } + + async function pular() { + await pularOnboarding(); + toast.success("Onboarding ignorado. Você pode configurar depois em Configurações."); + router.push("/dashboard"); + } + + const etapasCompletas = status.etapasCompletas.length; + const total = ETAPAS.length; + const progresso = Math.round((etapasCompletas / total) * 100); + + if (loading) { + return ( +
+
+
+ ); + } + + return ( +
+
+
+ M +
+

Configure sua loja

+

+ Complete as etapas abaixo para que seus clientes possam fazer pedidos. +

+
+ + {/* Barra de progresso */} +
+
+ {etapasCompletas} de {total} etapas + {progresso}% +
+
+
+
+
+ + {/* Etapas */} +
+ {ETAPAS.map((etapa, idx) => { + const completa = status.etapasCompletas.includes(etapa.id); + const Icon = etapa.icon; + return ( + + +
+
+ {completa ? : } +
+
+
+

{etapa.titulo}

+ {completa && ( + + Concluído + + )} +
+

{etapa.descricao}

+
+ +
+
+
+ ); + })} +
+ +
+ {etapasCompletas === total ? ( + + ) : ( + + )} +

+ Você pode retornar a estas configurações a qualquer momento em{" "} + . +

+
+
+ ); +} diff --git a/web/src/app/(dashboard)/dashboard/relatorios/page.tsx b/web/src/app/(dashboard)/dashboard/relatorios/page.tsx new file mode 100644 index 0000000..2006910 --- /dev/null +++ b/web/src/app/(dashboard)/dashboard/relatorios/page.tsx @@ -0,0 +1,175 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { BarChart2, Users, Repeat, TrendingUp, Clock } from "lucide-react"; +import { toast } from "sonner"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { MetricCard } from "@/components/shared/MetricCard"; +import { buscarRelatorioOperacional } from "@/lib/api/relatorios.api"; +import { formatCurrency } from "@/lib/utils/format"; +import type { RelatorioOperacional, PeriodoRelatorio } from "@/types"; + +const PERIODOS: { value: PeriodoRelatorio; label: string }[] = [ + { value: "7dias", label: "7 dias" }, + { value: "30dias", label: "30 dias" }, + { value: "90dias", label: "90 dias" }, +]; + +function GraficoBarras({ dados, altura = 112 }: { dados: { label: string; valor: number }[]; altura?: number }) { + const max = Math.max(...dados.map((d) => d.valor), 1); + return ( +
+ {dados.map((d, i) => ( +
+
0 ? 4 : 0)}%` }} + title={formatCurrency(d.valor)} + /> + {d.label} +
+ ))} +
+ ); +} + +function GraficoHorarios({ dados }: { dados: { hora: string; pedidos: number }[] }) { + const max = Math.max(...dados.map((d) => d.pedidos), 1); + const horariosFiltrados = dados.filter((_, i) => i >= 8 && i <= 23); + return ( +
+ {horariosFiltrados.map((d, i) => ( +
+
0 ? 4 : 0)}%` }} + title={`${d.hora}: ${d.pedidos} pedidos`} + /> + {i % 4 === 0 && {d.hora.slice(0, 2)}h} +
+ ))} +
+ ); +} + +export default function RelatoriosPage() { + const [relatorio, setRelatorio] = useState(null); + const [loading, setLoading] = useState(true); + const [periodo, setPeriodo] = useState("30dias"); + + async function carregar(p: PeriodoRelatorio) { + setLoading(true); + try { + setRelatorio(await buscarRelatorioOperacional(p)); + } catch { + toast.error("Não foi possível carregar os relatórios."); + } finally { + setLoading(false); + } + } + + useEffect(() => { void carregar(periodo); }, [periodo]); + + return ( +
+
+
+

Relatórios

+

Produtos, horários e clientes

+
+
+ {PERIODOS.map(({ value, label }) => ( + + ))} +
+
+ + {loading ? ( +
+
+
+ ) : !relatorio ? ( +

Nenhum dado disponível.

+ ) : ( +
+ {/* Clientes */} +
+ + + +
+ + {/* Vendas por dia */} + + + + + Vendas por dia + + + + {relatorio.vendasPorDia.every((d) => d.valor === 0) ? ( +

Nenhuma venda no período.

+ ) : ( + + )} +
+
+ + {/* Top produtos */} + + + Produtos mais vendidos + + + {relatorio.topProdutos.length === 0 ? ( +

Nenhum produto vendido no período.

+ ) : ( +
+ {relatorio.topProdutos.map((p) => ( +
+ {p.posicao}º +
+

{p.nome}

+

{p.unidades} unidades

+
+ {formatCurrency(p.faturamento)} +
+ ))} +
+ )} +
+
+ + {/* Horários de pico */} + + + + + Horários de pico + + + + {relatorio.horariosPico.every((h) => h.pedidos === 0) ? ( +

Nenhum pedido no período.

+ ) : ( + + )} +
+
+
+ )} +
+ ); +} diff --git a/web/src/app/(dashboard)/dashboard/whatsapp/page.tsx b/web/src/app/(dashboard)/dashboard/whatsapp/page.tsx new file mode 100644 index 0000000..eed622c --- /dev/null +++ b/web/src/app/(dashboard)/dashboard/whatsapp/page.tsx @@ -0,0 +1,146 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { MessageCircle, Save } from "lucide-react"; +import { toast } from "sonner"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { buscarConfigWhatsApp, salvarConfigWhatsApp } from "@/lib/api/whatsapp.api"; +import type { ConfigWhatsApp } from "@/types"; + +const STATUS_LABELS: Record = { + recebido: "Pedido recebido", + em_preparo: "Em preparo", + saiu_entrega: "Saiu para entrega", + finalizado: "Pedido finalizado", +}; + +const STATUS_HINTS: Record = { + recebido: "Enviada assim que o pedido é confirmado.", + em_preparo: "Enviada quando o status muda para 'Em preparo'.", + saiu_entrega: "Enviada quando o pedido sai para entrega.", + finalizado: "Enviada quando o pedido é finalizado.", +}; + +const VARIAVEIS = ["{{nome}}", "{{numero}}", "{{total}}"]; + +export default function WhatsAppPage() { + const [config, setConfig] = useState({ + numero: "", + mensagens: { recebido: "", em_preparo: "", saiu_entrega: "", finalizado: "" }, + }); + const [loading, setLoading] = useState(true); + const [salvando, setSalvando] = useState(false); + + useEffect(() => { + buscarConfigWhatsApp() + .then(setConfig) + .catch(() => toast.error("Erro ao carregar configurações.")) + .finally(() => setLoading(false)); + }, []); + + async function salvar() { + setSalvando(true); + try { + await salvarConfigWhatsApp(config); + toast.success("Configurações de WhatsApp salvas!"); + } catch { + toast.error("Erro ao salvar configurações."); + } finally { + setSalvando(false); + } + } + + if (loading) { + return ( +
+
+
+ ); + } + + return ( +
+
+

WhatsApp

+

Configure o número e as mensagens automáticas do checkout

+
+ + {/* Número */} + + + + + Número do WhatsApp + + + +
+ + setConfig((c) => ({ ...c, numero: e.target.value }))} + placeholder="(11) 99999-9999" + data-testid="whatsapp-numero-input" + /> +

+ Este número será usado no checkout para abrir a conversa com o cliente. +

+
+
+
+ + {/* Variáveis disponíveis */} + + +

Variáveis disponíveis nas mensagens

+
+ {VARIAVEIS.map((v) => ( + + {v} + + ))} +
+
+
+ + {/* Mensagens */} + + + Modelos de mensagem por status + + + {(Object.keys(STATUS_LABELS) as Array).map((status) => ( +
+ +