feat: initial setup with WhatsApp CRM apps and sync services
This commit is contained in:
126
apps/whatsapp-crm/src/app/api/contacts/route.ts
Normal file
126
apps/whatsapp-crm/src/app/api/contacts/route.ts
Normal 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 })
|
||||
}
|
||||
}
|
||||
53
apps/whatsapp-crm/src/app/api/messages/route.ts
Normal file
53
apps/whatsapp-crm/src/app/api/messages/route.ts
Normal 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 })
|
||||
}
|
||||
}
|
||||
36
apps/whatsapp-crm/src/app/api/seed/route.ts
Normal file
36
apps/whatsapp-crm/src/app/api/seed/route.ts
Normal 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 })
|
||||
}
|
||||
}
|
||||
100
apps/whatsapp-crm/src/app/api/tasks/route.ts
Normal file
100
apps/whatsapp-crm/src/app/api/tasks/route.ts
Normal 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 })
|
||||
}
|
||||
}
|
||||
155
apps/whatsapp-crm/src/app/api/whatsapp/route.ts
Normal file
155
apps/whatsapp-crm/src/app/api/whatsapp/route.ts
Normal 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 })
|
||||
}
|
||||
393
apps/whatsapp-crm/src/app/globals.css
Normal file
393
apps/whatsapp-crm/src/app/globals.css
Normal 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;
|
||||
}
|
||||
183
apps/whatsapp-crm/src/app/kanban/page.tsx
Normal file
183
apps/whatsapp-crm/src/app/kanban/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
22
apps/whatsapp-crm/src/app/layout.tsx
Normal file
22
apps/whatsapp-crm/src/app/layout.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
132
apps/whatsapp-crm/src/app/page.tsx
Normal file
132
apps/whatsapp-crm/src/app/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
52
apps/whatsapp-crm/src/components/ContactCard/ContactCard.tsx
Normal file
52
apps/whatsapp-crm/src/components/ContactCard/ContactCard.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
1
apps/whatsapp-crm/src/components/ContactCard/index.ts
Normal file
1
apps/whatsapp-crm/src/components/ContactCard/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { default as ContactCard } from './ContactCard'
|
||||
50
apps/whatsapp-crm/src/components/Kanban/KanbanBoard.tsx
Normal file
50
apps/whatsapp-crm/src/components/Kanban/KanbanBoard.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
1
apps/whatsapp-crm/src/components/Kanban/index.ts
Normal file
1
apps/whatsapp-crm/src/components/Kanban/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { default as KanbanBoard } from './KanbanBoard'
|
||||
212
apps/whatsapp-crm/src/components/Modal/ContactDetailModal.tsx
Normal file
212
apps/whatsapp-crm/src/components/Modal/ContactDetailModal.tsx
Normal 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}>×</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>
|
||||
)
|
||||
}
|
||||
1
apps/whatsapp-crm/src/components/Modal/index.ts
Normal file
1
apps/whatsapp-crm/src/components/Modal/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { default as ContactDetailModal } from './ContactDetailModal'
|
||||
528
apps/whatsapp-crm/src/lib/db.ts
Normal file
528
apps/whatsapp-crm/src/lib/db.ts
Normal 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,
|
||||
}))
|
||||
}
|
||||
68
apps/whatsapp-crm/src/lib/types.ts
Normal file
68
apps/whatsapp-crm/src/lib/types.ts
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user