voltar ~/portfolio

$ open projects/case-flow

InovaApp2025 | operações e suporte

Case Flow pega conversa solta e vira chamado organizado.

Sistema de chamados com Kanban, controle por setor e chatbot que entende a mensagem do usuário, extrai contexto e cria ticket com categoria e prioridade.

Mensagem -> tickettítulo, descrição, categoria e prioridade
4 statusnovos, andamento, aguardando e finalizados
4 rolesAdmin, TI, SAC e Financeiro
Históricoconversa vira contexto operacional

$ cat story.md

História

O Case Flow ataca um problema comum: o usuário explica o problema do jeito dele, mas o time precisa de um ticket organizado para agir. A IA fica nessa passagem. Ela entende a mensagem, sugere estrutura e entrega ao Kanban um chamado mais pronto para triagem.

$ cat architecture.svg

Arquitetura

Diagrama da arquitetura do Case Flow com usuários, frontend Next.js, API Routes serverless, Abacus.AI NLP, Prisma ORM, NextAuth e PostgreSQL.
Fluxo da aplicação: a interface envia mensagens e ações para API Routes serverless. O NLP externo interpreta a demanda e o Prisma grava tickets, usuários e comentários no PostgreSQL.

$ grep selling-points

Por que vale olhar

Automação sem teatro

A IA entra onde ajuda: transformar texto livre em dados que o time consegue usar.

Responsabilidade por setor

Roles e categorias deixam claro quem pode ver, assumir e resolver cada demanda.

Atendimento fechado

Ticket, comentários, status, Kanban e histórico ficam no mesmo fluxo.

$ view code --tabs

Viewer de código

model Ticket {
  id          String       @id @default(cuid())
  title       String
  description String       @db.Text
  category    TicketCategory
  status      TicketStatus @default(NEW)
  priority    Priority     @default(MEDIUM)
  createdBy   String
  assignedTo  String?
  createdAt   DateTime     @default(now())
  updatedAt   DateTime     @updatedAt

  creator     User         @relation("CreatedTickets", fields: [createdBy], references: [id])
  assignee    User?        @relation("AssignedTickets", fields: [assignedTo], references: [id])
  comments    Comment[]
}

enum UserRole {
  ADM
  TI
  SAC
  FINANCEIRO
}

enum TicketStatus {
  NEW
  IN_PROGRESS
  FINISHED
  RETURNED
}
export const authOptions: NextAuthOptions = {
  providers: [
    CredentialsProvider({
      name: "credentials",
      credentials: { email: { type: "email" }, password: { type: "password" } },
      async authorize(credentials) {
        if (!credentials?.email || !credentials?.password) return null;

        const user = await prisma.user.findUnique({ where: { email: credentials.email } });
        if (!user) return null;

        const isPasswordValid = await bcrypt.compare(credentials.password, user.password);
        if (!isPasswordValid) return null;

        return { id: user.id, email: user.email, name: user.name, role: user.role };
      }
    })
  ],
  callbacks: {
    async jwt({ token, user }) {
      if (user) {
        token.role = (user as any).role;
        token.id = user.id;
      }
      return token;
    },
    async session({ session, token }) {
      if (session.user && token) {
        (session.user as any).id = token.id;
        (session.user as any).role = token.role;
      }
      return session;
    }
  }
}
export async function POST(request: NextRequest) {
  try {
    const session = await getServerSession(authOptions)
    if (!session?.user) {
      return new Response(JSON.stringify({ error: "Unauthorized" }), { status: 401 })
    }

    const { message } = await request.json()

    // Detecta se a mensagem do chat é uma intenção de criar um chamado
    const isTicketRequest = detectTicketIntent(message)

    return Response.json({ isTicketRequest, message })
  } catch (error) {
    console.error("Intent analysis error:", error)
    return Response.json({ error: "Internal server error" }, { status: 500 })
  }
}

function detectTicketIntent(message: string): boolean {
  const ticketPhrases = ["abrir um chamado", "criar um chamado", "abrir um ticket"]
  return ticketPhrases.some((phrase) => message.toLowerCase().includes(phrase))
}
<motion.div
  initial={{ opacity: 0, y: 20 }}
  animate={{ opacity: 1, y: 0 }}
  transition={{ delay: 0.2 }}
  className="grid grid-cols-1 lg:grid-cols-4 gap-6"
>
  {Object.entries(statusConfig).map(([status, config], index) => {
    const statusTickets = getTicketsByStatus(status)

    return (
      <motion.div
        key={status}
        initial={{ opacity: 0, x: -20 }}
        animate={{ opacity: 1, x: 0 }}
        transition={{ delay: 0.1 * index }}
        className={`${config.bgColor} ${config.borderColor} border rounded-xl p-4 min-h-[500px]`}
      >
        <div className="flex items-center gap-3 mb-4">
          <div className={`p-2 ${config.color} rounded-lg`}>
            <config.icon className="h-5 w-5 text-white" />
          </div>
          <div className="flex-1">
            <h3 className="font-semibold text-foreground">{config.title}</h3>
            <p className="text-sm text-muted-foreground">{statusTickets.length} chamados</p>
          </div>
        </div>

        <div className="space-y-3">
          {statusTickets.map((ticket) => (
            <TicketCard key={ticket.id} ticket={ticket} onUpdate={handleTicketUpdate} />
          ))}
        </div>
      </motion.div>
    )
  })}
</motion.div>
<Card className="h-full hover:shadow-lg transition-all duration-200 border-l-4 border-l-blue-500">
  <CardHeader className="pb-3">
    <div className="flex items-start justify-between gap-2">
      <h3 className="font-medium text-sm line-clamp-2 flex-1">
        {ticket.title}
      </h3>
      <Badge className={cn("text-xs", priorityColors[ticket.priority])}>
        {ticket.priority}
      </Badge>
    </div>
    <div className="flex flex-wrap gap-1">
      <Badge variant="outline" className={cn("text-xs", categoryColors[ticket.category])}>
        {ticket.category}
      </Badge>
    </div>
  </CardHeader>

  <CardContent className="pt-0">
    <p className="text-xs text-muted-foreground line-clamp-2 mb-3">
      {ticket.description}
    </p>

    <div className="flex items-center justify-between text-xs text-muted-foreground">
      <div className="flex items-center gap-1">
        <User className="h-3 w-3" />
        <span>{ticket.creator.name}</span>
      </div>
      <div className="flex items-center gap-1">
        <Calendar className="h-3 w-3" />
        <span>{formatDistanceToNow(new Date(ticket.createdAt), { locale: ptBR })}</span>
      </div>
    </div>
  </CardContent>
</Card>

$ result

Mostra IA no meio da operação: entende a intenção, organiza a demanda e manda o chamado para o fluxo de suporte.