feat: initial setup with WhatsApp CRM apps and sync services

This commit is contained in:
2026-04-05 17:29:28 +00:00
parent d983027450
commit e8bb767884
57 changed files with 26156 additions and 0 deletions

View File

@@ -0,0 +1,126 @@
import { NextRequest, NextResponse } from 'next/server'
import { initDB, createContact, getContactById, getContactByPhone, getContactsByStage, getAllContacts, updateContact, deleteContact, searchContacts, getContactsByPaymentStatus, getTaskCountByContactId } from '@/lib/db'
import { STAGES } from '@/lib/types'
export async function GET(request: NextRequest) {
const searchParams = request.nextUrl.searchParams
const id = searchParams.get('id')
const phone = searchParams.get('phone')
const stage = searchParams.get('stage')
const search = searchParams.get('search')
const payment_status = searchParams.get('payment_status')
try {
await initDB()
let contacts: any[] = []
if (id) {
const contact = getContactById(parseInt(id))
if (!contact) {
return NextResponse.json({ error: 'Contact not found' }, { status: 404 })
}
const taskCount = getTaskCountByContactId(contact.id)
return NextResponse.json({ ...contact, taskCount })
}
if (phone) {
const contact = getContactByPhone(phone)
if (!contact) {
return NextResponse.json({ error: 'Contact not found' }, { status: 404 })
}
return NextResponse.json(contact)
}
if (stage) {
contacts = getContactsByStage(stage as any)
} else if (search) {
contacts = searchContacts(search)
} else if (payment_status) {
contacts = getContactsByPaymentStatus(payment_status as any)
} else {
contacts = getAllContacts()
}
if (!Array.isArray(contacts)) {
contacts = []
}
return NextResponse.json(contacts)
} catch (error: any) {
console.error('API Error:', error)
return NextResponse.json({ error: error.message }, { status: 500 })
}
}
export async function POST(request: NextRequest) {
try {
await initDB()
const body = await request.json()
const { name, phone, notes, schedule_date, follow_up_date } = body
if (!name || !phone) {
return NextResponse.json(
{ error: 'Name and phone are required' },
{ status: 400 }
)
}
const existingContact = getContactByPhone(phone)
if (existingContact) {
return NextResponse.json(
{ error: 'Contact with this phone already exists' },
{ status: 409 }
)
}
const contact = createContact({ name, phone, notes, schedule_date, follow_up_date })
return NextResponse.json(contact, { status: 201 })
} catch (error: any) {
return NextResponse.json({ error: error.message }, { status: 500 })
}
}
export async function PUT(request: NextRequest) {
try {
await initDB()
const body = await request.json()
const { id, name, stage, notes, schedule_date, follow_up_date, payment_status } = body
if (!id) {
return NextResponse.json({ error: 'Contact ID is required' }, { status: 400 })
}
if (stage && !Object.values(STAGES).includes(stage)) {
return NextResponse.json({ error: 'Invalid stage' }, { status: 400 })
}
const updated = updateContact(id, { name, stage, notes, schedule_date, follow_up_date, payment_status })
if (!updated) {
return NextResponse.json({ error: 'Contact not found' }, { status: 404 })
}
return NextResponse.json(updated)
} catch (error: any) {
return NextResponse.json({ error: error.message }, { status: 500 })
}
}
export async function DELETE(request: NextRequest) {
try {
await initDB()
const searchParams = request.nextUrl.searchParams
const id = searchParams.get('id')
if (!id) {
return NextResponse.json({ error: 'Contact ID is required' }, { status: 400 })
}
deleteContact(parseInt(id))
return NextResponse.json({ success: true })
} catch (error: any) {
return NextResponse.json({ error: error.message }, { status: 500 })
}
}

View File

@@ -0,0 +1,53 @@
import { NextRequest, NextResponse } from 'next/server'
import { initDB, createMessage, getMessagesByContactId } from '@/lib/db'
export async function GET(request: NextRequest) {
const searchParams = request.nextUrl.searchParams
const contactId = searchParams.get('contact_id')
try {
await initDB()
if (!contactId) {
return NextResponse.json({ error: 'contact_id is required' }, { status: 400 })
}
const messages = getMessagesByContactId(parseInt(contactId))
return NextResponse.json(messages)
} catch (error: any) {
return NextResponse.json({ error: error.message }, { status: 500 })
}
}
export async function POST(request: NextRequest) {
try {
await initDB()
const body = await request.json()
const { contact_id, external_id, body: messageBody, from_phone, timestamp, has_media } = body
if (!contact_id || !external_id || !messageBody || !from_phone || !timestamp) {
return NextResponse.json(
{ error: 'contact_id, external_id, body, from_phone, and timestamp are required' },
{ status: 400 }
)
}
const message = createMessage({
contact_id,
external_id,
body: messageBody,
from_phone,
timestamp,
has_media
})
if (!message) {
return NextResponse.json({ error: 'Contact not found or message already exists' }, { status: 400 })
}
return NextResponse.json(message, { status: 201 })
} catch (error: any) {
return NextResponse.json({ error: error.message }, { status: 500 })
}
}

View File

@@ -0,0 +1,36 @@
import { NextRequest, NextResponse } from 'next/server'
import { initDB, createContact, createTask, updateContact, closeDB, getAllContacts } from '@/lib/db'
import { STAGES } from '@/lib/types'
export async function POST() {
try {
await initDB()
const existing = getAllContacts()
if (existing.length > 0) {
return NextResponse.json({ message: 'Database already seeded', seeded: false })
}
createContact({ name: 'João Silva', phone: '+5511999999001' })
createContact({ name: 'Maria Santos', phone: '+5511999999002' })
createContact({ name: 'Pedro Oliveira', phone: '+5511999999003' })
const c1 = createContact({ name: 'Ana Costa', phone: '+5511999999004', notes: 'Interessado em pacote premium' })
const c2 = createContact({ name: 'Carlos Lima', phone: '+5511999999005', notes: 'Ligou pedindo orçamento' })
const c3 = createContact({ name: 'Juliana Alves', phone: '+5511999999006', notes: 'Cliente antigo, muito bom' })
updateContact(c1.id, { stage: STAGES.DECIDINDO, payment_status: 'paid' })
updateContact(c2.id, { stage: STAGES.DISCUSSAO_DE_CONTRATO, schedule_date: '2026-04-15' })
updateContact(c3.id, { stage: STAGES.DECISAO_FINAL })
createTask({ contact_id: c1.id, title: 'Enviar proposta', due_date: '2026-04-10' })
createTask({ contact_id: c2.id, title: 'Agendar reunião', due_date: '2026-04-08' })
closeDB()
return NextResponse.json({ message: 'Database seeded successfully', seeded: true })
} catch (error: any) {
console.error('Seed error:', error)
return NextResponse.json({ error: error.message }, { status: 500 })
}
}

