feat: complete UI/UX refinement, email flow updates, and deep black theme
All checks were successful
Build and Deploy / build-and-push (push) Successful in 2m18s
All checks were successful
Build and Deploy / build-and-push (push) Successful in 2m18s
- Updated all email templates to a clean light theme and changed button text to 'Finalizar Cadastro'. - Enforced a strict 15-minute expiration on all auth/reset tokens. - Created SetupAccount flow distinct from ResetPassword to capture user name during admin init. - Refined dark mode to a premium True Black (Onyx) palette using Zinc. - Fixed Dashboard KPI visibility and true period-over-period trend logic. - Enhanced TeamManagement with global tenant filtering for Super Admins. - Implemented secure User URL routing via slugs instead of raw UUIDs. - Enforced strict Agent-level RBAC for viewing attendances.
This commit is contained in:
@@ -25,6 +25,7 @@ interface SellerStats {
|
||||
export const Dashboard: React.FC = () => {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [data, setData] = useState<Attendance[]>([]);
|
||||
const [prevData, setPrevData] = useState<Attendance[]>([]);
|
||||
const [users, setUsers] = useState<User[]>([]);
|
||||
const [teams, setTeams] = useState<any[]>([]);
|
||||
const [currentUser, setCurrentUser] = useState<User | null>(null);
|
||||
@@ -48,16 +49,24 @@ export const Dashboard: React.FC = () => {
|
||||
const storedUserId = localStorage.getItem('ctms_user_id');
|
||||
if (!tenantId) return;
|
||||
|
||||
// Calculate previous date range for accurate period-over-period trend
|
||||
const duration = filters.dateRange.end.getTime() - filters.dateRange.start.getTime();
|
||||
const prevStart = new Date(filters.dateRange.start.getTime() - duration);
|
||||
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, fetchedTeams, me] = await Promise.all([
|
||||
const [fetchedUsers, fetchedData, prevFetchedData, fetchedTeams, me] = await Promise.all([
|
||||
getUsers(tenantId),
|
||||
getAttendances(tenantId, filters),
|
||||
getAttendances(tenantId, prevFilters),
|
||||
getTeams(tenantId),
|
||||
storedUserId ? getUserById(storedUserId) : null
|
||||
]);
|
||||
|
||||
setUsers(fetchedUsers);
|
||||
setData(fetchedData);
|
||||
setPrevData(prevFetchedData);
|
||||
setTeams(fetchedTeams);
|
||||
if (me) setCurrentUser(me);
|
||||
} catch (error) {
|
||||
@@ -74,6 +83,48 @@ export const Dashboard: React.FC = () => {
|
||||
const avgScore = data.length > 0 ? (data.reduce((acc, curr) => acc + curr.score, 0) / data.length).toFixed(1) : "0";
|
||||
const avgResponseTime = data.length > 0 ? (data.reduce((acc, curr) => acc + curr.first_response_time_min, 0) / data.length).toFixed(0) : "0";
|
||||
const avgHandleTime = data.length > 0 ? (data.reduce((acc, curr) => acc + curr.handling_time_min, 0) / data.length).toFixed(0) : "0";
|
||||
|
||||
// --- Dynamic Trends Calculation ---
|
||||
const trends = useMemo(() => {
|
||||
if (data.length === 0 && prevData.length === 0) {
|
||||
return { leads: null, score: null, resp: null };
|
||||
}
|
||||
|
||||
const calcAvg = (arr: Attendance[], key: keyof Attendance) =>
|
||||
arr.length ? arr.reduce((acc, curr) => acc + (curr[key] as number), 0) / arr.length : 0;
|
||||
|
||||
// Leads Trend (%)
|
||||
const recentLeads = data.length;
|
||||
const prevLeads = prevData.length;
|
||||
let leadsTrend = 0;
|
||||
if (prevLeads > 0) {
|
||||
leadsTrend = ((recentLeads - prevLeads) / prevLeads) * 100;
|
||||
} else if (recentLeads > 0) {
|
||||
leadsTrend = 100;
|
||||
}
|
||||
|
||||
// Score Trend (Absolute point difference)
|
||||
const recentScore = calcAvg(data, 'score');
|
||||
const prevScore = calcAvg(prevData, 'score');
|
||||
let scoreTrend = 0;
|
||||
if (prevData.length > 0) {
|
||||
scoreTrend = recentScore - prevScore;
|
||||
} else {
|
||||
scoreTrend = recentScore;
|
||||
}
|
||||
|
||||
// Response Time Trend (%)
|
||||
const recentResp = calcAvg(data, 'first_response_time_min');
|
||||
const prevResp = calcAvg(prevData, 'first_response_time_min');
|
||||
let respTrend = 0;
|
||||
if (prevResp > 0) {
|
||||
respTrend = ((recentResp - prevResp) / prevResp) * 100;
|
||||
} else if (recentResp > 0) {
|
||||
respTrend = 100; // Time increased from 0, which is worse
|
||||
}
|
||||
|
||||
return { leads: leadsTrend, score: scoreTrend, resp: respTrend };
|
||||
}, [data, prevData]);
|
||||
|
||||
// --- Chart Data: Funnel ---
|
||||
const funnelData = useMemo(() => {
|
||||
@@ -245,25 +296,25 @@ export const Dashboard: React.FC = () => {
|
||||
<KPICard
|
||||
title="Total de Leads"
|
||||
value={totalLeads}
|
||||
trend="up"
|
||||
trendValue="12%"
|
||||
trend={trends.leads > 0 ? 'up' : trends.leads < 0 ? 'down' : 'neutral'}
|
||||
trendValue={`${Math.abs(trends.leads).toFixed(1)}%`}
|
||||
icon={Users}
|
||||
colorClass="text-yellow-600"
|
||||
colorClass="text-brand-yellow"
|
||||
/>
|
||||
<KPICard
|
||||
title="Nota Média Qualidade"
|
||||
value={avgScore}
|
||||
subValue="/ 100"
|
||||
trend={Number(avgScore) > 75 ? 'up' : 'down'}
|
||||
trendValue="2.1"
|
||||
trend={trends.score > 0 ? 'up' : trends.score < 0 ? 'down' : 'neutral'}
|
||||
trendValue={Math.abs(trends.score).toFixed(1)}
|
||||
icon={TrendingUp}
|
||||
colorClass="text-zinc-600"
|
||||
colorClass="text-zinc-500"
|
||||
/>
|
||||
<KPICard
|
||||
title="Média 1ª Resposta"
|
||||
value={`${avgResponseTime}m`}
|
||||
trend="down"
|
||||
trendValue="bom"
|
||||
trend={trends.resp < 0 ? 'up' : trends.resp > 0 ? 'down' : 'neutral'} // Faster response is better (up)
|
||||
trendValue={`${Math.abs(trends.resp).toFixed(1)}%`}
|
||||
icon={Clock}
|
||||
colorClass="text-orange-500"
|
||||
/>
|
||||
|
||||
Reference in New Issue
Block a user