diff --git a/package.json b/package.json index cb88b015..7897b96d 100644 --- a/package.json +++ b/package.json @@ -62,6 +62,7 @@ "@vercel/analytics": "^1.5.0", "@vercel/speed-insights": "^1.2.0", "ai": "^4.2.10", + "animated-fluent-emojis": "^0.1.2", "canvas-confetti": "^1.9.4", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3c6ebeec..25e49643 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -116,6 +116,9 @@ importers: ai: specifier: ^4.2.10 version: 4.3.19(react@19.2.1)(zod@3.25.76) + animated-fluent-emojis: + specifier: ^0.1.2 + version: 0.1.2(react-dom@19.2.1(react@19.2.1))(react@19.2.1) canvas-confetti: specifier: ^1.9.4 version: 1.9.4 @@ -2193,6 +2196,12 @@ packages: ajv@6.12.6: resolution: {integrity: sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==} + animated-fluent-emojis@0.1.2: + resolution: {integrity: sha512-JPKvOk8JVwDqPk9tza2c1qSBcufkJJJDQQ87kV1GpikZ0IWVFJkH1S8avAB+/zDUPfDOCatnIfKmrEDPfrycJg==} + peerDependencies: + react: ^18.3.1 + react-dom: ^18.3.1 + ansi-regex@5.0.1: resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} engines: {node: '>=8'} @@ -6915,6 +6924,11 @@ snapshots: json-schema-traverse: 0.4.1 uri-js: 4.4.1 + animated-fluent-emojis@0.1.2(react-dom@19.2.1(react@19.2.1))(react@19.2.1): + dependencies: + react: 19.2.1 + react-dom: 19.2.1(react@19.2.1) + ansi-regex@5.0.1: {} ansi-styles@4.3.0: diff --git a/prisma/schema.prisma b/prisma/schema.prisma index e6b2cb83..9266a701 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -71,6 +71,10 @@ model User { qualityDocumentsApprovedManager QualityDocument[] @relation("QualityDocumentApprovedManager") qualityDocumentAccess QualityDocumentAccess[] + // Régua de Emoções + emotionRulerResponses EmotionRulerResponse[] + emotionRulerDailyAccesses EmotionRulerDailyAccess[] + @@map("users") } @@ -945,4 +949,78 @@ enum DocRevPeriod { TRIMESTRAL SEMESTRAL ANUAL +} + +// Módulo Régua de Emoções +model EmotionRuler { + id String @id @default(cuid()) + question String @default("Como você está se sentindo hoje?") + isActive Boolean @default(true) + startDate DateTime? + endDate DateTime? + backgroundColor String? // Cor de fundo do layout + emotions EmotionRulerEmotion[] + responses EmotionRulerResponse[] + dailyAccesses EmotionRulerDailyAccess[] + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@map("emotion_rulers") +} + +model EmotionRulerEmotion { + id String @id @default(cuid()) + rulerId String + value Int // Valor numérico (0-5, onde 0 é extremamente chateado e 5 é extremamente feliz) + emoji String? // Emoji para este nível (ex: "😢", "😊") + color String // Cor da emoção (ex: "#FF0000" para vermelho, "#00FF00" para verde) + states String[] @default([]) // Estados/emoções para este nível (ex: ["frustrado", "chateado", "triste"]) + order Int @default(0) // Ordem na régua + ruler EmotionRuler @relation(fields: [rulerId], references: [id], onDelete: Cascade) + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@unique([rulerId, value]) + @@index([rulerId, order]) + @@map("emotion_ruler_emotions") +} + +model EmotionRulerResponse { + id String @id @default(cuid()) + userId String + rulerId String + emotionValue Int // Valor da emoção selecionada (0-5) + comment String? // Comentário opcional + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + ruler EmotionRuler @relation(fields: [rulerId], references: [id], onDelete: Cascade) + + @@index([userId, createdAt]) + @@index([rulerId, createdAt]) + @@index([emotionValue]) + @@map("emotion_ruler_responses") +} + +model EmotionRulerDailyAccess { + id String @id @default(cuid()) + userId String + rulerId String + date DateTime @default(now()) @db.Date // Data do acesso (sem hora) + accessedAt DateTime @default(now()) // Data e hora exata do acesso + wasDismissed Boolean @default(false) // Se o usuário fechou no X + responded Boolean @default(false) // Se o usuário respondeu + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + ruler EmotionRuler @relation(fields: [rulerId], references: [id], onDelete: Cascade) + + @@unique([userId, rulerId, date]) + @@index([userId, date]) + @@index([rulerId, date]) + @@index([date]) + @@map("emotion_ruler_daily_accesses") } \ No newline at end of file diff --git a/src/app/(authenticated)/admin/emotion-ruler/page.tsx b/src/app/(authenticated)/admin/emotion-ruler/page.tsx new file mode 100644 index 00000000..15352638 --- /dev/null +++ b/src/app/(authenticated)/admin/emotion-ruler/page.tsx @@ -0,0 +1,148 @@ +"use client" + +import { useState } from "react" +import { DashboardShell } from "@/components/dashboard-shell" +import { Button } from "@/components/ui/button" +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card" +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs" +import { api } from "@/trpc/react" +import { toast } from "sonner" +import { Plus, Loader2, Settings, BarChart3 } from "lucide-react" +import { EmotionRulerForm } from "@/components/admin/emotion-ruler/emotion-ruler-form" +import { EmotionRulerList } from "@/components/admin/emotion-ruler/emotion-ruler-list" +import { EmotionRulerStats } from "@/components/admin/emotion-ruler/emotion-ruler-stats" +import { useAccessControl } from "@/hooks/use-access-control" + +export default function AdminEmotionRulerPage() { + const [selectedRulerId, setSelectedRulerId] = useState(null) + const [isCreating, setIsCreating] = useState(false) + const [activeTab, setActiveTab] = useState("list") + + const { hasAdminAccess, isLoading: isLoadingAccess } = useAccessControl() + + const { data: rulers, isLoading, refetch } = api.emotionRuler.getAll.useQuery( + undefined, + { + enabled: hasAdminAccess("/admin/emotion-ruler"), + } + ) + + if (isLoadingAccess) { + return ( + +
+ +
+
+ ) + } + + if (!hasAdminAccess("/admin/emotion-ruler")) { + return ( + +
+

+ Você não tem permissão para acessar esta página. +

+
+
+ ) + } + + const handleCreateNew = () => { + setSelectedRulerId(null) + setIsCreating(true) + setActiveTab("form") + } + + const handleEdit = (rulerId: string) => { + setSelectedRulerId(rulerId) + setIsCreating(false) + setActiveTab("form") + } + + const handleFormSuccess = () => { + toast.success("Régua salva com sucesso!") + setSelectedRulerId(null) + setIsCreating(false) + setActiveTab("list") + void refetch() + } + + const handleFormCancel = () => { + setSelectedRulerId(null) + setIsCreating(false) + setActiveTab("list") + } + + return ( + +
+
+
+

+ Régua de Emoções +

+

+ Gerencie as réguas de emoções, configure emoções, datas e acompanhe as respostas dos colaboradores +

+
+ +
+ + {/* Formulário de criação/edição */} + {(isCreating || selectedRulerId) && ( + + + + {isCreating ? "Criar Nova Régua" : "Editar Régua"} + + + Configure a pergunta, emoções, datas de ativação e cor de fundo + + + + + + + )} + + {/* Tabs apenas quando não está criando/editando */} + {!isCreating && !selectedRulerId && ( + + + + + Gerenciar Réguas + + + + Estatísticas e KPIs + + + + + + + + + + + + )} +
+
+ ) +} diff --git a/src/app/(authenticated)/admin/food/_components/dre-report.tsx b/src/app/(authenticated)/admin/food/_components/dre-report.tsx index 20871826..05565abd 100644 --- a/src/app/(authenticated)/admin/food/_components/dre-report.tsx +++ b/src/app/(authenticated)/admin/food/_components/dre-report.tsx @@ -13,7 +13,7 @@ import { DateRangeCalendar } from "@/components/forms/date-range-calendar" import { toast } from "sonner" import { format, startOfMonth, endOfMonth } from "date-fns" import { ptBR } from "date-fns/locale" -import { Calculator, Download, FileText, Eye, FileSpreadsheet } from "lucide-react" +import { Calculator, FileText, Eye, FileSpreadsheet } from "lucide-react" import * as XLSX from "xlsx" import { Dialog, @@ -49,7 +49,7 @@ type RateioType = 'proportional' | 'headquarters' | 'branch' // Constante com os valores do enum Enterprise que representam filiais const BRANCH_ENTERPRISES = ['Box_Filial', 'Cristallux_Filial'] as const - +// eslint-disable-next-line @typescript-eslint/no-unused-vars export default function DREReport({ selectedDate, setSelectedDate }: DREReportProps) { const [invoiceValue, setInvoiceValue] = useState("") const [startDate, setStartDate] = useState( diff --git a/src/app/(authenticated)/admin/page.tsx b/src/app/(authenticated)/admin/page.tsx index 4bda1209..6e90ed4c 100644 --- a/src/app/(authenticated)/admin/page.tsx +++ b/src/app/(authenticated)/admin/page.tsx @@ -18,7 +18,8 @@ export default async function Page() { db_user.role_config?.admin_pages || [], route.id, db_user.role_config?.can_manage_produtos === true, - db_user.role_config?.can_manage_quality_management === true + db_user.role_config?.can_manage_quality_management === true, + db_user.role_config?.can_manage_emotion_rules === true ); } diff --git a/src/app/(authenticated)/dashboard/page.tsx b/src/app/(authenticated)/dashboard/page.tsx index c159b6d6..98d9e615 100644 --- a/src/app/(authenticated)/dashboard/page.tsx +++ b/src/app/(authenticated)/dashboard/page.tsx @@ -18,6 +18,7 @@ import { Separator } from "@/components/ui/separator" import { SuggestionsWrapper } from "./suggestions-wrapper" import { CompleteProfileModal } from "@/components/complete-profile-modal" import { WelcomeCard } from "@/components/dashboard/welcome-card" +import { EmotionRulerWrapper } from "@/components/emotion-ruler/emotion-ruler-wrapper" import { useState, useEffect, useMemo } from "react" // Variantes de animação para o footer @@ -57,15 +58,14 @@ export default function DashboardPage() { } const today = new Date() - // Usa UTC para evitar problemas de timezone, especialmente para datas como 31/12 - const currentDay = today.getUTCDate() - const currentMonth = today.getUTCMonth() + const currentDay = today.getDate() + const currentMonth = today.getMonth() return birthdays.filter((birthday) => { const birthdayDate = new Date(birthday.data) return ( - birthdayDate.getUTCDate() === currentDay && - birthdayDate.getUTCMonth() === currentMonth + birthdayDate.getDate() === currentDay && + birthdayDate.getMonth() === currentMonth ) }) }, [birthdays]) @@ -106,6 +106,9 @@ export default function DashboardPage() { return (
+ {/* Modal da Régua de Emoções + + */} {/* Card de Boas-vindas para novos colaboradores */}
diff --git a/src/app/(authenticated)/forms/emotion-ruler/page.tsx b/src/app/(authenticated)/forms/emotion-ruler/page.tsx new file mode 100644 index 00000000..2ed64baa --- /dev/null +++ b/src/app/(authenticated)/forms/emotion-ruler/page.tsx @@ -0,0 +1,323 @@ +"use client" + +import { useState } from "react" +import { DashboardShell } from "@/components/dashboard-shell" +import { Button } from "@/components/ui/button" +import { Textarea } from "@/components/ui/textarea" +import { Label } from "@/components/ui/label" +import { api } from "@/trpc/react" +import { toast } from "sonner" +import { cn } from "@/lib/utils" +import { Loader2 } from "lucide-react" +import Link from "next/link" +import { ChevronLeft } from "lucide-react" +import { motion, AnimatePresence } from "framer-motion" +import { AnimatedEmoji } from "@/components/emotion-ruler/animated-emoji" + +export default function EmotionRulerPage() { + const [selectedValue, setSelectedValue] = useState(null) + const [comment, setComment] = useState("") + const [isSubmitting, setIsSubmitting] = useState(false) + + const { data: ruler, isLoading, refetch } = api.emotionRuler.getActive.useQuery() + + const createResponse = api.emotionRuler.createResponse.useMutation({ + onSuccess: () => { + toast.success("Resposta registrada com sucesso!") + setSelectedValue(null) + setComment("") + void refetch() + }, + onError: (error) => { + toast.error(error.message || "Erro ao registrar resposta. Tente novamente.") + }, + onSettled: () => { + setIsSubmitting(false) + }, + }) + + const handleSubmit = async () => { + if (!ruler) { + toast.error("Régua de emoções não disponível") + return + } + + if (selectedValue === null) { + toast.error("Por favor, selecione como você está se sentindo") + return + } + + setIsSubmitting(true) + + try { + await createResponse.mutateAsync({ + rulerId: ruler.id, + emotionValue: selectedValue, + comment: comment.trim() || undefined, + }) + } catch (error) { + console.error("Erro ao criar resposta:", error) + } + } + + if (isLoading) { + return ( + +
+ +
+
+ ) + } + + if (!ruler) { + return ( + +
+ + + +
+

+ Nenhuma régua de emoções ativa no momento. +

+
+
+
+ ) + } + + // Ordenar emoções por valor + const sortedEmotions = (Array.isArray(ruler.emotions) ? ruler.emotions : []).sort((a, b) => a.value - b.value) + + // Debug: verificar se os emojis estão sendo carregados + if (process.env.NODE_ENV === 'development') { + console.log('Emoções carregadas:', sortedEmotions.map(e => ({ value: e.value, emoji: e.emoji }))) + } + + // Se não há emoções, mostrar mensagem + if (sortedEmotions.length === 0) { + return ( + +
+ + + +
+

+ Nenhuma emoção configurada para esta régua. +

+
+
+
+ ) + } + + return ( + +
+
+ + + +

+ Régua de Emoções +

+

+ {ruler.question} +

+
+
+
+ {/* Régua de Emoções */} +
+ {/* Régua com quadrados separados - Mobile: vertical, Desktop: horizontal */} +
+ {sortedEmotions.map((emotion, index) => { + const isSelected = selectedValue === emotion.value + + return ( + setSelectedValue(emotion.value)} + className={cn( + "flex-1 flex flex-row md:flex-col items-center justify-between md:justify-center p-4 cursor-pointer", + "min-h-[80px] md:min-h-[140px]", + "focus:outline-none focus:ring-2 focus:ring-primary focus:ring-offset-2", + "border-b md:border-b-0 md:border-r border-border/50 dark:border-border/30", + "last:border-b-0 md:last:border-r-0", + isSelected && "ring-2 ring-primary ring-offset-2 z-10" + )} + style={{ + backgroundColor: emotion.color, + }} + initial={{ opacity: 0, scale: 0.8 }} + animate={{ + opacity: 1, + scale: 1, + }} + transition={{ + delay: index * 0.1, + duration: 0.3, + ease: "easeOut" + }} + whileHover={{ + scale: 1.02, + transition: { duration: 0.2 } + }} + whileTap={{ scale: 0.98 }} + > + {/* Mobile: emoji à esquerda, texto à direita */} +
+ + + + + + + Nível {emotion.value} + +
+
+ ) + })} +
+ + {/* Estados/Emoções da seleção */} + + {selectedValue !== null && ( + + + Você selecionou o nível {selectedValue} + + {(() => { + const selectedEmotion = sortedEmotions.find((e) => e.value === selectedValue) + if (!selectedEmotion || selectedEmotion.states.length === 0) { + return ( +

+ Nenhum estado configurado para este nível +

+ ) + } + return ( +
+ {selectedEmotion.states.map((state, index) => ( + + {state} + + ))} +
+ ) + })()} +
+ )} +
+
+ + {/* Campo de comentário opcional */} +
+ +