View File

@@ -0,0 +1,100 @@
import { NextRequest, NextResponse } from 'next/server'
import { initDB, createTask, getTaskById, getTasksByContactId, updateTask, deleteTask, getPendingTasksByContactId, getOverdueTasks } from '@/lib/db'
export async function GET(request: NextRequest) {
const searchParams = request.nextUrl.searchParams
const contactId = searchParams.get('contact_id')
const taskId = searchParams.get('id')
const pending = searchParams.get('pending')
const overdue = searchParams.get('overdue')
try {
await initDB()
if (taskId) {
const task = getTaskById(parseInt(taskId))
if (!task) {
return NextResponse.json({ error: 'Task not found' }, { status: 404 })
}
return NextResponse.json(task)
}
if (overdue === 'true') {
const tasks = getOverdueTasks()
return NextResponse.json(tasks)
}
if (contactId) {
if (pending === 'true') {
const tasks = getPendingTasksByContactId(parseInt(contactId))
return NextResponse.json(tasks)
}
const tasks = getTasksByContactId(parseInt(contactId))
return NextResponse.json(tasks)
}
return NextResponse.json({ error: 'Missing required parameter' }, { status: 400 })
} catch (error: any) {
return NextResponse.json({ error: error.message }, { status: 500 })
}
}
export async function POST(request: NextRequest) {
try {
await initDB()
const body = await request.json()
const { contact_id, title, due_date } = body
if (!contact_id || !title || !due_date) {
return NextResponse.json(
{ error: 'contact_id, title, and due_date are required' },
{ status: 400 }
)
}
const task = createTask({ contact_id, title, due_date })
return NextResponse.json(task, { status: 201 })
} catch (error: any) {
return NextResponse.json({ error: error.message }, { status: 500 })
}
}
export async function PUT(request: NextRequest) {
try {
await initDB()
const body = await request.json()
const { id, title, due_date, status } = body
if (!id) {
return NextResponse.json({ error: 'Task ID is required' }, { status: 400 })
}
const updated = updateTask(id, { title, due_date, status })
if (!updated) {
return NextResponse.json({ error: 'Task not found' }, { status: 404 })
}
return NextResponse.json(updated)
} catch (error: any) {
return NextResponse.json({ error: error.message }, { status: 500 })
}
}
export async function DELETE(request: NextRequest) {
try {
await initDB()
const searchParams = request.nextUrl.searchParams
const id = searchParams.get('id')
if (!id) {
return NextResponse.json({ error: 'Task ID is required' }, { status: 400 })
}
deleteTask(parseInt(id))
return NextResponse.json({ success: true })
} catch (error: any) {
return NextResponse.json({ error: error.message }, { status: 500 })
}
}

View File

@@ -0,0 +1,155 @@
import { NextRequest, NextResponse } from 'next/server'
import { Client, LocalAuth } from 'whatsapp-web.js'
import * as fs from 'fs'
import * as path from 'path'
let client: Client | null = null
let qrCode: string | null = null
let status: 'disconnected' | 'loading' | 'qr' | 'ready' = 'disconnected'
let syncedContacts = 0
const MAX_CONTACTS = 5
const apiUrl = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001'
const clientDir = path.join(process.cwd(), '.wwebjs_auth_crm')
function isAuthenticated(): boolean {
const sessionDir = path.join(clientDir, 'session')
if (!fs.existsSync(sessionDir)) return false
const files = fs.readdirSync(sessionDir)
return files.length > 0
}
function killExistingBrowser(): void {
try {
const { execSync } = require('child_process')
execSync(`pkill -f "wwebjs"`, { stdio: 'ignore' })
execSync(`pkill -f "chromium"`, { stdio: 'ignore' })
} catch (e) {}
}
function startClient() {
if (client) return
killExistingBrowser()
if (!fs.existsSync(clientDir)) {
fs.mkdirSync(clientDir, { recursive: true })
}
status = 'loading'
qrCode = null
client = new Client({
authStrategy: new LocalAuth({ dataPath: clientDir }),
puppeteer: {
executablePath: '/usr/sbin/chromium',
args: ['--no-sandbox', '--disable-setuid-sandbox', '--disable-dev-shm-usage']
}
})
client.on('qr', (qr) => {
status = 'qr'
qrCode = qr
console.log('QR Code received')
})
client.on('ready', async () => {
status = 'ready'
qrCode = null
console.log('WhatsApp client ready')
const chats = await client!.getChats()
syncedContacts = 0
const existingPhones = new Set<string>()
for (const chat of chats) {
if (chat.isGroup) continue
if (syncedContacts >= MAX_CONTACTS) break
const msgs = await chat.fetchMessages({ limit: 10 })
for (const msg of msgs) {
if (syncedContacts >= MAX_CONTACTS) break
if (!msg.body || !msg.body.trim()) continue
const phone = msg.from.split('@')[0]
if (existingPhones.has(phone)) continue
try {
const res = await fetch(`${apiUrl}/api/contacts`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: chat.name, phone })
})
if (res.ok) {
syncedContacts++
existingPhones.add(phone)
}
} catch (e) {}
}
}
console.log(`Synced ${syncedContacts} contacts`)
})
client.on('message', async (msg) => {
if (syncedContacts >= MAX_CONTACTS) return
const chat = await msg.getChat()
if (chat.isGroup) return
if (!msg.body || !msg.body.trim()) return
const phone = msg.from.split('@')[0]
try {
const res = await fetch(`${apiUrl}/api/contacts`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: chat.name, phone })
})
if (res.ok) syncedContacts++
} catch (e) {}
})
client.on('disconnected', () => {
status = 'disconnected'
client = null
})
client.initialize()
}
export async function GET() {
if (!client && isAuthenticated()) {
startClient()
await new Promise(resolve => setTimeout(resolve, 5000))
}
const currentStatus = client ? (status === 'loading' ? 'connecting' : status) : 'disconnected'
return NextResponse.json({
status: currentStatus,
qrCode,
syncedContacts,
isAuthenticated: isAuthenticated(),
clientReady: client !== null
})
}
export async function POST() {
if (!client) {
startClient()
}
return NextResponse.json({ success: true })
}
export async function DELETE() {
if (client) {
client.destroy()
client = null
}
if (fs.existsSync(clientDir)) {
fs.rmSync(clientDir, { recursive: true, force: true })
}
status = 'disconnected'
qrCode = null
syncedContacts = 0
return NextResponse.json({ success: true })
}

View File

