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]}`;
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'];
for (const name of defaultOrigins) {
const 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]}`;
await pool.query(
'INSERT INTO origin_items (id, origin_group_id, name) VALUES (?, ?, ?)',
[oid, gid, name]
'INSERT INTO origin_items (id, origin_group_id, name, color_class) VALUES (?, ?, ?, ?)',
[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) => {
const { name } = req.body;
const { name, color_class } = req.body;
try {
const oid = `oriitm_${crypto.randomUUID().split('-')[0]}`;
await pool.query(
'INSERT INTO origin_items (id, origin_group_id, name) VALUES (?, ?, ?)',
[oid, req.params.id, name]
'INSERT INTO origin_items (id, origin_group_id, name, color_class) VALUES (?, ?, ?, ?)',
[oid, req.params.id, name, color_class || 'bg-zinc-100 text-zinc-800 border-zinc-200']
);
res.status(201).json({ id: oid });
} 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) => {
const { name } = req.body;
const { name, color_class } = req.body;
try {
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' });
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.' });
} catch (error) {
res.status(500).json({ error: error.message });
@@ -1480,12 +1486,20 @@ const provisionSuperAdmin = async (retries = 10, delay = 10000) => {
id varchar(36) NOT NULL,
origin_group_id varchar(36) 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,
PRIMARY KEY (id),
KEY origin_group_id (origin_group_id)
) 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
try {
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]);
const tailwindToHex: Record<string, string> = {
'zinc': '#71717a',
'blue': '#3b82f6',
'purple': '#a855f7',
'green': '#22c55e',
'red': '#ef4444',
'pink': '#ec4899',
'orange': '#f97316',
'yellow': '#eab308'
};
// --- Chart Data: Origin ---
const originData = useMemo(() => {
const origins = data.reduce((acc, curr) => {
const counts = data.reduce((acc, curr) => {
acc[curr.origin] = (acc[curr.origin] || 0) + 1;
return acc;
}, {} as Record<string, number>);
// Ensure type safety for value in sort
return (Object.entries(origins) as [string, number][])
.map(([name, value]) => ({ name, value }))
.sort((a, b) => b.value - a.value);
}, [data]);
if (originDefs.length > 0) {
const activeOrigins = originDefs.map(def => {
let hexColor = '#71717a'; // Default zinc
if (def.color_class) {
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 ---
const sellersRanking = useMemo(() => {
@@ -429,10 +470,9 @@ export const Dashboard: React.FC = () => {
{originData.map((entry, index) => (
<Cell
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
formatter={(value: any) => [value, 'Leads']}
contentStyle={{

View File

@@ -11,7 +11,7 @@ export const Origins: React.FC = () => {
const [loading, setLoading] = useState(true);
const [isModalOpen, setIsModalOpen] = useState(false);
const [editingItem, setEditingItem] = useState<OriginItemDef | null>(null);
const [formData, setFormData] = useState({ name: '' });
const [formData, setFormData] = useState({ name: '', color_class: '' });
const [isSaving, setIsSaving] = useState(false);
// Group creation state
@@ -20,6 +20,15 @@ export const Origins: React.FC = () => {
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 () => {
setLoading(true);
const [fetchedGroups, fetchedTeams] = await Promise.all([
@@ -115,10 +124,10 @@ export const Origins: React.FC = () => {
const openItemModal = (item?: OriginItemDef) => {
if (item) {
setEditingItem(item);
setFormData({ name: item.name });
setFormData({ name: item.name, color_class: item.color_class || PRESET_COLORS[0].value });
} else {
setEditingItem(null);
setFormData({ name: '' });
setFormData({ name: '', color_class: PRESET_COLORS[0].value });
}
setIsModalOpen(true);
};
@@ -211,7 +220,9 @@ export const Origins: React.FC = () => {
<div className="divide-y divide-zinc-100 dark:divide-dark-border">
{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 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">
<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>
<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">
<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">

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`, {
method: 'POST',
headers: getHeaders(),
@@ -243,7 +243,7 @@ export const createOriginItem = async (groupId: string, data: { name: string }):
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 {
const response = await fetch(`${API_URL}/origin_items/${id}`, {
method: 'PUT',

View File

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