feat: implement customizable funnel stages per tenant

- Modified attendances.funnel_stage in DB from ENUM to VARCHAR.

- Created tenant_funnels table and backend API routes to manage custom stages.

- Added /admin/funnels page for Admins/Managers to create, edit, order, and color-code their funnel stages.

- Updated Dashboard, UserDetail, and AttendanceDetail to fetch and render dynamic funnel stages instead of hardcoded enums.

- Added defensive checks and logging to GET /users/:idOrSlug to fix sporadic 500 errors during impersonation handoffs.
This commit is contained in:
Cauê Faleiros
2026-03-13 10:25:23 -03:00
parent 1d49161a05
commit 7ab54053db
18 changed files with 588 additions and 33 deletions

View File

@@ -5,9 +5,9 @@ import {
import {
Users, Clock, Phone, TrendingUp, Filter
} from 'lucide-react';
import { getAttendances, getUsers, getTeams, getUserById } from '../services/dataService';
import { getAttendances, getUsers, getTeams, getUserById, getFunnels } from '../services/dataService';
import { COLORS } from '../constants';
import { Attendance, DashboardFilter, FunnelStage, User } from '../types';
import { Attendance, DashboardFilter, FunnelStage, User, FunnelStageDef } from '../types';
import { KPICard } from '../components/KPICard';
import { DateRangePicker } from '../components/DateRangePicker';
import { SellersTable } from '../components/SellersTable';
@@ -28,6 +28,7 @@ export const Dashboard: React.FC = () => {
const [prevData, setPrevData] = useState<Attendance[]>([]);
const [users, setUsers] = useState<User[]>([]);
const [teams, setTeams] = useState<any[]>([]);
const [funnelDefs, setFunnelDefs] = useState<FunnelStageDef[]>([]);
const [currentUser, setCurrentUser] = useState<User | null>(null);
const [filters, setFilters] = useState<DashboardFilter>({
@@ -55,12 +56,13 @@ export const Dashboard: React.FC = () => {
const prevEnd = new Date(filters.dateRange.start.getTime());
const prevFilters = { ...filters, dateRange: { start: prevStart, end: prevEnd } };
// Fetch users, attendances, teams and current user in parallel
const [fetchedUsers, fetchedData, prevFetchedData, fetchedTeams, me] = await Promise.all([
// Fetch users, attendances, teams, funnels and current user in parallel
const [fetchedUsers, fetchedData, prevFetchedData, fetchedTeams, fetchedFunnels, me] = await Promise.all([
getUsers(tenantId),
getAttendances(tenantId, filters),
getAttendances(tenantId, prevFilters),
getTeams(tenantId),
getFunnels(tenantId),
storedUserId ? getUserById(storedUserId) : null
]);
@@ -68,6 +70,7 @@ export const Dashboard: React.FC = () => {
setData(fetchedData);
setPrevData(prevFetchedData);
setTeams(fetchedTeams);
setFunnelDefs(fetchedFunnels.sort((a, b) => a.order_index - b.order_index));
if (me) setCurrentUser(me);
} catch (error) {
console.error("Error loading dashboard data:", error);
@@ -128,6 +131,20 @@ export const Dashboard: React.FC = () => {
// --- Chart Data: Funnel ---
const funnelData = useMemo(() => {
const counts = data.reduce((acc, curr) => {
acc[curr.funnel_stage] = (acc[curr.funnel_stage] || 0) + 1;
return acc;
}, {} as Record<string, number>);
if (funnelDefs.length > 0) {
return funnelDefs.map(stage => ({
name: stage.name,
value: counts[stage.name] || 0,
color: stage.color_class.split(' ')[0].replace('bg-', '') // Extract base color name for Recharts
}));
}
// Fallback if funnels aren't loaded yet
const stagesOrder = [
FunnelStage.NO_CONTACT,
FunnelStage.IDENTIFICATION,
@@ -135,17 +152,11 @@ export const Dashboard: React.FC = () => {
FunnelStage.WON,
FunnelStage.LOST
];
const counts = data.reduce((acc, curr) => {
acc[curr.funnel_stage] = (acc[curr.funnel_stage] || 0) + 1;
return acc;
}, {} as Record<string, number>);
return stagesOrder.map(stage => ({
name: stage,
value: counts[stage] || 0
}));
}, [data]);
}, [data, funnelDefs]);
// --- Chart Data: Origin ---
const originData = useMemo(() => {
@@ -266,17 +277,18 @@ export const Dashboard: React.FC = () => {
</>
)}
<select
<select
className="bg-zinc-50 dark:bg-dark-bg border border-zinc-200 dark:border-dark-border px-3 py-2 rounded-lg text-sm text-zinc-700 dark:text-zinc-200 outline-none focus:ring-2 focus:ring-brand-yellow/20 cursor-pointer hover:border-zinc-300 dark:hover:border-dark-border transition-all"
value={filters.funnelStage}
onChange={(e) => handleFilterChange('funnelStage', e.target.value)}
>
<option value="all">Todas Etapas</option>
{Object.values(FunnelStage).map(stage => (
{funnelDefs.length > 0 ? funnelDefs.map(stage => (
<option key={stage.id} value={stage.name}>{stage.name}</option>
)) : Object.values(FunnelStage).map(stage => (
<option key={stage} value={stage}>{stage}</option>
))}
</select>
<select
className="bg-zinc-50 dark:bg-dark-bg border border-zinc-200 dark:border-dark-border px-3 py-2 rounded-lg text-sm text-zinc-700 dark:text-zinc-200 outline-none focus:ring-2 focus:ring-brand-yellow/20 cursor-pointer hover:border-zinc-300 dark:hover:border-dark-border transition-all"
value={filters.origin}