@@ -0,0 +1,393 @@
* {
box-sizing: border-box;
padding: 0;
margin: 0;
}
html,
body {
max-width: 100vw;
overflow-x: hidden;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen,
Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif;
background-color: #f5f5f5;
}
a {
color: inherit;
text-decoration: none;
}
.app-header {
padding: 16px 24px;
background: white;
border-bottom: 1px solid #ddd;
display: flex;
justify-content: space-between;
align-items: center;
flex-wrap: wrap;
gap: 16px;
}
.app-header h1 {
font-size: 24px;
font-weight: 600;
}
.header-controls {
display: flex;
gap: 12px;
align-items: center;
flex-wrap: wrap;
}
.search-input {
padding: 8px 12px;
border: 1px solid #ddd;
border-radius: 4px;
width: 200px;
font-size: 14px;
}
.filter-select {
padding: 8px 12px;
border: 1px solid #ddd;
border-radius: 4px;
font-size: 14px;
background: white;
}
.kanban-board {
display: flex;
gap: 16px;
padding: 24px;
min-height: calc(100vh - 80px);
overflow-x: auto;
}
.kanban-column {
flex: 0 0 300px;
background: #ebecf0;
border-radius: 8px;
padding: 12px;
display: flex;
flex-direction: column;
}
.kanban-column-header {
font-weight: 600;
padding: 8px;
margin-bottom: 8px;
display: flex;
align-items: center;
}
.kanban-column-count {
background: #0079bf;
color: white;
border-radius: 12px;
padding: 2px 8px;
font-size: 12px;
margin-left: 8px;
}
.kanban-cards {
flex: 1;
display: flex;
flex-direction: column;
gap: 8px;
overflow-y: auto;
min-height: 100px;
}
.contact-card {
background: white;
border-radius: 8px;
padding: 12px;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
cursor: pointer;
transition: box-shadow 0.2s;
}
.contact-card:hover {
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.15);
}
.contact-card.paid {
border-left: 4px solid #4caf50;
}
.contact-card.pending {
border-left: 4px solid #ff9800;
}
.contact-card.overdue {
border-left-color: #f44336;
}
.contact-card-name {
font-weight: 500;
margin-bottom: 4px;
}
.contact-card-phone {
font-size: 12px;
color: #666;
margin-bottom: 8px;
}
.contact-card-preview {
font-size: 12px;
color: #888;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
margin-bottom: 8px;
}
.contact-card-meta {
display: flex;
gap: 8px;
align-items: center;
font-size: 12px;
}
.task-badge {
background: #0079bf;
color: white;
border-radius: 10px;
padding: 2px 6px;
font-size: 11px;
}
.schedule-date {
color: #666;
}
.schedule-date.overdue {
color: #f44336;
}
.empty-column {
text-align: center;
color: #888;
padding: 24px;
font-size: 14px;
}
.loading {
display: flex;
justify-content: center;
align-items: center;
min-height: 200px;
color: #666;
}
/* Modal styles */
.modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
justify-content: center;
align-items: center;
z-index: 1000;
padding: 20px;
}
.modal-content {
background: white;
border-radius: 8px;
width: 100%;
max-width: 600px;
max-height: 80vh;
overflow-y: auto;
position: relative;
padding: 24px;
}
.modal-close {
position: absolute;
top: 12px;
right: 12px;
background: none;
border: none;
font-size: 24px;
cursor: pointer;
color: #666;
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 4px;
}
.modal-close:hover {
background: #f0f0f0;
}
.modal-header {
margin-bottom: 20px;
}
.modal-header h2 {
font-size: 20px;
margin-bottom: 4px;
}
.modal-phone {
color: #666;
font-size: 14px;
}
.modal-tabs {
display: flex;
gap: 8px;
margin-bottom: 20px;
border-bottom: 1px solid #eee;
padding-bottom: 12px;
}
.modal-tabs .tab {
padding: 8px 16px;
border: none;
background: none;
cursor: pointer;
font-size: 14px;
border-radius: 4px;
color: #666;
}
.modal-tabs .tab.active {
background: #0079bf;
color: white;
}
.modal-body {
min-height: 200px;
}
.empty-state {
text-align: center;
color: #888;
padding: 40px;
}
.messages-list {
display: flex;
flex-direction: column;
gap: 16px;
}
.message-item {
padding: 12px;
background: #f9f9f9;
border-radius: 8px;
}
.message-time {
font-size: 12px;
color: #888;
margin-bottom: 8px;
}
.message-body {
font-size: 14px;
}
.tasks-section .add-task-form {
display: flex;
gap: 8px;
margin-bottom: 16px;
}
.tasks-section .add-task-form input {
padding: 8px;
border: 1px solid #ddd;
border-radius: 4px;
font-size: 14px;
}
.tasks-section .add-task-form input[type="text"] {
flex: 1;
}
.tasks-section .add-task-form button {
padding: 8px 16px;
background: #0079bf;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 14px;
}
.tasks-section .add-task-form button:hover {
background: #005a8c;
}
.tasks-list {
display: flex;
flex-direction: column;
gap: 8px;
}
.task-item {
display: flex;
align-items: center;
gap: 12px;
padding: 12px;
background: #f9f9f9;
border-radius: 8px;
cursor: pointer;
}
.task-item.completed {
opacity: 0.6;
}
.task-item.completed .completed {
text-decoration: line-through;
}
.task-item input[type="checkbox"] {
width: 18px;
height: 18px;
}
.task-item .task-due {
margin-left: auto;
font-size: 12px;
color: #888;
}
.details-form .form-group {
margin-bottom: 16px;
}
.details-form .form-group label {
display: block;
font-size: 14px;
font-weight: 500;
margin-bottom: 8px;
color: #333;
}
.details-form .form-group select,
.details-form .form-group textarea {
width: 100%;
padding: 8px;
border: 1px solid #ddd;
border-radius: 4px;
font-size: 14px;
}
.details-form .form-group textarea {
min-height: 100px;
resize: vertical;
}

View File

