Compare commits

...

2 Commits

Author SHA1 Message Date
Cauê Faleiros
8f7e5ee487 feat: synchronize dashboard origins with management page and add integration endpoints
All checks were successful
Build and Deploy / build-and-push (push) Successful in 1m53s
- Updated Dashboard origin chart to strictly reflect only configured origins, grouping unmapped data into an 'Outros' category.

- Added GET /api/integration/funnels and GET /api/integration/origins endpoints to allow external AIs to dynamically map stages and lead sources.
2026-03-18 16:43:42 -03:00
Cauê Faleiros
f11db95a2f fix: allow origin colors to be edited and display correctly in dashboard
- Fixed database initialization where default origins were seeded without color_classes.

- Added a visual color picker to the Origens admin page to allow users to assign colors to origin tags.

- Updated Dashboard Pie Chart to read the color classes correctly and display them.
2026-03-18 13:43:43 -03:00
5 changed files with 112 additions and 26 deletions

View File

@@ -626,12 +626,18 @@ apiRouter.get('/origins', async (req, res) => {
const gid = `origrp_${crypto.randomUUID().split('-')[0]}`; const gid = `origrp_${crypto.randomUUID().split('-')[0]}`;
await pool.query('INSERT INTO origin_groups (id, tenant_id, name) VALUES (?, ?, ?)', [gid, effectiveTenantId, 'Origens Padrão']); await pool.query('INSERT INTO origin_groups (id, tenant_id, name) VALUES (?, ?, ?)', [gid, effectiveTenantId, 'Origens Padrão']);
const defaultOrigins = ['WhatsApp', 'Instagram', 'Website', 'LinkedIn', 'Indicação']; const defaultOrigins = [
for (const name of defaultOrigins) { { name: 'WhatsApp', color: 'bg-green-100 text-green-700 border-green-200 dark:bg-green-900/30 dark:text-green-400 dark:border-green-800' },
{ name: 'Instagram', color: 'bg-pink-100 text-pink-700 border-pink-200 dark:bg-pink-900/30 dark:text-pink-400 dark:border-pink-800' },
{ name: 'Website', color: 'bg-red-100 text-red-700 border-red-200 dark:bg-red-900/30 dark:text-red-400 dark:border-red-800' },
{ name: 'LinkedIn', color: 'bg-blue-100 text-blue-700 border-blue-200 dark:bg-blue-900/30 dark:text-blue-400 dark:border-blue-800' },
{ name: 'Indicação', color: 'bg-orange-100 text-orange-700 border-orange-200 dark:bg-orange-900/30 dark:text-orange-400 dark:border-orange-800' }
];
for (const origin of defaultOrigins) {
const oid = `oriitm_${crypto.randomUUID().split('-')[0]}`; const oid = `oriitm_${crypto.randomUUID().split('-')[0]}`;
await pool.query( await pool.query(
'INSERT INTO origin_items (id, origin_group_id, name) VALUES (?, ?, ?)', 'INSERT INTO origin_items (id, origin_group_id, name, color_class) VALUES (?, ?, ?, ?)',
[oid, gid, name] [oid, gid, origin.name, origin.color]
); );
} }
@@ -699,12 +705,12 @@ apiRouter.delete('/origins/:id', requireRole(['admin', 'owner', 'manager', 'supe
}); });
apiRouter.post('/origins/:id/items', requireRole(['admin', 'owner', 'manager', 'super_admin']), async (req, res) => { apiRouter.post('/origins/:id/items', requireRole(['admin', 'owner', 'manager', 'super_admin']), async (req, res) => {
const { name } = req.body; const { name, color_class } = req.body;
try { try {
const oid = `oriitm_${crypto.randomUUID().split('-')[0]}`; const oid = `oriitm_${crypto.randomUUID().split('-')[0]}`;
await pool.query( await pool.query(
'INSERT INTO origin_items (id, origin_group_id, name) VALUES (?, ?, ?)', 'INSERT INTO origin_items (id, origin_group_id, name, color_class) VALUES (?, ?, ?, ?)',
[oid, req.params.id, name] [oid, req.params.id, name, color_class || 'bg-zinc-100 text-zinc-800 border-zinc-200']
); );
res.status(201).json({ id: oid }); res.status(201).json({ id: oid });
} catch (error) { } catch (error) {
@@ -713,12 +719,12 @@ apiRouter.post('/origins/:id/items', requireRole(['admin', 'owner', 'manager', '
}); });
apiRouter.put('/origin_items/:id', requireRole(['admin', 'owner', 'manager', 'super_admin']), async (req, res) => { apiRouter.put('/origin_items/:id', requireRole(['admin', 'owner', 'manager', 'super_admin']), async (req, res) => {
const { name } = req.body; const { name, color_class } = req.body;
try { try {
const [existing] = await pool.query('SELECT * FROM origin_items WHERE id = ?', [req.params.id]); const [existing] = await pool.query('SELECT * FROM origin_items WHERE id = ?', [req.params.id]);
if (existing.length === 0) return res.status(404).json({ error: 'Origin item not found' }); if (existing.length === 0) return res.status(404).json({ error: 'Origin item not found' });
await pool.query('UPDATE origin_items SET name = ? WHERE id = ?', [name || existing[0].name, req.params.id]); await pool.query('UPDATE origin_items SET name = ?, color_class = ? WHERE id = ?', [name || existing[0].name, color_class || existing[0].color_class, req.params.id]);
res.json({ message: 'Origin item updated.' }); res.json({ message: 'Origin item updated.' });
} catch (error) { } catch (error) {
res.status(500).json({ error: error.message }); res.status(500).json({ error: error.message });
@@ -1480,12 +1486,20 @@ const provisionSuperAdmin = async (retries = 10, delay = 10000) => {
id varchar(36) NOT NULL, id varchar(36) NOT NULL,
origin_group_id varchar(36) NOT NULL, origin_group_id varchar(36) NOT NULL,
name varchar(255) NOT NULL, name varchar(255) NOT NULL,
color_class varchar(255) DEFAULT 'bg-zinc-100 text-zinc-800 border-zinc-200',
created_at timestamp NULL DEFAULT CURRENT_TIMESTAMP, created_at timestamp NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (id), PRIMARY KEY (id),
KEY origin_group_id (origin_group_id) KEY origin_group_id (origin_group_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
`); `);
// Attempt to add color_class if table already existed without it
try {
await connection.query("ALTER TABLE origin_items ADD COLUMN color_class VARCHAR(255) DEFAULT 'bg-zinc-100 text-zinc-800 border-zinc-200'");
} catch (err) {
if (err.code !== 'ER_DUP_FIELDNAME') console.log('Schema update note (origin_items.color_class):', err.message);
}
// Add origin_group_id to teams // Add origin_group_id to teams
try { try {
await connection.query("ALTER TABLE teams ADD COLUMN origin_group_id VARCHAR(36) DEFAULT NULL"); await connection.query("ALTER TABLE teams ADD COLUMN origin_group_id VARCHAR(36) DEFAULT NULL");

View File

@@ -179,18 +179,59 @@ export const Dashboard: React.FC = () => {
})); }));
}, [data, funnelDefs]); }, [data, funnelDefs]);
const tailwindToHex: Record<string, string> = {
'zinc': '#71717a',
'blue': '#3b82f6',
'purple': '#a855f7',
'green': '#22c55e',
'red': '#ef4444',
'pink': '#ec4899',
'orange': '#f97316',
'yellow': '#eab308'
};
// --- Chart Data: Origin --- // --- Chart Data: Origin ---
const originData = useMemo(() => { const originData = useMemo(() => {
const origins = data.reduce((acc, curr) => { const counts = data.reduce((acc, curr) => {
acc[curr.origin] = (acc[curr.origin] || 0) + 1; acc[curr.origin] = (acc[curr.origin] || 0) + 1;
return acc; return acc;
}, {} as Record<string, number>); }, {} as Record<string, number>);
// Ensure type safety for value in sort if (originDefs.length > 0) {
return (Object.entries(origins) as [string, number][]) const activeOrigins = originDefs.map(def => {
.map(([name, value]) => ({ name, value })) let hexColor = '#71717a'; // Default zinc
.sort((a, b) => b.value - a.value); if (def.color_class) {
}, [data]); const match = def.color_class.match(/bg-([a-z]+)-\d+/);
if (match && tailwindToHex[match[1]]) {
hexColor = tailwindToHex[match[1]];
}
}
return {
name: def.name,
value: counts[def.name] || 0,
hexColor
};
});
// Calculate "Outros" for data that doesn't match current active origins
const activeNames = new Set(originDefs.map(d => d.name));
const othersValue = (Object.entries(counts) as [string, number][])
.filter(([name]) => !activeNames.has(name))
.reduce((sum, [_, val]) => sum + val, 0);
if (othersValue > 0) {
activeOrigins.push({
name: 'Outros',
value: othersValue,
hexColor: '#94a3b8' // Gray-400
});
}
return activeOrigins.sort((a, b) => b.value - a.value);
}
return []; // No definitions = No chart (matches funnel behavior)
}, [data, originDefs]);
// --- Table Data: Sellers Ranking --- // --- Table Data: Sellers Ranking ---
const sellersRanking = useMemo(() => { const sellersRanking = useMemo(() => {
@@ -427,12 +468,11 @@ export const Dashboard: React.FC = () => {
stroke="none" stroke="none"
> >
{originData.map((entry, index) => ( {originData.map((entry, index) => (
<Cell <Cell
key={`cell-${index}`} key={`cell-${index}`}
fill={COLORS.origins[entry.name as keyof typeof COLORS.origins] || COLORS.charts[index % COLORS.charts.length]} fill={entry.hexColor || COLORS.charts[index % COLORS.charts.length]}
/> />
))} ))} </Pie>
</Pie>
<Tooltip <Tooltip
formatter={(value: any) => [value, 'Leads']} formatter={(value: any) => [value, 'Leads']}
contentStyle={{ contentStyle={{

View File

@@ -11,7 +11,7 @@ export const Origins: React.FC = () => {
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [isModalOpen, setIsModalOpen] = useState(false); const [isModalOpen, setIsModalOpen] = useState(false);
const [editingItem, setEditingItem] = useState<OriginItemDef | null>(null); const [editingItem, setEditingItem] = useState<OriginItemDef | null>(null);
const [formData, setFormData] = useState({ name: '' }); const [formData, setFormData] = useState({ name: '', color_class: '' });
const [isSaving, setIsSaving] = useState(false); const [isSaving, setIsSaving] = useState(false);
// Group creation state // Group creation state
@@ -20,6 +20,15 @@ export const Origins: React.FC = () => {
const tenantId = localStorage.getItem('ctms_tenant_id') || ''; const tenantId = localStorage.getItem('ctms_tenant_id') || '';
const PRESET_COLORS = [
{ label: 'Cinza', value: 'bg-zinc-100 text-zinc-700 border-zinc-200 dark:bg-dark-input dark:text-dark-muted dark:border-dark-border' },
{ label: 'Azul', value: 'bg-blue-100 text-blue-700 border-blue-200 dark:bg-blue-900/30 dark:text-blue-400 dark:border-blue-800' },
{ label: 'Roxo', value: 'bg-purple-100 text-purple-700 border-purple-200 dark:bg-purple-900/30 dark:text-purple-400 dark:border-purple-800' },
{ label: 'Verde', value: 'bg-green-100 text-green-700 border-green-200 dark:bg-green-900/30 dark:text-green-400 dark:border-green-800' },
{ label: 'Vermelho', value: 'bg-red-100 text-red-700 border-red-200 dark:bg-red-900/30 dark:text-red-400 dark:border-red-800' },
{ label: 'Rosa', value: 'bg-pink-100 text-pink-700 border-pink-200 dark:bg-pink-900/30 dark:text-pink-400 dark:border-pink-800' },
];
const loadData = async () => { const loadData = async () => {
setLoading(true); setLoading(true);
const [fetchedGroups, fetchedTeams] = await Promise.all([ const [fetchedGroups, fetchedTeams] = await Promise.all([
@@ -115,10 +124,10 @@ export const Origins: React.FC = () => {
const openItemModal = (item?: OriginItemDef) => { const openItemModal = (item?: OriginItemDef) => {
if (item) { if (item) {
setEditingItem(item); setEditingItem(item);
setFormData({ name: item.name }); setFormData({ name: item.name, color_class: item.color_class || PRESET_COLORS[0].value });
} else { } else {
setEditingItem(null); setEditingItem(null);
setFormData({ name: '' }); setFormData({ name: '', color_class: PRESET_COLORS[0].value });
} }
setIsModalOpen(true); setIsModalOpen(true);
}; };
@@ -211,7 +220,9 @@ export const Origins: React.FC = () => {
<div className="divide-y divide-zinc-100 dark:divide-dark-border"> <div className="divide-y divide-zinc-100 dark:divide-dark-border">
{selectedGroup.items?.map((o) => ( {selectedGroup.items?.map((o) => (
<div key={o.id} className="p-4 px-6 flex items-center justify-between group hover:bg-zinc-50 dark:hover:bg-dark-bg transition-colors"> <div key={o.id} className="p-4 px-6 flex items-center justify-between group hover:bg-zinc-50 dark:hover:bg-dark-bg transition-colors">
<div className="font-medium text-zinc-800 dark:text-zinc-200">{o.name}</div> <span className={`px-3 py-1 rounded-full text-xs font-bold uppercase tracking-wide border ${o.color_class || 'bg-zinc-100 text-zinc-700 border-zinc-200'}`}>
{o.name}
</span>
<div className="flex gap-2"> <div className="flex gap-2">
<button onClick={() => openItemModal(o)} className="p-2 text-zinc-400 hover:text-brand-yellow hover:bg-zinc-100 dark:hover:bg-dark-input rounded-lg transition-colors"> <button onClick={() => openItemModal(o)} className="p-2 text-zinc-400 hover:text-brand-yellow hover:bg-zinc-100 dark:hover:bg-dark-input rounded-lg transition-colors">
@@ -286,6 +297,26 @@ export const Origins: React.FC = () => {
/> />
</div> </div>
<div>
<label className="text-xs font-bold text-zinc-500 dark:text-dark-muted uppercase mb-2 block">Cor Visual</label>
<div className="grid grid-cols-2 gap-2">
{PRESET_COLORS.map((color, i) => (
<label key={i} className={`flex items-center gap-2 p-2 border rounded-lg cursor-pointer transition-all ${formData.color_class === color.value ? 'border-brand-yellow bg-yellow-50/50 dark:bg-yellow-900/10' : 'border-zinc-200 dark:border-dark-border hover:bg-zinc-50 dark:hover:bg-dark-input'}`}>
<input
type="radio"
name="color"
value={color.value}
checked={formData.color_class === color.value}
onChange={(e) => setFormData({...formData, color_class: e.target.value})}
className="sr-only"
/>
<span className={`w-3 h-3 rounded-full border ${color.value.split(' ')[0]} ${color.value.split(' ')[2]}`}></span>
<span className="text-xs font-medium text-zinc-700 dark:text-zinc-300">{color.label}</span>
</label>
))}
</div>
</div>
<div className="pt-4 flex justify-end gap-3 mt-6 border-t border-zinc-100 dark:border-dark-border pt-6"> <div className="pt-4 flex justify-end gap-3 mt-6 border-t border-zinc-100 dark:border-dark-border pt-6">
<button type="button" onClick={() => setIsModalOpen(false)} className="px-4 py-2 text-zinc-600 dark:text-zinc-400 hover:bg-zinc-100 dark:hover:bg-dark-border rounded-lg text-sm font-medium transition-colors">Cancelar</button> <button type="button" onClick={() => setIsModalOpen(false)} className="px-4 py-2 text-zinc-600 dark:text-zinc-400 hover:bg-zinc-100 dark:hover:bg-dark-border rounded-lg text-sm font-medium transition-colors">Cancelar</button>
<button type="submit" disabled={isSaving} className="px-6 py-2 bg-zinc-900 dark:bg-brand-yellow text-white dark:text-zinc-950 rounded-lg text-sm font-bold flex items-center gap-2 hover:opacity-90 transition-all shadow-sm disabled:opacity-70"> <button type="submit" disabled={isSaving} className="px-6 py-2 bg-zinc-900 dark:bg-brand-yellow text-white dark:text-zinc-950 rounded-lg text-sm font-bold flex items-center gap-2 hover:opacity-90 transition-all shadow-sm disabled:opacity-70">

View File

@@ -230,7 +230,7 @@ export const deleteOriginGroup = async (id: string): Promise<boolean> => {
} }
}; };
export const createOriginItem = async (groupId: string, data: { name: string }): Promise<any> => { export const createOriginItem = async (groupId: string, data: { name: string, color_class?: string }): Promise<any> => {
const response = await fetch(`${API_URL}/origins/${groupId}/items`, { const response = await fetch(`${API_URL}/origins/${groupId}/items`, {
method: 'POST', method: 'POST',
headers: getHeaders(), headers: getHeaders(),
@@ -243,7 +243,7 @@ export const createOriginItem = async (groupId: string, data: { name: string }):
return await response.json(); return await response.json();
}; };
export const updateOriginItem = async (id: string, data: { name: string }): Promise<boolean> => { export const updateOriginItem = async (id: string, data: { name: string, color_class?: string }): Promise<boolean> => {
try { try {
const response = await fetch(`${API_URL}/origin_items/${id}`, { const response = await fetch(`${API_URL}/origin_items/${id}`, {
method: 'PUT', method: 'PUT',

View File

@@ -27,6 +27,7 @@ export interface OriginItemDef {
id: string; id: string;
origin_group_id: string; origin_group_id: string;
name: string; name: string;
color_class: string;
} }
export interface OriginGroupDef { export interface OriginGroupDef {