diff --git a/.env.example b/.env.example deleted file mode 100644 index 661c5fa..0000000 --- a/.env.example +++ /dev/null @@ -1,62 +0,0 @@ -# Ambiente local — Sistema de Chamados -# Copie este arquivo para `.env` e preencha os valores sensíveis. -# Nunca faça commit de `.env` com segredos reais. - -# Convex -CONVEX_DEPLOYMENT=anonymous:anonymous-sistema-de-chamados -NEXT_PUBLIC_CONVEX_URL=http://127.0.0.1:3210 -CONVEX_SYNC_SECRET=dev-sync-secret - -# Next.js / App URL -NEXT_PUBLIC_APP_URL=http://localhost:3000 - -# Better Auth -# Gere um segredo forte (ex.: `openssl rand -hex 32`) -BETTER_AUTH_SECRET=change-me -BETTER_AUTH_URL=http://localhost:3000 - -# Banco de dados (Prisma) -DATABASE_URL=file:./prisma/db.dev.sqlite - -# Seeds automáticos (Better Auth) -# Por padrão (true), garantindo apenas existência dos usuários padrão sem resetar senhas -SEED_ENSURE_ONLY=true - -# Provisionamento e inventário de máquinas -# Segredo obrigatório para registrar/atualizar máquinas (Convex) -MACHINE_PROVISIONING_SECRET=change-me-provisioning -# Tempo de vida do token de máquina (ms) — padrão 30 dias -MACHINE_TOKEN_TTL_MS=2592000000 -# Opcional: segredo dedicado para webhook do FleetDM (senão usa o de provisionamento) -FLEET_SYNC_SECRET= - -# SMTP (envio de e-mails) -SMTP_ADDRESS= -SMTP_PORT=465 -SMTP_DOMAIN= -SMTP_USERNAME= -SMTP_PASSWORD= -SMTP_AUTHENTICATION=login -SMTP_ENABLE_STARTTLS_AUTO=false -SMTP_TLS=true -MAILER_SENDER_EMAIL="Suporte " - -# Alertas (actions do Convex) -# Hora local (America/Sao_Paulo) para rodar alertas automáticos -ALERTS_LOCAL_HOUR=8 - -# Seeds e sincronizações auxiliares -SYNC_TENANT_ID=tenant-atlas -SYNC_DEFAULT_ASSIGNEE=agent@example.com -SEED_TENANT_ID=tenant-atlas -SEED_ADMIN_PASSWORD=admin123 -SEED_AGENT_PASSWORD=agent123 -SEED_USER_TENANT=tenant-atlas -SEED_USER_EMAIL= -SEED_USER_PASSWORD= -SEED_USER_NAME= -SEED_USER_ROLE=admin - -# Desenvolvimento Desktop (Tauri/Vite) -# Em redes locais, defina o IP do host para HMR. -TAURI_DEV_HOST= diff --git a/env (1) b/env (1) new file mode 100644 index 0000000..8b2fee3 --- /dev/null +++ b/env (1) @@ -0,0 +1,47 @@ +# Configurações locais padrão + +NEXT_PUBLIC_CONVEX_URL=https://convex.esdrasrenan.com.br +BETTER_AUTH_SECRET=44bf2b9f97b94d0ea68b4518eec0c565a9b5660fbd3f54ef958d36b2b2a2d4e3 +BETTER_AUTH_URL=https://tickets.esdrasrenan.com.br +NEXT_PUBLIC_APP_URL=https://tickets.esdrasrenan.com.br +DATABASE_URL=file:./prisma/db.sqlite +CONVEX_SYNC_SECRET=dev-sync-secret +SMTP_ADDRESS=smtp.hostinger.com +SMTP_PORT=465 +SMTP_DOMAIN=esdrasrenan.com.br +SMTP_USERNAME=chat@esdrasrenan.com.br +SMTP_PASSWORD=5ffB7Ny4#@qA!0e19NG5 +SMTP_AUTHENTICATION=login +SMTP_ENABLE_STARTTLS_AUTO=false +SMTP_TLS=true +MAILER_SENDER_EMAIL="Chatwoot chat@esdrasrenan.com.br (chat@esdrasrenan.com.br)" + +# ===== Adições para provisionamento/integrações ===== +# Segredo para provisionamento de máquinas (Convex) +MACHINE_PROVISIONING_SECRET=71daa9ef54cb224547e378f8121ca898b614446c142a132f73c2221b4d53d7d6 +# TTL do token de máquina (ms) — 30 dias +MACHINE_TOKEN_TTL_MS=2592000000 +# Opcional: segredo para webhook do FleetDM (senão usa o de provisionamento) +FLEET_SYNC_SECRET=71daa9ef54cb224547e378f8121ca898b614446c142a132f73c2221b4d53d7d6 + +# Hora local (America/Sao_Paulo) para rodar alertas automáticos (actions do Convex) +ALERTS_LOCAL_HOUR=8 + +# ===== Seeds/sync auxiliares ===== +SYNC_TENANT_ID=tenant-atlas +SYNC_DEFAULT_ASSIGNEE=agent@example.com +SEED_TENANT_ID=tenant-atlas +SEED_ADMIN_PASSWORD=admin123 +SEED_AGENT_PASSWORD=agent123 +SEED_USER_TENANT=tenant-atlas +SEED_USER_EMAIL= +SEED_USER_PASSWORD= +SEED_USER_NAME= +SEED_USER_ROLE=admin + +# ===== Desenvolvimento Desktop ===== +# IP do host para HMR quando rodar `pnpm --filter appsdesktop tauri dev` +TAURI_DEV_HOST= + +VITE_APP_URL=https://tickets.esdrasrenan.com.br +VITE_API_BASE_URL=https://tickets.esdrasrenan.com.br diff --git a/scripts/seed-convex-demo.mjs b/scripts/seed-convex-demo.mjs new file mode 100644 index 0000000..13dc2f1 --- /dev/null +++ b/scripts/seed-convex-demo.mjs @@ -0,0 +1,19 @@ +import 'dotenv/config' +import { ConvexHttpClient } from 'convex/browser' +import { api } from '../convex/_generated/api.js' + +async function main() { + const url = process.env.NEXT_PUBLIC_CONVEX_URL || 'http://127.0.0.1:3210' + const client = new ConvexHttpClient(url) + console.log(`[seed] Using Convex at ${url}`) + try { + await client.mutation(api.seed.seedDemo, {}) + console.log('[seed] Convex demo data ensured (queues/users)') + } catch (err) { + console.error('[seed] Failed to seed Convex demo data:', err?.message || err) + process.exitCode = 1 + } +} + +main() + diff --git a/src/app/api/export/pdf/route.ts b/src/app/api/export/pdf/route.ts index 33e2ddc..ed66457 100644 --- a/src/app/api/export/pdf/route.ts +++ b/src/app/api/export/pdf/route.ts @@ -31,7 +31,23 @@ export async function POST(request: Request) { let browser: Awaited> | null = null try { - browser = await chromium.launch() + try { + browser = await chromium.launch({ headless: true }) + } catch (launchError) { + const msg = (launchError as Error)?.message || String(launchError) + // Dev-friendly response when Playwright browsers are missing + if (/Executable doesn't exist|Please run the following command to download new browsers/i.test(msg)) { + return NextResponse.json( + { + error: "Playwright browsers not installed", + hint: "Execute: npx playwright install", + details: msg, + }, + { status: 501 }, + ) + } + throw launchError + } const page = await browser.newPage({ viewport: { width, height } }) await page.goto(payload.url, { waitUntil: "networkidle" }) if (waitForSelector) { diff --git a/src/app/play/page.tsx b/src/app/play/page.tsx index 4a6e001..6e61335 100644 --- a/src/app/play/page.tsx +++ b/src/app/play/page.tsx @@ -1,5 +1,5 @@ import { AppShell } from "@/components/app-shell" -import { SiteHeader } from "@/components/site-header" +import { SiteHeader, SiteHeaderPrimaryButton, SiteHeaderSecondaryButton } from "@/components/site-header" import { PlayNextTicketCard } from "@/components/tickets/play-next-ticket-card" import { TicketQueueSummaryCards } from "@/components/tickets/ticket-queue-summary" import { requireAuthenticatedSession } from "@/lib/auth-server" @@ -12,8 +12,8 @@ export default async function PlayPage() { Pausar notificações} - primaryAction={Iniciar sessão} + secondaryAction={Pausar notificações} + primaryAction={Iniciar sessão} /> } > diff --git a/src/components/dashboards/dashboard-builder.tsx b/src/components/dashboards/dashboard-builder.tsx index 1e3a724..2a22e37 100644 --- a/src/components/dashboards/dashboard-builder.tsx +++ b/src/components/dashboards/dashboard-builder.tsx @@ -441,6 +441,14 @@ function filtersEqual(a: DashboardFilters, b: DashboardFilters) { ) } +function deepEqual(a: T, b: T) { + try { + return JSON.stringify(a) === JSON.stringify(b) + } catch { + return false + } +} + const widgetConfigSchema = z.object({ title: z.string().min(1, "Informe um título"), type: z.string(), @@ -500,12 +508,14 @@ export function DashboardBuilder({ dashboardId, editable = true, mode = "edit" } useEffect(() => { if (!detail) return - setDashboard(detail.dashboard) - setWidgets(detail.widgets) - setShares(detail.shares) + setDashboard((prev) => (prev && deepEqual(prev, detail.dashboard) ? prev : detail.dashboard)) + setWidgets((prev) => (deepEqual(prev, detail.widgets) ? prev : detail.widgets)) + setShares((prev) => (deepEqual(prev, detail.shares) ? prev : detail.shares)) const nextFilters = normalizeFilters(detail.dashboard.filters) - filtersHydratingRef.current = true - setFilters(nextFilters) + if (!filtersEqual(filters, nextFilters)) { + filtersHydratingRef.current = true + setFilters(nextFilters) + } const syncedLayout = syncLayoutWithWidgets(layoutRef.current, detail.widgets, detail.dashboard.layout) if (!layoutItemsEqual(layoutRef.current, syncedLayout)) { layoutRef.current = syncedLayout @@ -543,6 +553,30 @@ export function DashboardBuilder({ dashboardId, editable = true, mode = "edit" } return new Set(currentSection?.widgetKeys ?? []) }, [enforceTv, sections, activeSectionIndex]) + // Ready handlers (stable per widget) defined before usage in canvasItems + const handleWidgetReady = useCallback((key: string, ready: boolean) => { + setReadyWidgets((prev) => { + const currentlyReady = prev.has(key) + if (currentlyReady === ready) return prev + const next = new Set(prev) + if (ready) next.add(key) + else next.delete(key) + return next + }) + }, []) + + const readyHandlersRef = useRef(new Map void>()) + const getReadyHandler = useCallback( + (key: string) => { + const cached = readyHandlersRef.current.get(key) + if (cached) return cached + const handler = (ready: boolean) => handleWidgetReady(key, ready) + readyHandlersRef.current.set(key, handler) + return handler + }, + [handleWidgetReady], + ) + const canvasItems = packedLayout .map((item) => { const widget = widgetMap.get(item.i) @@ -565,7 +599,7 @@ export function DashboardBuilder({ dashboardId, editable = true, mode = "edit" } onDuplicate={() => handleDuplicateWidget(widget)} onRemove={() => handleRemoveWidget(widget)} onViewData={() => setDataTarget(widget)} - onReadyChange={(ready) => handleWidgetReady(widget.widgetKey, ready)} + onReadyChange={getReadyHandler(widget.widgetKey)} /> ), ...(item.minW !== undefined ? { minW: item.minW } : {}), @@ -576,18 +610,6 @@ export function DashboardBuilder({ dashboardId, editable = true, mode = "edit" } const allWidgetsReady = canvasItems.length > 0 && canvasItems.every((item) => readyWidgets.has(item.key)) - const handleWidgetReady = useCallback((key: string, ready: boolean) => { - setReadyWidgets((prev) => { - const next = new Set(prev) - if (ready) { - next.add(key) - } else { - next.delete(key) - } - return next - }) - }, []) - useEffect(() => { const keys = new Set(canvasItems.map((item) => item.key)) setReadyWidgets((prev) => { @@ -799,7 +821,13 @@ export function DashboardBuilder({ dashboardId, editable = true, mode = "edit" } }), }) if (!response.ok) { - throw new Error("Export request failed") + let msg = "Export request failed" + try { + const data = await response.json() + if (data?.hint) msg = `${msg}: ${data.hint}` + else if (data?.error) msg = `${msg}: ${data.error}` + } catch {} + throw new Error(msg) } const blob = await response.blob() const url = URL.createObjectURL(blob) @@ -811,7 +839,10 @@ export function DashboardBuilder({ dashboardId, editable = true, mode = "edit" } toast.success("Exportação gerada com sucesso!") } catch (error) { console.error(error) - toast.error("Não foi possível exportar o dashboard.") + const message = error instanceof Error ? error.message : String(error) + toast.error(message.includes("Playwright browsers not installed") || message.includes("npx playwright install") + ? "Exportação indisponível em DEV: rode `npx playwright install` para habilitar." + : "Não foi possível exportar o dashboard.") } finally { setIsExporting(false) } diff --git a/src/components/portal/portal-ticket-detail.tsx b/src/components/portal/portal-ticket-detail.tsx index 756f8df..5736fce 100644 --- a/src/components/portal/portal-ticket-detail.tsx +++ b/src/components/portal/portal-ticket-detail.tsx @@ -15,7 +15,7 @@ import { useAuth } from "@/lib/auth-client" import { Badge } from "@/components/ui/badge" import { Button } from "@/components/ui/button" import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" -import { Dialog, DialogClose, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog" +import { Dialog, DialogClose, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog" import { Dropzone } from "@/components/ui/dropzone" import { Empty, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from "@/components/ui/empty" import { Skeleton } from "@/components/ui/skeleton" @@ -513,6 +513,11 @@ export function PortalTicketDetail({ ticketId }: PortalTicketDetailProps) { + + {isPreviewImage + ? "Pré-visualização da imagem anexada." + : "Pré-visualização do arquivo selecionado. Utilize o botão para abrir em uma nova aba."} + {previewAttachment ? ( isPreviewImage ? (
@@ -696,5 +701,3 @@ function PortalCommentAttachmentCard({ - - diff --git a/src/components/site-header.tsx b/src/components/site-header.tsx index 8f29983..0fc0e46 100644 --- a/src/components/site-header.tsx +++ b/src/components/site-header.tsx @@ -42,8 +42,8 @@ export function SiteHeader({ ) } - -SiteHeader.PrimaryButton = function SiteHeaderPrimaryButton({ + +export function SiteHeaderPrimaryButton({ children, className, ...props @@ -55,7 +55,7 @@ SiteHeader.PrimaryButton = function SiteHeaderPrimaryButton({ ) } -SiteHeader.SecondaryButton = function SiteHeaderSecondaryButton({ +export function SiteHeaderSecondaryButton({ children, className, ...props @@ -66,3 +66,7 @@ SiteHeader.SecondaryButton = function SiteHeaderSecondaryButton({ ) } + +// Backward compatibility: attach as static members (client-only usage) +;(SiteHeader as any).PrimaryButton = SiteHeaderPrimaryButton +;(SiteHeader as any).SecondaryButton = SiteHeaderSecondaryButton diff --git a/src/components/tickets/ticket-csat-card.tsx b/src/components/tickets/ticket-csat-card.tsx index c0e0849..52c611f 100644 --- a/src/components/tickets/ticket-csat-card.tsx +++ b/src/components/tickets/ticket-csat-card.tsx @@ -90,12 +90,20 @@ export function TicketCsatCard({ ticket }: TicketCsatCardProps) { const [hoverScore, setHoverScore] = useState(null) const [submitting, setSubmitting] = useState(false) + const ratedAtTimestamp = ticket.csatRatedAt ? ticket.csatRatedAt.getTime() : null + useEffect(() => { - setScore(initialScore) - setComment(initialComment) - setRatedAt(ticket.csatRatedAt ?? null) - setHasSubmitted(initialScore > 0) - }, [initialScore, initialComment, ticket.csatRatedAt]) + setScore((prev) => (prev === initialScore ? prev : initialScore)) + setComment((prev) => (prev === initialComment ? prev : initialComment)) + const nextRatedAt = ratedAtTimestamp == null ? null : new Date(ratedAtTimestamp) + setRatedAt((prev) => { + if (prev === nextRatedAt) return prev + if (prev && nextRatedAt && prev.getTime() === nextRatedAt.getTime()) return prev + return nextRatedAt + }) + const nextHasSubmitted = initialScore > 0 + setHasSubmitted((prev) => (prev === nextHasSubmitted ? prev : nextHasSubmitted)) + }, [initialScore, initialComment, ratedAtTimestamp]) const effectiveScore = hasSubmitted ? score : hoverScore ?? score const viewerIsStaff = viewerRole === "ADMIN" || viewerRole === "AGENT" || viewerRole === "MANAGER" diff --git a/src/lib/auth-client.tsx b/src/lib/auth-client.tsx index 753b7c3..8460398 100644 --- a/src/lib/auth-client.tsx +++ b/src/lib/auth-client.tsx @@ -90,7 +90,22 @@ export function useAuth() { export const { signIn, signOut, useSession } = authClient export function AuthProvider({ children }: { children: React.ReactNode }) { - const { data: session, isPending } = useSession() + const devBypass = process.env.NODE_ENV !== "production" && process.env.NEXT_PUBLIC_DEV_BYPASS_AUTH === "1" + const { data: baseSession, isPending } = useSession() + const session: AppSession | null = baseSession ?? (devBypass + ? { + session: { id: "dev-session", expiresAt: Date.now() + 1000 * 60 * 60 }, + user: { + id: "dev-user", + name: "Dev Admin", + email: "admin@sistema.dev", + role: "admin", + tenantId: "tenant-atlas", + avatarUrl: null, + machinePersona: null, + }, + } + : null) const ensureUser = useMutation(api.users.ensureUser) const [convexUserId, setConvexUserId] = useState(null) const [machineContext, setMachineContext] = useState(null) diff --git a/src/lib/auth-server.ts b/src/lib/auth-server.ts index a1d10c7..7d16d92 100644 --- a/src/lib/auth-server.ts +++ b/src/lib/auth-server.ts @@ -55,6 +55,24 @@ async function buildRequest() { export async function getServerSession(): Promise { try { + // Dev-only bypass to simplify local dashboard access when auth is misconfigured. + if (process.env.NODE_ENV !== "production" && process.env.DEV_BYPASS_AUTH === "1") { + return { + session: { + id: "dev-session", + expiresAt: Date.now() + 1000 * 60 * 60, + }, + user: { + id: "dev-user", + name: "Dev Admin", + email: "admin@sistema.dev", + role: "admin", + tenantId: "tenant-atlas", + avatarUrl: null, + machinePersona: null, + }, + } + } const request = await buildRequest() const session = await auth.api.getSession({ headers: request.headers, diff --git a/src/lib/auth.ts b/src/lib/auth.ts index c7d0842..3600a88 100644 --- a/src/lib/auth.ts +++ b/src/lib/auth.ts @@ -23,7 +23,8 @@ export const auth = betterAuth({ provider: "sqlite", }), user: { - modelName: "authUser", + // Prisma model name (case-sensitive) + modelName: "AuthUser", additionalFields: { role: { type: "string", @@ -47,17 +48,17 @@ export const auth = betterAuth({ }, }, session: { - modelName: "authSession", + modelName: "AuthSession", cookieCache: { enabled: true, maxAge: 60 * 5, }, }, account: { - modelName: "authAccount", + modelName: "AuthAccount", }, verification: { - modelName: "authVerification", + modelName: "AuthVerification", }, emailAndPassword: { enabled: true, diff --git a/src/lib/prisma.ts b/src/lib/prisma.ts index 8b98439..78bd401 100644 --- a/src/lib/prisma.ts +++ b/src/lib/prisma.ts @@ -42,7 +42,8 @@ const resolvedDatabaseUrl = (() => { if (process.env.NODE_ENV === "production") { return "file:/app/data/db.sqlite" } - return resolveFileUrl("file:./prisma/db.sqlite") + // In development, prefer a dedicated dev DB file + return resolveFileUrl("file:./prisma/db.dev.sqlite") })() export const prisma = @@ -51,3 +52,9 @@ export const prisma = if (process.env.NODE_ENV !== "production") { global.prisma = prisma } + +if (process.env.NODE_ENV !== "production") { + // Helps detect mismatched DB path during dev server bootstrap + // eslint-disable-next-line no-console + console.log("[prisma] Using database:", resolvedDatabaseUrl) +}