Compare commits
2 Commits
1d3315a1d0
...
8f7e5ee487
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8f7e5ee487 | ||
|
|
f11db95a2f |
@@ -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");
|
||||||
|
|||||||
@@ -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={{
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
Reference in New Issue
Block a user