@@ -0,0 +1,183 @@
'use client'
import { useState, useEffect } from 'react'
import { KanbanBoard } from '@/components/Kanban'
import { Contact, Stage, Message, Task } from '@/lib/types'
import ContactDetailModal from '@/components/Modal/ContactDetailModal'
export default function KanbanPage() {
const [contacts, setContacts] = useState<Contact[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [selectedContact, setSelectedContact] = useState<Contact | null>(null)
const [messages, setMessages] = useState<Message[]>([])
const [tasks, setTasks] = useState<Task[]>([])
const [modalOpen, setModalOpen] = useState(false)
const fetchContacts = async () => {
try {
const res = await fetch('/api/contacts')
const data = await res.json()
if (Array.isArray(data)) {
setContacts(data)
}
} catch (e: any) {
setError(e.message)
} finally {
setLoading(false)
}
}
const fetchMessages = async (contactId: number) => {
try {
const res = await fetch(`/api/messages?contact_id=${contactId}`)
const data = await res.json()
if (Array.isArray(data)) {
setMessages(data)
}
} catch (e: any) {
console.error(e)
}
}
const fetchTasks = async (contactId: number) => {
try {
const res = await fetch(`/api/tasks?contact_id=${contactId}`)
const data = await res.json()
if (Array.isArray(data)) {
setTasks(data)
}
} catch (e: any) {
console.error(e)
}
}
useEffect(() => {
fetchContacts()
}, [])
const handleContactClick = async (contact: Contact) => {
setSelectedContact(contact)
await Promise.all([fetchMessages(contact.id), fetchTasks(contact.id)])
setModalOpen(true)
}
const handleStageChange = async (contactId: number, newStage: Stage) => {
try {
await fetch('/api/contacts', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id: contactId, stage: newStage })
})
setContacts(prev => prev.map(c =>
c.id === contactId ? { ...c, stage: newStage } : c
))
} catch (e: any) {
alert(e.message)
}
}
const handleUpdateStage = async (stage: Stage) => {
if (!selectedContact) return
try {
await fetch('/api/contacts', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id: selectedContact.id, stage })
})
const updated = { ...selectedContact, stage }
setSelectedContact(updated)
setContacts(prev => prev.map(c => c.id === updated.id ? updated : c))
} catch (e: any) {
alert(e.message)
}
}
const handleAddNote = async (notes: string) => {
if (!selectedContact) return
try {
await fetch('/api/contacts', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id: selectedContact.id, notes })
})
const updated = { ...selectedContact, notes }
setSelectedContact(updated)
setContacts(prev => prev.map(c => c.id === updated.id ? updated : c))
} catch (e: any) {
alert(e.message)
}
}
const handleUpdatePaymentStatus = async (payment_status: 'pending' | 'paid') => {
if (!selectedContact) return
try {
await fetch('/api/contacts', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id: selectedContact.id, payment_status })
})
const updated = { ...selectedContact, payment_status }
setSelectedContact(updated)
setContacts(prev => prev.map(c => c.id === updated.id ? updated : c))
} catch (e: any) {
alert(e.message)
}
}
const handleAddTask = async (title: string, due_date: string) => {
if (!selectedContact) return
try {
const res = await fetch('/api/tasks', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ contact_id: selectedContact.id, title, due_date })
})
const newTask = await res.json()
setTasks(prev => [...prev, newTask])
} catch (e: any) {
alert(e.message)
}
}
const handleToggleTask = async (taskId: number, status: 'pending' | 'completed') => {
try {
await fetch(`/api/tasks?id=${taskId}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ status })
})
setTasks(prev => prev.map(t =>
t.id === taskId ? { ...t, status } : t
))
} catch (e: any) {
alert(e.message)
}
}
if (loading) return <div style={{ padding: 20 }}>Carregando...</div>
if (error) return <div style={{ padding: 20, color: 'red' }}>Erro: {error}</div>
return (
<div style={{ padding: 20 }}>
<h1>Kanban - Leads</h1>
<KanbanBoard
contacts={contacts}
onContactClick={handleContactClick}
onStageChange={handleStageChange}
/>
<ContactDetailModal
contact={selectedContact!}
isOpen={modalOpen}
onClose={() => setModalOpen(false)}
messages={messages}
tasks={tasks}
onUpdateStage={handleUpdateStage}
onAddNote={handleAddNote}
onUpdatePaymentStatus={handleUpdatePaymentStatus}
onAddTask={handleAddTask}
onToggleTask={handleToggleTask}
/>
</div>
)
}

View File

@@ -0,0 +1,22 @@
import type { Metadata } from 'next'
import { Inter } from 'next/font/google'
import './globals.css'
const inter = Inter({ subsets: ['latin'] })
export const metadata: Metadata = {
title: 'WhatsApp CRM',
description: 'WhatsApp CRM with Kanban board',
}
export default function RootLayout({
children,
}: {
children: React.ReactNode
}) {
return (
<html lang="pt-BR">
<body className={inter.className}>{children}</body>
</html>
)
}

View File

@@ -0,0 +1,132 @@
'use client'
import { useState, useEffect } from 'react'
import { useRouter } from 'next/navigation'
export default function HomePage() {
const router = useRouter()
const [connected, setConnected] = useState(false)
const [hasExistingSession, setHasExistingSession] = useState(false)
const [syncedContacts, setSyncedContacts] = useState(0)
const [showQr, setShowQr] = useState(false)
const [qrCode, setQrCode] = useState<string | null>(null)
useEffect(() => {
const checkStatus = async () => {
try {
const res = await fetch('/api/whatsapp')
const data = await res.json()
setConnected(data.status === 'ready')
setHasExistingSession(data.isAuthenticated || false)
setSyncedContacts(data.syncedContacts || 0)
setQrCode(data.qrCode)
setShowQr(data.status === 'qr')
} catch (e) {
setConnected(false)
}
}
checkStatus()
const interval = setInterval(checkStatus, 3000)
return () => clearInterval(interval)
}, [])
useEffect(() => {
if (connected) {
router.push('/kanban')
}
}, [connected, router])
useEffect(() => {
if (hasExistingSession && !connected) {
fetch('/api/whatsapp', { method: 'POST' })
}
}, [hasExistingSession, connected])
const connect = async () => {
await fetch('/api/whatsapp', { method: 'POST' })
}
const disconnect = async () => {
await fetch('/api/whatsapp', { method: 'DELETE' })
setConnected(false)
setHasExistingSession(false)
setSyncedContacts(0)
}
return (
<div style={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
minHeight: '100vh',
fontFamily: 'system-ui, sans-serif',
padding: 20
}}>
<h1 style={{ fontSize: 32, marginBottom: 16 }}>WhatsApp CRM</h1>
<p style={{ color: '#666', marginBottom: 32 }}>Conecte seu WhatsApp para começar</p>
{showQr && qrCode && (
<div style={{ textAlign: 'center', marginBottom: 24 }}>
<p style={{ marginBottom: 16 }}>Escaneie o QR code com seu WhatsApp:</p>
<img src={`https://api.qrserver.com/v1/create-qr-code/?size=200x200&data=${encodeURIComponent(qrCode)}`} alt="QR Code" style={{ border: '1px solid #ccc', borderRadius: 8 }} />
</div>
)}
{!connected && (
<button
onClick={connect}
style={{
padding: '12px 24px',
fontSize: 16,
background: '#0079bf',
color: 'white',
border: 'none',
borderRadius: 8,
cursor: 'pointer'
}}
>
Conectar WhatsApp
</button>
)}
{connected && (
<div style={{ textAlign: 'center' }}>
<p style={{ color: 'green', marginBottom: 16 }}> WhatsApp conectado ({syncedContacts} contatos)</p>
<div style={{ display: 'flex', gap: 12 }}>
<a
href="/kanban"
style={{
padding: '12px 24px',
fontSize: 16,
background: '#0079bf',
color: 'white',
border: 'none',
borderRadius: 8,
textDecoration: 'none',
display: 'inline-block'
}}
>
Acessar Kanban
</a>
<button
onClick={disconnect}
style={{
padding: '12px 24px',
fontSize: 16,
background: '#dc3545',
color: 'white',
border: 'none',
borderRadius: 8,
cursor: 'pointer'
}}
>
Desconectar
</button>
</div>
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,52 @@
'use client'
import { Contact, Stage } from '@/lib/types'
interface ContactCardProps {
contact: Contact
onClick: () => void
onStageChange?: (contactId: number, newStage: Stage) => void
}
export default function ContactCard({ contact, onClick, onStageChange }: ContactCardProps) {
const formatPhone = (phone: string) => {
const cleaned = phone.replace(/\D/g, '')
if (cleaned.length === 13) {
return `+${cleaned.slice(0, 2)} (${cleaned.slice(3, 5)}) ${cleaned.slice(5, 10)}-${cleaned.slice(10)}`
}
if (cleaned.length === 12) {
return `+${cleaned.slice(0, 2)} (${cleaned.slice(2, 4)}) ${cleaned.slice(4, 9)}-${cleaned.slice(9)}`
}
if (cleaned.length === 11) {
return `(${cleaned.slice(0, 2)}) ${cleaned.slice(2, 7)}-${cleaned.slice(7)}`
}
return phone
}
const isOverdue = contact.schedule_date && new Date(contact.schedule_date) < new Date()
return (
<div
className={`contact-card ${contact.payment_status === 'paid' ? 'paid' : 'pending'} ${isOverdue ? 'overdue' : ''}`}
onClick={onClick}
>
<div className="contact-card-name">{contact.name}</div>
<div className="contact-card-phone">{formatPhone(contact.phone)}</div>
{contact.notes && (
<div className="contact-card-preview">
{contact.notes.substring(0, 50)}{contact.notes.length > 50 ? '...' : ''}
</div>
)}
<div className="contact-card-meta">
{contact.taskCount !== undefined && contact.taskCount > 0 && (
<span className="task-badge">{contact.taskCount}</span>
)}
{contact.schedule_date && (
<span className={`schedule-date ${isOverdue ? 'overdue' : ''}`}>
{new Date(contact.schedule_date).toLocaleDateString('pt-BR')}
</span>
)}
</div>
</div>
)
}

View File

@@ -0,0 +1 @@
export { default as ContactCard } from './ContactCard'

View File

@@ -0,0 +1,50 @@
'use client'
import { useState, useMemo } from 'react'
import { Stage, STAGES, STAGE_LIST, Contact } from '@/lib/types'
import { ContactCard } from '@/components/ContactCard'
interface KanbanBoardProps {
contacts: Contact[]
onContactClick: (contact: Contact) => void
onStageChange: (contactId: number, newStage: Stage) => void
}
export default function KanbanBoard({ contacts, onContactClick, onStageChange }: KanbanBoardProps) {
const safeContacts = useMemo(() => {
return Array.isArray(contacts) ? contacts : []
}, [contacts])
const getContactsByStage = (stage: Stage) => {
return safeContacts.filter(c => c.stage === stage)
}
return (
<div className="kanban-board">
{STAGE_LIST.map(stage => (
<div key={stage} className="kanban-column">
<div className="kanban-column-header">
{stage}
<span className="kanban-column-count">
{getContactsByStage(stage).length}
</span>
</div>
<div className="kanban-cards">
{getContactsByStage(stage).length === 0 ? (
<div className="empty-column">Nenhum lead</div>
) : (
getContactsByStage(stage).map(contact => (
<ContactCard
key={contact.id}
contact={contact}
onClick={() => onContactClick(contact)}
onStageChange={onStageChange}
/>
))
)}
</div>
</div>
))}
</div>
)
}

View File

@@ -0,0 +1 @@
export { default as KanbanBoard } from './KanbanBoard'

View File

@@ -0,0 +1,212 @@
'use client'
import { useEffect, useState } from 'react'
import { Contact, Message, Task, STAGE_LIST, Stage } from '@/lib/types'
interface ContactDetailModalProps {
contact: Contact
isOpen: boolean
onClose: () => void
messages: Message[]
tasks: Task[]
onUpdateStage: (stage: Stage) => void
onAddNote: (notes: string) => void
onUpdatePaymentStatus: (status: 'pending' | 'paid') => void
onAddTask: (title: string, due_date: string) => void
onToggleTask: (taskId: number, status: 'pending' | 'completed') => void
}
export default function ContactDetailModal({
contact,
isOpen,
onClose,
messages,
tasks,
onUpdateStage,
onAddNote,
onUpdatePaymentStatus,
onAddTask,
onToggleTask,
}: ContactDetailModalProps) {
const [noteInput, setNoteInput] = useState('')
const [taskTitle, setTaskTitle] = useState('')
const [taskDueDate, setTaskDueDate] = useState('')
const [activeTab, setActiveTab] = useState<'messages' | 'tasks' | 'details'>('messages')
useEffect(() => {
const handleEscape = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose()
}
if (isOpen) {
document.addEventListener('keydown', handleEscape)
document.body.style.overflow = 'hidden'
}
return () => {
document.removeEventListener('keydown', handleEscape)
document.body.style.overflow = 'auto'
}
}, [isOpen, onClose])
if (!isOpen) return null
const formatDate = (timestamp: number) => {
return new Date(timestamp * 1000).toLocaleString('pt-BR', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
})
}
const formatPhone = (phone: string) => {
const cleaned = phone.replace(/\D/g, '')
if (cleaned.length === 13) {
return `+${cleaned.slice(0, 2)} (${cleaned.slice(3, 5)}) ${cleaned.slice(5, 10)}-${cleaned.slice(10)}`
}
if (cleaned.length === 12) {
return `+${cleaned.slice(0, 2)} (${cleaned.slice(2, 4)}) ${cleaned.slice(4, 9)}-${cleaned.slice(9)}`
}
if (cleaned.length === 11) {
return `(${cleaned.slice(0, 2)}) ${cleaned.slice(2, 7)}-${cleaned.slice(7)}`
}
return phone
}
return (
<div className="modal-overlay" onClick={onClose}>
<div className="modal-content" onClick={e => e.stopPropagation()}>
<button className="modal-close" onClick={onClose}>&times;</button>
<div className="modal-header">
<h2>{contact.name}</h2>
<p className="modal-phone">{formatPhone(contact.phone)}</p>
</div>
<div className="modal-tabs">
<button
className={`tab ${activeTab === 'messages' ? 'active' : ''}`}
onClick={() => setActiveTab('messages')}
>
Mensagens ({messages.length})
</button>
<button
className={`tab ${activeTab === 'tasks' ? 'active' : ''}`}
onClick={() => setActiveTab('tasks')}
>
Tarefas ({tasks.length})
</button>
<button
className={`tab ${activeTab === 'details' ? 'active' : ''}`}
onClick={() => setActiveTab('details')}
>
Detalhes
</button>
</div>
<div className="modal-body">
{activeTab === 'messages' && (
<div className="messages-list">
{messages.length === 0 ? (
<div className="empty-state">Nenhuma mensagem</div>
) : (
messages.map(msg => (
<div key={msg.id} className="message-item">
<div className="message-time">{formatDate(msg.timestamp)}</div>
<div className="message-body">{msg.body}</div>
</div>
))
)}
</div>
)}
{activeTab === 'tasks' && (
<div className="tasks-section">
<div className="add-task-form">
<input
type="text"
placeholder="Nova tarefa..."
value={taskTitle}
onChange={e => setTaskTitle(e.target.value)}
/>
<input
type="date"
value={taskDueDate}
onChange={e => setTaskDueDate(e.target.value)}
/>
<button
onClick={() => {
if (taskTitle && taskDueDate) {
onAddTask(taskTitle, taskDueDate)
setTaskTitle('')
setTaskDueDate('')
}
}}
>
Adicionar
</button>
</div>
<div className="tasks-list">
{tasks.length === 0 ? (
<div className="empty-state">Nenhuma tarefa</div>
) : (
tasks.map(task => (
<div
key={task.id}
className={`task-item ${task.status}`}
onClick={() => onToggleTask(task.id, task.status === 'pending' ? 'completed' : 'pending')}
>
<input
type="checkbox"
checked={task.status === 'completed'}
onChange={() => {}}
/>
<span className={task.status}>{task.title}</span>
<span className="task-due">{task.due_date}</span>
</div>
))
)}
</div>
</div>
)}
{activeTab === 'details' && (
<div className="details-form">
<div className="form-group">
<label>Estágio</label>
<select
value={contact.stage}
onChange={e => onUpdateStage(e.target.value as Stage)}
>
{STAGE_LIST.map(stage => (
<option key={stage} value={stage}>{stage}</option>
))}
</select>
</div>
<div className="form-group">
<label>Status de Pagamento</label>
<select
value={contact.payment_status}
onChange={e => onUpdatePaymentStatus(e.target.value as 'pending' | 'paid')}
>
<option value="pending">Pendente</option>
<option value="paid">Pago</option>
</select>
</div>
<div className="form-group">
<label>Notas</label>
<textarea
value={contact.notes || ''}
onChange={e => onAddNote(e.target.value)}
placeholder="Adicione notas sobre este contato..."
/>
</div>
</div>
)}
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1 @@
export { default as ContactDetailModal } from './ContactDetailModal'

View File

@@ -0,0 +1,528 @@
import { Contact, Message, Task, Stage, STAGES } from './types'
import * as fs from 'fs'
import * as path from 'path'
const DB_NAME = 'whatsapp-crm.db'
let db: any = null
export async function initDB(): Promise<any> {
if (db) return db
const initSqlJs = (await import('sql.js')).default
const SQLITE = await initSqlJs()
db = new SQLITE.Database()
const dbPath = path.join(process.cwd(), DB_NAME)
if (fs.existsSync(dbPath)) {
const fileBuffer = fs.readFileSync(dbPath)
db = new SQLITE.Database(fileBuffer)
}
db.run(`
CREATE TABLE IF NOT EXISTS contacts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
phone TEXT NOT NULL UNIQUE,
stage TEXT NOT NULL DEFAULT 'LEADS DE ENTRADA',
notes TEXT,
schedule_date TEXT,
follow_up_date TEXT,
payment_status TEXT DEFAULT 'pending',
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT DEFAULT CURRENT_TIMESTAMP
)
`)
db.run(`
CREATE TABLE IF NOT EXISTS messages (
id INTEGER PRIMARY KEY AUTOINCREMENT,
contact_id INTEGER NOT NULL,
external_id TEXT NOT NULL UNIQUE,
body TEXT NOT NULL,
from_phone TEXT NOT NULL,
timestamp INTEGER NOT NULL,
has_media INTEGER DEFAULT 0,
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (contact_id) REFERENCES contacts(id)
)
`)
db.run(`
CREATE TABLE IF NOT EXISTS tasks (
id INTEGER PRIMARY KEY AUTOINCREMENT,
contact_id INTEGER NOT NULL,
title TEXT NOT NULL,
due_date TEXT NOT NULL,
status TEXT DEFAULT 'pending',
completed_at TEXT,
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (contact_id) REFERENCES contacts(id)
)
`)
db.run(`CREATE INDEX IF NOT EXISTS idx_contacts_phone ON contacts(phone)`)
db.run(`CREATE INDEX IF NOT EXISTS idx_contacts_stage ON contacts(stage)`)
db.run(`CREATE INDEX IF NOT EXISTS idx_messages_contact_id ON messages(contact_id)`)
db.run(`CREATE INDEX IF NOT EXISTS idx_tasks_contact_id ON tasks(contact_id)`)
db.run(`CREATE INDEX IF NOT EXISTS idx_tasks_status ON tasks(status)`)
const data = db.export()
const buffer = Buffer.from(data)
fs.writeFileSync(dbPath, buffer)
return db
}
export function getDB(): any {
if (!db) throw new Error('Database not initialized. Call initDB() first.')
return db
}
function saveDB(): void {
if (!db) return
const dbPath = path.join(process.cwd(), DB_NAME)
const data = db.export()
const buffer = Buffer.from(data)
fs.writeFileSync(dbPath, buffer)
}
export function closeDB(): void {
if (db) {
db.close()
db = null
}
}
export function createContact(input: { name: string; phone: string; notes?: string; schedule_date?: string; follow_up_date?: string }): Contact {
const database = getDB()
if (!input.name || !input.phone) {
throw new Error('Name and phone are required')
}
const now = new Date().toISOString()
try {
database.run(
`INSERT INTO contacts (name, phone, stage, notes, schedule_date, follow_up_date, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
[input.name, input.phone, STAGES.LEADS_DE_ENTRADA, input.notes || null, input.schedule_date || null, input.follow_up_date || null, now, now]
)
saveDB()
} catch (err: any) {
if (err.message.includes('UNIQUE constraint')) {
throw new Error('Contact with this phone already exists')
}
throw err
}
const result = database.exec('SELECT last_insert_rowid() as id')
const id = result[0].values[0][0] as number
return {
id,
name: input.name,
phone: input.phone,
stage: STAGES.LEADS_DE_ENTRADA,
notes: input.notes || null,
schedule_date: input.schedule_date || null,
follow_up_date: input.follow_up_date || null,
payment_status: 'pending',
created_at: now,
updated_at: now,
}
}
export function getContactById(id: number): Contact | null {
const database = getDB()
const result = database.exec('SELECT * FROM contacts WHERE id = ?', [id])
if (result.length === 0 || result[0].values.length === 0) return null
const row = result[0].values[0]
return {
id: row[0] as number,
name: row[1] as string,
phone: row[2] as string,
stage: row[3] as Stage,
notes: row[4] as string | null,
schedule_date: row[5] as string | null,
follow_up_date: row[6] as string | null,
payment_status: row[7] as 'pending' | 'paid',
created_at: row[8] as string,
updated_at: row[9] as string,
}
}
export function getContactByPhone(phone: string): Contact | null {
const database = getDB()
const result = database.exec('SELECT * FROM contacts WHERE phone = ?', [phone])
if (result.length === 0 || result[0].values.length === 0) return null
const row = result[0].values[0]
return {
id: row[0] as number,
name: row[1] as string,
phone: row[2] as string,
stage: row[3] as Stage,
notes: row[4] as string | null,
schedule_date: row[5] as string | null,
follow_up_date: row[6] as string | null,
payment_status: row[7] as 'pending' | 'paid',
created_at: row[8] as string,
updated_at: row[9] as string,
}
}
export function getContactsByStage(stage: Stage): Contact[] {
const database = getDB()
const result = database.exec('SELECT * FROM contacts WHERE stage = ? ORDER BY updated_at DESC', [stage])
if (result.length === 0) return []
return result[0].values.map((row: any[]) => ({
id: row[0] as number,
name: row[1] as string,
phone: row[2] as string,
stage: row[3] as Stage,
notes: row[4] as string | null,
schedule_date: row[5] as string | null,
follow_up_date: row[6] as string | null,
payment_status: row[7] as 'pending' | 'paid',
created_at: row[8] as string,
updated_at: row[9] as string,
}))
}
export function getAllContacts(): Contact[] {
const database = getDB()
const result = database.exec('SELECT * FROM contacts ORDER BY stage, updated_at DESC')
if (result.length === 0) return []
return result[0].values.map((row: any[]) => ({
id: row[0] as number,
name: row[1] as string,
phone: row[2] as string,
stage: row[3] as Stage,
notes: row[4] as string | null,
schedule_date: row[5] as string | null,
follow_up_date: row[6] as string | null,
payment_status: row[7] as 'pending' | 'paid',
created_at: row[8] as string,
updated_at: row[9] as string,
}))
}
export function updateContact(id: number, input: { name?: string; stage?: Stage; notes?: string; schedule_date?: string; follow_up_date?: string; payment_status?: 'pending' | 'paid' }): Contact | null {
const database = getDB()
const contact = getContactById(id)
if (!contact) return null
const now = new Date().toISOString()
const updates: string[] = []
const values: any[] = []
if (input.name !== undefined) {
updates.push('name = ?')
values.push(input.name)
}
if (input.stage !== undefined) {
updates.push('stage = ?')
values.push(input.stage)
}
if (input.notes !== undefined) {
updates.push('notes = ?')
values.push(input.notes)
}
if (input.schedule_date !== undefined) {
updates.push('schedule_date = ?')
values.push(input.schedule_date)
}
if (input.follow_up_date !== undefined) {
updates.push('follow_up_date = ?')
values.push(input.follow_up_date)
}
if (input.payment_status !== undefined) {
updates.push('payment_status = ?')
values.push(input.payment_status)
}
if (updates.length === 0) return contact
updates.push('updated_at = ?')
values.push(now)
values.push(id)
database.run(`UPDATE contacts SET ${updates.join(', ')} WHERE id = ?`, values)
saveDB()
return getContactById(id)
}
export function deleteContact(id: number): boolean {
const database = getDB()
database.run('DELETE FROM messages WHERE contact_id = ?', [id])
database.run('DELETE FROM tasks WHERE contact_id = ?', [id])
database.run('DELETE FROM contacts WHERE id = ?', [id])
saveDB()
return true
}
export function createMessage(input: { contact_id: number; external_id: string; body: string; from_phone: string; timestamp: number; has_media?: boolean }): Message | null {
const database = getDB()
const existing = database.exec('SELECT id FROM messages WHERE external_id = ?', [input.external_id])
if (existing.length > 0 && existing[0].values.length > 0) {
return null
}
const contact = getContactById(input.contact_id)
if (!contact) return null
const now = new Date().toISOString()
database.run(
`INSERT INTO messages (contact_id, external_id, body, from_phone, timestamp, has_media, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?)`,
[input.contact_id, input.external_id, input.body, input.from_phone, input.timestamp, input.has_media ? 1 : 0, now]
)
saveDB()
const result = database.exec('SELECT last_insert_rowid() as id')
const id = result[0].values[0][0] as number
return {
id,
contact_id: input.contact_id,
external_id: input.external_id,
body: input.body,
from: input.from_phone,
timestamp: input.timestamp,
has_media: input.has_media || false,
created_at: now,
}
}
export function getMessagesByContactId(contactId: number): Message[] {
const database = getDB()
const result = database.exec('SELECT * FROM messages WHERE contact_id = ? ORDER BY timestamp DESC', [contactId])
if (result.length === 0) return []
return result[0].values.map((row: any[]) => ({
id: row[0] as number,
contact_id: row[1] as number,
external_id: row[2] as string,
body: row[3] as string,
from: row[4] as string,
timestamp: row[5] as number,
has_media: Boolean(row[6]),
created_at: row[7] as string,
}))
}
export function createTask(input: { contact_id: number; title: string; due_date: string }): Task {
const database = getDB()
if (!input.title || !input.due_date) {
throw new Error('Title and due date are required')
}
const contact = getContactById(input.contact_id)
if (!contact) {
throw new Error('Contact not found')
}
const now = new Date().toISOString()
database.run(
`INSERT INTO tasks (contact_id, title, due_date, status, created_at)
VALUES (?, ?, ?, 'pending', ?)`,
[input.contact_id, input.title, input.due_date, now]
)
saveDB()
const result = database.exec('SELECT last_insert_rowid() as id')
const id = result[0].values[0][0] as number
return {
id,
contact_id: input.contact_id,
title: input.title,
due_date: input.due_date,
status: 'pending',
completed_at: null,
created_at: now,
}
}
export function getTaskById(id: number): Task | null {
const database = getDB()
const result = database.exec('SELECT * FROM tasks WHERE id = ?', [id])
if (result.length === 0 || result[0].values.length === 0) return null
const row = result[0].values[0]
return {
id: row[0] as number,
contact_id: row[1] as number,
title: row[2] as string,
due_date: row[3] as string,
status: row[4] as 'pending' | 'completed',
completed_at: row[5] as string | null,
created_at: row[6] as string,
}
}
export function getTasksByContactId(contactId: number): Task[] {
const database = getDB()
const result = database.exec('SELECT * FROM tasks WHERE contact_id = ? ORDER BY due_date ASC', [contactId])
if (result.length === 0) return []
return result[0].values.map((row: any[]) => ({
id: row[0] as number,
contact_id: row[1] as number,
title: row[2] as string,
due_date: row[3] as string,
status: row[4] as 'pending' | 'completed',
completed_at: row[5] as string | null,
created_at: row[6] as string,
}))
}
export function getPendingTasksByContactId(contactId: number): Task[] {
const database = getDB()
const result = database.exec("SELECT * FROM tasks WHERE contact_id = ? AND status = 'pending' ORDER BY due_date ASC", [contactId])
if (result.length === 0) return []
return result[0].values.map((row: any[]) => ({
id: row[0] as number,
contact_id: row[1] as number,
title: row[2] as string,
due_date: row[3] as string,
status: row[4] as 'pending' | 'completed',
completed_at: row[5] as string | null,
created_at: row[6] as string,
}))
}
export function getOverdueTasks(): Task[] {
const database = getDB()
const now = new Date().toISOString()
const result = database.exec("SELECT * FROM tasks WHERE status = 'pending' AND due_date < ? ORDER BY due_date ASC", [now.split('T')[0]])
if (result.length === 0) return []
return result[0].values.map((row: any[]) => ({
id: row[0] as number,
contact_id: row[1] as number,
title: row[2] as string,
due_date: row[3] as string,
status: row[4] as 'pending' | 'completed',
completed_at: row[5] as string | null,
created_at: row[6] as string,
}))
}
export function updateTask(id: number, input: { title?: string; due_date?: string; status?: 'pending' | 'completed' }): Task | null {
const database = getDB()
const task = getTaskById(id)
if (!task) return null
const updates: string[] = []
const values: any[] = []
if (input.title !== undefined) {
updates.push('title = ?')
values.push(input.title)
}
if (input.due_date !== undefined) {
updates.push('due_date = ?')
values.push(input.due_date)
}
if (input.status !== undefined) {
updates.push('status = ?')
values.push(input.status)
if (input.status === 'completed') {
updates.push('completed_at = ?')
values.push(new Date().toISOString())
} else {
updates.push('completed_at = ?')
values.push(null)
}
}
if (updates.length === 0) return task
values.push(id)
database.run(`UPDATE tasks SET ${updates.join(', ')} WHERE id = ?`, values)
saveDB()
return getTaskById(id)
}
export function deleteTask(id: number): boolean {
const database = getDB()
database.run('DELETE FROM tasks WHERE id = ?', [id])
saveDB()
return true
}
export function getTaskCountByContactId(contactId: number): number {
const database = getDB()
const result = database.exec("SELECT COUNT(*) as count FROM tasks WHERE contact_id = ? AND status = 'pending'", [contactId])
if (result.length === 0) return 0
return result[0].values[0][0] as number
}
export function searchContacts(query: string): Contact[] {
const database = getDB()
const searchTerm = `%${query}%`
const result = database.exec(
'SELECT * FROM contacts WHERE name LIKE ? OR phone LIKE ? ORDER BY updated_at DESC',
[searchTerm, searchTerm]
)
if (result.length === 0) return []
return result[0].values.map((row: any[]) => ({
id: row[0] as number,
name: row[1] as string,
phone: row[2] as string,
stage: row[3] as Stage,
notes: row[4] as string | null,
schedule_date: row[5] as string | null,
follow_up_date: row[6] as string | null,
payment_status: row[7] as 'pending' | 'paid',
created_at: row[8] as string,
updated_at: row[9] as string,
}))
}
export function getContactsByPaymentStatus(status: 'pending' | 'paid'): Contact[] {
const database = getDB()
const result = database.exec('SELECT * FROM contacts WHERE payment_status = ? ORDER BY updated_at DESC', [status])
if (result.length === 0) return []
return result[0].values.map((row: any[]) => ({
id: row[0] as number,
name: row[1] as string,
phone: row[2] as string,
stage: row[3] as Stage,
notes: row[4] as string | null,
schedule_date: row[5] as string | null,
follow_up_date: row[6] as string | null,
payment_status: row[7] as 'pending' | 'paid',
created_at: row[8] as string,
updated_at: row[9] as string,
}))
}

View File

@@ -0,0 +1,68 @@
export const STAGES = {
LEADS_DE_ENTRADA: 'LEADS DE ENTRADA',
DECIDINDO: 'DECIDINDO',
DISCUSSAO_DE_CONTRATO: 'DISCUSSAO DE CONTRATO',
DECISAO_FINAL: 'DECISAO FINAL',
} as const
export type Stage = typeof STAGES[keyof typeof STAGES]
export const STAGE_LIST = Object.values(STAGES)
export interface Contact {
id: number
name: string
phone: string
stage: Stage
notes: string | null
schedule_date: string | null
follow_up_date: string | null
payment_status: 'pending' | 'paid'
created_at: string
updated_at: string
taskCount?: number
}
export interface Message {
id: number
contact_id: number
external_id: string
body: string
from: string
timestamp: number
has_media: boolean
created_at: string
}
export interface Task {
id: number
contact_id: number
title: string
due_date: string
status: 'pending' | 'completed'
completed_at: string | null
created_at: string
}
export interface CreateContactInput {
name: string
phone: string
notes?: string
schedule_date?: string
follow_up_date?: string
}
export interface UpdateContactInput {
name?: string
stage?: Stage
notes?: string
schedule_date?: string
follow_up_date?: string
payment_status?: 'pending' | 'paid'
}
export interface CreateTaskInput {
contact_id: number
title: string
due_date: string
}