feat(reports): add date range filters and extend machine reports
This commit is contained in:
parent
82875a2252
commit
5b22065609
11 changed files with 742 additions and 290 deletions
|
|
@ -109,6 +109,53 @@ function withUpperBound<T>(range: T, field: string, value: number): T {
|
||||||
return range;
|
return range;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function parseIsoDateToMs(value?: string | null): number | null {
|
||||||
|
if (!value) return null;
|
||||||
|
const [yearStr, monthStr, dayStr] = value.split("-");
|
||||||
|
const year = Number(yearStr);
|
||||||
|
const month = Number(monthStr);
|
||||||
|
const day = Number(dayStr);
|
||||||
|
if (!year || !month || !day) return null;
|
||||||
|
const date = new Date(Date.UTC(year, month - 1, day, 0, 0, 0, 0));
|
||||||
|
return date.getTime();
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveRangeWindow(
|
||||||
|
range: string | undefined,
|
||||||
|
dateFrom: string | undefined,
|
||||||
|
dateTo: string | undefined,
|
||||||
|
defaultDays: number,
|
||||||
|
): { startMs: number; endMs: number; days: number } {
|
||||||
|
const fromMs = parseIsoDateToMs(dateFrom);
|
||||||
|
const toMs = parseIsoDateToMs(dateTo);
|
||||||
|
|
||||||
|
if (fromMs !== null || toMs !== null) {
|
||||||
|
const startMs = fromMs ?? toMs ?? Date.now();
|
||||||
|
const endMsBase = toMs ?? fromMs ?? startMs;
|
||||||
|
const endMs = endMsBase + ONE_DAY_MS;
|
||||||
|
const days = Math.max(1, Math.round((endMs - startMs) / ONE_DAY_MS));
|
||||||
|
return { startMs, endMs, days };
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizedRange = range ?? "90d";
|
||||||
|
let days = defaultDays;
|
||||||
|
if (normalizedRange === "7d") days = 7;
|
||||||
|
else if (normalizedRange === "30d") days = 30;
|
||||||
|
else if (normalizedRange === "365d" || normalizedRange === "12m") days = 365;
|
||||||
|
else if (normalizedRange === "all") {
|
||||||
|
const now = new Date();
|
||||||
|
now.setUTCHours(0, 0, 0, 0);
|
||||||
|
const endMs = now.getTime() + ONE_DAY_MS;
|
||||||
|
return { startMs: 0, endMs, days: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
const end = new Date();
|
||||||
|
end.setUTCHours(0, 0, 0, 0);
|
||||||
|
const endMs = end.getTime() + ONE_DAY_MS;
|
||||||
|
const startMs = endMs - days * ONE_DAY_MS;
|
||||||
|
return { startMs, endMs, days };
|
||||||
|
}
|
||||||
|
|
||||||
function resolveScopedCompanyId(
|
function resolveScopedCompanyId(
|
||||||
viewer: Awaited<ReturnType<typeof requireStaff>>,
|
viewer: Awaited<ReturnType<typeof requireStaff>>,
|
||||||
companyId?: Id<"companies">,
|
companyId?: Id<"companies">,
|
||||||
|
|
@ -469,15 +516,24 @@ function formatDateKey(timestamp: number) {
|
||||||
|
|
||||||
export async function slaOverviewHandler(
|
export async function slaOverviewHandler(
|
||||||
ctx: QueryCtx,
|
ctx: QueryCtx,
|
||||||
{ tenantId, viewerId, range, companyId }: { tenantId: string; viewerId: Id<"users">; range?: string; companyId?: Id<"companies"> }
|
{
|
||||||
|
tenantId,
|
||||||
|
viewerId,
|
||||||
|
range,
|
||||||
|
companyId,
|
||||||
|
dateFrom,
|
||||||
|
dateTo,
|
||||||
|
}: {
|
||||||
|
tenantId: string
|
||||||
|
viewerId: Id<"users">
|
||||||
|
range?: string
|
||||||
|
companyId?: Id<"companies">
|
||||||
|
dateFrom?: string
|
||||||
|
dateTo?: string
|
||||||
|
}
|
||||||
) {
|
) {
|
||||||
const viewer = await requireStaff(ctx, viewerId, tenantId);
|
const viewer = await requireStaff(ctx, viewerId, tenantId);
|
||||||
// Optional range filter (createdAt) for reporting purposes, similar ao backlog/csat
|
const { startMs, endMs, days } = resolveRangeWindow(range, dateFrom, dateTo, 90);
|
||||||
const days = range === "7d" ? 7 : range === "30d" ? 30 : 90;
|
|
||||||
const end = new Date();
|
|
||||||
end.setUTCHours(0, 0, 0, 0);
|
|
||||||
const endMs = end.getTime() + ONE_DAY_MS;
|
|
||||||
const startMs = endMs - days * ONE_DAY_MS;
|
|
||||||
const inRange = await fetchScopedTicketsByCreatedRange(ctx, tenantId, viewer, startMs, endMs, companyId);
|
const inRange = await fetchScopedTicketsByCreatedRange(ctx, tenantId, viewer, startMs, endMs, companyId);
|
||||||
const queues = await fetchQueues(ctx, tenantId);
|
const queues = await fetchQueues(ctx, tenantId);
|
||||||
const categoriesMap = await fetchCategoryMap(ctx, tenantId);
|
const categoriesMap = await fetchCategoryMap(ctx, tenantId);
|
||||||
|
|
@ -616,14 +672,17 @@ export const triggerScheduledExports = action({
|
||||||
|
|
||||||
export async function csatOverviewHandler(
|
export async function csatOverviewHandler(
|
||||||
ctx: QueryCtx,
|
ctx: QueryCtx,
|
||||||
{ tenantId, viewerId, range, companyId }: { tenantId: string; viewerId: Id<"users">; range?: string; companyId?: Id<"companies"> }
|
{
|
||||||
|
tenantId,
|
||||||
|
viewerId,
|
||||||
|
range,
|
||||||
|
companyId,
|
||||||
|
dateFrom,
|
||||||
|
dateTo,
|
||||||
|
}: { tenantId: string; viewerId: Id<"users">; range?: string; companyId?: Id<"companies">; dateFrom?: string; dateTo?: string }
|
||||||
) {
|
) {
|
||||||
const viewer = await requireStaff(ctx, viewerId, tenantId);
|
const viewer = await requireStaff(ctx, viewerId, tenantId);
|
||||||
const days = range === "7d" ? 7 : range === "30d" ? 30 : 90;
|
const { startMs, endMs } = resolveRangeWindow(range, dateFrom, dateTo, 90);
|
||||||
const end = new Date();
|
|
||||||
end.setUTCHours(0, 0, 0, 0);
|
|
||||||
const endMs = end.getTime() + ONE_DAY_MS;
|
|
||||||
const startMs = endMs - days * ONE_DAY_MS;
|
|
||||||
const tickets = await fetchScopedTicketsByCreatedRange(ctx, tenantId, viewer, startMs, endMs, companyId);
|
const tickets = await fetchScopedTicketsByCreatedRange(ctx, tenantId, viewer, startMs, endMs, companyId);
|
||||||
const surveys = (await collectCsatSurveys(ctx, tickets)).filter((s) => s.receivedAt >= startMs && s.receivedAt < endMs);
|
const surveys = (await collectCsatSurveys(ctx, tickets)).filter((s) => s.receivedAt >= startMs && s.receivedAt < endMs);
|
||||||
|
|
||||||
|
|
@ -700,27 +759,37 @@ export async function csatOverviewHandler(
|
||||||
assigneeId: item.assigneeId,
|
assigneeId: item.assigneeId,
|
||||||
assigneeName: item.assigneeName,
|
assigneeName: item.assigneeName,
|
||||||
})),
|
})),
|
||||||
rangeDays: days,
|
rangeDays: Math.max(1, Math.round((endMs - startMs) / ONE_DAY_MS)),
|
||||||
positiveRate,
|
positiveRate,
|
||||||
byAgent,
|
byAgent,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export const csatOverview = query({
|
export const csatOverview = query({
|
||||||
args: { tenantId: v.string(), viewerId: v.id("users"), range: v.optional(v.string()), companyId: v.optional(v.id("companies")) },
|
args: {
|
||||||
|
tenantId: v.string(),
|
||||||
|
viewerId: v.id("users"),
|
||||||
|
range: v.optional(v.string()),
|
||||||
|
companyId: v.optional(v.id("companies")),
|
||||||
|
dateFrom: v.optional(v.string()),
|
||||||
|
dateTo: v.optional(v.string()),
|
||||||
|
},
|
||||||
handler: csatOverviewHandler,
|
handler: csatOverviewHandler,
|
||||||
});
|
});
|
||||||
|
|
||||||
export async function openedResolvedByDayHandler(
|
export async function openedResolvedByDayHandler(
|
||||||
ctx: QueryCtx,
|
ctx: QueryCtx,
|
||||||
{ tenantId, viewerId, range, companyId }: { tenantId: string; viewerId: Id<"users">; range?: string; companyId?: Id<"companies"> }
|
{
|
||||||
|
tenantId,
|
||||||
|
viewerId,
|
||||||
|
range,
|
||||||
|
companyId,
|
||||||
|
dateFrom,
|
||||||
|
dateTo,
|
||||||
|
}: { tenantId: string; viewerId: Id<"users">; range?: string; companyId?: Id<"companies">; dateFrom?: string; dateTo?: string }
|
||||||
) {
|
) {
|
||||||
const viewer = await requireStaff(ctx, viewerId, tenantId);
|
const viewer = await requireStaff(ctx, viewerId, tenantId);
|
||||||
const days = range === "7d" ? 7 : range === "30d" ? 30 : 90;
|
const { startMs, endMs, days } = resolveRangeWindow(range, dateFrom, dateTo, 90);
|
||||||
const end = new Date();
|
|
||||||
end.setUTCHours(0, 0, 0, 0);
|
|
||||||
const endMs = end.getTime() + ONE_DAY_MS;
|
|
||||||
const startMs = endMs - days * ONE_DAY_MS;
|
|
||||||
|
|
||||||
const openedTickets = await fetchScopedTicketsByCreatedRange(ctx, tenantId, viewer, startMs, endMs, companyId);
|
const openedTickets = await fetchScopedTicketsByCreatedRange(ctx, tenantId, viewer, startMs, endMs, companyId);
|
||||||
const resolvedTickets = await fetchScopedTicketsByResolvedRange(ctx, tenantId, viewer, startMs, endMs, companyId);
|
const resolvedTickets = await fetchScopedTicketsByResolvedRange(ctx, tenantId, viewer, startMs, endMs, companyId);
|
||||||
|
|
@ -761,21 +830,30 @@ export async function openedResolvedByDayHandler(
|
||||||
}
|
}
|
||||||
|
|
||||||
export const openedResolvedByDay = query({
|
export const openedResolvedByDay = query({
|
||||||
args: { tenantId: v.string(), viewerId: v.id("users"), range: v.optional(v.string()), companyId: v.optional(v.id("companies")) },
|
args: {
|
||||||
|
tenantId: v.string(),
|
||||||
|
viewerId: v.id("users"),
|
||||||
|
range: v.optional(v.string()),
|
||||||
|
companyId: v.optional(v.id("companies")),
|
||||||
|
dateFrom: v.optional(v.string()),
|
||||||
|
dateTo: v.optional(v.string()),
|
||||||
|
},
|
||||||
handler: openedResolvedByDayHandler,
|
handler: openedResolvedByDayHandler,
|
||||||
})
|
})
|
||||||
|
|
||||||
export async function backlogOverviewHandler(
|
export async function backlogOverviewHandler(
|
||||||
ctx: QueryCtx,
|
ctx: QueryCtx,
|
||||||
{ tenantId, viewerId, range, companyId }: { tenantId: string; viewerId: Id<"users">; range?: string; companyId?: Id<"companies"> }
|
{
|
||||||
|
tenantId,
|
||||||
|
viewerId,
|
||||||
|
range,
|
||||||
|
companyId,
|
||||||
|
dateFrom,
|
||||||
|
dateTo,
|
||||||
|
}: { tenantId: string; viewerId: Id<"users">; range?: string; companyId?: Id<"companies">; dateFrom?: string; dateTo?: string }
|
||||||
) {
|
) {
|
||||||
const viewer = await requireStaff(ctx, viewerId, tenantId);
|
const viewer = await requireStaff(ctx, viewerId, tenantId);
|
||||||
// Optional range filter (createdAt) for reporting purposes
|
const { startMs, endMs, days } = resolveRangeWindow(range, dateFrom, dateTo, 90);
|
||||||
const days = range === "7d" ? 7 : range === "30d" ? 30 : 90;
|
|
||||||
const end = new Date();
|
|
||||||
end.setUTCHours(0, 0, 0, 0);
|
|
||||||
const endMs = end.getTime() + ONE_DAY_MS;
|
|
||||||
const startMs = endMs - days * ONE_DAY_MS;
|
|
||||||
const inRange = await fetchScopedTicketsByCreatedRange(ctx, tenantId, viewer, startMs, endMs, companyId);
|
const inRange = await fetchScopedTicketsByCreatedRange(ctx, tenantId, viewer, startMs, endMs, companyId);
|
||||||
|
|
||||||
const statusCounts = inRange.reduce<Record<TicketStatusNormalized, number>>((acc, ticket) => {
|
const statusCounts = inRange.reduce<Record<TicketStatusNormalized, number>>((acc, ticket) => {
|
||||||
|
|
@ -821,7 +899,14 @@ export async function backlogOverviewHandler(
|
||||||
}
|
}
|
||||||
|
|
||||||
export const backlogOverview = query({
|
export const backlogOverview = query({
|
||||||
args: { tenantId: v.string(), viewerId: v.id("users"), range: v.optional(v.string()), companyId: v.optional(v.id("companies")) },
|
args: {
|
||||||
|
tenantId: v.string(),
|
||||||
|
viewerId: v.id("users"),
|
||||||
|
range: v.optional(v.string()),
|
||||||
|
companyId: v.optional(v.id("companies")),
|
||||||
|
dateFrom: v.optional(v.string()),
|
||||||
|
dateTo: v.optional(v.string()),
|
||||||
|
},
|
||||||
handler: backlogOverviewHandler,
|
handler: backlogOverviewHandler,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -1023,14 +1108,12 @@ export async function ticketCategoryInsightsHandler(
|
||||||
viewerId,
|
viewerId,
|
||||||
range,
|
range,
|
||||||
companyId,
|
companyId,
|
||||||
}: { tenantId: string; viewerId: Id<"users">; range?: string; companyId?: Id<"companies"> }
|
dateFrom,
|
||||||
|
dateTo,
|
||||||
|
}: { tenantId: string; viewerId: Id<"users">; range?: string; companyId?: Id<"companies">; dateFrom?: string; dateTo?: string }
|
||||||
) {
|
) {
|
||||||
const viewer = await requireStaff(ctx, viewerId, tenantId)
|
const viewer = await requireStaff(ctx, viewerId, tenantId)
|
||||||
const days = range === "7d" ? 7 : range === "30d" ? 30 : 90
|
const { startMs, endMs, days } = resolveRangeWindow(range, dateFrom, dateTo, 90)
|
||||||
const end = new Date()
|
|
||||||
end.setUTCHours(0, 0, 0, 0)
|
|
||||||
const endMs = end.getTime() + ONE_DAY_MS
|
|
||||||
const startMs = endMs - days * ONE_DAY_MS
|
|
||||||
|
|
||||||
const inRange = await fetchScopedTicketsByCreatedRange(ctx, tenantId, viewer, startMs, endMs, companyId)
|
const inRange = await fetchScopedTicketsByCreatedRange(ctx, tenantId, viewer, startMs, endMs, companyId)
|
||||||
const categories = await ctx.db
|
const categories = await ctx.db
|
||||||
|
|
@ -1141,6 +1224,8 @@ export const categoryInsights = query({
|
||||||
viewerId: v.id("users"),
|
viewerId: v.id("users"),
|
||||||
range: v.optional(v.string()),
|
range: v.optional(v.string()),
|
||||||
companyId: v.optional(v.id("companies")),
|
companyId: v.optional(v.id("companies")),
|
||||||
|
dateFrom: v.optional(v.string()),
|
||||||
|
dateTo: v.optional(v.string()),
|
||||||
},
|
},
|
||||||
handler: ticketCategoryInsightsHandler,
|
handler: ticketCategoryInsightsHandler,
|
||||||
})
|
})
|
||||||
|
|
@ -1347,14 +1432,17 @@ export async function ticketsByMachineAndCategoryHandler(
|
||||||
}
|
}
|
||||||
) {
|
) {
|
||||||
const viewer = await requireStaff(ctx, viewerId, tenantId)
|
const viewer = await requireStaff(ctx, viewerId, tenantId)
|
||||||
const days = range === "7d" ? 7 : range === "30d" ? 30 : 90
|
const { startMs, endMs, days } = resolveRangeWindow(range, undefined, undefined, 90)
|
||||||
|
|
||||||
const end = new Date()
|
const tickets =
|
||||||
end.setUTCHours(0, 0, 0, 0)
|
days === 0
|
||||||
const endMs = end.getTime() + ONE_DAY_MS
|
? await fetchScopedTickets(ctx, tenantId, viewer).then((all) =>
|
||||||
const startMs = endMs - days * ONE_DAY_MS
|
all.filter((ticket) => {
|
||||||
|
if (companyId && ticket.companyId && ticket.companyId !== companyId) return false
|
||||||
const tickets = await fetchScopedTicketsByCreatedRange(ctx, tenantId, viewer, startMs, endMs, companyId)
|
return true
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
: await fetchScopedTicketsByCreatedRange(ctx, tenantId, viewer, startMs, endMs, companyId)
|
||||||
const categoriesMap = await fetchCategoryMap(ctx, tenantId)
|
const categoriesMap = await fetchCategoryMap(ctx, tenantId)
|
||||||
|
|
||||||
const companyIds = new Set<Id<"companies">>()
|
const companyIds = new Set<Id<"companies">>()
|
||||||
|
|
@ -1458,7 +1546,7 @@ export async function ticketsByMachineAndCategoryHandler(
|
||||||
})
|
})
|
||||||
|
|
||||||
return {
|
return {
|
||||||
rangeDays: days,
|
rangeDays: days === 0 ? -1 : days,
|
||||||
items,
|
items,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1471,6 +1559,8 @@ export const ticketsByMachineAndCategory = query({
|
||||||
companyId: v.optional(v.id("companies")),
|
companyId: v.optional(v.id("companies")),
|
||||||
machineId: v.optional(v.id("machines")),
|
machineId: v.optional(v.id("machines")),
|
||||||
userId: v.optional(v.id("users")),
|
userId: v.optional(v.id("users")),
|
||||||
|
dateFrom: v.optional(v.string()),
|
||||||
|
dateTo: v.optional(v.string()),
|
||||||
},
|
},
|
||||||
handler: ticketsByMachineAndCategoryHandler,
|
handler: ticketsByMachineAndCategoryHandler,
|
||||||
})
|
})
|
||||||
|
|
@ -1506,11 +1596,7 @@ export async function hoursByMachineHandler(
|
||||||
const viewer = await requireStaff(ctx, viewerId, tenantId)
|
const viewer = await requireStaff(ctx, viewerId, tenantId)
|
||||||
const tickets = await fetchScopedTickets(ctx, tenantId, viewer)
|
const tickets = await fetchScopedTickets(ctx, tenantId, viewer)
|
||||||
|
|
||||||
const days = range === "7d" ? 7 : range === "30d" ? 30 : 90
|
const { startMs, endMs, days } = resolveRangeWindow(range, undefined, undefined, 90)
|
||||||
const end = new Date()
|
|
||||||
end.setUTCHours(0, 0, 0, 0)
|
|
||||||
const endMs = end.getTime() + ONE_DAY_MS
|
|
||||||
const startMs = endMs - days * ONE_DAY_MS
|
|
||||||
|
|
||||||
const machinesById = new Map<string, Doc<"machines"> | null>()
|
const machinesById = new Map<string, Doc<"machines"> | null>()
|
||||||
const companiesById = new Map<string, Doc<"companies"> | null>()
|
const companiesById = new Map<string, Doc<"companies"> | null>()
|
||||||
|
|
@ -1518,7 +1604,7 @@ export async function hoursByMachineHandler(
|
||||||
const map = new Map<string, MachineHoursEntry>()
|
const map = new Map<string, MachineHoursEntry>()
|
||||||
|
|
||||||
for (const t of tickets) {
|
for (const t of tickets) {
|
||||||
if (t.updatedAt < startMs || t.updatedAt >= endMs) continue
|
if (days !== 0 && (t.updatedAt < startMs || t.updatedAt >= endMs)) continue
|
||||||
if (companyId && t.companyId && t.companyId !== companyId) continue
|
if (companyId && t.companyId && t.companyId !== companyId) continue
|
||||||
if (machineId && t.machineId !== machineId) continue
|
if (machineId && t.machineId !== machineId) continue
|
||||||
if (userId && t.requesterId !== userId) continue
|
if (userId && t.requesterId !== userId) continue
|
||||||
|
|
@ -1588,7 +1674,7 @@ export async function hoursByMachineHandler(
|
||||||
})
|
})
|
||||||
|
|
||||||
return {
|
return {
|
||||||
rangeDays: days,
|
rangeDays: days === 0 ? -1 : days,
|
||||||
items,
|
items,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1601,22 +1687,20 @@ export const hoursByMachine = query({
|
||||||
companyId: v.optional(v.id("companies")),
|
companyId: v.optional(v.id("companies")),
|
||||||
machineId: v.optional(v.id("machines")),
|
machineId: v.optional(v.id("machines")),
|
||||||
userId: v.optional(v.id("users")),
|
userId: v.optional(v.id("users")),
|
||||||
|
dateFrom: v.optional(v.string()),
|
||||||
|
dateTo: v.optional(v.string()),
|
||||||
},
|
},
|
||||||
handler: hoursByMachineHandler,
|
handler: hoursByMachineHandler,
|
||||||
})
|
})
|
||||||
|
|
||||||
export async function hoursByClientHandler(
|
export async function hoursByClientHandler(
|
||||||
ctx: QueryCtx,
|
ctx: QueryCtx,
|
||||||
{ tenantId, viewerId, range }: { tenantId: string; viewerId: Id<"users">; range?: string }
|
{ tenantId, viewerId, range, dateFrom, dateTo }: { tenantId: string; viewerId: Id<"users">; range?: string; dateFrom?: string; dateTo?: string }
|
||||||
) {
|
) {
|
||||||
const viewer = await requireStaff(ctx, viewerId, tenantId)
|
const viewer = await requireStaff(ctx, viewerId, tenantId)
|
||||||
const tickets = await fetchScopedTickets(ctx, tenantId, viewer)
|
const tickets = await fetchScopedTickets(ctx, tenantId, viewer)
|
||||||
|
|
||||||
const days = range === "7d" ? 7 : range === "30d" ? 30 : 90
|
const { startMs, endMs, days } = resolveRangeWindow(range, dateFrom, dateTo, 90)
|
||||||
const end = new Date()
|
|
||||||
end.setUTCHours(0, 0, 0, 0)
|
|
||||||
const endMs = end.getTime() + ONE_DAY_MS
|
|
||||||
const startMs = endMs - days * ONE_DAY_MS
|
|
||||||
|
|
||||||
type Acc = {
|
type Acc = {
|
||||||
companyId: Id<"companies">
|
companyId: Id<"companies">
|
||||||
|
|
@ -1671,22 +1755,24 @@ export async function hoursByClientHandler(
|
||||||
}
|
}
|
||||||
|
|
||||||
export const hoursByClient = query({
|
export const hoursByClient = query({
|
||||||
args: { tenantId: v.string(), viewerId: v.id("users"), range: v.optional(v.string()) },
|
args: {
|
||||||
|
tenantId: v.string(),
|
||||||
|
viewerId: v.id("users"),
|
||||||
|
range: v.optional(v.string()),
|
||||||
|
dateFrom: v.optional(v.string()),
|
||||||
|
dateTo: v.optional(v.string()),
|
||||||
|
},
|
||||||
handler: hoursByClientHandler,
|
handler: hoursByClientHandler,
|
||||||
})
|
})
|
||||||
|
|
||||||
// Internal variant used by scheduled jobs: skips viewer scoping and aggregates for the whole tenant
|
// Internal variant used by scheduled jobs: skips viewer scoping and aggregates for the whole tenant
|
||||||
export async function hoursByClientInternalHandler(
|
export async function hoursByClientInternalHandler(
|
||||||
ctx: QueryCtx,
|
ctx: QueryCtx,
|
||||||
{ tenantId, range }: { tenantId: string; range?: string }
|
{ tenantId, range, dateFrom, dateTo }: { tenantId: string; range?: string; dateFrom?: string; dateTo?: string }
|
||||||
) {
|
) {
|
||||||
const tickets = await fetchTickets(ctx, tenantId)
|
const tickets = await fetchTickets(ctx, tenantId)
|
||||||
|
|
||||||
const days = range === "7d" ? 7 : range === "30d" ? 30 : 90
|
const { startMs, endMs, days } = resolveRangeWindow(range, dateFrom, dateTo, 90)
|
||||||
const end = new Date()
|
|
||||||
end.setUTCHours(0, 0, 0, 0)
|
|
||||||
const endMs = end.getTime() + ONE_DAY_MS
|
|
||||||
const startMs = endMs - days * ONE_DAY_MS
|
|
||||||
|
|
||||||
type Acc = {
|
type Acc = {
|
||||||
companyId: Id<"companies">
|
companyId: Id<"companies">
|
||||||
|
|
@ -1741,7 +1827,12 @@ export async function hoursByClientInternalHandler(
|
||||||
}
|
}
|
||||||
|
|
||||||
export const hoursByClientInternal = query({
|
export const hoursByClientInternal = query({
|
||||||
args: { tenantId: v.string(), range: v.optional(v.string()) },
|
args: {
|
||||||
|
tenantId: v.string(),
|
||||||
|
range: v.optional(v.string()),
|
||||||
|
dateFrom: v.optional(v.string()),
|
||||||
|
dateTo: v.optional(v.string()),
|
||||||
|
},
|
||||||
handler: hoursByClientInternalHandler,
|
handler: hoursByClientInternalHandler,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -303,7 +303,7 @@ export function ReportTemplatesManager({ tenantId: tenantIdProp }: ReportTemplat
|
||||||
onValueChange={(value) => setFilterCompanyId(value ?? "all")}
|
onValueChange={(value) => setFilterCompanyId(value ?? "all")}
|
||||||
options={companyOptions}
|
options={companyOptions}
|
||||||
placeholder="Todas as empresas"
|
placeholder="Todas as empresas"
|
||||||
className="h-9 w-full min-w-56 rounded-full border border-slate-300 bg-white px-3 text-sm font-medium text-neutral-800 md:w-64"
|
triggerClassName="h-9 w-full min-w-56 rounded-full border border-slate-300 bg-white px-3 text-sm font-medium text-neutral-800 md:w-64"
|
||||||
/>
|
/>
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
|
|
@ -396,7 +396,7 @@ export function ReportTemplatesManager({ tenantId: tenantIdProp }: ReportTemplat
|
||||||
onValueChange={(value) => setTargetCompanyId(value ?? "all")}
|
onValueChange={(value) => setTargetCompanyId(value ?? "all")}
|
||||||
options={companyOptions}
|
options={companyOptions}
|
||||||
placeholder="Todas as empresas"
|
placeholder="Todas as empresas"
|
||||||
className="h-9 w-full rounded-full border border-slate-300 bg-white px-3 text-sm font-medium text-neutral-800"
|
triggerClassName="h-9 w-full rounded-full border border-slate-300 bg-white px-3 text-sm font-medium text-neutral-800"
|
||||||
/>
|
/>
|
||||||
<p className="mt-2 text-xs text-neutral-500">
|
<p className="mt-2 text-xs text-neutral-500">
|
||||||
Defina se este template será global ou específico para uma empresa.
|
Defina se este template será global ou específico para uma empresa.
|
||||||
|
|
|
||||||
102
src/components/date-range-button.tsx
Normal file
102
src/components/date-range-button.tsx
Normal file
|
|
@ -0,0 +1,102 @@
|
||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useMemo } from "react"
|
||||||
|
import type { DateRange } from "react-day-picker"
|
||||||
|
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { Calendar } from "@/components/ui/calendar"
|
||||||
|
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"
|
||||||
|
import { IconCalendar } from "@tabler/icons-react"
|
||||||
|
|
||||||
|
export type DateRangeValue = {
|
||||||
|
from: string | null
|
||||||
|
to: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
type DateRangeButtonProps = {
|
||||||
|
from: string | null
|
||||||
|
to: string | null
|
||||||
|
onChange: (next: DateRangeValue) => void
|
||||||
|
className?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
function strToDate(value?: string | null): Date | undefined {
|
||||||
|
if (!value) return undefined
|
||||||
|
const [y, m, d] = value.split("-").map(Number)
|
||||||
|
if (!y || !m || !d) return undefined
|
||||||
|
return new Date(y, m - 1, d)
|
||||||
|
}
|
||||||
|
|
||||||
|
function dateToStr(value?: Date): string | null {
|
||||||
|
if (!value) return null
|
||||||
|
const y = value.getFullYear()
|
||||||
|
const m = String(value.getMonth() + 1).padStart(2, "0")
|
||||||
|
const d = String(value.getDate()).padStart(2, "0")
|
||||||
|
return `${y}-${m}-${d}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatPtBR(value?: Date): string {
|
||||||
|
return value ? value.toLocaleDateString("pt-BR") : ""
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DateRangeButton({ from, to, onChange, className }: DateRangeButtonProps) {
|
||||||
|
const range: DateRange | undefined = useMemo(
|
||||||
|
() => ({
|
||||||
|
from: strToDate(from),
|
||||||
|
to: strToDate(to),
|
||||||
|
}),
|
||||||
|
[from, to],
|
||||||
|
)
|
||||||
|
|
||||||
|
const label =
|
||||||
|
range?.from && range?.to
|
||||||
|
? `${formatPtBR(range.from)} - ${formatPtBR(range.to)}`
|
||||||
|
: "Período"
|
||||||
|
|
||||||
|
const handleSelect = (next?: DateRange) => {
|
||||||
|
if (!next?.from && !next?.to) {
|
||||||
|
onChange({ from: null, to: null })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (next?.from && !next?.to) {
|
||||||
|
const single = dateToStr(next.from)
|
||||||
|
if (from && to && from === to && single === from) {
|
||||||
|
onChange({ from: null, to: null })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
onChange({ from: single, to: single })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextFrom = dateToStr(next?.from) ?? null
|
||||||
|
const nextTo = dateToStr(next?.to) ?? nextFrom
|
||||||
|
onChange({ from: nextFrom, to: nextTo })
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Popover>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className={`flex h-10 w-full items-center justify-start gap-2 rounded-2xl border-slate-300 bg-white/95 text-sm font-semibold text-neutral-700 ${className ?? ""}`}
|
||||||
|
>
|
||||||
|
<IconCalendar className="size-4 text-neutral-500" />
|
||||||
|
<span className="truncate">{label}</span>
|
||||||
|
</Button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent className="w-auto overflow-hidden p-0" align="end">
|
||||||
|
<Calendar
|
||||||
|
className="w-full"
|
||||||
|
mode="range"
|
||||||
|
defaultMonth={range?.from}
|
||||||
|
selected={range}
|
||||||
|
onSelect={handleSelect}
|
||||||
|
fixedWeeks
|
||||||
|
showOutsideDays
|
||||||
|
/>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -44,15 +44,26 @@ const queueBacklogChartConfig = {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function BacklogReport() {
|
export function BacklogReport() {
|
||||||
const [timeRange, setTimeRange] = useState("90d")
|
const [timeRange, setTimeRange] = useState<"90d" | "30d" | "7d">("90d")
|
||||||
const [companyId, setCompanyId] = usePersistentCompanyFilter("all")
|
const [companyId, setCompanyId] = usePersistentCompanyFilter("all")
|
||||||
const [schedulerOpen, setSchedulerOpen] = useState(false)
|
const [schedulerOpen, setSchedulerOpen] = useState(false)
|
||||||
|
const [dateFrom, setDateFrom] = useState<string | null>(null)
|
||||||
|
const [dateTo, setDateTo] = useState<string | null>(null)
|
||||||
const { session, convexUserId, isStaff, isAdmin } = useAuth()
|
const { session, convexUserId, isStaff, isAdmin } = useAuth()
|
||||||
const tenantId = session?.user.tenantId ?? DEFAULT_TENANT_ID
|
const tenantId = session?.user.tenantId ?? DEFAULT_TENANT_ID
|
||||||
const enabled = Boolean(isStaff && convexUserId)
|
const enabled = Boolean(isStaff && convexUserId)
|
||||||
const data = useQuery(
|
const data = useQuery(
|
||||||
api.reports.backlogOverview,
|
api.reports.backlogOverview,
|
||||||
enabled ? { tenantId, viewerId: convexUserId as Id<"users">, range: timeRange, companyId: companyId === "all" ? undefined : (companyId as Id<"companies">) } : "skip"
|
enabled
|
||||||
|
? {
|
||||||
|
tenantId,
|
||||||
|
viewerId: convexUserId as Id<"users">,
|
||||||
|
range: timeRange,
|
||||||
|
companyId: companyId === "all" ? undefined : (companyId as Id<"companies">),
|
||||||
|
dateFrom,
|
||||||
|
dateTo,
|
||||||
|
}
|
||||||
|
: "skip"
|
||||||
)
|
)
|
||||||
const companies = useQuery(
|
const companies = useQuery(
|
||||||
api.companies.list,
|
api.companies.list,
|
||||||
|
|
@ -106,8 +117,18 @@ export function BacklogReport() {
|
||||||
companyId={companyId}
|
companyId={companyId}
|
||||||
onCompanyChange={(value) => setCompanyId(value)}
|
onCompanyChange={(value) => setCompanyId(value)}
|
||||||
companyOptions={companyOptions}
|
companyOptions={companyOptions}
|
||||||
timeRange={timeRange as "90d" | "30d" | "7d"}
|
timeRange={timeRange}
|
||||||
onTimeRangeChange={(value) => setTimeRange(value)}
|
onTimeRangeChange={(value) => {
|
||||||
|
setTimeRange(value as "90d" | "30d" | "7d")
|
||||||
|
setDateFrom(null)
|
||||||
|
setDateTo(null)
|
||||||
|
}}
|
||||||
|
dateFrom={dateFrom}
|
||||||
|
dateTo={dateTo}
|
||||||
|
onDateRangeChange={({ from, to }) => {
|
||||||
|
setDateFrom(from)
|
||||||
|
setDateTo(to)
|
||||||
|
}}
|
||||||
exportHref={`/api/reports/backlog.xlsx?range=${timeRange}${
|
exportHref={`/api/reports/backlog.xlsx?range=${timeRange}${
|
||||||
companyId !== "all" ? `&companyId=${companyId}` : ""
|
companyId !== "all" ? `&companyId=${companyId}` : ""
|
||||||
}`}
|
}`}
|
||||||
|
|
|
||||||
|
|
@ -53,9 +53,11 @@ const chartConfig = {
|
||||||
export function CategoryReport() {
|
export function CategoryReport() {
|
||||||
const { session, convexUserId, isStaff, isAdmin } = useAuth()
|
const { session, convexUserId, isStaff, isAdmin } = useAuth()
|
||||||
const tenantId = session?.user.tenantId ?? DEFAULT_TENANT_ID
|
const tenantId = session?.user.tenantId ?? DEFAULT_TENANT_ID
|
||||||
const [timeRange, setTimeRange] = useState("90d")
|
const [timeRange, setTimeRange] = useState<"90d" | "30d" | "7d">("90d")
|
||||||
const [companyId, setCompanyId] = usePersistentCompanyFilter("all")
|
const [companyId, setCompanyId] = usePersistentCompanyFilter("all")
|
||||||
const [schedulerOpen, setSchedulerOpen] = useState(false)
|
const [schedulerOpen, setSchedulerOpen] = useState(false)
|
||||||
|
const [dateFrom, setDateFrom] = useState<string | null>(null)
|
||||||
|
const [dateTo, setDateTo] = useState<string | null>(null)
|
||||||
const enabled = Boolean(isStaff && convexUserId)
|
const enabled = Boolean(isStaff && convexUserId)
|
||||||
|
|
||||||
const companyFilter = companyId !== "all" ? (companyId as Id<"companies">) : undefined
|
const companyFilter = companyId !== "all" ? (companyId as Id<"companies">) : undefined
|
||||||
|
|
@ -68,6 +70,8 @@ export function CategoryReport() {
|
||||||
viewerId: convexUserId as Id<"users">,
|
viewerId: convexUserId as Id<"users">,
|
||||||
range: timeRange,
|
range: timeRange,
|
||||||
companyId: companyFilter,
|
companyId: companyFilter,
|
||||||
|
dateFrom,
|
||||||
|
dateTo,
|
||||||
}
|
}
|
||||||
: "skip",
|
: "skip",
|
||||||
) as CategoryInsightsResponse | undefined
|
) as CategoryInsightsResponse | undefined
|
||||||
|
|
@ -181,8 +185,18 @@ export function CategoryReport() {
|
||||||
companyId={companyId}
|
companyId={companyId}
|
||||||
onCompanyChange={(value) => setCompanyId(value)}
|
onCompanyChange={(value) => setCompanyId(value)}
|
||||||
companyOptions={companyOptions}
|
companyOptions={companyOptions}
|
||||||
timeRange={timeRange as "90d" | "30d" | "7d"}
|
timeRange={timeRange}
|
||||||
onTimeRangeChange={(value) => setTimeRange(value)}
|
onTimeRangeChange={(value) => {
|
||||||
|
setTimeRange(value as "90d" | "30d" | "7d")
|
||||||
|
setDateFrom(null)
|
||||||
|
setDateTo(null)
|
||||||
|
}}
|
||||||
|
dateFrom={dateFrom}
|
||||||
|
dateTo={dateTo}
|
||||||
|
onDateRangeChange={({ from, to }) => {
|
||||||
|
setDateFrom(from)
|
||||||
|
setDateTo(to)
|
||||||
|
}}
|
||||||
onOpenScheduler={isAdmin ? () => setSchedulerOpen(true) : undefined}
|
onOpenScheduler={isAdmin ? () => setSchedulerOpen(true) : undefined}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -26,13 +26,22 @@ export function CsatReport() {
|
||||||
const [companyId, setCompanyId] = usePersistentCompanyFilter("all")
|
const [companyId, setCompanyId] = usePersistentCompanyFilter("all")
|
||||||
const [timeRange, setTimeRange] = useState<string>("90d")
|
const [timeRange, setTimeRange] = useState<string>("90d")
|
||||||
const [schedulerOpen, setSchedulerOpen] = useState(false)
|
const [schedulerOpen, setSchedulerOpen] = useState(false)
|
||||||
|
const [dateFrom, setDateFrom] = useState<string | null>(null)
|
||||||
|
const [dateTo, setDateTo] = useState<string | null>(null)
|
||||||
const { session, convexUserId, isStaff, isAdmin } = useAuth()
|
const { session, convexUserId, isStaff, isAdmin } = useAuth()
|
||||||
const tenantId = session?.user.tenantId ?? DEFAULT_TENANT_ID
|
const tenantId = session?.user.tenantId ?? DEFAULT_TENANT_ID
|
||||||
const enabled = Boolean(isStaff && convexUserId)
|
const enabled = Boolean(isStaff && convexUserId)
|
||||||
const data = useQuery(
|
const data = useQuery(
|
||||||
api.reports.csatOverview,
|
api.reports.csatOverview,
|
||||||
enabled
|
enabled
|
||||||
? ({ tenantId, viewerId: convexUserId as Id<"users">, range: timeRange, companyId: companyId === "all" ? undefined : (companyId as Id<"companies">) })
|
? ({
|
||||||
|
tenantId,
|
||||||
|
viewerId: convexUserId as Id<"users">,
|
||||||
|
range: timeRange,
|
||||||
|
companyId: companyId === "all" ? undefined : (companyId as Id<"companies">),
|
||||||
|
dateFrom,
|
||||||
|
dateTo,
|
||||||
|
})
|
||||||
: "skip"
|
: "skip"
|
||||||
)
|
)
|
||||||
const companies = useQuery(
|
const companies = useQuery(
|
||||||
|
|
@ -94,7 +103,17 @@ export function CsatReport() {
|
||||||
onCompanyChange={(value) => handleCompanyChange(value)}
|
onCompanyChange={(value) => handleCompanyChange(value)}
|
||||||
companyOptions={comboboxOptions}
|
companyOptions={comboboxOptions}
|
||||||
timeRange={timeRange as "90d" | "30d" | "7d"}
|
timeRange={timeRange as "90d" | "30d" | "7d"}
|
||||||
onTimeRangeChange={(value) => setTimeRange(value)}
|
onTimeRangeChange={(value) => {
|
||||||
|
setTimeRange(value)
|
||||||
|
setDateFrom(null)
|
||||||
|
setDateTo(null)
|
||||||
|
}}
|
||||||
|
dateFrom={dateFrom}
|
||||||
|
dateTo={dateTo}
|
||||||
|
onDateRangeChange={({ from, to }) => {
|
||||||
|
setDateFrom(from)
|
||||||
|
setDateTo(to)
|
||||||
|
}}
|
||||||
exportHref={`/api/reports/csat.xlsx?range=${timeRange}${
|
exportHref={`/api/reports/csat.xlsx?range=${timeRange}${
|
||||||
companyId !== "all" ? `&companyId=${companyId}` : ""
|
companyId !== "all" ? `&companyId=${companyId}` : ""
|
||||||
}`}
|
}`}
|
||||||
|
|
|
||||||
|
|
@ -41,6 +41,21 @@ type HoursItem = {
|
||||||
contractedHoursPerMonth?: number | null
|
contractedHoursPerMonth?: number | null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type MachineHoursItem = {
|
||||||
|
machineId: string
|
||||||
|
machineHostname: string | null
|
||||||
|
companyId: string | null
|
||||||
|
companyName: string | null
|
||||||
|
internalMs: number
|
||||||
|
externalMs: number
|
||||||
|
totalMs: number
|
||||||
|
}
|
||||||
|
|
||||||
|
type HoursByMachineResponse = {
|
||||||
|
rangeDays: number
|
||||||
|
items: MachineHoursItem[]
|
||||||
|
}
|
||||||
|
|
||||||
const topClientsChartConfig = {
|
const topClientsChartConfig = {
|
||||||
internas: {
|
internas: {
|
||||||
label: "Horas internas",
|
label: "Horas internas",
|
||||||
|
|
@ -53,19 +68,44 @@ const topClientsChartConfig = {
|
||||||
} satisfies ChartConfig
|
} satisfies ChartConfig
|
||||||
|
|
||||||
export function HoursReport() {
|
export function HoursReport() {
|
||||||
const [timeRange, setTimeRange] = useState("90d")
|
const [timeRange, setTimeRange] = useState<"90d" | "30d" | "7d" | "365d" | "all">("90d")
|
||||||
const [companyId, setCompanyId] = usePersistentCompanyFilter("all")
|
const [companyId, setCompanyId] = usePersistentCompanyFilter("all")
|
||||||
const [billingFilter, setBillingFilter] = useState<"all" | "avulso" | "contratado">("all")
|
const [billingFilter, setBillingFilter] = useState<"all" | "avulso" | "contratado">("all")
|
||||||
const [schedulerOpen, setSchedulerOpen] = useState(false)
|
const [schedulerOpen, setSchedulerOpen] = useState(false)
|
||||||
|
const [groupBy, setGroupBy] = useState<"company" | "machine">("company")
|
||||||
|
const [dateFrom, setDateFrom] = useState<string | null>(null)
|
||||||
|
const [dateTo, setDateTo] = useState<string | null>(null)
|
||||||
const { session, convexUserId, isStaff, isAdmin } = useAuth()
|
const { session, convexUserId, isStaff, isAdmin } = useAuth()
|
||||||
const tenantId = session?.user.tenantId ?? DEFAULT_TENANT_ID
|
const tenantId = session?.user.tenantId ?? DEFAULT_TENANT_ID
|
||||||
|
|
||||||
const enabled = Boolean(isStaff && convexUserId)
|
const enabled = Boolean(isStaff && convexUserId)
|
||||||
const data = useQuery(
|
const data = useQuery(
|
||||||
api.reports.hoursByClient,
|
api.reports.hoursByClient,
|
||||||
enabled ? { tenantId, viewerId: convexUserId as Id<"users">, range: timeRange } : "skip"
|
enabled && groupBy === "company"
|
||||||
|
? {
|
||||||
|
tenantId,
|
||||||
|
viewerId: convexUserId as Id<"users">,
|
||||||
|
range: timeRange === "365d" || timeRange === "all" ? "90d" : timeRange,
|
||||||
|
dateFrom,
|
||||||
|
dateTo,
|
||||||
|
}
|
||||||
|
: "skip"
|
||||||
) as { rangeDays: number; items: HoursItem[] } | undefined
|
) as { rangeDays: number; items: HoursItem[] } | undefined
|
||||||
|
|
||||||
|
const machinesData = useQuery(
|
||||||
|
api.reports.hoursByMachine,
|
||||||
|
enabled && groupBy === "machine"
|
||||||
|
? {
|
||||||
|
tenantId,
|
||||||
|
viewerId: convexUserId as Id<"users">,
|
||||||
|
range: timeRange,
|
||||||
|
companyId: companyId === "all" ? undefined : (companyId as Id<"companies">),
|
||||||
|
dateFrom,
|
||||||
|
dateTo,
|
||||||
|
}
|
||||||
|
: "skip"
|
||||||
|
) as HoursByMachineResponse | undefined
|
||||||
|
|
||||||
const companies = useQuery(
|
const companies = useQuery(
|
||||||
api.companies.list,
|
api.companies.list,
|
||||||
enabled ? { tenantId, viewerId: convexUserId as Id<"users"> } : "skip"
|
enabled ? { tenantId, viewerId: convexUserId as Id<"users"> } : "skip"
|
||||||
|
|
@ -84,7 +124,7 @@ export function HoursReport() {
|
||||||
})),
|
})),
|
||||||
]
|
]
|
||||||
}, [companies])
|
}, [companies])
|
||||||
const filtered = useMemo(() => {
|
const filteredCompanies = useMemo(() => {
|
||||||
let items = data?.items ?? []
|
let items = data?.items ?? []
|
||||||
if (companyId !== "all") {
|
if (companyId !== "all") {
|
||||||
items = items.filter((it) => String(it.companyId) === companyId)
|
items = items.filter((it) => String(it.companyId) === companyId)
|
||||||
|
|
@ -97,8 +137,24 @@ export function HoursReport() {
|
||||||
return items
|
return items
|
||||||
}, [data?.items, companyId, billingFilter])
|
}, [data?.items, companyId, billingFilter])
|
||||||
|
|
||||||
|
const filteredMachines = useMemo(() => {
|
||||||
|
let items = machinesData?.items ?? []
|
||||||
|
if (companyId !== "all") {
|
||||||
|
items = items.filter((it) => String(it.companyId) === companyId)
|
||||||
|
}
|
||||||
|
if (billingFilter === "avulso") {
|
||||||
|
// Para futuro: quando tivermos flag de avulso por máquina; por enquanto, não filtra
|
||||||
|
return items
|
||||||
|
}
|
||||||
|
if (billingFilter === "contratado") {
|
||||||
|
return items
|
||||||
|
}
|
||||||
|
return items
|
||||||
|
}, [machinesData?.items, companyId, billingFilter])
|
||||||
|
|
||||||
const totals = useMemo(() => {
|
const totals = useMemo(() => {
|
||||||
return filtered.reduce(
|
const source = groupBy === "machine" ? filteredMachines : filteredCompanies
|
||||||
|
return source.reduce(
|
||||||
(acc, item) => {
|
(acc, item) => {
|
||||||
acc.internal += item.internalMs / 3600000
|
acc.internal += item.internalMs / 3600000
|
||||||
acc.external += item.externalMs / 3600000
|
acc.external += item.externalMs / 3600000
|
||||||
|
|
@ -107,13 +163,13 @@ export function HoursReport() {
|
||||||
},
|
},
|
||||||
{ internal: 0, external: 0, total: 0 }
|
{ internal: 0, external: 0, total: 0 }
|
||||||
)
|
)
|
||||||
}, [filtered])
|
}, [filteredCompanies, filteredMachines, groupBy])
|
||||||
|
|
||||||
// No number formatter needed; we use formatHoursCompact for hours
|
// No number formatter needed; we use formatHoursCompact for hours
|
||||||
|
|
||||||
const filteredWithComputed = useMemo(
|
const filteredCompaniesWithComputed = useMemo(
|
||||||
() =>
|
() =>
|
||||||
filtered.map((row) => {
|
filteredCompanies.map((row) => {
|
||||||
const internal = row.internalMs / 3600000
|
const internal = row.internalMs / 3600000
|
||||||
const external = row.externalMs / 3600000
|
const external = row.externalMs / 3600000
|
||||||
const total = row.totalMs / 3600000
|
const total = row.totalMs / 3600000
|
||||||
|
|
@ -129,11 +185,11 @@ export function HoursReport() {
|
||||||
usagePercent,
|
usagePercent,
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
[filtered]
|
[filteredCompanies]
|
||||||
)
|
)
|
||||||
const topClientsData = useMemo(
|
const topClientsData = useMemo(
|
||||||
() =>
|
() =>
|
||||||
filteredWithComputed
|
filteredCompaniesWithComputed
|
||||||
.slice()
|
.slice()
|
||||||
.sort((a, b) => b.total - a.total)
|
.sort((a, b) => b.total - a.total)
|
||||||
.slice(0, 10)
|
.slice(0, 10)
|
||||||
|
|
@ -142,7 +198,7 @@ export function HoursReport() {
|
||||||
internas: row.internal,
|
internas: row.internal,
|
||||||
externas: row.external,
|
externas: row.external,
|
||||||
})),
|
})),
|
||||||
[filteredWithComputed]
|
[filteredCompaniesWithComputed]
|
||||||
)
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -161,24 +217,73 @@ export function HoursReport() {
|
||||||
companyId={companyId}
|
companyId={companyId}
|
||||||
onCompanyChange={(value) => setCompanyId(value)}
|
onCompanyChange={(value) => setCompanyId(value)}
|
||||||
companyOptions={companyOptions}
|
companyOptions={companyOptions}
|
||||||
timeRange={timeRange as "90d" | "30d" | "7d"}
|
timeRange={timeRange}
|
||||||
onTimeRangeChange={(value) => setTimeRange(value)}
|
onTimeRangeChange={(value) => {
|
||||||
|
setTimeRange(value)
|
||||||
|
setDateFrom(null)
|
||||||
|
setDateTo(null)
|
||||||
|
}}
|
||||||
showBillingFilter
|
showBillingFilter
|
||||||
billingFilter={billingFilter}
|
billingFilter={billingFilter}
|
||||||
onBillingFilterChange={(value) => setBillingFilter(value)}
|
onBillingFilterChange={(value) => setBillingFilter(value)}
|
||||||
exportHref={`/api/reports/hours-by-client.xlsx?range=${timeRange}${
|
exportHref={`/api/reports/hours-by-client.xlsx?range=${
|
||||||
companyId !== "all" ? `&companyId=${companyId}` : ""
|
timeRange === "365d" || timeRange === "all" ? "90d" : timeRange
|
||||||
}`}
|
}${companyId !== "all" ? `&companyId=${companyId}` : ""}`}
|
||||||
onOpenScheduler={isAdmin ? () => setSchedulerOpen(true) : undefined}
|
onOpenScheduler={isAdmin ? () => setSchedulerOpen(true) : undefined}
|
||||||
|
dateFrom={dateFrom}
|
||||||
|
dateTo={dateTo}
|
||||||
|
onDateRangeChange={({ from, to }) => {
|
||||||
|
setDateFrom(from)
|
||||||
|
setDateTo(to)
|
||||||
|
}}
|
||||||
|
allowExtendedRanges={groupBy === "machine"}
|
||||||
|
extraFilters={
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<span className="text-xs font-medium text-neutral-500">Agrupar por</span>
|
||||||
|
<div className="inline-flex rounded-full border border-slate-200 bg-slate-50 p-1 text-xs">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`px-3 py-1 rounded-full transition ${
|
||||||
|
groupBy === "company"
|
||||||
|
? "bg-white text-neutral-900 shadow-sm"
|
||||||
|
: "text-neutral-500 hover:text-neutral-800"
|
||||||
|
}`}
|
||||||
|
onClick={() => setGroupBy("company")}
|
||||||
|
>
|
||||||
|
Empresa
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`px-3 py-1 rounded-full transition ${
|
||||||
|
groupBy === "machine"
|
||||||
|
? "bg-white text-neutral-900 shadow-sm"
|
||||||
|
: "text-neutral-500 hover:text-neutral-800"
|
||||||
|
}`}
|
||||||
|
onClick={() => setGroupBy("machine")}
|
||||||
|
>
|
||||||
|
Máquina
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
<Card className="border-slate-200">
|
<Card className="border-slate-200">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Top clientes por horas</CardTitle>
|
<CardTitle>
|
||||||
<CardDescription>Comparativo empilhado de horas internas x externas (top 10).</CardDescription>
|
{groupBy === "company" ? "Top clientes por horas" : "Top máquinas por horas"}
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
{groupBy === "company"
|
||||||
|
? "Comparativo empilhado de horas internas x externas (top 10 empresas)."
|
||||||
|
: "Comparativo empilhado de horas internas x externas (top 10 máquinas)."}
|
||||||
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
{!filteredWithComputed || filteredWithComputed.length === 0 ? (
|
{groupBy === "company" ? (
|
||||||
<p className="rounded-lg border border-dashed border-slate-200 p-6 text-sm text-neutral-500">Sem dados para o período.</p>
|
!filteredCompaniesWithComputed || filteredCompaniesWithComputed.length === 0 ? (
|
||||||
|
<p className="rounded-lg border border-dashed border-slate-200 p-6 text-sm text-neutral-500">
|
||||||
|
Sem dados para o período.
|
||||||
|
</p>
|
||||||
) : (
|
) : (
|
||||||
<ChartContainer config={topClientsChartConfig} className="aspect-auto h-[320px] w-full">
|
<ChartContainer config={topClientsChartConfig} className="aspect-auto h-[320px] w-full">
|
||||||
<BarChart
|
<BarChart
|
||||||
|
|
@ -199,13 +304,73 @@ export function HoursReport() {
|
||||||
content={
|
content={
|
||||||
<ChartTooltipContent
|
<ChartTooltipContent
|
||||||
className="w-[220px]"
|
className="w-[220px]"
|
||||||
labelFormatter={(value) => <span className="font-semibold text-foreground">{String(value)}</span>}
|
labelFormatter={(value) => (
|
||||||
|
<span className="font-semibold text-foreground">{String(value)}</span>
|
||||||
|
)}
|
||||||
formatter={(value, name) => (
|
formatter={(value, name) => (
|
||||||
<>
|
<>
|
||||||
<span className="text-muted-foreground">
|
<span className="text-muted-foreground">
|
||||||
{name === "internas" ? "Horas internas" : "Horas externas"}
|
{name === "internas" ? "Horas internas" : "Horas externas"}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-foreground font-mono font-medium tabular-nums">{formatHoursCompact(Number(value))}</span>
|
<span className="text-foreground font-mono font-medium tabular-nums">
|
||||||
|
{formatHoursCompact(Number(value))}
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<ChartLegend content={<ChartLegendContent />} />
|
||||||
|
<Bar dataKey="internas" stackId="hours" fill="var(--color-internas)" radius={[8, 0, 0, 8]} barSize={18} />
|
||||||
|
<Bar dataKey="externas" stackId="hours" fill="var(--color-externas)" radius={[0, 8, 8, 0]} barSize={18} />
|
||||||
|
</BarChart>
|
||||||
|
</ChartContainer>
|
||||||
|
)
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{!filteredMachines || filteredMachines.length === 0 ? (
|
||||||
|
<p className="rounded-lg border border-dashed border-slate-200 p-6 text-sm text-neutral-500">
|
||||||
|
Nenhuma máquina encontrada para o filtro selecionado.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<ChartContainer config={topClientsChartConfig} className="aspect-auto h-[320px] w-full">
|
||||||
|
<BarChart
|
||||||
|
data={filteredMachines
|
||||||
|
.slice()
|
||||||
|
.sort((a, b) => b.totalMs - a.totalMs)
|
||||||
|
.slice(0, 10)
|
||||||
|
.map((row) => ({
|
||||||
|
name: row.machineHostname ?? row.machineId,
|
||||||
|
internas: row.internalMs / 3600000,
|
||||||
|
externas: row.externalMs / 3600000,
|
||||||
|
}))}
|
||||||
|
layout="vertical"
|
||||||
|
margin={{ top: 16, right: 24, bottom: 16, left: 0 }}
|
||||||
|
barCategoryGap={12}
|
||||||
|
>
|
||||||
|
<CartesianGrid horizontal vertical={false} />
|
||||||
|
<XAxis
|
||||||
|
type="number"
|
||||||
|
tickLine={false}
|
||||||
|
axisLine={false}
|
||||||
|
tickFormatter={(value) => formatHoursCompact(Number(value))}
|
||||||
|
/>
|
||||||
|
<YAxis dataKey="name" type="category" tickLine={false} axisLine={false} width={200} />
|
||||||
|
<ChartTooltip
|
||||||
|
content={
|
||||||
|
<ChartTooltipContent
|
||||||
|
className="w-[220px]"
|
||||||
|
labelFormatter={(value) => (
|
||||||
|
<span className="font-semibold text-foreground">{String(value)}</span>
|
||||||
|
)}
|
||||||
|
formatter={(value, name) => (
|
||||||
|
<>
|
||||||
|
<span className="text-muted-foreground">
|
||||||
|
{name === "internas" ? "Horas internas" : "Horas externas"}
|
||||||
|
</span>
|
||||||
|
<span className="text-foreground font-mono font-medium tabular-nums">
|
||||||
|
{formatHoursCompact(Number(value))}
|
||||||
|
</span>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|
@ -217,12 +382,19 @@ export function HoursReport() {
|
||||||
</BarChart>
|
</BarChart>
|
||||||
</ChartContainer>
|
</ChartContainer>
|
||||||
)}
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Card className="border-slate-200">
|
<Card className="border-slate-200">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Horas</CardTitle>
|
<CardTitle>Horas</CardTitle>
|
||||||
<CardDescription>Visualize o esforço interno e externo por empresa e acompanhe o consumo contratado.</CardDescription>
|
<CardDescription>
|
||||||
|
{groupBy === "company"
|
||||||
|
? "Visualize o esforço interno e externo por empresa e acompanhe o consumo contratado."
|
||||||
|
: "Visualize o esforço interno e externo por máquina dentro dos filtros selecionados."}
|
||||||
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-3">
|
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-3">
|
||||||
|
|
@ -238,13 +410,14 @@ export function HoursReport() {
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{filteredWithComputed.length === 0 ? (
|
{groupBy === "company" ? (
|
||||||
|
!filteredCompaniesWithComputed.length ? (
|
||||||
<div className="mt-6 rounded-2xl border border-dashed border-slate-200 bg-slate-50/80 p-8 text-center text-sm text-muted-foreground">
|
<div className="mt-6 rounded-2xl border border-dashed border-slate-200 bg-slate-50/80 p-8 text-center text-sm text-muted-foreground">
|
||||||
Nenhuma empresa encontrada para o filtro selecionado.
|
Nenhuma empresa encontrada para o filtro selecionado.
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="mt-6 grid gap-4 lg:grid-cols-2">
|
<div className="mt-6 grid gap-4 lg:grid-cols-2">
|
||||||
{filteredWithComputed.map((row) => (
|
{filteredCompaniesWithComputed.map((row) => (
|
||||||
<div
|
<div
|
||||||
key={row.companyId}
|
key={row.companyId}
|
||||||
className="flex flex-col justify-between rounded-2xl border border-slate-200 bg-white p-5 shadow-sm transition hover:border-slate-300"
|
className="flex flex-col justify-between rounded-2xl border border-slate-200 bg-white p-5 shadow-sm transition hover:border-slate-300"
|
||||||
|
|
@ -254,22 +427,31 @@ export function HoursReport() {
|
||||||
<h3 className="text-base font-semibold text-neutral-900">{row.name}</h3>
|
<h3 className="text-base font-semibold text-neutral-900">{row.name}</h3>
|
||||||
<p className="text-xs text-neutral-500">ID {row.companyId}</p>
|
<p className="text-xs text-neutral-500">ID {row.companyId}</p>
|
||||||
</div>
|
</div>
|
||||||
<Badge variant={row.isAvulso ? "secondary" : "outline"} className="rounded-full px-3 py-1 text-xs font-medium">
|
<Badge
|
||||||
|
variant={row.isAvulso ? "secondary" : "outline"}
|
||||||
|
className="rounded-full px-3 py-1 text-xs font-medium"
|
||||||
|
>
|
||||||
{row.isAvulso ? "Cliente avulso" : "Recorrente"}
|
{row.isAvulso ? "Cliente avulso" : "Recorrente"}
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-4 grid gap-3 rounded-xl border border-slate-100 bg-slate-50/70 p-4 text-sm text-neutral-700">
|
<div className="mt-4 grid gap-3 rounded-xl border border-slate-100 bg-slate-50/70 p-4 text-sm text-neutral-700">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<span className="text-xs uppercase text-neutral-500">Horas internas</span>
|
<span className="text-xs uppercase text-neutral-500">Horas internas</span>
|
||||||
<span className="font-semibold text-neutral-900">{formatHoursCompact(row.internal)}</span>
|
<span className="font-semibold text-neutral-900">
|
||||||
|
{formatHoursCompact(row.internal)}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<span className="text-xs uppercase text-neutral-500">Horas externas</span>
|
<span className="text-xs uppercase text-neutral-500">Horas externas</span>
|
||||||
<span className="font-semibold text-neutral-900">{formatHoursCompact(row.external)}</span>
|
<span className="font-semibold text-neutral-900">
|
||||||
|
{formatHoursCompact(row.external)}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<span className="text-xs uppercase text-neutral-500">Total</span>
|
<span className="text-xs uppercase text-neutral-500">Total</span>
|
||||||
<span className="font-semibold text-neutral-900">{formatHoursCompact(row.total)}</span>
|
<span className="font-semibold text-neutral-900">
|
||||||
|
{formatHoursCompact(row.total)}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-4 space-y-2">
|
<div className="mt-4 space-y-2">
|
||||||
|
|
@ -298,6 +480,56 @@ export function HoursReport() {
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
)
|
||||||
|
) : !filteredMachines.length ? (
|
||||||
|
<div className="mt-6 rounded-2xl border border-dashed border-slate-200 bg-slate-50/80 p-8 text-center text-sm text-muted-foreground">
|
||||||
|
Nenhuma máquina encontrada para o filtro selecionado.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="mt-6 grid gap-4 lg:grid-cols-2">
|
||||||
|
{filteredMachines.map((row) => {
|
||||||
|
const internal = row.internalMs / 3_600_000
|
||||||
|
const external = row.externalMs / 3_600_000
|
||||||
|
const total = row.totalMs / 3_600_000
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={row.machineId}
|
||||||
|
className="flex flex-col justify-between rounded-2xl border border-slate-200 bg-white p-5 shadow-sm transition hover:border-slate-300"
|
||||||
|
>
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-base font-semibold text-neutral-900">
|
||||||
|
{row.machineHostname ?? row.machineId}
|
||||||
|
</h3>
|
||||||
|
<p className="text-xs text-neutral-500">
|
||||||
|
{row.companyName ?? "Sem empresa"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="mt-4 grid gap-3 rounded-xl border border-slate-100 bg-slate-50/70 p-4 text-sm text-neutral-700">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-xs uppercase text-neutral-500">Horas internas</span>
|
||||||
|
<span className="font-semibold text-neutral-900">
|
||||||
|
{formatHoursCompact(internal)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-xs uppercase text-neutral-500">Horas externas</span>
|
||||||
|
<span className="font-semibold text-neutral-900">
|
||||||
|
{formatHoursCompact(external)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-xs uppercase text-neutral-500">Total</span>
|
||||||
|
<span className="font-semibold text-neutral-900">
|
||||||
|
{formatHoursCompact(total)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
|
||||||
|
|
@ -48,8 +48,10 @@ type MachineHoursResponse = {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function MachineCategoryReport() {
|
export function MachineCategoryReport() {
|
||||||
const [timeRange, setTimeRange] = useState("30d")
|
const [timeRange, setTimeRange] = useState<"90d" | "30d" | "7d" | "365d" | "all">("30d")
|
||||||
const [companyId, setCompanyId] = usePersistentCompanyFilter("all")
|
const [companyId, setCompanyId] = usePersistentCompanyFilter("all")
|
||||||
|
const [dateFrom, setDateFrom] = useState<string | null>(null)
|
||||||
|
const [dateTo, setDateTo] = useState<string | null>(null)
|
||||||
const { session, convexUserId, isStaff } = useAuth()
|
const { session, convexUserId, isStaff } = useAuth()
|
||||||
const tenantId = session?.user.tenantId ?? DEFAULT_TENANT_ID
|
const tenantId = session?.user.tenantId ?? DEFAULT_TENANT_ID
|
||||||
|
|
||||||
|
|
@ -64,6 +66,8 @@ export function MachineCategoryReport() {
|
||||||
viewerId: convexUserId as Id<"users">,
|
viewerId: convexUserId as Id<"users">,
|
||||||
range: timeRange,
|
range: timeRange,
|
||||||
companyId: companyId === "all" ? undefined : (companyId as Id<"companies">),
|
companyId: companyId === "all" ? undefined : (companyId as Id<"companies">),
|
||||||
|
dateFrom,
|
||||||
|
dateTo,
|
||||||
} as const)
|
} as const)
|
||||||
: "skip"
|
: "skip"
|
||||||
) as MachineCategoryReportData | undefined
|
) as MachineCategoryReportData | undefined
|
||||||
|
|
@ -159,6 +163,8 @@ export function MachineCategoryReport() {
|
||||||
companyId: companyId === "all" ? undefined : (companyId as Id<"companies">),
|
companyId: companyId === "all" ? undefined : (companyId as Id<"companies">),
|
||||||
machineId: selectedMachineId !== "all" ? (selectedMachineId as Id<"machines">) : undefined,
|
machineId: selectedMachineId !== "all" ? (selectedMachineId as Id<"machines">) : undefined,
|
||||||
userId: selectedUserId !== "all" ? (selectedUserId as Id<"users">) : undefined,
|
userId: selectedUserId !== "all" ? (selectedUserId as Id<"users">) : undefined,
|
||||||
|
dateFrom,
|
||||||
|
dateTo,
|
||||||
} as const)
|
} as const)
|
||||||
: "skip"
|
: "skip"
|
||||||
) as MachineHoursResponse | undefined
|
) as MachineHoursResponse | undefined
|
||||||
|
|
@ -236,8 +242,19 @@ export function MachineCategoryReport() {
|
||||||
companyId={companyId}
|
companyId={companyId}
|
||||||
onCompanyChange={(value) => setCompanyId(value)}
|
onCompanyChange={(value) => setCompanyId(value)}
|
||||||
companyOptions={companyOptions}
|
companyOptions={companyOptions}
|
||||||
timeRange={timeRange as "90d" | "30d" | "7d"}
|
timeRange={timeRange}
|
||||||
onTimeRangeChange={(value) => setTimeRange(value)}
|
onTimeRangeChange={(value) => {
|
||||||
|
setTimeRange(value)
|
||||||
|
setDateFrom(null)
|
||||||
|
setDateTo(null)
|
||||||
|
}}
|
||||||
|
dateFrom={dateFrom}
|
||||||
|
dateTo={dateTo}
|
||||||
|
onDateRangeChange={({ from, to }) => {
|
||||||
|
setDateFrom(from)
|
||||||
|
setDateTo(to)
|
||||||
|
}}
|
||||||
|
allowExtendedRanges
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Card className="border-slate-200">
|
<Card className="border-slate-200">
|
||||||
|
|
@ -260,7 +277,7 @@ export function MachineCategoryReport() {
|
||||||
onValueChange={(value) => setSelectedMachineId(value ?? "all")}
|
onValueChange={(value) => setSelectedMachineId(value ?? "all")}
|
||||||
options={machineOptions}
|
options={machineOptions}
|
||||||
placeholder="Todas as máquinas"
|
placeholder="Todas as máquinas"
|
||||||
className="h-10 w-full rounded-2xl border border-slate-300 bg-white px-3 text-sm font-medium text-neutral-800"
|
triggerClassName="h-10 w-full rounded-2xl border border-slate-300 bg-white px-3 text-sm font-medium text-neutral-800"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
|
|
@ -272,7 +289,7 @@ export function MachineCategoryReport() {
|
||||||
onValueChange={(value) => setSelectedUserId(value ?? "all")}
|
onValueChange={(value) => setSelectedUserId(value ?? "all")}
|
||||||
options={userOptions}
|
options={userOptions}
|
||||||
placeholder="Todos os usuários"
|
placeholder="Todos os usuários"
|
||||||
className="h-10 w-full rounded-2xl border border-slate-300 bg-white px-3 text-sm font-medium text-neutral-800"
|
triggerClassName="h-10 w-full rounded-2xl border border-slate-300 bg-white px-3 text-sm font-medium text-neutral-800"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
|
|
|
||||||
|
|
@ -6,9 +6,10 @@ import { Button } from "@/components/ui/button"
|
||||||
import { SearchableCombobox, type SearchableComboboxOption } from "@/components/ui/searchable-combobox"
|
import { SearchableCombobox, type SearchableComboboxOption } from "@/components/ui/searchable-combobox"
|
||||||
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"
|
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
|
import { DateRangeButton } from "@/components/date-range-button"
|
||||||
|
|
||||||
type BillingFilter = "all" | "avulso" | "contratado"
|
type BillingFilter = "all" | "avulso" | "contratado"
|
||||||
type TimeRange = "90d" | "30d" | "7d"
|
type TimeRange = "90d" | "30d" | "7d" | "365d" | "all"
|
||||||
|
|
||||||
type ReportsFilterToolbarProps = {
|
type ReportsFilterToolbarProps = {
|
||||||
companyId: string
|
companyId: string
|
||||||
|
|
@ -25,6 +26,10 @@ type ReportsFilterToolbarProps = {
|
||||||
onExportClick?: () => void
|
onExportClick?: () => void
|
||||||
isExporting?: boolean
|
isExporting?: boolean
|
||||||
onOpenScheduler?: () => void
|
onOpenScheduler?: () => void
|
||||||
|
allowExtendedRanges?: boolean
|
||||||
|
dateFrom?: string | null
|
||||||
|
dateTo?: string | null
|
||||||
|
onDateRangeChange?: (next: { from: string | null; to: string | null }) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
const BILLING_TOGGLE_ITEM =
|
const BILLING_TOGGLE_ITEM =
|
||||||
|
|
@ -45,6 +50,10 @@ export function ReportsFilterToolbar({
|
||||||
onExportClick,
|
onExportClick,
|
||||||
isExporting = false,
|
isExporting = false,
|
||||||
onOpenScheduler,
|
onOpenScheduler,
|
||||||
|
allowExtendedRanges = false,
|
||||||
|
dateFrom,
|
||||||
|
dateTo,
|
||||||
|
onDateRangeChange,
|
||||||
}: ReportsFilterToolbarProps) {
|
}: ReportsFilterToolbarProps) {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-3 rounded-3xl border border-border/60 bg-white/90 p-4 shadow-sm">
|
<div className="flex flex-col gap-3 rounded-3xl border border-border/60 bg-white/90 p-4 shadow-sm">
|
||||||
|
|
@ -54,8 +63,16 @@ export function ReportsFilterToolbar({
|
||||||
onValueChange={(next) => onCompanyChange(next ?? "all")}
|
onValueChange={(next) => onCompanyChange(next ?? "all")}
|
||||||
options={companyOptions}
|
options={companyOptions}
|
||||||
placeholder="Todas as empresas"
|
placeholder="Todas as empresas"
|
||||||
className="h-10 w-full min-w-56 rounded-2xl border border-border/60 bg-background/95 px-3 text-left text-sm font-semibold text-neutral-800 md:w-64"
|
triggerClassName="h-10 w-full min-w-56 rounded-2xl border border-border/60 bg-background/95 px-3 text-left text-sm font-semibold text-neutral-800 md:w-64"
|
||||||
/>
|
/>
|
||||||
|
{onDateRangeChange ? (
|
||||||
|
<DateRangeButton
|
||||||
|
from={dateFrom ?? null}
|
||||||
|
to={dateTo ?? null}
|
||||||
|
onChange={onDateRangeChange}
|
||||||
|
className="w-full min-w-[200px] md:w-auto"
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
{showBillingFilter ? (
|
{showBillingFilter ? (
|
||||||
<ToggleGroup
|
<ToggleGroup
|
||||||
type="single"
|
type="single"
|
||||||
|
|
@ -97,6 +114,16 @@ export function ReportsFilterToolbar({
|
||||||
<ToggleGroupItem value="7d" className="min-w-[80px] justify-center px-4">
|
<ToggleGroupItem value="7d" className="min-w-[80px] justify-center px-4">
|
||||||
7 dias
|
7 dias
|
||||||
</ToggleGroupItem>
|
</ToggleGroupItem>
|
||||||
|
{allowExtendedRanges ? (
|
||||||
|
<>
|
||||||
|
<ToggleGroupItem value="365d" className="hidden min-w-[96px] justify-center px-4 lg:inline-flex">
|
||||||
|
12 meses
|
||||||
|
</ToggleGroupItem>
|
||||||
|
<ToggleGroupItem value="all" className="hidden min-w-[96px] justify-center px-4 lg:inline-flex">
|
||||||
|
Todo histórico
|
||||||
|
</ToggleGroupItem>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
</ToggleGroup>
|
</ToggleGroup>
|
||||||
<div className="ml-auto flex items-center gap-2">
|
<div className="ml-auto flex items-center gap-2">
|
||||||
{onOpenScheduler ? (
|
{onOpenScheduler ? (
|
||||||
|
|
|
||||||
|
|
@ -63,13 +63,22 @@ export function SlaReport() {
|
||||||
const [companyId, setCompanyId] = usePersistentCompanyFilter("all")
|
const [companyId, setCompanyId] = usePersistentCompanyFilter("all")
|
||||||
const [timeRange, setTimeRange] = useState<"90d" | "30d" | "7d">("90d")
|
const [timeRange, setTimeRange] = useState<"90d" | "30d" | "7d">("90d")
|
||||||
const [schedulerOpen, setSchedulerOpen] = useState(false)
|
const [schedulerOpen, setSchedulerOpen] = useState(false)
|
||||||
|
const [dateFrom, setDateFrom] = useState<string | null>(null)
|
||||||
|
const [dateTo, setDateTo] = useState<string | null>(null)
|
||||||
const { session, convexUserId, isStaff, isAdmin } = useAuth()
|
const { session, convexUserId, isStaff, isAdmin } = useAuth()
|
||||||
const tenantId = session?.user.tenantId ?? DEFAULT_TENANT_ID
|
const tenantId = session?.user.tenantId ?? DEFAULT_TENANT_ID
|
||||||
const enabled = Boolean(isStaff && convexUserId)
|
const enabled = Boolean(isStaff && convexUserId)
|
||||||
const data = useQuery(
|
const data = useQuery(
|
||||||
api.reports.slaOverview,
|
api.reports.slaOverview,
|
||||||
enabled
|
enabled
|
||||||
? ({ tenantId, viewerId: convexUserId as Id<"users">, range: timeRange, companyId: companyId === "all" ? undefined : (companyId as Id<"companies">) })
|
? ({
|
||||||
|
tenantId,
|
||||||
|
viewerId: convexUserId as Id<"users">,
|
||||||
|
range: timeRange,
|
||||||
|
companyId: companyId === "all" ? undefined : (companyId as Id<"companies">),
|
||||||
|
dateFrom,
|
||||||
|
dateTo,
|
||||||
|
})
|
||||||
: "skip"
|
: "skip"
|
||||||
)
|
)
|
||||||
const agents = useQuery(
|
const agents = useQuery(
|
||||||
|
|
@ -82,7 +91,14 @@ export function SlaReport() {
|
||||||
const openedResolved = useQuery(
|
const openedResolved = useQuery(
|
||||||
api.reports.openedResolvedByDay,
|
api.reports.openedResolvedByDay,
|
||||||
enabled
|
enabled
|
||||||
? ({ tenantId, viewerId: convexUserId as Id<"users">, range: timeRange, companyId: companyId === "all" ? undefined : (companyId as Id<"companies">) })
|
? ({
|
||||||
|
tenantId,
|
||||||
|
viewerId: convexUserId as Id<"users">,
|
||||||
|
range: timeRange,
|
||||||
|
companyId: companyId === "all" ? undefined : (companyId as Id<"companies">),
|
||||||
|
dateFrom,
|
||||||
|
dateTo,
|
||||||
|
})
|
||||||
: "skip"
|
: "skip"
|
||||||
) as { rangeDays: number; series: Array<{ date: string; opened: number; resolved: number }> } | undefined
|
) as { rangeDays: number; series: Array<{ date: string; opened: number; resolved: number }> } | undefined
|
||||||
|
|
||||||
|
|
@ -139,7 +155,17 @@ export function SlaReport() {
|
||||||
onCompanyChange={(value) => setCompanyId(value)}
|
onCompanyChange={(value) => setCompanyId(value)}
|
||||||
companyOptions={companyOptions}
|
companyOptions={companyOptions}
|
||||||
timeRange={timeRange}
|
timeRange={timeRange}
|
||||||
onTimeRangeChange={(value) => setTimeRange(value)}
|
onTimeRangeChange={(value) => {
|
||||||
|
setTimeRange(value as "90d" | "30d" | "7d")
|
||||||
|
setDateFrom(null)
|
||||||
|
setDateTo(null)
|
||||||
|
}}
|
||||||
|
dateFrom={dateFrom}
|
||||||
|
dateTo={dateTo}
|
||||||
|
onDateRangeChange={({ from, to }) => {
|
||||||
|
setDateFrom(from)
|
||||||
|
setDateTo(to)
|
||||||
|
}}
|
||||||
exportHref={`/api/reports/sla.xlsx?range=${timeRange}${
|
exportHref={`/api/reports/sla.xlsx?range=${timeRange}${
|
||||||
companyId !== "all" ? `&companyId=${companyId}` : ""
|
companyId !== "all" ? `&companyId=${companyId}` : ""
|
||||||
}`}
|
}`}
|
||||||
|
|
|
||||||
|
|
@ -1,17 +1,7 @@
|
||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { useEffect, useMemo, useState } from "react"
|
import { useEffect, useMemo, useState } from "react"
|
||||||
import {
|
import { IconFilter, IconRefresh, IconSearch, IconList, IconBuilding, IconTags, IconUser } from "@tabler/icons-react"
|
||||||
IconCalendar,
|
|
||||||
IconFilter,
|
|
||||||
IconRefresh,
|
|
||||||
IconSearch,
|
|
||||||
IconList,
|
|
||||||
IconBuilding,
|
|
||||||
IconTags,
|
|
||||||
IconUser,
|
|
||||||
} from "@tabler/icons-react"
|
|
||||||
import type { DateRange } from "react-day-picker"
|
|
||||||
|
|
||||||
import { ticketPrioritySchema, type TicketStatus } from "@/lib/schemas/ticket"
|
import { ticketPrioritySchema, type TicketStatus } from "@/lib/schemas/ticket"
|
||||||
import { PriorityIcon } from "@/components/tickets/priority-select"
|
import { PriorityIcon } from "@/components/tickets/priority-select"
|
||||||
|
|
@ -24,7 +14,6 @@ export { defaultTicketFilters }
|
||||||
import { Badge } from "@/components/ui/badge"
|
import { Badge } from "@/components/ui/badge"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { Input } from "@/components/ui/input"
|
import { Input } from "@/components/ui/input"
|
||||||
import { Calendar } from "@/components/ui/calendar"
|
|
||||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"
|
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"
|
||||||
import {
|
import {
|
||||||
Select,
|
Select,
|
||||||
|
|
@ -35,6 +24,7 @@ import {
|
||||||
} from "@/components/ui/select"
|
} from "@/components/ui/select"
|
||||||
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"
|
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"
|
||||||
import { SearchableCombobox, type SearchableComboboxOption } from "@/components/ui/searchable-combobox"
|
import { SearchableCombobox, type SearchableComboboxOption } from "@/components/ui/searchable-combobox"
|
||||||
|
import { DateRangeButton } from "@/components/date-range-button"
|
||||||
|
|
||||||
type QueueOption = string
|
type QueueOption = string
|
||||||
|
|
||||||
|
|
@ -72,93 +62,6 @@ const priorityOptions = ticketPrioritySchema.options.map((priority) => ({
|
||||||
}[priority],
|
}[priority],
|
||||||
}))
|
}))
|
||||||
|
|
||||||
function strToDate(value?: string | null): Date | undefined {
|
|
||||||
if (!value) return undefined
|
|
||||||
const [y, m, d] = value.split("-").map(Number)
|
|
||||||
if (!y || !m || !d) return undefined
|
|
||||||
return new Date(y, m - 1, d)
|
|
||||||
}
|
|
||||||
|
|
||||||
function dateToStr(value?: Date): string | null {
|
|
||||||
if (!value) return null
|
|
||||||
const y = value.getFullYear()
|
|
||||||
const m = String(value.getMonth() + 1).padStart(2, "0")
|
|
||||||
const d = String(value.getDate()).padStart(2, "0")
|
|
||||||
return `${y}-${m}-${d}`
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatPtBR(value?: Date): string {
|
|
||||||
return value ? value.toLocaleDateString("pt-BR") : ""
|
|
||||||
}
|
|
||||||
|
|
||||||
type DateRangeButtonProps = {
|
|
||||||
from: string | null
|
|
||||||
to: string | null
|
|
||||||
onChange: (next: { from: string | null; to: string | null }) => void
|
|
||||||
className?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
function DateRangeButton({ from, to, onChange, className }: DateRangeButtonProps) {
|
|
||||||
const range: DateRange | undefined = useMemo(
|
|
||||||
() => ({
|
|
||||||
from: strToDate(from),
|
|
||||||
to: strToDate(to),
|
|
||||||
}),
|
|
||||||
[from, to]
|
|
||||||
)
|
|
||||||
|
|
||||||
const label =
|
|
||||||
range?.from && range?.to
|
|
||||||
? `${formatPtBR(range.from)} - ${formatPtBR(range.to)}`
|
|
||||||
: "Período"
|
|
||||||
|
|
||||||
const handleSelect = (next?: DateRange) => {
|
|
||||||
if (!next?.from && !next?.to) {
|
|
||||||
onChange({ from: null, to: null })
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (next?.from && !next?.to) {
|
|
||||||
const single = dateToStr(next.from)
|
|
||||||
if (from && to && from === to && single === from) {
|
|
||||||
onChange({ from: null, to: null })
|
|
||||||
return
|
|
||||||
}
|
|
||||||
onChange({ from: single, to: single })
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const nextFrom = dateToStr(next?.from) ?? null
|
|
||||||
const nextTo = dateToStr(next?.to) ?? nextFrom
|
|
||||||
onChange({ from: nextFrom, to: nextTo })
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Popover>
|
|
||||||
<PopoverTrigger asChild>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
className={`flex h-10 w-full items-center justify-start gap-2 rounded-2xl border-slate-300 bg-white/95 text-sm font-semibold text-neutral-700 ${className ?? ""}`}
|
|
||||||
>
|
|
||||||
<IconCalendar className="size-4 text-neutral-500" />
|
|
||||||
<span className="truncate">{label}</span>
|
|
||||||
</Button>
|
|
||||||
</PopoverTrigger>
|
|
||||||
<PopoverContent className="w-auto overflow-hidden p-0" align="end">
|
|
||||||
<Calendar
|
|
||||||
className="w-full"
|
|
||||||
mode="range"
|
|
||||||
defaultMonth={range?.from}
|
|
||||||
selected={range}
|
|
||||||
onSelect={handleSelect}
|
|
||||||
fixedWeeks
|
|
||||||
showOutsideDays
|
|
||||||
/>
|
|
||||||
</PopoverContent>
|
|
||||||
</Popover>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function TicketsFilters({
|
export function TicketsFilters({
|
||||||
onChange,
|
onChange,
|
||||||
queues = [],
|
queues = [],
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue