feat: agenda polish, SLA sync, filters

This commit is contained in:
Esdras Renan 2025-11-08 02:34:43 -03:00
parent 7fb6c65d9a
commit 6ab8a6ce89
40 changed files with 2771 additions and 154 deletions

View file

@ -22,6 +22,7 @@
"@radix-ui/react-select": "^2.2.6", "@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-separator": "^1.1.7", "@radix-ui/react-separator": "^1.1.7",
"@radix-ui/react-slot": "^1.2.3", "@radix-ui/react-slot": "^1.2.3",
"@radix-ui/react-switch": "^1.2.6",
"@radix-ui/react-tabs": "^1.1.13", "@radix-ui/react-tabs": "^1.1.13",
"@radix-ui/react-toggle": "^1.1.10", "@radix-ui/react-toggle": "^1.1.10",
"@radix-ui/react-toggle-group": "^1.1.11", "@radix-ui/react-toggle-group": "^1.1.11",
@ -491,6 +492,8 @@
"@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "optionalDependencies": { "@types/react": "18.3.26" }, "peerDependencies": { "react": "19.2.0" } }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], "@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "optionalDependencies": { "@types/react": "18.3.26" }, "peerDependencies": { "react": "19.2.0" } }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
"@radix-ui/react-switch": ["@radix-ui/react-switch@1.2.6", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-previous": "1.1.1", "@radix-ui/react-use-size": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-bByzr1+ep1zk4VubeEVViV592vu2lHE2BZY5OnzehZqOOgogN80+mNtCqPkhn2gklJqOpxWgPoYTSnhBCqpOXQ=="],
"@radix-ui/react-tabs": ["@radix-ui/react-tabs@1.1.13", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-roving-focus": "1.1.11", "@radix-ui/react-use-controllable-state": "1.2.2" }, "optionalDependencies": { "@types/react": "18.3.26", "@types/react-dom": "18.3.7" }, "peerDependencies": { "react": "19.2.0", "react-dom": "19.2.0" } }, "sha512-7xdcatg7/U+7+Udyoj2zodtI9H/IIopqo+YOIcZOq1nJwXWBZ9p8xiu5llXlekDbZkca79a/fozEYQXIA4sW6A=="], "@radix-ui/react-tabs": ["@radix-ui/react-tabs@1.1.13", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-roving-focus": "1.1.11", "@radix-ui/react-use-controllable-state": "1.2.2" }, "optionalDependencies": { "@types/react": "18.3.26", "@types/react-dom": "18.3.7" }, "peerDependencies": { "react": "19.2.0", "react-dom": "19.2.0" } }, "sha512-7xdcatg7/U+7+Udyoj2zodtI9H/IIopqo+YOIcZOq1nJwXWBZ9p8xiu5llXlekDbZkca79a/fozEYQXIA4sW6A=="],
"@radix-ui/react-toggle": ["@radix-ui/react-toggle@1.1.10", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-controllable-state": "1.2.2" }, "optionalDependencies": { "@types/react": "18.3.26", "@types/react-dom": "18.3.7" }, "peerDependencies": { "react": "19.2.0", "react-dom": "19.2.0" } }, "sha512-lS1odchhFTeZv3xwHH31YPObmJn8gOg7Lq12inrr0+BH/l3Tsq32VfjqH1oh80ARM3mlkfMic15n0kg4sD1poQ=="], "@radix-ui/react-toggle": ["@radix-ui/react-toggle@1.1.10", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-controllable-state": "1.2.2" }, "optionalDependencies": { "@types/react": "18.3.26", "@types/react-dom": "18.3.7" }, "peerDependencies": { "react": "19.2.0", "react-dom": "19.2.0" } }, "sha512-lS1odchhFTeZv3xwHH31YPObmJn8gOg7Lq12inrr0+BH/l3Tsq32VfjqH1oh80ARM3mlkfMic15n0kg4sD1poQ=="],

View file

@ -12,6 +12,7 @@ import type * as alerts from "../alerts.js";
import type * as alerts_actions from "../alerts_actions.js"; import type * as alerts_actions from "../alerts_actions.js";
import type * as bootstrap from "../bootstrap.js"; import type * as bootstrap from "../bootstrap.js";
import type * as categories from "../categories.js"; import type * as categories from "../categories.js";
import type * as categorySlas from "../categorySlas.js";
import type * as commentTemplates from "../commentTemplates.js"; import type * as commentTemplates from "../commentTemplates.js";
import type * as companies from "../companies.js"; import type * as companies from "../companies.js";
import type * as crons from "../crons.js"; import type * as crons from "../crons.js";
@ -58,6 +59,7 @@ declare const fullApi: ApiFromModules<{
alerts_actions: typeof alerts_actions; alerts_actions: typeof alerts_actions;
bootstrap: typeof bootstrap; bootstrap: typeof bootstrap;
categories: typeof categories; categories: typeof categories;
categorySlas: typeof categorySlas;
commentTemplates: typeof commentTemplates; commentTemplates: typeof commentTemplates;
companies: typeof companies; companies: typeof companies;
crons: typeof crons; crons: typeof crons;

169
convex/categorySlas.ts Normal file
View file

@ -0,0 +1,169 @@
import { mutation, query } from "./_generated/server"
import { ConvexError, v } from "convex/values"
import { requireAdmin } from "./rbac"
const PRIORITY_VALUES = ["URGENT", "HIGH", "MEDIUM", "LOW", "DEFAULT"] as const
const VALID_STATUSES = ["PENDING", "AWAITING_ATTENDANCE", "PAUSED", "RESOLVED"] as const
const VALID_TIME_MODES = ["business", "calendar"] as const
type CategorySlaRuleInput = {
priority: string
responseTargetMinutes?: number | null
responseMode?: string | null
solutionTargetMinutes?: number | null
solutionMode?: string | null
alertThreshold?: number | null
pauseStatuses?: string[] | null
calendarType?: string | null
}
const ruleInput = v.object({
priority: v.string(),
responseTargetMinutes: v.optional(v.number()),
responseMode: v.optional(v.string()),
solutionTargetMinutes: v.optional(v.number()),
solutionMode: v.optional(v.string()),
alertThreshold: v.optional(v.number()),
pauseStatuses: v.optional(v.array(v.string())),
calendarType: v.optional(v.string()),
})
function normalizePriority(value: string) {
const upper = value.trim().toUpperCase()
return PRIORITY_VALUES.includes(upper as (typeof PRIORITY_VALUES)[number]) ? upper : "DEFAULT"
}
function sanitizeTime(value?: number | null) {
if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) return undefined
return Math.round(value)
}
function normalizeMode(value?: string | null) {
if (!value) return "calendar"
const normalized = value.toLowerCase()
return VALID_TIME_MODES.includes(normalized as (typeof VALID_TIME_MODES)[number]) ? normalized : "calendar"
}
function normalizeThreshold(value?: number | null) {
if (typeof value !== "number" || Number.isNaN(value)) {
return 0.8
}
const clamped = Math.min(Math.max(value, 0.1), 0.95)
return Math.round(clamped * 100) / 100
}
function normalizePauseStatuses(value?: string[] | null) {
if (!Array.isArray(value)) return ["PAUSED"]
const normalized = new Set<string>()
for (const status of value) {
if (typeof status !== "string") continue
const upper = status.trim().toUpperCase()
if (VALID_STATUSES.includes(upper as (typeof VALID_STATUSES)[number])) {
normalized.add(upper)
}
}
if (normalized.size === 0) {
normalized.add("PAUSED")
}
return Array.from(normalized)
}
export const get = query({
args: {
tenantId: v.string(),
viewerId: v.id("users"),
categoryId: v.id("ticketCategories"),
},
handler: async (ctx, { tenantId, viewerId, categoryId }) => {
await requireAdmin(ctx, viewerId, tenantId)
const category = await ctx.db.get(categoryId)
if (!category || category.tenantId !== tenantId) {
throw new ConvexError("Categoria não encontrada")
}
const records = await ctx.db
.query("categorySlaSettings")
.withIndex("by_tenant_category", (q) => q.eq("tenantId", tenantId).eq("categoryId", categoryId))
.collect()
return {
categoryId,
categoryName: category.name,
rules: records.map((record) => ({
priority: record.priority,
responseTargetMinutes: record.responseTargetMinutes ?? null,
responseMode: record.responseMode ?? "calendar",
solutionTargetMinutes: record.solutionTargetMinutes ?? null,
solutionMode: record.solutionMode ?? "calendar",
alertThreshold: record.alertThreshold ?? 0.8,
pauseStatuses: record.pauseStatuses ?? ["PAUSED"],
})),
}
},
})
export const save = mutation({
args: {
tenantId: v.string(),
actorId: v.id("users"),
categoryId: v.id("ticketCategories"),
rules: v.array(ruleInput),
},
handler: async (ctx, { tenantId, actorId, categoryId, rules }) => {
await requireAdmin(ctx, actorId, tenantId)
const category = await ctx.db.get(categoryId)
if (!category || category.tenantId !== tenantId) {
throw new ConvexError("Categoria não encontrada")
}
const sanitized = sanitizeRules(rules)
const existing = await ctx.db
.query("categorySlaSettings")
.withIndex("by_tenant_category", (q) => q.eq("tenantId", tenantId).eq("categoryId", categoryId))
.collect()
await Promise.all(existing.map((record) => ctx.db.delete(record._id)))
const now = Date.now()
for (const rule of sanitized) {
await ctx.db.insert("categorySlaSettings", {
tenantId,
categoryId,
priority: rule.priority,
responseTargetMinutes: rule.responseTargetMinutes,
responseMode: rule.responseMode,
solutionTargetMinutes: rule.solutionTargetMinutes,
solutionMode: rule.solutionMode,
alertThreshold: rule.alertThreshold,
pauseStatuses: rule.pauseStatuses,
calendarType: rule.calendarType ?? undefined,
createdAt: now,
updatedAt: now,
actorId,
})
}
},
})
function sanitizeRules(rules: CategorySlaRuleInput[]) {
const normalized: Record<string, ReturnType<typeof buildRule>> = {}
for (const rule of rules) {
const built = buildRule(rule)
normalized[built.priority] = built
}
return Object.values(normalized)
}
function buildRule(rule: CategorySlaRuleInput) {
const priority = normalizePriority(rule.priority)
const responseTargetMinutes = sanitizeTime(rule.responseTargetMinutes)
const solutionTargetMinutes = sanitizeTime(rule.solutionTargetMinutes)
return {
priority,
responseTargetMinutes,
responseMode: normalizeMode(rule.responseMode),
solutionTargetMinutes,
solutionMode: normalizeMode(rule.solutionMode),
alertThreshold: normalizeThreshold(rule.alertThreshold),
pauseStatuses: normalizePauseStatuses(rule.pauseStatuses),
calendarType: rule.calendarType ?? null,
}
}

View file

@ -75,6 +75,67 @@ function pruneUndefined<T extends Record<string, unknown>>(input: T): T {
return input return input
} }
type TicketSlaSnapshotRecord = {
categoryId?: Id<"ticketCategories">
categoryName?: string
priority?: string
responseTargetMinutes?: number
responseMode?: string
solutionTargetMinutes?: number
solutionMode?: string
alertThreshold?: number
pauseStatuses?: string[]
}
type ExportedSlaSnapshot = {
categoryId?: string
categoryName?: string
priority?: string
responseTargetMinutes?: number
responseMode?: string
solutionTargetMinutes?: number
solutionMode?: string
alertThreshold?: number
pauseStatuses?: string[]
}
function serializeSlaSnapshot(snapshot?: TicketSlaSnapshotRecord | null): ExportedSlaSnapshot | undefined {
if (!snapshot) return undefined
const exported = pruneUndefined<ExportedSlaSnapshot>({
categoryId: snapshot.categoryId ? String(snapshot.categoryId) : undefined,
categoryName: snapshot.categoryName,
priority: snapshot.priority,
responseTargetMinutes: snapshot.responseTargetMinutes,
responseMode: snapshot.responseMode,
solutionTargetMinutes: snapshot.solutionTargetMinutes,
solutionMode: snapshot.solutionMode,
alertThreshold: snapshot.alertThreshold,
pauseStatuses: snapshot.pauseStatuses && snapshot.pauseStatuses.length > 0 ? snapshot.pauseStatuses : undefined,
})
return Object.keys(exported).length > 0 ? exported : undefined
}
function normalizeImportedSlaSnapshot(snapshot: unknown): TicketSlaSnapshotRecord | undefined {
if (!snapshot || typeof snapshot !== "object") return undefined
const record = snapshot as Record<string, unknown>
const pauseStatuses = Array.isArray(record.pauseStatuses)
? record.pauseStatuses.filter((value): value is string => typeof value === "string")
: undefined
const normalized = pruneUndefined<TicketSlaSnapshotRecord>({
categoryName: typeof record.categoryName === "string" ? record.categoryName : undefined,
priority: typeof record.priority === "string" ? record.priority : undefined,
responseTargetMinutes: typeof record.responseTargetMinutes === "number" ? record.responseTargetMinutes : undefined,
responseMode: typeof record.responseMode === "string" ? record.responseMode : undefined,
solutionTargetMinutes: typeof record.solutionTargetMinutes === "number" ? record.solutionTargetMinutes : undefined,
solutionMode: typeof record.solutionMode === "string" ? record.solutionMode : undefined,
alertThreshold: typeof record.alertThreshold === "number" ? record.alertThreshold : undefined,
pauseStatuses: pauseStatuses && pauseStatuses.length > 0 ? pauseStatuses : undefined,
})
return Object.keys(normalized).length > 0 ? normalized : undefined
}
async function ensureUser( async function ensureUser(
ctx: MutationCtx, ctx: MutationCtx,
tenantId: string, tenantId: string,
@ -333,6 +394,14 @@ export const exportTenantSnapshot = query({
createdAt: ticket.createdAt, createdAt: ticket.createdAt,
updatedAt: ticket.updatedAt, updatedAt: ticket.updatedAt,
tags: ticket.tags ?? [], tags: ticket.tags ?? [],
slaSnapshot: serializeSlaSnapshot(ticket.slaSnapshot as TicketSlaSnapshotRecord | null),
slaResponseDueAt: ticket.slaResponseDueAt ?? undefined,
slaSolutionDueAt: ticket.slaSolutionDueAt ?? undefined,
slaResponseStatus: ticket.slaResponseStatus ?? undefined,
slaSolutionStatus: ticket.slaSolutionStatus ?? undefined,
slaPausedAt: ticket.slaPausedAt ?? undefined,
slaPausedBy: ticket.slaPausedBy ?? undefined,
slaPausedMs: ticket.slaPausedMs ?? undefined,
comments: comments comments: comments
.map((comment) => { .map((comment) => {
const author = userMap.get(comment.authorId) const author = userMap.get(comment.authorId)
@ -446,6 +515,14 @@ export const importPrismaSnapshot = mutation({
createdAt: v.number(), createdAt: v.number(),
updatedAt: v.number(), updatedAt: v.number(),
tags: v.optional(v.array(v.string())), tags: v.optional(v.array(v.string())),
slaSnapshot: v.optional(v.any()),
slaResponseDueAt: v.optional(v.number()),
slaSolutionDueAt: v.optional(v.number()),
slaResponseStatus: v.optional(v.string()),
slaSolutionStatus: v.optional(v.string()),
slaPausedAt: v.optional(v.number()),
slaPausedBy: v.optional(v.string()),
slaPausedMs: v.optional(v.number()),
comments: v.array( comments: v.array(
v.object({ v.object({
authorEmail: v.string(), authorEmail: v.string(),
@ -513,6 +590,9 @@ export const importPrismaSnapshot = mutation({
let eventsInserted = 0 let eventsInserted = 0
for (const ticket of snapshot.tickets) { for (const ticket of snapshot.tickets) {
const normalizedSnapshot = normalizeImportedSlaSnapshot(ticket.slaSnapshot)
const slaPausedMs = typeof ticket.slaPausedMs === "number" ? ticket.slaPausedMs : undefined
const requesterId = await ensureUser( const requesterId = await ensureUser(
ctx, ctx,
snapshot.tenantId, snapshot.tenantId,
@ -567,6 +647,14 @@ export const importPrismaSnapshot = mutation({
updatedAt: ticket.updatedAt, updatedAt: ticket.updatedAt,
createdAt: ticket.createdAt, createdAt: ticket.createdAt,
tags: ticket.tags && ticket.tags.length > 0 ? ticket.tags : undefined, tags: ticket.tags && ticket.tags.length > 0 ? ticket.tags : undefined,
slaSnapshot: normalizedSnapshot,
slaResponseDueAt: ticket.slaResponseDueAt ?? undefined,
slaSolutionDueAt: ticket.slaSolutionDueAt ?? undefined,
slaResponseStatus: ticket.slaResponseStatus ?? undefined,
slaSolutionStatus: ticket.slaSolutionStatus ?? undefined,
slaPausedAt: ticket.slaPausedAt ?? undefined,
slaPausedBy: ticket.slaPausedBy ?? undefined,
slaPausedMs,
customFields: undefined, customFields: undefined,
totalWorkedMs: undefined, totalWorkedMs: undefined,
activeSessionId: undefined, activeSessionId: undefined,

View file

@ -250,6 +250,26 @@ export default defineSchema({
), ),
working: v.optional(v.boolean()), working: v.optional(v.boolean()),
slaPolicyId: v.optional(v.id("slaPolicies")), slaPolicyId: v.optional(v.id("slaPolicies")),
slaSnapshot: v.optional(
v.object({
categoryId: v.optional(v.id("ticketCategories")),
categoryName: v.optional(v.string()),
priority: v.optional(v.string()),
responseTargetMinutes: v.optional(v.number()),
responseMode: v.optional(v.string()),
solutionTargetMinutes: v.optional(v.number()),
solutionMode: v.optional(v.string()),
alertThreshold: v.optional(v.number()),
pauseStatuses: v.optional(v.array(v.string())),
})
),
slaResponseDueAt: v.optional(v.number()),
slaSolutionDueAt: v.optional(v.number()),
slaResponseStatus: v.optional(v.string()),
slaSolutionStatus: v.optional(v.string()),
slaPausedAt: v.optional(v.number()),
slaPausedBy: v.optional(v.string()),
slaPausedMs: v.optional(v.number()),
dueAt: v.optional(v.number()), // ms since epoch dueAt: v.optional(v.number()), // ms since epoch
firstResponseAt: v.optional(v.number()), firstResponseAt: v.optional(v.number()),
resolvedAt: v.optional(v.number()), resolvedAt: v.optional(v.number()),
@ -437,6 +457,24 @@ export default defineSchema({
.index("by_category_slug", ["categoryId", "slug"]) .index("by_category_slug", ["categoryId", "slug"])
.index("by_tenant_slug", ["tenantId", "slug"]), .index("by_tenant_slug", ["tenantId", "slug"]),
categorySlaSettings: defineTable({
tenantId: v.string(),
categoryId: v.id("ticketCategories"),
priority: v.string(),
responseTargetMinutes: v.optional(v.number()),
responseMode: v.optional(v.string()),
solutionTargetMinutes: v.optional(v.number()),
solutionMode: v.optional(v.string()),
alertThreshold: v.optional(v.number()),
pauseStatuses: v.optional(v.array(v.string())),
calendarType: v.optional(v.string()),
createdAt: v.number(),
updatedAt: v.number(),
actorId: v.optional(v.id("users")),
})
.index("by_tenant_category_priority", ["tenantId", "categoryId", "priority"])
.index("by_tenant_category", ["tenantId", "categoryId"]),
ticketFields: defineTable({ ticketFields: defineTable({
tenantId: v.string(), tenantId: v.string(),
key: v.string(), key: v.string(),

View file

@ -48,6 +48,19 @@ const LEGACY_STATUS_MAP: Record<string, TicketStatusNormalized> = {
CLOSED: "RESOLVED", CLOSED: "RESOLVED",
}; };
function normalizePriorityFilter(input: string | string[] | null | undefined): string[] {
if (!input) return [];
const list = Array.isArray(input) ? input : [input];
const set = new Set<string>();
for (const entry of list) {
if (typeof entry !== "string") continue;
const normalized = entry.trim().toUpperCase();
if (!normalized) continue;
set.add(normalized);
}
return Array.from(set);
}
const missingRequesterLogCache = new Set<string>(); const missingRequesterLogCache = new Set<string>();
const missingCommentAuthorLogCache = new Set<string>(); const missingCommentAuthorLogCache = new Set<string>();
@ -80,6 +93,249 @@ function plainTextLength(html: string): number {
} }
} }
const SLA_DEFAULT_ALERT_THRESHOLD = 0.8;
const BUSINESS_DAY_START_HOUR = 8;
const BUSINESS_DAY_END_HOUR = 18;
type SlaTimeMode = "business" | "calendar";
type TicketSlaSnapshot = {
categoryId?: Id<"ticketCategories">;
categoryName?: string;
priority: string;
responseTargetMinutes?: number;
responseMode: SlaTimeMode;
solutionTargetMinutes?: number;
solutionMode: SlaTimeMode;
alertThreshold: number;
pauseStatuses: TicketStatusNormalized[];
};
type SlaStatusValue = "pending" | "met" | "breached" | "n/a";
function normalizeSlaMode(input?: string | null): SlaTimeMode {
if (!input) return "calendar";
return input.toLowerCase() === "business" ? "business" : "calendar";
}
function normalizeSnapshotPauseStatuses(statuses?: string[] | null): TicketStatusNormalized[] {
if (!Array.isArray(statuses)) {
return ["PAUSED"];
}
const set = new Set<TicketStatusNormalized>();
for (const value of statuses) {
if (typeof value !== "string") continue;
const normalized = normalizeStatus(value);
set.add(normalized);
}
if (set.size === 0) {
set.add("PAUSED");
}
return Array.from(set);
}
async function resolveTicketSlaSnapshot(
ctx: AnyCtx,
tenantId: string,
category: Doc<"ticketCategories"> | null,
priority: string
): Promise<TicketSlaSnapshot | null> {
if (!category) {
return null;
}
const normalizedPriority = priority.trim().toUpperCase();
const rule =
(await ctx.db
.query("categorySlaSettings")
.withIndex("by_tenant_category_priority", (q) =>
q.eq("tenantId", tenantId).eq("categoryId", category._id).eq("priority", normalizedPriority)
)
.first()) ??
(await ctx.db
.query("categorySlaSettings")
.withIndex("by_tenant_category_priority", (q) =>
q.eq("tenantId", tenantId).eq("categoryId", category._id).eq("priority", "DEFAULT")
)
.first());
if (!rule) {
return null;
}
return {
categoryId: category._id,
categoryName: category.name,
priority: normalizedPriority,
responseTargetMinutes: rule.responseTargetMinutes ?? undefined,
responseMode: normalizeSlaMode(rule.responseMode),
solutionTargetMinutes: rule.solutionTargetMinutes ?? undefined,
solutionMode: normalizeSlaMode(rule.solutionMode),
alertThreshold:
typeof rule.alertThreshold === "number" && Number.isFinite(rule.alertThreshold)
? rule.alertThreshold
: SLA_DEFAULT_ALERT_THRESHOLD,
pauseStatuses: normalizeSnapshotPauseStatuses(rule.pauseStatuses),
};
}
function computeSlaDueDates(snapshot: TicketSlaSnapshot, startAt: number) {
return {
responseDueAt: addMinutesWithMode(startAt, snapshot.responseTargetMinutes, snapshot.responseMode),
solutionDueAt: addMinutesWithMode(startAt, snapshot.solutionTargetMinutes, snapshot.solutionMode),
};
}
function addMinutesWithMode(startAt: number, minutes: number | null | undefined, mode: SlaTimeMode): number | null {
if (minutes === null || minutes === undefined || minutes <= 0) {
return null;
}
if (mode === "calendar") {
return startAt + minutes * 60000;
}
let remaining = minutes;
let cursor = alignToBusinessStart(new Date(startAt));
while (remaining > 0) {
if (!isBusinessDay(cursor)) {
cursor = advanceToNextBusinessStart(cursor);
continue;
}
const endOfDay = new Date(cursor);
endOfDay.setHours(BUSINESS_DAY_END_HOUR, 0, 0, 0);
const minutesAvailable = (endOfDay.getTime() - cursor.getTime()) / 60000;
if (minutesAvailable >= remaining) {
cursor = new Date(cursor.getTime() + remaining * 60000);
remaining = 0;
} else {
remaining -= minutesAvailable;
cursor = advanceToNextBusinessStart(endOfDay);
}
}
return cursor.getTime();
}
function alignToBusinessStart(date: Date): Date {
let result = new Date(date);
if (!isBusinessDay(result)) {
return advanceToNextBusinessStart(result);
}
if (result.getHours() >= BUSINESS_DAY_END_HOUR) {
return advanceToNextBusinessStart(result);
}
if (result.getHours() < BUSINESS_DAY_START_HOUR) {
result.setHours(BUSINESS_DAY_START_HOUR, 0, 0, 0);
}
return result;
}
function advanceToNextBusinessStart(date: Date): Date {
const next = new Date(date);
next.setHours(BUSINESS_DAY_START_HOUR, 0, 0, 0);
next.setDate(next.getDate() + 1);
while (!isBusinessDay(next)) {
next.setDate(next.getDate() + 1);
}
return next;
}
function isBusinessDay(date: Date) {
const day = date.getDay();
return day !== 0 && day !== 6;
}
function applySlaSnapshot(snapshot: TicketSlaSnapshot | null, now: number) {
if (!snapshot) return {};
const { responseDueAt, solutionDueAt } = computeSlaDueDates(snapshot, now);
return {
slaSnapshot: snapshot,
slaResponseDueAt: responseDueAt ?? undefined,
slaSolutionDueAt: solutionDueAt ?? undefined,
slaResponseStatus: responseDueAt ? ("pending" as SlaStatusValue) : ("n/a" as SlaStatusValue),
slaSolutionStatus: solutionDueAt ? ("pending" as SlaStatusValue) : ("n/a" as SlaStatusValue),
dueAt: solutionDueAt ?? undefined,
};
}
function buildSlaStatusPatch(ticketDoc: Doc<"tickets">, nextStatus: TicketStatusNormalized, now: number) {
const snapshot = ticketDoc.slaSnapshot as TicketSlaSnapshot | undefined;
if (!snapshot) return {};
const pauseSet = new Set(snapshot.pauseStatuses);
const currentlyPaused = typeof ticketDoc.slaPausedAt === "number";
if (pauseSet.has(nextStatus)) {
if (currentlyPaused) {
return {};
}
return {
slaPausedAt: now,
slaPausedBy: nextStatus,
};
}
if (currentlyPaused) {
const pauseStart = ticketDoc.slaPausedAt ?? now;
const delta = Math.max(0, now - pauseStart);
const patch: Record<string, unknown> = {
slaPausedAt: undefined,
slaPausedBy: undefined,
slaPausedMs: (ticketDoc.slaPausedMs ?? 0) + delta,
};
if (ticketDoc.slaResponseDueAt && ticketDoc.slaResponseStatus !== "met" && ticketDoc.slaResponseStatus !== "breached") {
patch.slaResponseDueAt = ticketDoc.slaResponseDueAt + delta;
}
if (ticketDoc.slaSolutionDueAt && ticketDoc.slaSolutionStatus !== "met" && ticketDoc.slaSolutionStatus !== "breached") {
patch.slaSolutionDueAt = ticketDoc.slaSolutionDueAt + delta;
patch.dueAt = ticketDoc.slaSolutionDueAt + delta;
}
return patch;
}
return {};
}
function mergeTicketState(ticketDoc: Doc<"tickets">, patch: Record<string, unknown>): Doc<"tickets"> {
const merged = { ...ticketDoc } as Record<string, unknown>;
for (const [key, value] of Object.entries(patch)) {
if (value === undefined) {
delete merged[key];
} else {
merged[key] = value;
}
}
return merged as Doc<"tickets">;
}
function buildResponseCompletionPatch(ticketDoc: Doc<"tickets">, now: number) {
if (ticketDoc.firstResponseAt) {
return {};
}
if (!ticketDoc.slaResponseDueAt) {
return {
firstResponseAt: now,
slaResponseStatus: "n/a",
};
}
const status = now <= ticketDoc.slaResponseDueAt ? "met" : "breached";
return {
firstResponseAt: now,
slaResponseStatus: status,
};
}
function buildSolutionCompletionPatch(ticketDoc: Doc<"tickets">, now: number) {
if (ticketDoc.slaSolutionStatus === "met" || ticketDoc.slaSolutionStatus === "breached") {
return {};
}
if (!ticketDoc.slaSolutionDueAt) {
return { slaSolutionStatus: "n/a" };
}
const status = now <= ticketDoc.slaSolutionDueAt ? "met" : "breached";
return {
slaSolutionStatus: status,
};
}
function resolveFormTemplateLabel( function resolveFormTemplateLabel(
templateKey: string | null | undefined, templateKey: string | null | undefined,
storedLabel: string | null | undefined storedLabel: string | null | undefined
@ -223,51 +479,77 @@ async function fetchTicketFieldsByScopes(
tenantId: string, tenantId: string,
scopes: string[] scopes: string[]
): Promise<TicketFieldScopeMap> { ): Promise<TicketFieldScopeMap> {
const uniqueScopes = Array.from(new Set(scopes)); const uniqueScopes = Array.from(new Set(scopes.filter((scope) => Boolean(scope))));
if (uniqueScopes.length === 0) {
return new Map();
}
const scopeSet = new Set(uniqueScopes);
const result: TicketFieldScopeMap = new Map(); const result: TicketFieldScopeMap = new Map();
for (const scope of uniqueScopes) { const allFields = await ctx.db
const fields = await ctx.db
.query("ticketFields") .query("ticketFields")
.withIndex("by_tenant_scope", (q) => q.eq("tenantId", tenantId).eq("scope", scope)) .withIndex("by_tenant", (q) => q.eq("tenantId", tenantId))
.collect(); .collect();
result.set(scope, fields);
for (const field of allFields) {
const scope = field.scope ?? "";
if (!scopeSet.has(scope)) {
continue;
}
const current = result.get(scope);
if (current) {
current.push(field);
} else {
result.set(scope, [field]);
}
} }
return result; return result;
} }
async function fetchScopedFormSettings( async function fetchViewerScopedFormSettings(
ctx: QueryCtx, ctx: QueryCtx,
tenantId: string, tenantId: string,
templateKey: string, templateKeys: string[],
viewerId: Id<"users">, viewerId: Id<"users">,
viewerCompanyId: Id<"companies"> | null viewerCompanyId: Id<"companies"> | null
): Promise<Doc<"ticketFormSettings">[]> { ): Promise<Map<string, Doc<"ticketFormSettings">[]>> {
const tenantSettingsPromise = ctx.db const uniqueTemplates = Array.from(new Set(templateKeys));
if (uniqueTemplates.length === 0) {
return new Map();
}
const keySet = new Set(uniqueTemplates);
const viewerIdStr = String(viewerId);
const viewerCompanyIdStr = viewerCompanyId ? String(viewerCompanyId) : null;
const scopedMap = new Map<string, Doc<"ticketFormSettings">[]>();
const allSettings = await ctx.db
.query("ticketFormSettings") .query("ticketFormSettings")
.withIndex("by_tenant_template_scope", (q) => q.eq("tenantId", tenantId).eq("template", templateKey).eq("scope", "tenant")) .withIndex("by_tenant", (q) => q.eq("tenantId", tenantId))
.collect(); .collect();
const companySettingsPromise = viewerCompanyId for (const setting of allSettings) {
? ctx.db if (!keySet.has(setting.template)) {
.query("ticketFormSettings") continue;
.withIndex("by_tenant_template_company", (q) => }
q.eq("tenantId", tenantId).eq("template", templateKey).eq("companyId", viewerCompanyId) if (setting.scope === "company") {
) if (!viewerCompanyIdStr || !setting.companyId || String(setting.companyId) !== viewerCompanyIdStr) {
.collect() continue;
: Promise.resolve<Doc<"ticketFormSettings">[]>([]); }
} else if (setting.scope === "user") {
if (!setting.userId || String(setting.userId) !== viewerIdStr) {
continue;
}
} else if (setting.scope !== "tenant") {
continue;
}
const userSettingsPromise = ctx.db if (scopedMap.has(setting.template)) {
.query("ticketFormSettings") scopedMap.get(setting.template)!.push(setting);
.withIndex("by_tenant_template_user", (q) => q.eq("tenantId", tenantId).eq("template", templateKey).eq("userId", viewerId)) } else {
.collect(); scopedMap.set(setting.template, [setting]);
}
}
const [tenantSettings, companySettings, userSettings] = await Promise.all([ return scopedMap;
tenantSettingsPromise,
companySettingsPromise,
userSettingsPromise,
]);
return [...tenantSettings, ...companySettings, ...userSettings];
} }
function normalizeDateOnlyValue(value: unknown): string | null { function normalizeDateOnlyValue(value: unknown): string | null {
@ -991,34 +1273,36 @@ function mapCustomFieldsToRecord(entries: NormalizedCustomField[] | undefined) {
type CustomFieldRecordEntry = { label: string; type: string; value: unknown; displayValue?: string } | undefined; type CustomFieldRecordEntry = { label: string; type: string; value: unknown; displayValue?: string } | undefined;
function areValuesEqual(a: unknown, b: unknown): boolean { function areCustomFieldEntriesEqual(a: CustomFieldRecordEntry, b: CustomFieldRecordEntry): boolean {
if (a === b) return true; return serializeCustomFieldEntry(a) === serializeCustomFieldEntry(b);
if ((a === null || a === undefined) && (b === null || b === undefined)) {
return true;
}
if (typeof a !== typeof b) {
return false;
}
if (typeof a === "number" && typeof b === "number") {
return Number.isNaN(a) && Number.isNaN(b);
}
if (typeof a === "object" && typeof b === "object") {
try {
return JSON.stringify(a) === JSON.stringify(b);
} catch {
return false;
}
}
return false;
} }
function areCustomFieldEntriesEqual(a: CustomFieldRecordEntry, b: CustomFieldRecordEntry): boolean { function serializeCustomFieldEntry(entry: CustomFieldRecordEntry): string {
if (!a && !b) return true; if (!entry) return "__undefined__";
if (!a || !b) return false; return JSON.stringify({
if (!areValuesEqual(a.value ?? null, b.value ?? null)) return false; value: normalizeEntryValue(entry.value),
const prevDisplay = a.displayValue ?? null; displayValue: entry.displayValue ?? null,
const nextDisplay = b.displayValue ?? null; });
return prevDisplay === nextDisplay; }
function normalizeEntryValue(value: unknown): unknown {
if (value === undefined || value === null) return null;
if (value instanceof Date) return value.toISOString();
if (typeof value === "number" && Number.isNaN(value)) return "__nan__";
if (Array.isArray(value)) {
return value.map((item) => normalizeEntryValue(item));
}
if (typeof value === "object") {
const record = value as Record<string, unknown>;
const normalized: Record<string, unknown> = {};
Object.keys(record)
.sort()
.forEach((key) => {
normalized[key] = normalizeEntryValue(record[key]);
});
return normalized;
}
return value;
} }
function getCustomFieldRecordEntry( function getCustomFieldRecordEntry(
@ -1066,7 +1350,7 @@ export const list = query({
viewerId: v.optional(v.id("users")), viewerId: v.optional(v.id("users")),
tenantId: v.string(), tenantId: v.string(),
status: v.optional(v.string()), status: v.optional(v.string()),
priority: v.optional(v.string()), priority: v.optional(v.union(v.string(), v.array(v.string()))),
channel: v.optional(v.string()), channel: v.optional(v.string()),
queueId: v.optional(v.id("queues")), queueId: v.optional(v.id("queues")),
assigneeId: v.optional(v.id("users")), assigneeId: v.optional(v.id("users")),
@ -1085,7 +1369,9 @@ export const list = query({
} }
const normalizedStatusFilter = args.status ? normalizeStatus(args.status) : null; const normalizedStatusFilter = args.status ? normalizeStatus(args.status) : null;
const normalizedPriorityFilter = args.priority ? args.priority.toUpperCase() : null; const normalizedPriorityFilter = normalizePriorityFilter(args.priority);
const prioritySet = normalizedPriorityFilter.length > 0 ? new Set(normalizedPriorityFilter) : null;
const primaryPriorityFilter = normalizedPriorityFilter.length === 1 ? normalizedPriorityFilter[0] : null;
const normalizedChannelFilter = args.channel ? args.channel.toUpperCase() : null; const normalizedChannelFilter = args.channel ? args.channel.toUpperCase() : null;
const searchTerm = args.search?.trim().toLowerCase() ?? null; const searchTerm = args.search?.trim().toLowerCase() ?? null;
@ -1098,8 +1384,8 @@ export const list = query({
if (normalizedStatusFilter) { if (normalizedStatusFilter) {
working = working.filter((q) => q.eq(q.field("status"), normalizedStatusFilter)); working = working.filter((q) => q.eq(q.field("status"), normalizedStatusFilter));
} }
if (normalizedPriorityFilter) { if (primaryPriorityFilter) {
working = working.filter((q) => q.eq(q.field("priority"), normalizedPriorityFilter)); working = working.filter((q) => q.eq(q.field("priority"), primaryPriorityFilter));
} }
if (normalizedChannelFilter) { if (normalizedChannelFilter) {
working = working.filter((q) => q.eq(q.field("channel"), normalizedChannelFilter)); working = working.filter((q) => q.eq(q.field("channel"), normalizedChannelFilter));
@ -1188,7 +1474,7 @@ export const list = query({
if (role === "MANAGER") { if (role === "MANAGER") {
filtered = filtered.filter((t) => t.companyId === user.companyId); filtered = filtered.filter((t) => t.companyId === user.companyId);
} }
if (normalizedPriorityFilter) filtered = filtered.filter((t) => t.priority === normalizedPriorityFilter); if (prioritySet) filtered = filtered.filter((t) => prioritySet.has(t.priority));
if (normalizedChannelFilter) filtered = filtered.filter((t) => t.channel === normalizedChannelFilter); if (normalizedChannelFilter) filtered = filtered.filter((t) => t.channel === normalizedChannelFilter);
if (args.assigneeId) filtered = filtered.filter((t) => String(t.assigneeId ?? "") === String(args.assigneeId)); if (args.assigneeId) filtered = filtered.filter((t) => String(t.assigneeId ?? "") === String(args.assigneeId));
if (args.requesterId) filtered = filtered.filter((t) => String(t.requesterId) === String(args.requesterId)); if (args.requesterId) filtered = filtered.filter((t) => String(t.requesterId) === String(args.requesterId));
@ -1762,6 +2048,7 @@ export const create = mutation({
avatarUrl: requester.avatarUrl ?? undefined, avatarUrl: requester.avatarUrl ?? undefined,
teams: requester.teams ?? undefined, teams: requester.teams ?? undefined,
} }
const slaSnapshot = await resolveTicketSlaSnapshot(ctx, args.tenantId, category as Doc<"ticketCategories"> | null, args.priority)
let companyDoc = requester.companyId ? (await ctx.db.get(requester.companyId)) : null let companyDoc = requester.companyId ? (await ctx.db.get(requester.companyId)) : null
if (!companyDoc && machineDoc?.companyId) { if (!companyDoc && machineDoc?.companyId) {
const candidateCompany = await ctx.db.get(machineDoc.companyId) const candidateCompany = await ctx.db.get(machineDoc.companyId)
@ -1795,6 +2082,7 @@ export const create = mutation({
} }
} }
const slaFields = applySlaSnapshot(slaSnapshot, now)
const id = await ctx.db.insert("tickets", { const id = await ctx.db.insert("tickets", {
tenantId: args.tenantId, tenantId: args.tenantId,
reference: nextRef, reference: nextRef,
@ -1837,6 +2125,7 @@ export const create = mutation({
slaPolicyId: undefined, slaPolicyId: undefined,
dueAt: undefined, dueAt: undefined,
customFields: normalizedCustomFields.length ? normalizedCustomFields : undefined, customFields: normalizedCustomFields.length ? normalizedCustomFields : undefined,
...slaFields,
}); });
await ctx.db.insert("ticketEvents", { await ctx.db.insert("ticketEvents", {
ticketId: id, ticketId: id,
@ -1972,8 +2261,13 @@ export const addComment = mutation({
payload: { authorId: args.authorId, authorName: author.name, authorAvatar: author.avatarUrl }, payload: { authorId: args.authorId, authorName: author.name, authorAvatar: author.avatarUrl },
createdAt: now, createdAt: now,
}); });
// bump ticket updatedAt const isStaffResponder =
await ctx.db.patch(args.ticketId, { updatedAt: now }); requestedVisibility === "PUBLIC" &&
!isRequester &&
(normalizedRole === "ADMIN" || normalizedRole === "AGENT" || normalizedRole === "MANAGER");
const responsePatch =
isStaffResponder && !ticketDoc.firstResponseAt ? buildResponseCompletionPatch(ticketDoc, now) : {};
await ctx.db.patch(args.ticketId, { updatedAt: now, ...responsePatch });
// Notificação por e-mail: comentário público para o solicitante // Notificação por e-mail: comentário público para o solicitante
try { try {
const snapshotEmail = (ticketDoc.requesterSnapshot as { email?: string } | undefined)?.email const snapshotEmail = (ticketDoc.requesterSnapshot as { email?: string } | undefined)?.email
@ -2139,7 +2433,8 @@ export const updateStatus = mutation({
throw new ConvexError("Inicie o atendimento antes de marcar o ticket como em andamento.") throw new ConvexError("Inicie o atendimento antes de marcar o ticket como em andamento.")
} }
const now = Date.now(); const now = Date.now();
await ctx.db.patch(ticketId, { status: normalizedStatus, updatedAt: now }); const slaPatch = buildSlaStatusPatch(ticketDoc, normalizedStatus, now);
await ctx.db.patch(ticketId, { status: normalizedStatus, updatedAt: now, ...slaPatch });
await ctx.db.insert("ticketEvents", { await ctx.db.insert("ticketEvents", {
ticketId, ticketId,
type: "STATUS_CHANGED", type: "STATUS_CHANGED",
@ -2206,6 +2501,10 @@ export async function resolveTicketHandler(
), ),
).map((id) => id as Id<"tickets">) ).map((id) => id as Id<"tickets">)
const slaPausePatch = buildSlaStatusPatch(ticketDoc, normalizedStatus, now);
const mergedTicket = mergeTicketState(ticketDoc, slaPausePatch);
const slaSolutionPatch = buildSolutionCompletionPatch(mergedTicket, now);
await ctx.db.patch(ticketId, { await ctx.db.patch(ticketId, {
status: normalizedStatus, status: normalizedStatus,
resolvedAt: now, resolvedAt: now,
@ -2217,6 +2516,8 @@ export async function resolveTicketHandler(
relatedTicketIds: relatedIdList.length ? relatedIdList : undefined, relatedTicketIds: relatedIdList.length ? relatedIdList : undefined,
activeSessionId: undefined, activeSessionId: undefined,
working: false, working: false,
...slaPausePatch,
...slaSolutionPatch,
}) })
await ctx.db.insert("ticketEvents", { await ctx.db.insert("ticketEvents", {
@ -2324,12 +2625,14 @@ export async function reopenTicketHandler(
throw new ConvexError("Usuário não possui permissão para reabrir este chamado") throw new ConvexError("Usuário não possui permissão para reabrir este chamado")
} }
const slaPatch = buildSlaStatusPatch(ticketDoc, "AWAITING_ATTENDANCE", now)
await ctx.db.patch(ticketId, { await ctx.db.patch(ticketId, {
status: "AWAITING_ATTENDANCE", status: "AWAITING_ATTENDANCE",
reopenedAt: now, reopenedAt: now,
resolvedAt: undefined, resolvedAt: undefined,
closedAt: undefined, closedAt: undefined,
updatedAt: now, updatedAt: now,
...slaPatch,
}) })
await ctx.db.insert("ticketEvents", { await ctx.db.insert("ticketEvents", {
@ -2529,16 +2832,9 @@ export const listTicketForms = query({
const fieldsByScope = await fetchTicketFieldsByScopes(ctx, tenantId, scopes) const fieldsByScope = await fetchTicketFieldsByScopes(ctx, tenantId, scopes)
const staffOverride = viewerRole === "ADMIN" || viewerRole === "AGENT" const staffOverride = viewerRole === "ADMIN" || viewerRole === "AGENT"
const settingsByTemplate = new Map<string, Doc<"ticketFormSettings">[]>() const settingsByTemplate = staffOverride
? new Map<string, Doc<"ticketFormSettings">[]>()
if (!staffOverride) { : await fetchViewerScopedFormSettings(ctx, tenantId, scopes, viewer.user._id, viewerCompanyId)
await Promise.all(
templates.map(async (template) => {
const scopedSettings = await fetchScopedFormSettings(ctx, tenantId, template.key, viewer.user._id, viewerCompanyId)
settingsByTemplate.set(template.key, scopedSettings)
})
)
}
const forms = [] as Array<{ const forms = [] as Array<{
key: string key: string
@ -3272,11 +3568,13 @@ export const startWork = mutation({
startedAt: now, startedAt: now,
}) })
const slaStartPatch = buildSlaStatusPatch(ticketDoc, "AWAITING_ATTENDANCE", now);
await ctx.db.patch(ticketId, { await ctx.db.patch(ticketId, {
working: true, working: true,
activeSessionId: sessionId, activeSessionId: sessionId,
status: "AWAITING_ATTENDANCE", status: "AWAITING_ATTENDANCE",
updatedAt: now, updatedAt: now,
...slaStartPatch,
}) })
if (assigneePatched) { if (assigneePatched) {
@ -3336,10 +3634,12 @@ export const pauseWork = mutation({
const normalizedStatus = normalizeStatus(ticketDoc.status) const normalizedStatus = normalizeStatus(ticketDoc.status)
if (normalizedStatus === "AWAITING_ATTENDANCE") { if (normalizedStatus === "AWAITING_ATTENDANCE") {
const now = Date.now() const now = Date.now()
const slaPausePatch = buildSlaStatusPatch(ticketDoc, "PAUSED", now)
await ctx.db.patch(ticketId, { await ctx.db.patch(ticketId, {
status: "PAUSED", status: "PAUSED",
working: false, working: false,
updatedAt: now, updatedAt: now,
...slaPausePatch,
}) })
await ctx.db.insert("ticketEvents", { await ctx.db.insert("ticketEvents", {
ticketId, ticketId,
@ -3380,6 +3680,7 @@ export const pauseWork = mutation({
const deltaInternal = sessionType === "INTERNAL" ? durationMs : 0 const deltaInternal = sessionType === "INTERNAL" ? durationMs : 0
const deltaExternal = sessionType === "EXTERNAL" ? durationMs : 0 const deltaExternal = sessionType === "EXTERNAL" ? durationMs : 0
const slaPausePatch = buildSlaStatusPatch(ticketDoc, "PAUSED", now)
await ctx.db.patch(ticketId, { await ctx.db.patch(ticketId, {
working: false, working: false,
activeSessionId: undefined, activeSessionId: undefined,
@ -3388,6 +3689,7 @@ export const pauseWork = mutation({
internalWorkedMs: (ticket.internalWorkedMs ?? 0) + deltaInternal, internalWorkedMs: (ticket.internalWorkedMs ?? 0) + deltaInternal,
externalWorkedMs: (ticket.externalWorkedMs ?? 0) + deltaExternal, externalWorkedMs: (ticket.externalWorkedMs ?? 0) + deltaExternal,
updatedAt: now, updatedAt: now,
...slaPausePatch,
}) })
const actor = (await ctx.db.get(actorId)) as Doc<"users"> | null const actor = (await ctx.db.get(actorId)) as Doc<"users"> | null

View file

@ -13,6 +13,7 @@ Este índice consolida a documentação viva e move conteúdos históricos para
- Administração (UI): `docs/admin/admin-inventory-ui.md` - Administração (UI): `docs/admin/admin-inventory-ui.md`
## Arquivo (histórico/planejamento) ## Arquivo (histórico/planejamento)
- `docs/alteracoes-2025-11-08.md`
- `docs/archive/operacao-producao.md` (substituído por `docs/operations.md`) - `docs/archive/operacao-producao.md` (substituído por `docs/operations.md`)
- `docs/archive/deploy-runbook.md` - `docs/archive/deploy-runbook.md`
- `docs/archive/setup-historico.md` - `docs/archive/setup-historico.md`
@ -22,4 +23,3 @@ Este índice consolida a documentação viva e move conteúdos históricos para
- `docs/archive/historico-agente-desktop-2025-10-10.md` - `docs/archive/historico-agente-desktop-2025-10-10.md`
Se algum conteúdo arquivado voltar a ser relevante, mova-o de volta, atualizando a data e o escopo. Se algum conteúdo arquivado voltar a ser relevante, mova-o de volta, atualizando a data e o escopo.

View file

@ -0,0 +1,31 @@
# Alterações — 08/11/2025
## Concluído
- **Agenda (Resumo & Calendário)** — nova rota `/agenda` (AppShell + Tabs) com filtros persistentes (`AgendaFilters`), visão Resumo com KPIs por status/SLA e cards por seção, e Calendário mensal com eventos coloridos por SLA. Dataset derivado em `src/lib/agenda-utils.ts` normaliza tickets Convex → blocos (upcoming, overdue, unscheduled, completed) e gera eventos sintéticos até conectarmos ao modelo definitivo de agendamentos.
- **Sidebar & navegação** — link “Agenda” habilitado no `AppSidebar`, replicando permissões das páginas de tickets.
- **Datas de admissão/desligamento**`use-local-time-zone` + `Calendar` atualizados; todos os fluxos (novo ticket, edição dentro do ticket, portal) aplicam o mesmo picker e normalizam valores em UTC, eliminando o deslocamento -1/-2 dias para nascimento e início.
- **Layout dos campos personalizados** — seção “Editar campos personalizados” reutiliza o grid/style do modal de criação, mantendo labels compactos, espaçamento consistente e colunas responsivas semelhantes ao layout do portal.
- **CSAT no ticket individual**`ticket-csat-card.tsx` mantém a experiência para colaboradores, mas oculta a avaliação/“Obrigado pelo feedback!” de agentes/gestores. Também bloqueia o card inteiro para agentes (somente admins visualizam a nota rapidamente).
- **Toast global**`src/lib/toast-patch.ts` higieniza títulos/descrições removendo pontuação final (`!`, `.`, reticências). Patch tipado evita `any` e replica o comportamento em todos os métodos (`success`, `error`, `loading`, `promise`, etc.).
- **Linha do tempo mais útil** — mutations de campos personalizados ignoram saves sem alteração e registram apenas os campos realmente modificados, reduzindo spam como “Campos personalizados atualizados (Nome do solicitante, …)” quando nada mudou.
- **SLA por categoria/prioridade**
- Convex: tabela `categorySlaSettings`, helpers (`categorySlas.ts`) e drawer na UI de categorias permitem definir alvos por prioridade (resposta/solução, modo business/calendar, pausas e alert threshold).
- Tickets: snapshot (`ticket.slaSnapshot`) no momento da criação inclui regra aplicada; `computeSlaDueDates` trata horas úteis (08h18h, segsex) e calendário corrido; status respeita pausas configuradas, com `slaPausedAt/slaPausedMs` e `build*CompletionPatch`.
- Front-end: `ticket-details-panel` e `ticket-summary-header` exibem badges de SLA (on_track/at_risk/breached/met) com due dates; `sla-utils.ts` centraliza cálculo para UI.
- Prisma: modelo `Ticket` agora persiste `slaSnapshot`, due dates e estado de pausa; migration `20251108042551_add_ticket_sla_fields` aplicada e client regenerado.
- **Polyfill de performance**`src/lib/performance-measure-polyfill.ts` previne `performance.measure` negativo em browsers/server; importado em `app/layout.tsx`.
- **Admin auth fallback** — páginas server-side (`/admin`, `/admin/users`) tratam bancos recém-criados onde `AuthUser` ainda não existe, exibindo cards vazios em vez do crash `AuthUser table does not exist`.
- **Chips de admissão/desligamento**`convex/tickets.ts` garante `formTemplateLabel` com fallback nas labels configuradas (ex.: “Admissão de colaborador”), corrigindo etiquetas sem acentuação na listagem/título do ticket.
- **listTicketForms otimizada** — handler faz uma única leitura por tabela (templates, campos, configurações) e filtra em memória para o usuário/empresa atual. Remove o fan-out (3 queries x template) que excedia 1s e derrubava o websocket com erro 1006/digest 500.
## Observações e próximos passos
- Agenda ainda usa eventos mockados (derivados de due dates). Integrar ao modelo real de `TicketSchedule` quando estiver pronto (drag & drop, eventos por agente e digest diário).
- SLA business-hours está fixo em 08h18h (BR). Precisamos conectar com calendários por fila/categoria + feriados/expediente custom.
- Drawer de SLA nas categorias carece de validações finas (ex.: impedir negativos, tooltips com modo escolhido) e feedback de sucesso/erro.
- Relatórios/backlog ainda não consomem o snapshot Prisma recém-adicionado; alinhar APIs que leem tickets direto do banco.
## Testes & build
- `bun run lint`, `bun run test` e `bun run build` passam (webpack e prisma generate); Convex queries relacionadas foram atualizadas para manter índices consistentes.

View file

@ -43,6 +43,7 @@
"@radix-ui/react-select": "^2.2.6", "@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-separator": "^1.1.7", "@radix-ui/react-separator": "^1.1.7",
"@radix-ui/react-slot": "^1.2.3", "@radix-ui/react-slot": "^1.2.3",
"@radix-ui/react-switch": "^1.2.6",
"@radix-ui/react-tabs": "^1.1.13", "@radix-ui/react-tabs": "^1.1.13",
"@radix-ui/react-toggle": "^1.1.10", "@radix-ui/react-toggle": "^1.1.10",
"@radix-ui/react-toggle-group": "^1.1.11", "@radix-ui/react-toggle-group": "^1.1.11",
@ -54,10 +55,10 @@
"@tiptap/extension-link": "^3.10.0", "@tiptap/extension-link": "^3.10.0",
"@tiptap/extension-mention": "^3.10.0", "@tiptap/extension-mention": "^3.10.0",
"@tiptap/extension-placeholder": "^3.10.0", "@tiptap/extension-placeholder": "^3.10.0",
"@tiptap/markdown": "^3.10.0",
"@tiptap/react": "^3.10.0", "@tiptap/react": "^3.10.0",
"@tiptap/starter-kit": "^3.10.0", "@tiptap/starter-kit": "^3.10.0",
"@tiptap/suggestion": "^3.10.0", "@tiptap/suggestion": "^3.10.0",
"@tiptap/markdown": "^3.10.0",
"better-auth": "^1.3.26", "better-auth": "^1.3.26",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",

View file

@ -0,0 +1,9 @@
-- AlterTable
ALTER TABLE "Ticket" ADD COLUMN "slaPausedAt" DATETIME;
ALTER TABLE "Ticket" ADD COLUMN "slaPausedBy" TEXT;
ALTER TABLE "Ticket" ADD COLUMN "slaPausedMs" INTEGER;
ALTER TABLE "Ticket" ADD COLUMN "slaResponseDueAt" DATETIME;
ALTER TABLE "Ticket" ADD COLUMN "slaResponseStatus" TEXT;
ALTER TABLE "Ticket" ADD COLUMN "slaSnapshot" JSONB;
ALTER TABLE "Ticket" ADD COLUMN "slaSolutionDueAt" DATETIME;
ALTER TABLE "Ticket" ADD COLUMN "slaSolutionStatus" TEXT;

View file

@ -180,6 +180,14 @@ model Ticket {
assigneeId String? assigneeId String?
slaPolicyId String? slaPolicyId String?
companyId String? companyId String?
slaSnapshot Json?
slaResponseDueAt DateTime?
slaSolutionDueAt DateTime?
slaResponseStatus String?
slaSolutionStatus String?
slaPausedAt DateTime?
slaPausedBy String?
slaPausedMs Int?
dueAt DateTime? dueAt DateTime?
firstResponseAt DateTime? firstResponseAt DateTime?
resolvedAt DateTime? resolvedAt DateTime?

View file

@ -71,6 +71,37 @@ function normalizeStatus(status) {
return STATUS_MAP[key] ?? "PENDING" return STATUS_MAP[key] ?? "PENDING"
} }
function serializeConvexSlaSnapshot(snapshot) {
if (!snapshot || typeof snapshot !== "object") return null
const categoryIdRaw = snapshot.categoryId
let categoryId
if (typeof categoryIdRaw === "string") {
categoryId = categoryIdRaw
} else if (categoryIdRaw && typeof categoryIdRaw === "object" && "_id" in categoryIdRaw) {
categoryId = categoryIdRaw._id
}
const pauseStatuses =
Array.isArray(snapshot.pauseStatuses) && snapshot.pauseStatuses.length > 0
? snapshot.pauseStatuses.filter((value) => typeof value === "string")
: undefined
const normalized = {
categoryId,
categoryName: typeof snapshot.categoryName === "string" ? snapshot.categoryName : undefined,
priority: typeof snapshot.priority === "string" ? snapshot.priority : undefined,
responseTargetMinutes: typeof snapshot.responseTargetMinutes === "number" ? snapshot.responseTargetMinutes : undefined,
responseMode: typeof snapshot.responseMode === "string" ? snapshot.responseMode : undefined,
solutionTargetMinutes: typeof snapshot.solutionTargetMinutes === "number" ? snapshot.solutionTargetMinutes : undefined,
solutionMode: typeof snapshot.solutionMode === "string" ? snapshot.solutionMode : undefined,
alertThreshold: typeof snapshot.alertThreshold === "number" ? snapshot.alertThreshold : undefined,
pauseStatuses,
}
const hasValues = Object.values(normalized).some((value) => value !== undefined)
return hasValues ? normalized : null
}
async function upsertCompanies(snapshotCompanies) { async function upsertCompanies(snapshotCompanies) {
const map = new Map() const map = new Map()
@ -260,6 +291,8 @@ async function upsertTickets(snapshotTickets, userMap, queueMap, companyMap) {
const desiredAssigneeEmail = defaultAssigneeEmail || normalizeEmail(ticket.assigneeEmail) const desiredAssigneeEmail = defaultAssigneeEmail || normalizeEmail(ticket.assigneeEmail)
const assigneeId = desiredAssigneeEmail ? userMap.get(desiredAssigneeEmail) || fallbackAssigneeId || null : fallbackAssigneeId || null const assigneeId = desiredAssigneeEmail ? userMap.get(desiredAssigneeEmail) || fallbackAssigneeId || null : fallbackAssigneeId || null
const slaSnapshot = serializeConvexSlaSnapshot(ticket.slaSnapshot)
const existing = await prisma.ticket.findFirst({ const existing = await prisma.ticket.findFirst({
where: { where: {
tenantId, tenantId,
@ -283,6 +316,14 @@ async function upsertTickets(snapshotTickets, userMap, queueMap, companyMap) {
createdAt: toDate(ticket.createdAt) ?? new Date(), createdAt: toDate(ticket.createdAt) ?? new Date(),
updatedAt: toDate(ticket.updatedAt) ?? new Date(), updatedAt: toDate(ticket.updatedAt) ?? new Date(),
companyId, companyId,
slaSnapshot: slaSnapshot ?? null,
slaResponseDueAt: toDate(ticket.slaResponseDueAt),
slaSolutionDueAt: toDate(ticket.slaSolutionDueAt),
slaResponseStatus: typeof ticket.slaResponseStatus === "string" ? ticket.slaResponseStatus : null,
slaSolutionStatus: typeof ticket.slaSolutionStatus === "string" ? ticket.slaSolutionStatus : null,
slaPausedAt: toDate(ticket.slaPausedAt),
slaPausedBy: typeof ticket.slaPausedBy === "string" ? ticket.slaPausedBy : null,
slaPausedMs: typeof ticket.slaPausedMs === "number" ? ticket.slaPausedMs : null,
} }
let ticketRecord let ticketRecord

View file

@ -21,6 +21,29 @@ function slugify(value) {
.replace(/-+/g, "-") || undefined .replace(/-+/g, "-") || undefined
} }
function normalizePrismaSlaSnapshot(snapshot) {
if (!snapshot || typeof snapshot !== "object") return undefined
const record = snapshot
const pauseStatuses =
Array.isArray(record.pauseStatuses) && record.pauseStatuses.length > 0
? record.pauseStatuses.filter((value) => typeof value === "string")
: undefined
const normalized = {
categoryId: typeof record.categoryId === "string" ? record.categoryId : undefined,
categoryName: typeof record.categoryName === "string" ? record.categoryName : undefined,
priority: typeof record.priority === "string" ? record.priority : undefined,
responseTargetMinutes: typeof record.responseTargetMinutes === "number" ? record.responseTargetMinutes : undefined,
responseMode: typeof record.responseMode === "string" ? record.responseMode : undefined,
solutionTargetMinutes: typeof record.solutionTargetMinutes === "number" ? record.solutionTargetMinutes : undefined,
solutionMode: typeof record.solutionMode === "string" ? record.solutionMode : undefined,
alertThreshold: typeof record.alertThreshold === "number" ? record.alertThreshold : undefined,
pauseStatuses,
}
return Object.values(normalized).some((value) => value !== undefined) ? normalized : undefined
}
async function main() { async function main() {
const tenantId = process.env.SYNC_TENANT_ID || "tenant-atlas" const tenantId = process.env.SYNC_TENANT_ID || "tenant-atlas"
const convexUrl = process.env.NEXT_PUBLIC_CONVEX_URL || "http://127.0.0.1:3210" const convexUrl = process.env.NEXT_PUBLIC_CONVEX_URL || "http://127.0.0.1:3210"
@ -103,6 +126,14 @@ async function main() {
createdAt: toMillis(ticket.createdAt) ?? Date.now(), createdAt: toMillis(ticket.createdAt) ?? Date.now(),
updatedAt: toMillis(ticket.updatedAt) ?? Date.now(), updatedAt: toMillis(ticket.updatedAt) ?? Date.now(),
tags: Array.isArray(ticket.tags) ? ticket.tags : undefined, tags: Array.isArray(ticket.tags) ? ticket.tags : undefined,
slaSnapshot: normalizePrismaSlaSnapshot(ticket.slaSnapshot),
slaResponseDueAt: toMillis(ticket.slaResponseDueAt),
slaSolutionDueAt: toMillis(ticket.slaSolutionDueAt),
slaResponseStatus: ticket.slaResponseStatus ?? undefined,
slaSolutionStatus: ticket.slaSolutionStatus ?? undefined,
slaPausedAt: toMillis(ticket.slaPausedAt),
slaPausedBy: ticket.slaPausedBy ?? undefined,
slaPausedMs: typeof ticket.slaPausedMs === "number" ? ticket.slaPausedMs : undefined,
comments: ticket.comments.map((comment) => ({ comments: ticket.comments.map((comment) => ({
authorEmail: comment.author?.email ?? requesterEmail, authorEmail: comment.author?.email ?? requesterEmail,
visibility: comment.visibility, visibility: comment.visibility,

View file

@ -0,0 +1,121 @@
"use client"
import { useEffect, useMemo, useState } from "react"
import { useQuery } from "convex/react"
import { format } from "date-fns"
import { ptBR } from "date-fns/locale/pt-BR"
import { api } from "@/convex/_generated/api"
import type { Id } from "@/convex/_generated/dataModel"
import { DEFAULT_TENANT_ID } from "@/lib/constants"
import { mapTicketsFromServerList } from "@/lib/mappers/ticket"
import type { Ticket } from "@/lib/schemas/ticket"
import { AppShell } from "@/components/app-shell"
import { SiteHeader } from "@/components/site-header"
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
import { Button } from "@/components/ui/button"
import { CalendarPlus } from "lucide-react"
import { useAuth } from "@/lib/auth-client"
import { AgendaFilters, AgendaFilterState, AgendaPeriod, defaultAgendaFilters } from "@/components/agenda/agenda-filters"
import { AgendaSummaryView } from "@/components/agenda/agenda-summary-view"
import { AgendaCalendarView } from "@/components/agenda/agenda-calendar-view"
import { buildAgendaDataset, type AgendaDataset } from "@/lib/agenda-utils"
export function AgendaPageClient() {
const [activeTab, setActiveTab] = useState<"summary" | "calendar">("summary")
const [filters, setFilters] = useState<AgendaFilterState>(defaultAgendaFilters)
const { convexUserId, session } = useAuth()
const userId = convexUserId as Id<"users"> | null
const tenantId = session?.user?.tenantId ?? DEFAULT_TENANT_ID
const ticketsArgs = userId
? {
tenantId,
viewerId: userId,
status: undefined,
priority: filters.priorities.length ? filters.priorities : undefined,
queueId: undefined,
channel: undefined,
assigneeId: filters.onlyMyTickets ? userId : undefined,
search: undefined,
}
: "skip"
const ticketsRaw = useQuery(api.tickets.list, ticketsArgs)
const mappedTickets = useMemo<Ticket[] | null>(() => {
if (!Array.isArray(ticketsRaw)) return null
return mapTicketsFromServerList(ticketsRaw as unknown[])
}, [ticketsRaw])
const [cachedTickets, setCachedTickets] = useState<Ticket[]>([])
useEffect(() => {
if (mappedTickets) {
setCachedTickets(mappedTickets)
}
}, [mappedTickets])
const effectiveTickets = mappedTickets ?? cachedTickets
const isInitialLoading = !mappedTickets && cachedTickets.length === 0 && ticketsArgs !== "skip"
const dataset: AgendaDataset = useMemo(
() => buildAgendaDataset(effectiveTickets, filters),
[effectiveTickets, filters]
)
const greeting = getGreetingMessage()
const firstName = session?.user?.name?.split(" ")[0] ?? session?.user?.email?.split("@")[0] ?? "equipe"
const rangeDescription = formatRangeDescription(filters.period, dataset.range)
const headerLead = `${greeting}, ${firstName}! ${rangeDescription}`
return (
<AppShell
header={
<SiteHeader
title="Agenda"
lead={headerLead}
secondaryAction={
<Button variant="secondary" size="sm" className="gap-2" disabled>
<CalendarPlus className="size-4" />
Em breve: novo compromisso
</Button>
}
/>
}
>
<div className="flex flex-col gap-6 px-4 lg:px-6">
<AgendaFilters filters={filters} onChange={setFilters} queues={dataset.availableQueues} />
<Tabs value={activeTab} onValueChange={(value) => setActiveTab(value as typeof activeTab)}>
<TabsList className="mb-4">
<TabsTrigger value="summary">Resumo</TabsTrigger>
<TabsTrigger value="calendar">Calendário</TabsTrigger>
</TabsList>
<TabsContent value="summary" className="focus-visible:outline-none">
<AgendaSummaryView data={dataset} isLoading={isInitialLoading} />
</TabsContent>
<TabsContent value="calendar" className="focus-visible:outline-none">
<AgendaCalendarView events={dataset.calendarEvents} range={dataset.range} />
</TabsContent>
</Tabs>
</div>
</AppShell>
)
}
function getGreetingMessage(date: Date = new Date()) {
const hour = date.getHours()
if (hour < 12) return "Bom dia"
if (hour < 18) return "Boa tarde"
return "Boa noite"
}
function formatRangeDescription(period: AgendaPeriod, range: { start: Date; end: Date }) {
if (period === "today") {
return `Hoje é ${format(range.start, "eeee, d 'de' MMMM", { locale: ptBR })}`
}
if (period === "week") {
return `Semana de ${format(range.start, "d MMM", { locale: ptBR })} a ${format(range.end, "d MMM", { locale: ptBR })}`
}
return `Mês de ${format(range.start, "MMMM 'de' yyyy", { locale: ptBR })}`
}

9
src/app/agenda/page.tsx Normal file
View file

@ -0,0 +1,9 @@
import { requireAuthenticatedSession } from "@/lib/auth-server"
import { AgendaPageClient } from "./agenda-page-client"
export default async function AgendaPage() {
await requireAuthenticatedSession()
return <AgendaPageClient />
}

View file

@ -1,8 +1,49 @@
import { TicketsPageClient } from "./tickets-page-client" import { TicketsPageClient } from "./tickets-page-client"
import { requireAuthenticatedSession } from "@/lib/auth-server" import { requireAuthenticatedSession } from "@/lib/auth-server"
import type { TicketFiltersState } from "@/lib/ticket-filters"
import type { TicketStatus } from "@/lib/schemas/ticket"
export default async function TicketsPage() { type TicketsPageProps = {
await requireAuthenticatedSession() searchParams?: Record<string, string | string[] | undefined>
return <TicketsPageClient /> }
export default async function TicketsPage({ searchParams }: TicketsPageProps) {
await requireAuthenticatedSession()
const initialFilters = deriveInitialFilters(searchParams ?? {})
return <TicketsPageClient initialFilters={initialFilters} />
}
function getParamValue(value: string | string[] | undefined): string | undefined {
if (Array.isArray(value)) {
return value[0]
}
return value
}
function deriveInitialFilters(params: Record<string, string | string[] | undefined>): Partial<TicketFiltersState> {
const initial: Partial<TicketFiltersState> = {}
const view = getParamValue(params.view)
if (view === "completed" || view === "active") {
initial.view = view
}
const status = getParamValue(params.status)
if (status && ["PENDING", "AWAITING_ATTENDANCE", "PAUSED", "RESOLVED"].includes(status)) {
initial.status = status as TicketStatus
}
const priority = getParamValue(params.priority)
if (priority) initial.priority = priority
const queue = getParamValue(params.queue)
if (queue) initial.queue = queue
const channel = getParamValue(params.channel)
if (channel) initial.channel = channel
const company = getParamValue(params.company)
if (company) initial.company = company
const assigneeId = getParamValue(params.assignee)
if (assigneeId) initial.assigneeId = assigneeId
const focus = getParamValue(params.focus)
if (focus === "visits") {
initial.focusVisits = true
}
return initial
} }

View file

@ -4,6 +4,7 @@ import dynamic from "next/dynamic"
import { AppShell } from "@/components/app-shell" import { AppShell } from "@/components/app-shell"
import { SiteHeader } from "@/components/site-header" import { SiteHeader } from "@/components/site-header"
import type { TicketFiltersState } from "@/components/tickets/tickets-filters"
const TicketQueueSummaryCards = dynamic( const TicketQueueSummaryCards = dynamic(
() => () =>
@ -29,7 +30,11 @@ const NewTicketDialog = dynamic(
{ ssr: false } { ssr: false }
) )
export function TicketsPageClient() { type TicketsPageClientProps = {
initialFilters?: Partial<TicketFiltersState>
}
export function TicketsPageClient({ initialFilters }: TicketsPageClientProps = {}) {
return ( return (
<AppShell <AppShell
header={ header={
@ -44,7 +49,7 @@ export function TicketsPageClient() {
<div className="px-4 lg:px-6"> <div className="px-4 lg:px-6">
<TicketQueueSummaryCards /> <TicketQueueSummaryCards />
</div> </div>
<TicketsView /> <TicketsView initialFilters={initialFilters} />
</div> </div>
</AppShell> </AppShell>
) )

View file

@ -1,6 +1,6 @@
"use client" "use client"
import { useMemo, useState } from "react" import { useEffect, useMemo, useState } from "react"
import { useMutation, useQuery } from "convex/react" import { useMutation, useQuery } from "convex/react"
import { toast } from "sonner" import { toast } from "sonner"
import { api } from "@/convex/_generated/api" import { api } from "@/convex/_generated/api"
@ -22,6 +22,8 @@ import {
import { Input } from "@/components/ui/input" import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label" import { Label } from "@/components/ui/label"
import { Textarea } from "@/components/ui/textarea" import { Textarea } from "@/components/ui/textarea"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { cn } from "@/lib/utils"
type DeleteState<T extends "category" | "subcategory"> = type DeleteState<T extends "category" | "subcategory"> =
| { type: T; targetId: string; reason: string } | { type: T; targetId: string; reason: string }
@ -36,6 +38,7 @@ export function CategoriesManager() {
const [subcategoryDraft, setSubcategoryDraft] = useState("") const [subcategoryDraft, setSubcategoryDraft] = useState("")
const [subcategoryList, setSubcategoryList] = useState<string[]>([]) const [subcategoryList, setSubcategoryList] = useState<string[]>([])
const [deleteState, setDeleteState] = useState<DeleteState<"category" | "subcategory">>(null) const [deleteState, setDeleteState] = useState<DeleteState<"category" | "subcategory">>(null)
const [slaCategory, setSlaCategory] = useState<TicketCategory | null>(null)
const createCategory = useMutation(api.categories.createCategory) const createCategory = useMutation(api.categories.createCategory)
const deleteCategory = useMutation(api.categories.deleteCategory) const deleteCategory = useMutation(api.categories.deleteCategory)
const updateCategory = useMutation(api.categories.updateCategory) const updateCategory = useMutation(api.categories.updateCategory)
@ -196,6 +199,7 @@ export function CategoriesManager() {
const pendingDelete = deleteState const pendingDelete = deleteState
const isDisabled = !convexUserId const isDisabled = !convexUserId
const viewerId = convexUserId as Id<"users"> | null
return ( return (
<div className="space-y-6"> <div className="space-y-6">
@ -311,6 +315,7 @@ export function CategoriesManager() {
onDeleteSubcategory={(subcategoryId) => onDeleteSubcategory={(subcategoryId) =>
setDeleteState({ type: "subcategory", targetId: subcategoryId, reason: "" }) setDeleteState({ type: "subcategory", targetId: subcategoryId, reason: "" })
} }
onConfigureSla={() => setSlaCategory(category)}
disabled={isDisabled} disabled={isDisabled}
/> />
)) ))
@ -373,6 +378,12 @@ export function CategoriesManager() {
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
<CategorySlaDrawer
category={slaCategory}
tenantId={tenantId}
viewerId={viewerId}
onClose={() => setSlaCategory(null)}
/>
</div> </div>
) )
} }
@ -385,6 +396,7 @@ interface CategoryItemProps {
onCreateSubcategory: (categoryId: string, payload: { name: string }) => Promise<void> onCreateSubcategory: (categoryId: string, payload: { name: string }) => Promise<void>
onUpdateSubcategory: (subcategory: TicketSubcategory, name: string) => Promise<void> onUpdateSubcategory: (subcategory: TicketSubcategory, name: string) => Promise<void>
onDeleteSubcategory: (subcategoryId: string) => void onDeleteSubcategory: (subcategoryId: string) => void
onConfigureSla: () => void
} }
function CategoryItem({ function CategoryItem({
@ -395,6 +407,7 @@ function CategoryItem({
onCreateSubcategory, onCreateSubcategory,
onUpdateSubcategory, onUpdateSubcategory,
onDeleteSubcategory, onDeleteSubcategory,
onConfigureSla,
}: CategoryItemProps) { }: CategoryItemProps) {
const [isEditing, setIsEditing] = useState(false) const [isEditing, setIsEditing] = useState(false)
const [name, setName] = useState(category.name) const [name, setName] = useState(category.name)
@ -448,6 +461,9 @@ function CategoryItem({
</div> </div>
) : ( ) : (
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Button size="sm" variant="secondary" onClick={onConfigureSla} disabled={disabled}>
Configurar SLA
</Button>
<Button size="sm" variant="outline" onClick={() => setIsEditing(true)} disabled={disabled}> <Button size="sm" variant="outline" onClick={() => setIsEditing(true)} disabled={disabled}>
Editar Editar
</Button> </Button>
@ -552,3 +568,360 @@ function SubcategoryItem({ subcategory, disabled, onUpdate, onDelete }: Subcateg
</div> </div>
) )
} }
type RuleFormState = {
responseValue: string
responseUnit: "minutes" | "hours" | "days"
responseMode: "business" | "calendar"
solutionValue: string
solutionUnit: "minutes" | "hours" | "days"
solutionMode: "business" | "calendar"
alertThreshold: number
pauseStatuses: string[]
}
const PRIORITY_ROWS = [
{ value: "URGENT", label: "Crítico" },
{ value: "HIGH", label: "Alta" },
{ value: "MEDIUM", label: "Média" },
{ value: "LOW", label: "Baixa" },
{ value: "DEFAULT", label: "Sem prioridade" },
] as const
const TIME_UNITS: Array<{ value: RuleFormState["responseUnit"]; label: string; factor: number }> = [
{ value: "minutes", label: "Minutos", factor: 1 },
{ value: "hours", label: "Horas", factor: 60 },
{ value: "days", label: "Dias", factor: 1440 },
]
const MODE_OPTIONS: Array<{ value: RuleFormState["responseMode"]; label: string }> = [
{ value: "calendar", label: "Horas corridas" },
{ value: "business", label: "Horas úteis" },
]
const PAUSE_STATUS_OPTIONS = [
{ value: "PENDING", label: "Pendente" },
{ value: "AWAITING_ATTENDANCE", label: "Em atendimento" },
{ value: "PAUSED", label: "Pausado" },
] as const
const DEFAULT_RULE_STATE: RuleFormState = {
responseValue: "",
responseUnit: "hours",
responseMode: "calendar",
solutionValue: "",
solutionUnit: "hours",
solutionMode: "calendar",
alertThreshold: 80,
pauseStatuses: ["PAUSED"],
}
type CategorySlaDrawerProps = {
category: TicketCategory | null
tenantId: string
viewerId: Id<"users"> | null
onClose: () => void
}
function CategorySlaDrawer({ category, tenantId, viewerId, onClose }: CategorySlaDrawerProps) {
const [rules, setRules] = useState<Record<string, RuleFormState>>(() => buildDefaultRuleState())
const [saving, setSaving] = useState(false)
const drawerOpen = Boolean(category)
const canLoad = Boolean(category && viewerId)
const existing = useQuery(
api.categorySlas.get,
canLoad
? {
tenantId,
viewerId: viewerId as Id<"users">,
categoryId: category!.id as Id<"ticketCategories">,
}
: "skip"
) as { rules: Array<{ priority: string; responseTargetMinutes: number | null; responseMode?: string | null; solutionTargetMinutes: number | null; solutionMode?: string | null; alertThreshold?: number | null; pauseStatuses?: string[] | null }> } | undefined
const saveSla = useMutation(api.categorySlas.save)
useEffect(() => {
if (!existing?.rules) {
setRules(buildDefaultRuleState())
return
}
const next = buildDefaultRuleState()
for (const rule of existing.rules) {
const priority = rule.priority?.toUpperCase() ?? "DEFAULT"
next[priority] = convertRuleToForm(rule)
}
setRules(next)
}, [existing, category?.id])
const handleChange = (priority: string, patch: Partial<RuleFormState>) => {
setRules((current) => ({
...current,
[priority]: {
...current[priority],
...patch,
},
}))
}
const togglePause = (priority: string, status: string) => {
setRules((current) => {
const selected = new Set(current[priority].pauseStatuses)
if (selected.has(status)) {
selected.delete(status)
} else {
selected.add(status)
}
if (selected.size === 0) {
selected.add("PAUSED")
}
return {
...current,
[priority]: {
...current[priority],
pauseStatuses: Array.from(selected),
},
}
})
}
const handleSave = async () => {
if (!category || !viewerId) return
setSaving(true)
toast.loading("Salvando SLA...", { id: "category-sla" })
try {
const payload = PRIORITY_ROWS.map((row) => {
const form = rules[row.value]
return {
priority: row.value,
responseTargetMinutes: convertToMinutes(form.responseValue, form.responseUnit),
responseMode: form.responseMode,
solutionTargetMinutes: convertToMinutes(form.solutionValue, form.solutionUnit),
solutionMode: form.solutionMode,
alertThreshold: Math.min(Math.max(form.alertThreshold, 5), 95) / 100,
pauseStatuses: form.pauseStatuses,
}
})
await saveSla({
tenantId,
actorId: viewerId,
categoryId: category.id as Id<"ticketCategories">,
rules: payload,
})
toast.success("SLA atualizado", { id: "category-sla" })
onClose()
} catch (error) {
console.error(error)
toast.error("Não foi possível salvar as regras de SLA.", { id: "category-sla" })
} finally {
setSaving(false)
}
}
return (
<Dialog
open={drawerOpen}
onOpenChange={(open) => {
if (!open) {
onClose()
}
}}
>
<DialogContent className="max-w-4xl overflow-y-auto">
<DialogHeader>
<DialogTitle>Configurar SLA {category?.name ?? ""}</DialogTitle>
<DialogDescription>
Defina metas de resposta e resolução para cada prioridade. Os prazos em horas úteis consideram apenas
segunda a sexta, das 8h às 18h.
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-2">
{PRIORITY_ROWS.map((row) => {
const form = rules[row.value]
return (
<div key={row.value} className="space-y-3 rounded-2xl border border-slate-200 bg-white/80 p-4 shadow-sm">
<div className="flex flex-wrap items-center justify-between gap-2">
<div>
<p className="text-sm font-semibold text-neutral-900">{row.label}</p>
<p className="text-xs text-neutral-500">
{row.value === "DEFAULT" ? "Aplicado quando o ticket não tem prioridade definida." : "Aplica-se aos tickets desta prioridade."}
</p>
</div>
</div>
<div className="grid gap-4 md:grid-cols-2">
<SlaInputGroup
title="Tempo de resposta"
amount={form.responseValue}
unit={form.responseUnit}
mode={form.responseMode}
onAmountChange={(value) => handleChange(row.value, { responseValue: value })}
onUnitChange={(value) => handleChange(row.value, { responseUnit: value as RuleFormState["responseUnit"] })}
onModeChange={(value) => handleChange(row.value, { responseMode: value as RuleFormState["responseMode"] })}
/>
<SlaInputGroup
title="Tempo de solução"
amount={form.solutionValue}
unit={form.solutionUnit}
mode={form.solutionMode}
onAmountChange={(value) => handleChange(row.value, { solutionValue: value })}
onUnitChange={(value) => handleChange(row.value, { solutionUnit: value as RuleFormState["solutionUnit"] })}
onModeChange={(value) => handleChange(row.value, { solutionMode: value as RuleFormState["solutionMode"] })}
/>
</div>
<div className="grid gap-4 md:grid-cols-2">
<div>
<p className="text-xs font-semibold uppercase tracking-wide text-neutral-500">Alertar quando</p>
<div className="mt-2 flex items-center gap-2">
<Input
type="number"
min={10}
max={95}
step={5}
value={form.alertThreshold}
onChange={(event) => handleChange(row.value, { alertThreshold: Number(event.target.value) || 0 })}
/>
<span className="text-xs text-neutral-500">% do tempo for consumido.</span>
</div>
</div>
<div>
<p className="text-xs font-semibold uppercase tracking-wide text-neutral-500">Estados que pausam</p>
<div className="mt-2 flex flex-wrap gap-2">
{PAUSE_STATUS_OPTIONS.map((option) => {
const selected = form.pauseStatuses.includes(option.value)
return (
<button
key={option.value}
type="button"
onClick={() => togglePause(row.value, option.value)}
className={cn(
"rounded-full border px-3 py-1 text-xs font-semibold transition",
selected ? "border-primary bg-primary text-primary-foreground" : "border-slate-200 bg-white text-neutral-600"
)}
>
{option.label}
</button>
)
})}
</div>
</div>
</div>
</div>
)
})}
</div>
<DialogFooter className="gap-2">
<Button variant="outline" onClick={onClose}>
Cancelar
</Button>
<Button onClick={handleSave} disabled={saving || !viewerId}>
{saving ? "Salvando..." : "Salvar"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
function buildDefaultRuleState() {
return PRIORITY_ROWS.reduce<Record<string, RuleFormState>>((acc, row) => {
acc[row.value] = { ...DEFAULT_RULE_STATE }
return acc
}, {})
}
function convertRuleToForm(rule: {
priority: string
responseTargetMinutes: number | null
responseMode?: string | null
solutionTargetMinutes: number | null
solutionMode?: string | null
alertThreshold?: number | null
pauseStatuses?: string[] | null
}): RuleFormState {
const response = minutesToForm(rule.responseTargetMinutes)
const solution = minutesToForm(rule.solutionTargetMinutes)
return {
responseValue: response.amount,
responseUnit: response.unit,
responseMode: (rule.responseMode ?? "calendar") as RuleFormState["responseMode"],
solutionValue: solution.amount,
solutionUnit: solution.unit,
solutionMode: (rule.solutionMode ?? "calendar") as RuleFormState["solutionMode"],
alertThreshold: Math.round(((rule.alertThreshold ?? 0.8) * 100)),
pauseStatuses: rule.pauseStatuses && rule.pauseStatuses.length > 0 ? rule.pauseStatuses : ["PAUSED"],
}
}
function minutesToForm(input?: number | null) {
if (!input || input <= 0) {
return { amount: "", unit: "hours" as RuleFormState["responseUnit"] }
}
for (const option of [...TIME_UNITS].reverse()) {
if (input % option.factor === 0) {
return { amount: String(Math.round(input / option.factor)), unit: option.value }
}
}
return { amount: String(input), unit: "minutes" as RuleFormState["responseUnit"] }
}
function convertToMinutes(value: string, unit: RuleFormState["responseUnit"]) {
const numeric = Number(value)
if (!Number.isFinite(numeric) || numeric <= 0) {
return undefined
}
const factor = TIME_UNITS.find((item) => item.value === unit)?.factor ?? 1
return Math.round(numeric * factor)
}
type SlaInputGroupProps = {
title: string
amount: string
unit: RuleFormState["responseUnit"]
mode: RuleFormState["responseMode"]
onAmountChange: (value: string) => void
onUnitChange: (value: string) => void
onModeChange: (value: string) => void
}
function SlaInputGroup({ title, amount, unit, mode, onAmountChange, onUnitChange, onModeChange }: SlaInputGroupProps) {
return (
<div className="space-y-2 rounded-xl border border-slate-200 bg-white px-3 py-3">
<p className="text-xs font-semibold uppercase tracking-wide text-neutral-500">{title}</p>
<div className="flex flex-col gap-2 md:flex-row">
<Input
type="number"
min={0}
step={1}
value={amount}
onChange={(event) => onAmountChange(event.target.value)}
placeholder="0"
/>
<Select value={unit} onValueChange={onUnitChange}>
<SelectTrigger>
<SelectValue placeholder="Unidade" />
</SelectTrigger>
<SelectContent>
{TIME_UNITS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<Select value={mode} onValueChange={onModeChange}>
<SelectTrigger>
<SelectValue placeholder="Tipo de contagem" />
</SelectTrigger>
<SelectContent>
{MODE_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)
}

View file

@ -0,0 +1,372 @@
"use client"
import Link from "next/link"
import { Fragment, useMemo, useState } from "react"
import {
addDays,
addWeeks,
addMonths,
endOfMonth,
endOfWeek,
format,
isSameDay,
isSameMonth,
isToday,
startOfMonth,
startOfWeek,
} from "date-fns"
import { ptBR } from "date-fns/locale/pt-BR"
import { ChevronLeft, ChevronRight, CalendarDays, AlertCircle } from "lucide-react"
import { Button } from "@/components/ui/button"
import { Badge } from "@/components/ui/badge"
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"
import { ScrollArea } from "@/components/ui/scroll-area"
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"
import { priorityStyles } from "@/lib/ticket-priority-style"
import type { AgendaCalendarEvent } from "@/lib/agenda-utils"
import { cn } from "@/lib/utils"
type AgendaCalendarViewProps = {
events: AgendaCalendarEvent[]
range: { start: Date; end: Date }
}
const weekdayLabels = ["Seg", "Ter", "Qua", "Qui", "Sex", "Sáb", "Dom"]
const slaColors: Record<AgendaCalendarEvent["slaStatus"], string> = {
on_track: "border-emerald-200 bg-emerald-50 text-emerald-800",
at_risk: "border-amber-200 bg-amber-50 text-amber-800",
breached: "border-rose-200 bg-rose-50 text-rose-800",
met: "border-primary/20 bg-primary/5 text-primary",
}
export function AgendaCalendarView({ events, range }: AgendaCalendarViewProps) {
const [viewMode, setViewMode] = useState<"month" | "week">("month")
const [currentMonth, setCurrentMonth] = useState<Date>(startOfMonth(range.start))
const [currentWeekStart, setCurrentWeekStart] = useState<Date>(startOfWeek(range.start, { weekStartsOn: 1 }))
const monthMatrix = useMemo(() => buildCalendarMatrix(currentMonth), [currentMonth])
const availableYears = useMemo(() => {
const years = new Set<number>()
years.add(new Date().getFullYear())
years.add(currentMonth.getFullYear())
years.add(currentWeekStart.getFullYear())
events.forEach((event) => {
years.add(event.start.getFullYear())
years.add(event.end.getFullYear())
})
return Array.from(years).sort((a, b) => a - b)
}, [currentMonth, currentWeekStart, events])
const eventsByDay = useMemo(() => {
const map = new Map<string, AgendaCalendarEvent[]>()
for (const event of events) {
const key = format(event.start, "yyyy-MM-dd")
if (!map.has(key)) {
map.set(key, [])
}
map.get(key)!.push(event)
}
for (const value of map.values()) {
value.sort((a, b) => a.start.getTime() - b.start.getTime())
}
return map
}, [events])
const weekDays = useMemo(() => {
const start = currentWeekStart
return Array.from({ length: 7 }, (_, index) => addDays(start, index))
}, [currentWeekStart])
const handlePrev = () => {
if (viewMode === "month") {
setCurrentMonth(addMonths(currentMonth, -1))
} else {
setCurrentWeekStart(addWeeks(currentWeekStart, -1))
}
}
const handleNext = () => {
if (viewMode === "month") {
setCurrentMonth(addMonths(currentMonth, 1))
} else {
setCurrentWeekStart(addWeeks(currentWeekStart, 1))
}
}
const handleToday = () => {
const today = new Date()
setCurrentMonth(startOfMonth(today))
setCurrentWeekStart(startOfWeek(today, { weekStartsOn: 1 }))
}
const handleSelectMonth = (year: number, monthIndex: number) => {
const nextMonth = startOfMonth(new Date(year, monthIndex, 1))
setCurrentMonth(nextMonth)
setCurrentWeekStart(startOfWeek(nextMonth, { weekStartsOn: 1 }))
}
const currentLabel =
viewMode === "month"
? format(currentMonth, "MMMM 'de' yyyy", { locale: ptBR })
: `${format(currentWeekStart, "d MMM", { locale: ptBR })} ${format(
addDays(currentWeekStart, 6),
"d MMM",
{ locale: ptBR },
)}`
return (
<div className="rounded-2xl border border-border/60 bg-card/70 p-4 shadow-sm">
<div className="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
<div className="space-y-1">
<h3 className="text-lg font-semibold text-foreground">Calendário operacional</h3>
<p className="text-sm text-muted-foreground">{currentLabel}</p>
</div>
<div className="flex flex-wrap items-center gap-3">
<ToggleGroup
type="single"
value={viewMode}
onValueChange={(value) => value && setViewMode(value as "month" | "week")}
className="rounded-xl border border-border/70 bg-background/60 p-0.5"
>
<ToggleGroupItem value="month" className="rounded-lg px-4 py-2 text-sm font-medium data-[state=on]:bg-primary data-[state=on]:text-primary-foreground">
Mês
</ToggleGroupItem>
<ToggleGroupItem value="week" className="rounded-lg px-4 py-2 text-sm font-medium data-[state=on]:bg-primary data-[state=on]:text-primary-foreground">
Semana
</ToggleGroupItem>
</ToggleGroup>
<div className="flex flex-wrap items-center gap-2 rounded-2xl bg-white/80 px-2 py-1.5 shadow-sm">
<Button variant="ghost" size="icon" onClick={handlePrev}>
<ChevronLeft className="size-4" />
</Button>
<Button variant="ghost" size="icon" onClick={handleNext}>
<ChevronRight className="size-4" />
</Button>
<Button variant="secondary" onClick={handleToday}>
Hoje
</Button>
<YearPopover years={availableYears} onSelectMonth={handleSelectMonth} />
</div>
</div>
</div>
{viewMode === "month" ? (
<MonthView
monthMatrix={monthMatrix}
eventsByDay={eventsByDay}
currentMonth={currentMonth}
/>
) : (
<WeekView weekDays={weekDays} eventsByDay={eventsByDay} />
)}
{events.length === 0 ? (
<div className="mt-6 flex items-center gap-3 rounded-lg border border-dashed border-border/60 bg-background/60 p-4 text-sm text-muted-foreground">
<AlertCircle className="size-4" />
Nenhum compromisso previsto para o período filtrado.
</div>
) : null}
</div>
)
}
function MonthView({
monthMatrix,
eventsByDay,
currentMonth,
}: {
monthMatrix: Date[][]
eventsByDay: Map<string, AgendaCalendarEvent[]>
currentMonth: Date
}) {
return (
<div className="mt-6">
<div className="grid grid-cols-7 text-center text-xs font-medium text-muted-foreground">
{weekdayLabels.map((label) => (
<div key={label} className="pb-2">
{label}
</div>
))}
</div>
<div className="grid grid-cols-7 gap-1 text-sm">
{monthMatrix.map((week, weekIndex) => (
<Fragment key={`week-${weekIndex}`}>
{week.map((day) => {
const dayKey = format(day, "yyyy-MM-dd")
const dayEvents = eventsByDay.get(dayKey) ?? []
const isCurrent = isSameMonth(day, currentMonth)
return (
<div
key={dayKey}
className={cn(
"min-h-[110px] rounded-xl border bg-background/70 p-2",
isCurrent ? "border-border/80" : "border-border/40 bg-muted/30",
isToday(day) ? "ring-2 ring-primary/60" : ""
)}
>
<div className="flex items-center justify-between text-xs font-semibold text-foreground">
<span className={isCurrent ? "text-foreground" : "text-muted-foreground"}>{day.getDate()}</span>
{dayEvents.length > 0 ? (
<Badge variant="secondary" className="text-[10px]">
{dayEvents.length}
</Badge>
) : null}
</div>
<div className="mt-2 space-y-1">
{dayEvents.slice(0, 3).map((event) => (
<CalendarEventBadge key={event.id} event={event} />
))}
{dayEvents.length > 3 ? (
<p className="text-[10px] text-muted-foreground">+{dayEvents.length - 3} mais</p>
) : null}
</div>
</div>
)
})}
</Fragment>
))}
</div>
</div>
)
}
function WeekView({
weekDays,
eventsByDay,
}: {
weekDays: Date[]
eventsByDay: Map<string, AgendaCalendarEvent[]>
}) {
return (
<div className="mt-6">
<div className="grid grid-cols-7 text-center text-xs font-medium text-muted-foreground">
{weekDays.map((day, index) => (
<div key={`label-${index}`} className="pb-2">
{weekdayLabels[index]} <span className="ml-1 font-semibold text-foreground">{day.getDate()}</span>
</div>
))}
</div>
<div className="grid grid-cols-7 gap-2 text-sm">
{weekDays.map((day) => {
const dayKey = format(day, "yyyy-MM-dd")
const dayEvents = eventsByDay.get(dayKey) ?? []
return (
<div
key={`week-${dayKey}`}
className={cn(
"min-h-[150px] rounded-2xl border border-border/70 bg-background/70 p-3",
isToday(day) ? "ring-2 ring-primary/60" : ""
)}
>
<div className="space-y-2">
{dayEvents.length === 0 ? (
<p className="text-[11px] text-muted-foreground">Sem eventos</p>
) : (
dayEvents.map((event) => (
<CalendarEventBadge key={event.id} event={event} />
))
)}
</div>
</div>
)
})}
</div>
</div>
)
}
function YearPopover({
years,
onSelectMonth,
}: {
years: number[]
onSelectMonth: (year: number, monthIndex: number) => void
}) {
const monthLabels = ["Jan", "Fev", "Mar", "Abr", "Mai", "Jun", "Jul", "Ago", "Set", "Out", "Nov", "Dez"]
const sortedYears = years.length > 0 ? years : [new Date().getFullYear()]
return (
<Popover>
<PopoverTrigger asChild>
<Button
variant="ghost"
size="sm"
className="gap-2 rounded-full border border-slate-200 px-3 text-sm font-medium text-neutral-700 hover:border-slate-300 hover:bg-white"
>
Selecionar mês/ano
</Button>
</PopoverTrigger>
<PopoverContent className="w-72 p-0" align="end">
<ScrollArea className="h-64">
<div className="divide-y divide-slate-100">
{sortedYears.map((year) => (
<div key={year} className="px-3 py-2">
<p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">{year}</p>
<div className="mt-2 grid grid-cols-3 gap-2">
{monthLabels.map((label, index) => (
<Button
key={`${year}-${label}`}
variant="outline"
size="sm"
className="h-8 rounded-full text-xs"
onClick={() => onSelectMonth(year, index)}
>
{label}
</Button>
))}
</div>
</div>
))}
</div>
</ScrollArea>
</PopoverContent>
</Popover>
)
}
function CalendarEventBadge({ event }: { event: AgendaCalendarEvent }) {
const priorityStyle = priorityStyles[event.priority]
const slaColor = slaColors[event.slaStatus]
return (
<Link
href={event.href}
prefetch={false}
className={cn(
"block rounded-lg border px-1.5 py-1 text-[10px] transition hover:ring-2 hover:ring-primary/40 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/40",
slaColor
)}
aria-label={`Abrir ticket #${event.reference}`}
>
<div className="flex items-center justify-between gap-1">
<span className="truncate font-semibold">#{event.reference}</span>
<span className="text-[9px] text-muted-foreground">{format(event.start, "HH:mm")}</span>
</div>
<p className="truncate text-[10px] text-foreground">{event.title}</p>
<div className="mt-0.5 flex flex-wrap items-center gap-1">
{event.queue ? (
<Badge variant="outline" className="rounded-full px-1.5 text-[9px]">
{event.queue}
</Badge>
) : null}
<Badge className={cn("rounded-full px-1.5 text-[9px]", priorityStyle?.badgeClass ?? "")}>
{priorityStyle?.label ?? event.priority}
</Badge>
</div>
</Link>
)
}
function buildCalendarMatrix(currentMonth: Date): Date[][] {
const start = startOfWeek(startOfMonth(currentMonth), { weekStartsOn: 1 })
const end = endOfWeek(endOfMonth(currentMonth), { weekStartsOn: 1 })
const matrix: Date[][] = []
let cursor = start
while (cursor <= end) {
const week: Date[] = []
for (let i = 0; i < 7; i += 1) {
week.push(cursor)
cursor = addDays(cursor, 1)
}
matrix.push(week)
}
return matrix
}

View file

@ -0,0 +1,201 @@
"use client"
import { useMemo } from "react"
import { SlidersHorizontal } from "lucide-react"
import { Button } from "@/components/ui/button"
import {
DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
import { Switch } from "@/components/ui/switch"
import { Label } from "@/components/ui/label"
import { Badge } from "@/components/ui/badge"
import { priorityStyles } from "@/lib/ticket-priority-style"
import { cn } from "@/lib/utils"
import type { TicketPriority } from "@/lib/schemas/ticket"
export type AgendaPeriod = "today" | "week" | "month"
export type AgendaFilterState = {
period: AgendaPeriod
queues: string[]
priorities: TicketPriority[]
onlyMyTickets: boolean
focusVisits: boolean
}
export const defaultAgendaFilters: AgendaFilterState = {
period: "week",
queues: [],
priorities: [],
onlyMyTickets: true,
focusVisits: false,
}
type AgendaFiltersProps = {
filters: AgendaFilterState
queues: string[]
onChange: (next: AgendaFilterState) => void
}
export function AgendaFilters({ filters, queues, onChange }: AgendaFiltersProps) {
const periodOptions: Array<{ value: AgendaPeriod; label: string }> = [
{ value: "today", label: "Hoje" },
{ value: "week", label: "Semana" },
{ value: "month", label: "Mês" },
]
const queueLabel = useMemo(() => {
if (filters.queues.length === 0) return "Todas as filas"
if (filters.queues.length === 1) return filters.queues[0]
return `${filters.queues[0]} +${filters.queues.length - 1}`
}, [filters.queues])
const priorityLabel = useMemo(() => {
if (filters.priorities.length === 0) return "Todas prioridades"
if (filters.priorities.length === 1) {
return priorityStyles[filters.priorities[0]].label
}
return `${priorityStyles[filters.priorities[0]].label} +${filters.priorities.length - 1}`
}, [filters.priorities])
const updateFilters = (partial: Partial<AgendaFilterState>) => {
onChange({ ...filters, ...partial })
}
const handleQueueToggle = (queue: string, checked: boolean) => {
const set = new Set(filters.queues)
if (checked) {
set.add(queue)
} else {
set.delete(queue)
}
updateFilters({ queues: Array.from(set) })
}
const handlePriorityToggle = (priority: TicketPriority, checked: boolean) => {
const set = new Set(filters.priorities)
if (checked) {
set.add(priority)
} else {
set.delete(priority)
}
updateFilters({ priorities: Array.from(set) })
}
const resetFilters = () => {
onChange(defaultAgendaFilters)
}
return (
<div className="flex flex-col gap-3 rounded-2xl border border-border/70 bg-card/70 p-4 shadow-sm sm:flex-row sm:items-center sm:justify-between">
<div className="flex flex-wrap items-center gap-3">
<div className="flex flex-wrap items-center gap-2 rounded-2xl border border-slate-200 bg-white/80 px-2 py-1.5 shadow-sm">
{periodOptions.map((option) => {
const isActive = filters.period === option.value
return (
<Button
key={option.value}
type="button"
size="sm"
variant={isActive ? "secondary" : "ghost"}
onClick={() => updateFilters({ period: option.value })}
className={cn(
"gap-2 rounded-full border px-3 text-sm font-medium transition",
isActive
? "border-slate-200 bg-slate-900 text-white"
: "border-slate-200 text-neutral-700 hover:border-slate-300 hover:bg-white"
)}
>
{option.label}
</Button>
)
})}
</div>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" className="gap-2">
{queueLabel}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent className="w-56" align="start">
<DropdownMenuLabel>Filtrar filas</DropdownMenuLabel>
<DropdownMenuSeparator />
{queues.length === 0 ? (
<div className="px-2 py-1.5 text-sm text-muted-foreground">Nenhuma fila disponível.</div>
) : (
queues.map((queue) => (
<DropdownMenuCheckboxItem
key={queue}
checked={filters.queues.includes(queue)}
onCheckedChange={(checked) => handleQueueToggle(queue, Boolean(checked))}
>
{queue}
</DropdownMenuCheckboxItem>
))
)}
</DropdownMenuContent>
</DropdownMenu>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" className="gap-2">
{priorityLabel}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent className="w-64" align="start">
<DropdownMenuLabel>Prioridades</DropdownMenuLabel>
<DropdownMenuSeparator />
{(Object.keys(priorityStyles) as TicketPriority[]).map((priority) => (
<DropdownMenuCheckboxItem
key={priority}
checked={filters.priorities.includes(priority)}
onCheckedChange={(checked) => handlePriorityToggle(priority, Boolean(checked))}
className="gap-2"
>
<div className="flex items-center gap-2">
<Badge className={cn("rounded-full px-2.5 py-0.5 text-xs font-semibold", priorityStyles[priority].badgeClass ?? "")}>
{priorityStyles[priority].label}
</Badge>
<span className="text-sm text-foreground">{priorityStyles[priority].label}</span>
</div>
</DropdownMenuCheckboxItem>
))}
</DropdownMenuContent>
</DropdownMenu>
</div>
<div className="flex flex-wrap items-center gap-4">
<div className="flex items-center gap-2">
<Switch
id="onlyMe"
checked={filters.onlyMyTickets}
onCheckedChange={(checked) => updateFilters({ onlyMyTickets: checked })}
className="data-[state=checked]:border-sidebar-ring data-[state=checked]:bg-sidebar-accent"
/>
<Label htmlFor="onlyMe" className="text-sm text-muted-foreground">
Somente meus tickets
</Label>
</div>
<div className="flex items-center gap-2">
<Switch
id="visitsOnly"
checked={filters.focusVisits}
onCheckedChange={(checked) => updateFilters({ focusVisits: checked })}
className="data-[state=checked]:border-sidebar-ring data-[state=checked]:bg-sidebar-accent"
/>
<Label htmlFor="visitsOnly" className="text-sm text-muted-foreground">
Apenas visitas
</Label>
</div>
<Button variant="ghost" size="sm" className="gap-2" onClick={resetFilters}>
<SlidersHorizontal className="size-4" />
Resetar
</Button>
</div>
</div>
)
}

View file

@ -0,0 +1,191 @@
import Link from "next/link"
import { Fragment } from "react"
import { AlertTriangle, CalendarClock, Clock, CheckCircle2, Circle } from "lucide-react"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { Badge } from "@/components/ui/badge"
import { Skeleton } from "@/components/ui/skeleton"
import { priorityStyles } from "@/lib/ticket-priority-style"
import { cn } from "@/lib/utils"
import type { AgendaDataset, AgendaTicketSummary } from "@/lib/agenda-utils"
type AgendaSummaryViewProps = {
data: AgendaDataset
isLoading?: boolean
}
const statusIndicator: Record<string, { label: string; icon: React.ComponentType<{ className?: string }>; className: string }> = {
on_track: { label: "No prazo", icon: Circle, className: "text-emerald-500" },
at_risk: { label: "Em risco", icon: AlertTriangle, className: "text-amber-500" },
breached: { label: "Violado", icon: AlertTriangle, className: "text-rose-500" },
met: { label: "Concluído", icon: CheckCircle2, className: "text-emerald-500" },
}
export function AgendaSummaryView({ data, isLoading }: AgendaSummaryViewProps) {
if (isLoading) {
return (
<Card>
<CardContent className="space-y-4 py-6">
<Skeleton className="h-8 w-48" />
<Skeleton className="h-24 w-full rounded-xl" />
<Skeleton className="h-24 w-full rounded-xl" />
</CardContent>
</Card>
)
}
const sections: Array<{
key: keyof AgendaDataset["sections"]
title: string
empty: string
icon: React.ComponentType<{ className?: string }>
iconClass?: string
}> = [
{ key: "upcoming", title: "Próximas", empty: "Nenhum compromisso para o período selecionado.", icon: CalendarClock, iconClass: "text-foreground" },
{ key: "overdue", title: "Atrasadas", empty: "Sem pendências atrasadas — ótimo trabalho!", icon: AlertTriangle, iconClass: "text-amber-500" },
{ key: "unscheduled", title: "Sem agendamento", empty: "Todos os tickets críticos têm agenda.", icon: Clock, iconClass: "text-foreground" },
{ key: "completed", title: "Concluídas", empty: "Ainda não há visitas concluídas neste período.", icon: CheckCircle2, iconClass: "text-foreground" },
]
return (
<div className="space-y-6">
<KpiGrid data={data} />
<div className="grid gap-6 lg:grid-cols-2">
{sections.map((section) => {
const items = data.sections[section.key]
const SectionIcon = section.icon
const visibleItems = items.slice(0, 3)
const viewAllHref = buildSectionLink(section.key)
return (
<Card key={section.key} className="min-h-[320px]">
<CardHeader className="flex flex-row items-center justify-between gap-2">
<CardTitle className="flex items-center gap-2">
<SectionIcon className={cn("size-4", section.iconClass ?? "text-foreground")} />
{section.title}
{items.length > 0 ? (
<Badge variant="secondary" className="ml-2">
{items.length}
</Badge>
) : null}
</CardTitle>
<Link
href={viewAllHref}
className="text-sm font-semibold text-[#006879] underline-offset-4 transition-colors hover:text-[#004d5a] hover:underline"
>
Ver todos
</Link>
</CardHeader>
<CardContent className="space-y-3">
{items.length === 0 ? (
<p className="text-sm text-muted-foreground">{section.empty}</p>
) : (
<div className="space-y-3">
{visibleItems.map((item) => (
<AgendaSummaryRow key={`${section.key}-${item.id}`} item={item} />
))}
</div>
)}
</CardContent>
</Card>
)
})}
</div>
</div>
)
}
function KpiGrid({ data }: { data: AgendaDataset }) {
const kpis = [
{ label: "Pendentes", value: data.kpis.pending, description: "Aguardando início" },
{ label: "Em andamento", value: data.kpis.inProgress, description: "Atividades em curso" },
{ label: "Pausados", value: data.kpis.paused, description: "Dependem de cliente/terceiros" },
{ label: "% fora do SLA", value: data.kpis.outsideSla, description: "Chamados com risco/violação" },
]
return (
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
{kpis.map((item) => (
<Card
key={item.label}
className="rounded-2xl border border-border/60 bg-gradient-to-br from-white/95 via-white to-primary/5 shadow-sm"
>
<CardContent className="space-y-3 px-5 py-5">
<p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground/80">{item.label}</p>
<p className="text-4xl font-semibold leading-tight text-neutral-900">{item.value}</p>
<p className="text-sm text-muted-foreground">{item.description}</p>
</CardContent>
</Card>
))}
</div>
)
}
function AgendaSummaryRow({ item }: { item: AgendaTicketSummary }) {
const status = statusIndicator[item.slaStatus ?? "on_track"] ?? statusIndicator.on_track
return (
<Link
href={item.href}
prefetch={false}
className="block rounded-xl border border-border/60 bg-background/70 p-3 shadow-sm transition hover:border-primary/60 hover:shadow-lg focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/40"
aria-label={`Abrir ticket #${item.reference}`}
>
<div className="flex flex-col gap-2 lg:flex-row lg:items-center lg:justify-between">
<div>
<div className="flex items-center gap-2 text-sm font-semibold text-foreground">
<span>
#{item.reference} · {item.subject}
</span>
{item.queue ? (
<Badge variant="outline" className="rounded-full border-dashed px-2.5 py-0.5 text-[11px] font-medium">
{item.queue}
</Badge>
) : null}
<Badge className={cn("rounded-full px-2.5 py-0.5 text-[11px] font-semibold", priorityStyles[item.priority]?.badgeClass ?? "")}>
{priorityStyles[item.priority]?.label ?? item.priority}
</Badge>
</div>
<div className="mt-1 flex flex-wrap items-center gap-3 text-xs text-muted-foreground">
{item.company ? <span>{item.company}</span> : null}
{item.location ? <span className="flex items-center gap-1"><Clock className="size-3" /> {item.location}</span> : null}
{item.startAt ? (
<span className="flex items-center gap-1">
<CalendarClock className="size-3" />
{formatDateRange(item.startAt, item.endAt)}
</span>
) : (
<span className="flex items-center gap-1 text-foreground">
<AlertTriangle className="size-3" />
Não agendado
</span>
)}
</div>
</div>
<div className="flex items-center gap-2 text-xs font-semibold uppercase tracking-wide">
<status.icon className={cn("size-4", status.className)} />
<span className={status.className}>{status.label}</span>
</div>
</div>
</Link>
)
}
function formatDateRange(start?: Date | null, end?: Date | null) {
if (!start) return "Sem data"
if (!end) return new Intl.DateTimeFormat("pt-BR", { dateStyle: "medium", timeStyle: "short" }).format(start)
const sameDay = start.toDateString() === end.toDateString()
if (sameDay) {
return `${new Intl.DateTimeFormat("pt-BR", { dateStyle: "short" }).format(start)} · ${new Intl.DateTimeFormat("pt-BR", { timeStyle: "short" }).format(start)} - ${new Intl.DateTimeFormat("pt-BR", { timeStyle: "short" }).format(end)}`
}
return `${new Intl.DateTimeFormat("pt-BR", { dateStyle: "short", timeStyle: "short" }).format(start)} → ${new Intl.DateTimeFormat("pt-BR", { dateStyle: "short", timeStyle: "short" }).format(end)}`
}
function buildSectionLink(section: keyof AgendaDataset["sections"]) {
const params = new URLSearchParams()
params.set("focus", "visits")
params.set("view", section === "completed" ? "completed" : "active")
if (section === "completed") {
params.set("status", "RESOLVED")
}
return `/tickets?${params.toString()}`
}

View file

@ -22,6 +22,7 @@ import {
ShieldCheck, ShieldCheck,
Users, Users,
Layers3, Layers3,
CalendarDays,
} from "lucide-react" } from "lucide-react"
import { usePathname } from "next/navigation" import { usePathname } from "next/navigation"
import Link from "next/link" import Link from "next/link"
@ -80,6 +81,7 @@ const navigation: NavigationGroup[] = [
}, },
{ title: "Visualizações", url: "/views", icon: PanelsTopLeft, requiredRole: "staff" }, { title: "Visualizações", url: "/views", icon: PanelsTopLeft, requiredRole: "staff" },
{ title: "Modo Play", url: "/play", icon: PlayCircle, requiredRole: "staff" }, { title: "Modo Play", url: "/play", icon: PlayCircle, requiredRole: "staff" },
{ title: "Agenda", url: "/agenda", icon: CalendarDays, requiredRole: "staff" },
], ],
}, },
{ {

View file

@ -21,6 +21,7 @@ import { RichTextEditor } from "@/components/ui/rich-text-editor"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { normalizeCustomFieldInputs, hasMissingRequiredCustomFields } from "@/lib/ticket-form-helpers" import { normalizeCustomFieldInputs, hasMissingRequiredCustomFields } from "@/lib/ticket-form-helpers"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
import { useLocalTimeZone } from "@/hooks/use-local-time-zone"
import type { TicketFormDefinition } from "@/lib/ticket-form-types" import type { TicketFormDefinition } from "@/lib/ticket-form-types"
import { Calendar } from "@/components/ui/calendar" import { Calendar } from "@/components/ui/calendar"
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover" import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"
@ -69,6 +70,7 @@ export function PortalTicketForm() {
const [selectedFormKey, setSelectedFormKey] = useState<string>("default") const [selectedFormKey, setSelectedFormKey] = useState<string>("default")
const [customFieldValues, setCustomFieldValues] = useState<Record<string, unknown>>({}) const [customFieldValues, setCustomFieldValues] = useState<Record<string, unknown>>({})
const [openCalendarField, setOpenCalendarField] = useState<string | null>(null) const [openCalendarField, setOpenCalendarField] = useState<string | null>(null)
const calendarTimeZone = useLocalTimeZone()
const hasEnsuredFormsRef = useRef(false) const hasEnsuredFormsRef = useRef(false)
useEffect(() => { useEffect(() => {
@ -467,6 +469,7 @@ export function PortalTicketForm() {
startMonth={new Date(1900, 0)} startMonth={new Date(1900, 0)}
endMonth={new Date(new Date().getFullYear() + 5, 11)} endMonth={new Date(new Date().getFullYear() + 5, 11)}
locale={ptBR} locale={ptBR}
timeZone={calendarTimeZone}
/> />
</PopoverContent> </PopoverContent>
</Popover> </Popover>

View file

@ -12,6 +12,7 @@ import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle }
import { Separator } from "@/components/ui/separator" import { Separator } from "@/components/ui/separator"
import { useAuth, signOut } from "@/lib/auth-client" import { useAuth, signOut } from "@/lib/auth-client"
import { DEFAULT_TENANT_ID } from "@/lib/constants" import { DEFAULT_TENANT_ID } from "@/lib/constants"
import { FieldsManager } from "@/components/admin/fields/fields-manager"
import type { LucideIcon } from "lucide-react" import type { LucideIcon } from "lucide-react"
@ -270,6 +271,17 @@ export function SettingsContent() {
})} })}
</div> </div>
</section> </section>
{isStaff ? (
<section id="custom-fields" className="space-y-4">
<div>
<h2 className="text-base font-semibold text-neutral-900">Campos personalizados</h2>
<p className="text-sm text-neutral-600">
Ajuste os campos de admissão, desligamento e demais metadados diretamente pelo painel administrativo.
</p>
</div>
<FieldsManager />
</section>
) : null}
</div> </div>
) )
} }

View file

@ -29,6 +29,7 @@ import { SearchableCombobox, type SearchableComboboxOption } from "@/components/
import { Calendar } from "@/components/ui/calendar" import { Calendar } from "@/components/ui/calendar"
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover" import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"
import { useDefaultQueues } from "@/hooks/use-default-queues" import { useDefaultQueues } from "@/hooks/use-default-queues"
import { useLocalTimeZone } from "@/hooks/use-local-time-zone"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
import { priorityStyles } from "@/lib/ticket-priority-style" import { priorityStyles } from "@/lib/ticket-priority-style"
import { normalizeCustomFieldInputs } from "@/lib/ticket-form-helpers" import { normalizeCustomFieldInputs } from "@/lib/ticket-form-helpers"
@ -115,6 +116,7 @@ const schema = z.object({
export function NewTicketDialog({ triggerClassName }: { triggerClassName?: string } = {}) { export function NewTicketDialog({ triggerClassName }: { triggerClassName?: string } = {}) {
const [open, setOpen] = useState(false) const [open, setOpen] = useState(false)
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
const calendarTimeZone = useLocalTimeZone()
const form = useForm<z.infer<typeof schema>>({ const form = useForm<z.infer<typeof schema>>({
resolver: zodResolver(schema), resolver: zodResolver(schema),
defaultValues: { defaultValues: {
@ -1039,6 +1041,7 @@ export function NewTicketDialog({ triggerClassName }: { triggerClassName?: strin
startMonth={new Date(1900, 0)} startMonth={new Date(1900, 0)}
endMonth={new Date(new Date().getFullYear() + 5, 11)} endMonth={new Date(new Date().getFullYear() + 5, 11)}
locale={ptBR} locale={ptBR}
timeZone={calendarTimeZone}
/> />
</PopoverContent> </PopoverContent>
</Popover> </Popover>

View file

@ -107,11 +107,14 @@ export function TicketCsatCard({ ticket }: TicketCsatCardProps) {
const effectiveScore = hasSubmitted ? score : hoverScore ?? score const effectiveScore = hasSubmitted ? score : hoverScore ?? score
const viewerIsAdmin = viewerRole === "ADMIN" const viewerIsAdmin = viewerRole === "ADMIN"
const viewerIsStaff =
viewerRole === "MANAGER" || viewerRole === "AGENT" || viewerIsAdmin
const collaboratorCanView = !viewerIsStaff && isRequester
const adminCanInspect = viewerIsAdmin && ticket.status !== "PENDING" const adminCanInspect = viewerIsAdmin && ticket.status !== "PENDING"
const canSubmit = const canSubmit =
Boolean(viewerId && viewerRole === "COLLABORATOR" && isRequester && isResolved && !hasSubmitted) Boolean(viewerId && viewerRole === "COLLABORATOR" && isRequester && isResolved && !hasSubmitted)
const hasRating = hasSubmitted const hasRating = hasSubmitted
const showCard = adminCanInspect || isRequester const showCard = adminCanInspect || collaboratorCanView
const ratedAtRelative = useMemo(() => formatRelative(ratedAt), [ratedAt]) const ratedAtRelative = useMemo(() => formatRelative(ratedAt), [ratedAt])
@ -181,7 +184,7 @@ export function TicketCsatCard({ ticket }: TicketCsatCardProps) {
Conte como foi sua experiência com este chamado. Conte como foi sua experiência com este chamado.
</CardDescription> </CardDescription>
</div> </div>
{hasRating && !viewerIsAdmin ? ( {hasRating && collaboratorCanView ? (
<div className="flex items-center gap-1 rounded-full bg-emerald-50 px-3 py-1 text-xs font-medium text-emerald-700"> <div className="flex items-center gap-1 rounded-full bg-emerald-50 px-3 py-1 text-xs font-medium text-emerald-700">
Obrigado pelo feedback! Obrigado pelo feedback!
</div> </div>

View file

@ -27,6 +27,7 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@
import { Field, FieldLabel } from "@/components/ui/field" import { Field, FieldLabel } from "@/components/ui/field"
import { Textarea } from "@/components/ui/textarea" import { Textarea } from "@/components/ui/textarea"
import { Spinner } from "@/components/ui/spinner" import { Spinner } from "@/components/ui/spinner"
import { useLocalTimeZone } from "@/hooks/use-local-time-zone"
type TicketCustomFieldsListProps = { type TicketCustomFieldsListProps = {
record?: TicketCustomFieldRecord | null record?: TicketCustomFieldRecord | null
@ -216,6 +217,7 @@ type TicketCustomFieldsSectionProps = {
export function TicketCustomFieldsSection({ ticket, variant = "card", className }: TicketCustomFieldsSectionProps) { export function TicketCustomFieldsSection({ ticket, variant = "card", className }: TicketCustomFieldsSectionProps) {
const { convexUserId, role } = useAuth() const { convexUserId, role } = useAuth()
const canEdit = Boolean(convexUserId && (role === "admin" || role === "agent")) const canEdit = Boolean(convexUserId && (role === "admin" || role === "agent"))
const calendarTimeZone = useLocalTimeZone()
const viewerId = convexUserId as Id<"users"> | null const viewerId = convexUserId as Id<"users"> | null
const tenantId = ticket.tenantId const tenantId = ticket.tenantId
@ -368,11 +370,10 @@ export function TicketCustomFieldsSection({ ticket, variant = "card", className
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
{hasConfiguredFields ? ( {hasConfiguredFields ? (
<div className="rounded-2xl border border-slate-200 bg-white/70 px-4 py-4"> <div className="grid gap-4 rounded-2xl border border-slate-200 bg-white px-4 py-4 sm:grid-cols-2">
<div className="grid gap-4 sm:grid-cols-2"> <p className="text-sm font-semibold text-neutral-800 sm:col-span-2">Informações adicionais</p>
{selectedForm.fields.map((field) => renderFieldEditor(field))} {selectedForm.fields.map((field) => renderFieldEditor(field))}
</div> </div>
</div>
) : ( ) : (
<p className="text-sm text-neutral-500">Nenhum campo configurado ainda.</p> <p className="text-sm text-neutral-500">Nenhum campo configurado ainda.</p>
)} )}
@ -473,7 +474,7 @@ function renderFieldEditor(field: TicketFormFieldDefinition) {
if (field.type === "select") { if (field.type === "select") {
return ( return (
<Field key={field.id} className={spanClass}> <Field key={field.id} className={cn("gap-1.5", spanClass)}>
<FieldLabel className="flex items-center gap-1"> <FieldLabel className="flex items-center gap-1">
{field.label} {field.required ? <span className="text-destructive">*</span> : null} {field.label} {field.required ? <span className="text-destructive">*</span> : null}
</FieldLabel> </FieldLabel>
@ -501,7 +502,7 @@ function renderFieldEditor(field: TicketFormFieldDefinition) {
if (field.type === "number") { if (field.type === "number") {
return ( return (
<Field key={field.id} className={spanClass}> <Field key={field.id} className={cn("gap-1.5", spanClass)}>
<FieldLabel className="flex items-center gap-1"> <FieldLabel className="flex items-center gap-1">
{field.label} {field.required ? <span className="text-destructive">*</span> : null} {field.label} {field.required ? <span className="text-destructive">*</span> : null}
</FieldLabel> </FieldLabel>
@ -521,7 +522,7 @@ function renderFieldEditor(field: TicketFormFieldDefinition) {
const isoValue = toIsoDateString(value) const isoValue = toIsoDateString(value)
const parsedDate = isoValue ? parseIsoDate(isoValue) : null const parsedDate = isoValue ? parseIsoDate(isoValue) : null
return ( return (
<Field key={field.id} className={cn("flex flex-col gap-2", spanClass)}> <Field key={field.id} className={cn("flex flex-col gap-1.5", spanClass)}>
<FieldLabel className="flex items-center gap-1"> <FieldLabel className="flex items-center gap-1">
{field.label} {field.required ? <span className="text-destructive">*</span> : null} {field.label} {field.required ? <span className="text-destructive">*</span> : null}
</FieldLabel> </FieldLabel>
@ -554,6 +555,7 @@ function renderFieldEditor(field: TicketFormFieldDefinition) {
handleFieldChange(field, date ? format(date, "yyyy-MM-dd") : "") handleFieldChange(field, date ? format(date, "yyyy-MM-dd") : "")
setOpenCalendarField(null) setOpenCalendarField(null)
}} }}
timeZone={calendarTimeZone}
/> />
</PopoverContent> </PopoverContent>
</Popover> </Popover>
@ -564,7 +566,7 @@ function renderFieldEditor(field: TicketFormFieldDefinition) {
if (field.type === "text" && !isTextarea) { if (field.type === "text" && !isTextarea) {
return ( return (
<Field key={field.id} className={spanClass}> <Field key={field.id} className={cn("gap-1.5", spanClass)}>
<FieldLabel className="flex items-center gap-1"> <FieldLabel className="flex items-center gap-1">
{field.label} {field.required ? <span className="text-destructive">*</span> : null} {field.label} {field.required ? <span className="text-destructive">*</span> : null}
</FieldLabel> </FieldLabel>
@ -580,7 +582,7 @@ function renderFieldEditor(field: TicketFormFieldDefinition) {
} }
return ( return (
<Field key={field.id} className={spanClass}> <Field key={field.id} className={cn("gap-1.5", spanClass)}>
<FieldLabel className="flex items-center gap-1"> <FieldLabel className="flex items-center gap-1">
{field.label} {field.required ? <span className="text-destructive">*</span> : null} {field.label} {field.required ? <span className="text-destructive">*</span> : null}
</FieldLabel> </FieldLabel>

View file

@ -7,6 +7,7 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/com
import { getTicketStatusLabel, getTicketStatusSummaryTone } from "@/lib/ticket-status-style" import { getTicketStatusLabel, getTicketStatusSummaryTone } from "@/lib/ticket-status-style"
import { Badge } from "@/components/ui/badge" import { Badge } from "@/components/ui/badge"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
import { getSlaDisplayStatus, getSlaDueDate, type SlaDisplayStatus } from "@/lib/sla-utils"
interface TicketDetailsPanelProps { interface TicketDetailsPanelProps {
ticket: TicketWithDetails ticket: TicketWithDetails
@ -28,6 +29,13 @@ const priorityTone: Record<TicketWithDetails["priority"], SummaryTone> = {
URGENT: "danger", URGENT: "danger",
} }
const slaStatusTone: Record<Exclude<SlaDisplayStatus, "n/a">, { label: string; className: string }> = {
on_track: { label: "No prazo", className: "text-emerald-600" },
at_risk: { label: "Em risco", className: "text-amber-600" },
breached: { label: "Violado", className: "text-rose-600" },
met: { label: "Concluído", className: "text-emerald-600" },
}
function formatDuration(ms?: number | null) { function formatDuration(ms?: number | null) {
if (!ms || ms <= 0) return "0s" if (!ms || ms <= 0) return "0s"
const totalSeconds = Math.floor(ms / 1000) const totalSeconds = Math.floor(ms / 1000)
@ -48,6 +56,22 @@ function formatMinutes(value?: number | null) {
return `${value} min` return `${value} min`
} }
function formatSlaTarget(value?: number | null, mode?: string) {
if (!value) return "—"
if (value < 60) return `${value} min${mode === "business" ? " úteis" : ""}`
const hours = Math.floor(value / 60)
const minutes = value % 60
if (minutes === 0) {
return `${hours}h${mode === "business" ? " úteis" : ""}`
}
return `${hours}h ${minutes}m${mode === "business" ? " úteis" : ""}`
}
function getSlaStatusDisplay(status: SlaDisplayStatus) {
const normalized = status === "n/a" ? "on_track" : status
return slaStatusTone[normalized as Exclude<SlaDisplayStatus, "n/a">]
}
type SummaryChipConfig = { type SummaryChipConfig = {
key: string key: string
label: string label: string
@ -59,6 +83,10 @@ type SummaryChipConfig = {
export function TicketDetailsPanel({ ticket }: TicketDetailsPanelProps) { export function TicketDetailsPanel({ ticket }: TicketDetailsPanelProps) {
const isAvulso = Boolean(ticket.company?.isAvulso) const isAvulso = Boolean(ticket.company?.isAvulso)
const companyLabel = ticket.company?.name ?? (isAvulso ? "Cliente avulso" : "Sem empresa vinculada") const companyLabel = ticket.company?.name ?? (isAvulso ? "Cliente avulso" : "Sem empresa vinculada")
const responseStatus = getSlaDisplayStatus(ticket, "response")
const solutionStatus = getSlaDisplayStatus(ticket, "solution")
const responseDue = getSlaDueDate(ticket, "response")
const solutionDue = getSlaDueDate(ticket, "solution")
const summaryChips = useMemo(() => { const summaryChips = useMemo(() => {
const chips: SummaryChipConfig[] = [ const chips: SummaryChipConfig[] = [
@ -148,26 +176,37 @@ export function TicketDetailsPanel({ ticket }: TicketDetailsPanelProps) {
<section className="space-y-3"> <section className="space-y-3">
<div className="flex flex-wrap items-center justify-between gap-2"> <div className="flex flex-wrap items-center justify-between gap-2">
<h3 className="text-sm font-semibold text-neutral-900">SLA & métricas</h3> <h3 className="text-sm font-semibold text-neutral-900">SLA & métricas</h3>
{ticket.slaPolicy ? ( {ticket.slaSnapshot ? (
<span className="text-xs font-medium text-neutral-500">{ticket.slaPolicy.name}</span> <span className="text-xs font-medium text-neutral-500">
{ticket.slaSnapshot.categoryName ?? "Configuração personalizada"}
</span>
) : null} ) : null}
</div> </div>
<div className="grid gap-3 md:grid-cols-2"> <div className="grid gap-3 md:grid-cols-2">
<div className="rounded-2xl border border-slate-200 bg-white px-4 py-3 shadow-sm"> <div className="rounded-2xl border border-slate-200 bg-white px-4 py-3 shadow-sm">
<p className="text-xs font-semibold uppercase tracking-wide text-neutral-500">Política de SLA</p> <p className="text-xs font-semibold uppercase tracking-wide text-neutral-500">Política de SLA</p>
{ticket.slaPolicy ? ( {ticket.slaSnapshot ? (
<div className="mt-3 space-y-2 text-sm text-neutral-700"> <div className="mt-3 space-y-4 text-sm text-neutral-700">
<div className="flex items-center justify-between gap-4"> <div>
<span className="text-xs text-neutral-500">Resposta inicial</span> <span className="text-xs text-neutral-500">Categoria</span>
<span className="text-sm font-semibold text-neutral-900"> <p className="font-semibold text-neutral-900">{ticket.slaSnapshot.categoryName ?? "Categoria padrão"}</p>
{formatMinutes(ticket.slaPolicy.targetMinutesToFirstResponse)} <p className="text-xs text-neutral-500">
</span> Prioridade: {priorityLabel[ticket.priority] ?? ticket.priority}
</p>
</div> </div>
<div className="flex items-center justify-between gap-4"> <div className="grid gap-3 sm:grid-cols-2">
<span className="text-xs text-neutral-500">Resolução</span> <SlaMetric
<span className="text-sm font-semibold text-neutral-900"> label="Resposta"
{formatMinutes(ticket.slaPolicy.targetMinutesToResolution)} target={formatSlaTarget(ticket.slaSnapshot.responseTargetMinutes, ticket.slaSnapshot.responseMode)}
</span> dueDate={responseDue}
status={responseStatus}
/>
<SlaMetric
label="Resolução"
target={formatSlaTarget(ticket.slaSnapshot.solutionTargetMinutes, ticket.slaSnapshot.solutionMode)}
dueDate={solutionDue}
status={solutionStatus}
/>
</div> </div>
</div> </div>
) : ( ) : (
@ -289,3 +328,30 @@ function SummaryChip({
</div> </div>
) )
} }
interface SlaMetricProps {
label: string
target: string
dueDate: Date | null
status: SlaDisplayStatus
}
function SlaMetric({ label, target, dueDate, status }: SlaMetricProps) {
const display = getSlaStatusDisplay(status)
return (
<div className="rounded-xl border border-slate-200 bg-white px-3 py-3 shadow-sm">
<div className="flex items-center justify-between gap-3">
<div>
<p className="text-xs text-neutral-500">{label}</p>
<p className="text-sm font-semibold text-neutral-900">{target}</p>
{dueDate ? (
<p className="text-xs text-neutral-500">{format(dueDate, "dd/MM/yyyy HH:mm", { locale: ptBR })}</p>
) : (
<p className="text-xs text-neutral-500">Sem prazo calculado</p>
)}
</div>
<span className={cn("text-xs font-semibold uppercase", display.className)}>{display.label}</span>
</div>
</div>
)
}

View file

@ -1563,10 +1563,10 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
<span className={sectionValueClass}>{format(ticket.dueAt, "dd/MM/yyyy HH:mm", { locale: ptBR })}</span> <span className={sectionValueClass}>{format(ticket.dueAt, "dd/MM/yyyy HH:mm", { locale: ptBR })}</span>
</div> </div>
) : null} ) : null}
{ticket.slaPolicy ? ( {ticket.slaSnapshot ? (
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
<span className={sectionLabelClass}>Política</span> <span className={sectionLabelClass}>Política</span>
<span className={sectionValueClass}>{ticket.slaPolicy.name}</span> <span className={sectionValueClass}>{ticket.slaSnapshot.categoryName ?? "Configuração personalizada"}</span>
</div> </div>
) : null} ) : null}
<TicketCustomFieldsSection ticket={ticket} variant="inline" className="sm:col-span-2 lg:col-span-3" /> <TicketCustomFieldsSection ticket={ticket} variant="inline" className="sm:col-span-2 lg:col-span-3" />

View file

@ -8,6 +8,11 @@ import {
ticketPrioritySchema, ticketPrioritySchema,
type TicketStatus, type TicketStatus,
} from "@/lib/schemas/ticket" } from "@/lib/schemas/ticket"
import type { TicketFiltersState } from "@/lib/ticket-filters"
import { defaultTicketFilters } from "@/lib/ticket-filters"
export type { TicketFiltersState }
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"
@ -60,28 +65,6 @@ const channelOptions = ticketChannelSchema.options.map((channel) => ({
type QueueOption = string type QueueOption = string
export type TicketFiltersState = {
search: string
status: TicketStatus | null
priority: string | null
queue: string | null
channel: string | null
company: string | null
assigneeId: string | null
view: "active" | "completed"
}
export const defaultTicketFilters: TicketFiltersState = {
search: "",
status: null,
priority: null,
queue: null,
channel: null,
company: null,
assigneeId: null,
view: "active",
}
interface TicketsFiltersProps { interface TicketsFiltersProps {
onChange?: (filters: TicketFiltersState) => void onChange?: (filters: TicketFiltersState) => void
queues?: QueueOption[] queues?: QueueOption[]
@ -127,6 +110,7 @@ export function TicketsFilters({ onChange, queues = [], companies = [], assignee
chips.push(`Responsável: ${found?.name ?? filters.assigneeId}`) chips.push(`Responsável: ${found?.name ?? filters.assigneeId}`)
} }
if (!filters.status && filters.view === "completed") chips.push("Exibindo concluídos") if (!filters.status && filters.view === "completed") chips.push("Exibindo concluídos")
if (filters.focusVisits) chips.push("Somente visitas/lab")
return chips return chips
}, [filters, assignees]) }, [filters, assignees])

View file

@ -15,6 +15,7 @@ import { useAuth } from "@/lib/auth-client"
import { useDefaultQueues } from "@/hooks/use-default-queues" import { useDefaultQueues } from "@/hooks/use-default-queues"
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group" import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"
import { LayoutGrid, List } from "lucide-react" import { LayoutGrid, List } from "lucide-react"
import { isVisitTicket } from "@/lib/ticket-matchers"
type TicketsViewProps = { type TicketsViewProps = {
initialFilters?: Partial<TicketFiltersState> initialFilters?: Partial<TicketFiltersState>
@ -163,9 +164,12 @@ export function TicketsView({ initialFilters }: TicketsViewProps = {}) {
if (filters.company) { if (filters.company) {
working = working.filter((t) => (((t as unknown as { company?: { name?: string } })?.company?.name) ?? null) === filters.company) working = working.filter((t) => (((t as unknown as { company?: { name?: string } })?.company?.name) ?? null) === filters.company)
} }
if (filters.focusVisits) {
working = working.filter((t) => isVisitTicket(t))
}
return working return working
}, [tickets, filters.queue, filters.status, filters.view, filters.company]) }, [tickets, filters.queue, filters.status, filters.view, filters.company, filters.focusVisits])
const previousIdsRef = useRef<string[]>([]) const previousIdsRef = useRef<string[]>([])
const [enteringIds, setEnteringIds] = useState<Set<string>>(new Set()) const [enteringIds, setEnteringIds] = useState<Set<string>>(new Set())

View file

@ -0,0 +1,29 @@
"use client"
import * as React from "react"
import * as SwitchPrimitive from "@radix-ui/react-switch"
import { cn } from "@/lib/utils"
const Switch = React.forwardRef<
React.ElementRef<typeof SwitchPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof SwitchPrimitive.Root>
>(({ className, ...props }, ref) => (
<SwitchPrimitive.Root
ref={ref}
className={cn(
"peer inline-flex h-6 w-11 shrink-0 cursor-pointer items-center rounded-full border border-transparent bg-input transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-sidebar-accent data-[state=checked]:text-sidebar-accent-foreground",
className
)}
{...props}
>
<SwitchPrimitive.Thumb
className={cn(
"pointer-events-none block h-5 w-5 translate-x-0 rounded-full bg-background shadow transition-transform duration-200 data-[state=checked]:translate-x-[20px]",
)}
/>
</SwitchPrimitive.Root>
))
Switch.displayName = SwitchPrimitive.Root.displayName
export { Switch }

View file

@ -0,0 +1,19 @@
import { useEffect, useState } from "react"
export function useLocalTimeZone(fallback?: string) {
const [timeZone, setTimeZone] = useState<string | undefined>(fallback)
useEffect(() => {
if (typeof window === "undefined") return
try {
const resolved = Intl.DateTimeFormat().resolvedOptions().timeZone
if (resolved) {
setTimeZone(resolved)
}
} catch {
/* ignore */
}
}, [])
return timeZone
}

255
src/lib/agenda-utils.ts Normal file
View file

@ -0,0 +1,255 @@
import {
addMinutes,
endOfDay,
endOfMonth,
endOfWeek,
isAfter,
isBefore,
isWithinInterval,
startOfDay,
startOfMonth,
startOfWeek,
} from "date-fns"
import type { Ticket, TicketPriority } from "@/lib/schemas/ticket"
import type { AgendaFilterState, AgendaPeriod } from "@/components/agenda/agenda-filters"
import { getSlaDisplayStatus, getSlaDueDate } from "@/lib/sla-utils"
import { isVisitTicket } from "@/lib/ticket-matchers"
export type AgendaSlaStatus = "on_track" | "at_risk" | "breached" | "met"
export type AgendaTicketSummary = {
id: string
reference: number
subject: string
queue: string | null
company: string | null
priority: TicketPriority
location?: string | null
startAt: Date | null
endAt: Date | null
slaStatus: AgendaSlaStatus
completedAt?: Date | null
href: string
}
export type AgendaCalendarEvent = {
id: string
ticketId: string
reference: number
title: string
queue: string | null
priority: TicketPriority
start: Date
end: Date
slaStatus: AgendaSlaStatus
href: string
}
export type AgendaDataset = {
range: { start: Date; end: Date }
availableQueues: string[]
kpis: {
pending: number
inProgress: number
paused: number
outsideSla: string
}
sections: {
upcoming: AgendaTicketSummary[]
overdue: AgendaTicketSummary[]
unscheduled: AgendaTicketSummary[]
completed: AgendaTicketSummary[]
}
calendarEvents: AgendaCalendarEvent[]
}
const DEFAULT_EVENT_DURATION_MINUTES = 60
export function buildAgendaDataset(tickets: Ticket[], filters: AgendaFilterState): AgendaDataset {
const now = new Date()
const range = computeRange(filters.period, now)
const availableQueues = Array.from(
new Set(
tickets
.map((ticket) => ticket.queue?.trim())
.filter((queue): queue is string => Boolean(queue))
)
).sort((a, b) => a.localeCompare(b, "pt-BR"))
const filteredTickets = tickets
.filter((ticket) => matchesFilters(ticket, filters))
.filter((ticket) => isVisitTicket(ticket))
const enriched = filteredTickets.map((ticket) => {
const schedule = deriveScheduleWindow(ticket)
const slaStatus = computeSlaStatus(ticket, now)
return { ticket, schedule, slaStatus }
})
const summarySections = {
upcoming: [] as AgendaTicketSummary[],
overdue: [] as AgendaTicketSummary[],
unscheduled: [] as AgendaTicketSummary[],
completed: [] as AgendaTicketSummary[],
}
for (const entry of enriched) {
const summary = buildSummary(entry.ticket, entry.schedule, entry.slaStatus)
const dueDate = entry.schedule.startAt
const createdAt = entry.ticket.createdAt
const resolvedAt = entry.ticket.resolvedAt
if (dueDate && isWithinRange(dueDate, range)) {
if (!entry.ticket.resolvedAt && isAfter(dueDate, now)) {
summarySections.upcoming.push(summary)
}
if (!entry.ticket.resolvedAt && isBefore(dueDate, now)) {
summarySections.overdue.push(summary)
}
}
if (!dueDate && entry.ticket.status !== "RESOLVED" && isWithinRange(createdAt, range)) {
summarySections.unscheduled.push(summary)
}
if (resolvedAt && isWithinRange(resolvedAt, range)) {
summarySections.completed.push(summary)
}
}
summarySections.upcoming.sort((a, b) => compareNullableDate(a.startAt, b.startAt, 1))
summarySections.overdue.sort((a, b) => compareNullableDate(a.startAt, b.startAt, -1))
summarySections.unscheduled.sort((a, b) => compareByPriorityThenReference(a, b))
summarySections.completed.sort((a, b) => compareNullableDate(a.completedAt ?? null, b.completedAt ?? null, -1))
const calendarEvents = enriched
.filter((entry): entry is typeof entry & { schedule: { startAt: Date; endAt: Date } } => Boolean(entry.schedule.startAt && entry.schedule.endAt))
.map((entry) => ({
id: `${entry.ticket.id}-event`,
ticketId: entry.ticket.id,
reference: entry.ticket.reference,
title: entry.ticket.subject,
queue: entry.ticket.queue ?? null,
priority: entry.ticket.priority,
start: entry.schedule.startAt!,
end: entry.schedule.endAt!,
slaStatus: entry.slaStatus,
href: `/tickets/${entry.ticket.id}`,
}))
.sort((a, b) => a.start.getTime() - b.start.getTime())
const outsideSlaCount = enriched.filter((entry) => entry.slaStatus === "breached" || entry.slaStatus === "at_risk").length
const outsideSlaPct = filteredTickets.length ? Math.round((outsideSlaCount / filteredTickets.length) * 100) : 0
const dataset: AgendaDataset = {
range,
availableQueues,
kpis: {
pending: countByStatus(filteredTickets, ["PENDING"]),
inProgress: countByStatus(filteredTickets, ["AWAITING_ATTENDANCE"]),
paused: countByStatus(filteredTickets, ["PAUSED"]),
outsideSla: `${outsideSlaPct}%`,
},
sections: summarySections,
calendarEvents,
}
return dataset
}
function matchesFilters(ticket: Ticket, filters: AgendaFilterState) {
if (filters.queues.length > 0) {
if (!ticket.queue) return false
const normalizedQueue = ticket.queue.toLowerCase()
const matchesQueue = filters.queues.some((queue) => queue.toLowerCase() === normalizedQueue)
if (!matchesQueue) return false
}
if (filters.priorities.length > 0 && !filters.priorities.includes(ticket.priority)) {
return false
}
if (filters.focusVisits && !isVisitTicket(ticket)) {
return false
}
return true
}
function computeRange(period: AgendaPeriod, pivot: Date) {
if (period === "today") {
return {
start: startOfDay(pivot),
end: endOfDay(pivot),
}
}
if (period === "month") {
return {
start: startOfMonth(pivot),
end: endOfMonth(pivot),
}
}
return {
start: startOfWeek(pivot, { weekStartsOn: 1 }),
end: endOfWeek(pivot, { weekStartsOn: 1 }),
}
}
function deriveScheduleWindow(ticket: Ticket) {
const due = getSlaDueDate(ticket, "solution")
if (!due) {
return { startAt: null, endAt: null }
}
const startAt = due
const endAt = addMinutes(startAt, DEFAULT_EVENT_DURATION_MINUTES)
return { startAt, endAt }
}
function computeSlaStatus(ticket: Ticket, now: Date): AgendaSlaStatus {
const status = getSlaDisplayStatus(ticket, "solution", now)
if (status === "n/a") {
return "on_track"
}
return status
}
function buildSummary(ticket: Ticket, schedule: { startAt: Date | null; endAt: Date | null }, slaStatus: AgendaSlaStatus): AgendaTicketSummary {
return {
id: ticket.id,
reference: ticket.reference,
subject: ticket.subject,
queue: ticket.queue ?? null,
company: ticket.company?.name ?? null,
priority: ticket.priority,
location: null,
startAt: schedule.startAt,
endAt: ticket.resolvedAt ?? schedule.endAt,
slaStatus,
completedAt: ticket.resolvedAt ?? null,
href: `/tickets/${ticket.id}`,
}
}
function isWithinRange(date: Date, range: { start: Date; end: Date }) {
return isWithinInterval(date, range)
}
function countByStatus(tickets: Ticket[], statuses: Ticket["status"][]): number {
const set = new Set(statuses)
return tickets.filter((ticket) => set.has(ticket.status)).length
}
function compareNullableDate(a: Date | null, b: Date | null, direction: 1 | -1) {
const aTime = a ? a.getTime() : Number.MAX_SAFE_INTEGER
const bTime = b ? b.getTime() : Number.MAX_SAFE_INTEGER
return (aTime - bTime) * direction
}
function compareByPriorityThenReference(a: AgendaTicketSummary, b: AgendaTicketSummary) {
const rank: Record<TicketPriority, number> = { URGENT: 1, HIGH: 2, MEDIUM: 3, LOW: 4 }
const diff = (rank[a.priority] ?? 5) - (rank[b.priority] ?? 5)
if (diff !== 0) return diff
return a.reference - b.reference
}

View file

@ -89,6 +89,27 @@ const serverTicketSchema = z.object({
.nullable(), .nullable(),
machine: serverMachineSummarySchema.optional().nullable(), machine: serverMachineSummarySchema.optional().nullable(),
slaPolicy: z.any().nullable().optional(), slaPolicy: z.any().nullable().optional(),
slaSnapshot: z
.object({
categoryId: z.any().optional(),
categoryName: z.string().optional(),
priority: z.string().optional(),
responseTargetMinutes: z.number().optional().nullable(),
responseMode: z.string().optional(),
solutionTargetMinutes: z.number().optional().nullable(),
solutionMode: z.string().optional(),
alertThreshold: z.number().optional(),
pauseStatuses: z.array(z.string()).optional(),
})
.nullable()
.optional(),
slaResponseDueAt: z.number().nullable().optional(),
slaSolutionDueAt: z.number().nullable().optional(),
slaResponseStatus: z.string().nullable().optional(),
slaSolutionStatus: z.string().nullable().optional(),
slaPausedAt: z.number().nullable().optional(),
slaPausedBy: z.string().nullable().optional(),
slaPausedMs: z.number().nullable().optional(),
dueAt: z.number().nullable().optional(), dueAt: z.number().nullable().optional(),
firstResponseAt: z.number().nullable().optional(), firstResponseAt: z.number().nullable().optional(),
resolvedAt: z.number().nullable().optional(), resolvedAt: z.number().nullable().optional(),
@ -200,6 +221,19 @@ export function mapTicketFromServer(input: unknown) {
...base ...base
} = serverTicketSchema.parse(input); } = serverTicketSchema.parse(input);
const s = { csatScore, csatMaxScore, csatComment, csatRatedAt, csatRatedBy, ...base }; const s = { csatScore, csatMaxScore, csatComment, csatRatedAt, csatRatedBy, ...base };
const slaSnapshot = s.slaSnapshot
? {
categoryId: s.slaSnapshot.categoryId ? String(s.slaSnapshot.categoryId) : undefined,
categoryName: s.slaSnapshot.categoryName ?? undefined,
priority: s.slaSnapshot.priority ?? "",
responseTargetMinutes: s.slaSnapshot.responseTargetMinutes ?? null,
responseMode: (s.slaSnapshot.responseMode ?? "calendar") as "business" | "calendar",
solutionTargetMinutes: s.slaSnapshot.solutionTargetMinutes ?? null,
solutionMode: (s.slaSnapshot.solutionMode ?? "calendar") as "business" | "calendar",
alertThreshold: typeof s.slaSnapshot.alertThreshold === "number" ? s.slaSnapshot.alertThreshold : null,
pauseStatuses: s.slaSnapshot.pauseStatuses ?? [],
}
: null;
const ui = { const ui = {
...base, ...base,
status: normalizeTicketStatus(s.status), status: normalizeTicketStatus(s.status),
@ -230,6 +264,14 @@ export function mapTicketFromServer(input: unknown) {
csatRatedAt: csatRatedAt ? new Date(csatRatedAt) : null, csatRatedAt: csatRatedAt ? new Date(csatRatedAt) : null,
csatRatedBy: csatRatedBy ?? null, csatRatedBy: csatRatedBy ?? null,
formTemplateLabel: base.formTemplateLabel ?? null, formTemplateLabel: base.formTemplateLabel ?? null,
slaSnapshot,
slaResponseDueAt: s.slaResponseDueAt ? new Date(s.slaResponseDueAt) : null,
slaSolutionDueAt: s.slaSolutionDueAt ? new Date(s.slaSolutionDueAt) : null,
slaResponseStatus: typeof s.slaResponseStatus === "string" ? (s.slaResponseStatus as string) : null,
slaSolutionStatus: typeof s.slaSolutionStatus === "string" ? (s.slaSolutionStatus as string) : null,
slaPausedAt: s.slaPausedAt ? new Date(s.slaPausedAt) : null,
slaPausedBy: s.slaPausedBy ?? null,
slaPausedMs: typeof s.slaPausedMs === "number" ? s.slaPausedMs : null,
workSummary: s.workSummary workSummary: s.workSummary
? { ? {
totalWorkedMs: s.workSummary.totalWorkedMs, totalWorkedMs: s.workSummary.totalWorkedMs,
@ -271,6 +313,19 @@ export function mapTicketWithDetailsFromServer(input: unknown) {
...base ...base
} = serverTicketWithDetailsSchema.parse(input); } = serverTicketWithDetailsSchema.parse(input);
const s = { csatScore, csatMaxScore, csatComment, csatRatedAt, csatRatedBy, ...base }; const s = { csatScore, csatMaxScore, csatComment, csatRatedAt, csatRatedBy, ...base };
const slaSnapshot = s.slaSnapshot
? {
categoryId: s.slaSnapshot.categoryId ? String(s.slaSnapshot.categoryId) : undefined,
categoryName: s.slaSnapshot.categoryName ?? undefined,
priority: s.slaSnapshot.priority ?? "",
responseTargetMinutes: s.slaSnapshot.responseTargetMinutes ?? null,
responseMode: (s.slaSnapshot.responseMode ?? "calendar") as "business" | "calendar",
solutionTargetMinutes: s.slaSnapshot.solutionTargetMinutes ?? null,
solutionMode: (s.slaSnapshot.solutionMode ?? "calendar") as "business" | "calendar",
alertThreshold: typeof s.slaSnapshot.alertThreshold === "number" ? s.slaSnapshot.alertThreshold : null,
pauseStatuses: s.slaSnapshot.pauseStatuses ?? [],
}
: null;
const customFields = Object.entries(s.customFields ?? {}).reduce< const customFields = Object.entries(s.customFields ?? {}).reduce<
Record<string, { label: string; type: string; value?: unknown; displayValue?: string }> Record<string, { label: string; type: string; value?: unknown; displayValue?: string }>
>( >(
@ -317,6 +372,14 @@ export function mapTicketWithDetailsFromServer(input: unknown) {
status: base.machine.status ?? null, status: base.machine.status ?? null,
} }
: null, : null,
slaSnapshot,
slaResponseDueAt: base.slaResponseDueAt ? new Date(base.slaResponseDueAt) : null,
slaSolutionDueAt: base.slaSolutionDueAt ? new Date(base.slaSolutionDueAt) : null,
slaResponseStatus: typeof base.slaResponseStatus === "string" ? (base.slaResponseStatus as string) : null,
slaSolutionStatus: typeof base.slaSolutionStatus === "string" ? (base.slaSolutionStatus as string) : null,
slaPausedAt: base.slaPausedAt ? new Date(base.slaPausedAt) : null,
slaPausedBy: base.slaPausedBy ?? null,
slaPausedMs: typeof base.slaPausedMs === "number" ? base.slaPausedMs : null,
timeline: base.timeline.map((e) => ({ ...e, createdAt: new Date(e.createdAt) })), timeline: base.timeline.map((e) => ({ ...e, createdAt: new Date(e.createdAt) })),
comments: base.comments.map((c) => ({ comments: base.comments.map((c) => ({
...c, ...c,

View file

@ -9,6 +9,23 @@ export const ticketStatusSchema = z.enum([
export type TicketStatus = z.infer<typeof ticketStatusSchema> export type TicketStatus = z.infer<typeof ticketStatusSchema>
const slaStatusSchema = z.enum(["pending", "met", "breached", "n/a"])
const slaTimeModeSchema = z.enum(["business", "calendar"])
export const ticketSlaSnapshotSchema = z.object({
categoryId: z.string().optional(),
categoryName: z.string().optional(),
priority: z.string(),
responseTargetMinutes: z.number().nullable().optional(),
responseMode: slaTimeModeSchema.optional(),
solutionTargetMinutes: z.number().nullable().optional(),
solutionMode: slaTimeModeSchema.optional(),
alertThreshold: z.number().optional(),
pauseStatuses: z.array(z.string()).default([]),
})
export type TicketSlaSnapshot = z.infer<typeof ticketSlaSnapshotSchema>
export const ticketPrioritySchema = z.enum(["LOW", "MEDIUM", "HIGH", "URGENT"]) export const ticketPrioritySchema = z.enum(["LOW", "MEDIUM", "HIGH", "URGENT"])
export type TicketPriority = z.infer<typeof ticketPrioritySchema> export type TicketPriority = z.infer<typeof ticketPrioritySchema>
@ -137,6 +154,14 @@ export const ticketSchema = z.object({
targetMinutesToResolution: z.number().nullable(), targetMinutesToResolution: z.number().nullable(),
}) })
.nullable(), .nullable(),
slaSnapshot: ticketSlaSnapshotSchema.nullable().optional(),
slaResponseDueAt: z.coerce.date().nullable().optional(),
slaSolutionDueAt: z.coerce.date().nullable().optional(),
slaResponseStatus: slaStatusSchema.nullable().optional(),
slaSolutionStatus: slaStatusSchema.nullable().optional(),
slaPausedAt: z.coerce.date().nullable().optional(),
slaPausedBy: z.string().nullable().optional(),
slaPausedMs: z.number().nullable().optional(),
dueAt: z.coerce.date().nullable(), dueAt: z.coerce.date().nullable(),
firstResponseAt: z.coerce.date().nullable(), firstResponseAt: z.coerce.date().nullable(),
resolvedAt: z.coerce.date().nullable(), resolvedAt: z.coerce.date().nullable(),

66
src/lib/sla-utils.ts Normal file
View file

@ -0,0 +1,66 @@
import type { Ticket } from "@/lib/schemas/ticket"
export type SlaTimerType = "response" | "solution"
export type SlaDisplayStatus = "on_track" | "at_risk" | "breached" | "met" | "n/a"
const DEFAULT_ALERT_THRESHOLD = 0.8
export function getSlaDueDate(ticket: Ticket, type: SlaTimerType): Date | null {
if (type === "response") {
return ticket.slaResponseDueAt ?? null
}
return ticket.slaSolutionDueAt ?? ticket.dueAt ?? null
}
export function getSlaDisplayStatus(ticket: Ticket, type: SlaTimerType, now: Date = new Date()): SlaDisplayStatus {
const snapshot = ticket.slaSnapshot
const dueAt = getSlaDueDate(ticket, type)
const finalStatus = type === "response" ? ticket.slaResponseStatus : ticket.slaSolutionStatus
if (!snapshot || !dueAt) {
if (finalStatus === "met" || finalStatus === "breached") {
return finalStatus
}
return "n/a"
}
if (finalStatus === "met" || finalStatus === "breached") {
return finalStatus
}
const completedAt = type === "response" ? ticket.firstResponseAt : ticket.resolvedAt
if (completedAt) {
return completedAt.getTime() <= dueAt.getTime() ? "met" : "breached"
}
const elapsed = getEffectiveElapsedMs(ticket, now)
const total = dueAt.getTime() - ticket.createdAt.getTime()
if (total <= 0) {
return now.getTime() <= dueAt.getTime() ? "on_track" : "breached"
}
if (now.getTime() > dueAt.getTime()) {
return "breached"
}
const threshold = snapshot.alertThreshold ?? DEFAULT_ALERT_THRESHOLD
const ratio = elapsed / total
if (ratio >= 1) {
return "breached"
}
if (ratio >= threshold) {
return "at_risk"
}
return "on_track"
}
function getEffectiveElapsedMs(ticket: Ticket, now: Date) {
const pausedMs = ticket.slaPausedMs ?? 0
const pausedAt = ticket.slaPausedAt ?? null
const createdAt = ticket.createdAt instanceof Date ? ticket.createdAt : new Date(ticket.createdAt)
let elapsed = now.getTime() - createdAt.getTime() - pausedMs
if (pausedAt) {
elapsed -= now.getTime() - pausedAt.getTime()
}
return Math.max(0, elapsed)
}

25
src/lib/ticket-filters.ts Normal file
View file

@ -0,0 +1,25 @@
import type { TicketStatus } from "@/lib/schemas/ticket"
export type TicketFiltersState = {
search: string
status: TicketStatus | null
priority: string | null
queue: string | null
channel: string | null
company: string | null
assigneeId: string | null
view: "active" | "completed"
focusVisits: boolean
}
export const defaultTicketFilters: TicketFiltersState = {
search: "",
status: null,
priority: null,
queue: null,
channel: null,
company: null,
assigneeId: null,
view: "active",
focusVisits: false,
}

View file

@ -0,0 +1,12 @@
import type { Ticket } from "@/lib/schemas/ticket"
export const VISIT_KEYWORDS = ["visita", "visitas", "in loco", "laboratório", "laboratorio", "lab"]
export function isVisitTicket(ticket: Ticket): boolean {
const queueName = ticket.queue?.toLowerCase() ?? ""
if (VISIT_KEYWORDS.some((keyword) => queueName.includes(keyword))) {
return true
}
const tags = Array.isArray(ticket.tags) ? ticket.tags : []
return tags.some((tag) => VISIT_KEYWORDS.some((keyword) => tag.toLowerCase().includes(keyword)))
}

View file

@ -4,8 +4,14 @@ import { toast } from "sonner"
const METHODS = ["success", "error", "info", "warning", "message", "loading"] as const const METHODS = ["success", "error", "info", "warning", "message", "loading"] as const
const TRAILING_PUNCTUATION_REGEX = /[\s!?.,;:]+$/u const TRAILING_PUNCTUATION_REGEX = /[\s!?.,;:]+$/u
const toastAny = toast as typeof toast & { __punctuationPatched?: boolean }
type ToastMethodKey = (typeof METHODS)[number] type ToastMethodKey = (typeof METHODS)[number]
type PatchedToast = typeof toast &
Pick<typeof toast, ToastMethodKey | "promise"> & {
__punctuationPatched?: boolean
}
const patchedToast = toast as PatchedToast
function stripTrailingPunctuation(value: string): string { function stripTrailingPunctuation(value: string): string {
const trimmed = value.trimEnd() const trimmed = value.trimEnd()
@ -32,25 +38,27 @@ function sanitizeOptions<T>(options: T): T {
} }
function wrapSimpleMethod<K extends ToastMethodKey>(method: K) { function wrapSimpleMethod<K extends ToastMethodKey>(method: K) {
const original = toastAny[method] as typeof toast[K] const original = patchedToast[method]
if (typeof original !== "function") return if (typeof original !== "function") return
const patched = ((...args: Parameters<typeof toast[K]>) => { type ToastFn = (...args: unknown[]) => unknown
const nextArgs = args.slice() as Parameters<typeof toast[K]> const callable = original as ToastFn
const patched = ((...args: Parameters<ToastFn>) => {
const nextArgs = args.slice()
if (nextArgs.length > 0) { if (nextArgs.length > 0) {
nextArgs[0] = sanitizeContent(nextArgs[0]) nextArgs[0] = sanitizeContent(nextArgs[0])
} }
if (nextArgs.length > 1) { if (nextArgs.length > 1) {
nextArgs[1] = sanitizeOptions(nextArgs[1]) nextArgs[1] = sanitizeOptions(nextArgs[1])
} }
return original.apply(null, nextArgs as Parameters<typeof toast[K]>) return callable(...nextArgs)
}) as typeof toast[K] }) as typeof patchedToast[K]
toastAny[method] = patched patchedToast[method] = patched
} }
function wrapPromise() { function wrapPromise() {
const originalPromise = toastAny.promise const originalPromise = patchedToast.promise
if (typeof originalPromise !== "function") return if (typeof originalPromise !== "function") return
toastAny.promise = ((promise, messages) => { patchedToast.promise = ((promise, messages) => {
const normalizedMessages = const normalizedMessages =
messages && typeof messages === "object" messages && typeof messages === "object"
? ({ ? ({
@ -66,8 +74,8 @@ function wrapPromise() {
}) as typeof toast.promise }) as typeof toast.promise
} }
if (!toastAny.__punctuationPatched) { if (!patchedToast.__punctuationPatched) {
toastAny.__punctuationPatched = true patchedToast.__punctuationPatched = true
METHODS.forEach(wrapSimpleMethod) METHODS.forEach(wrapSimpleMethod)
wrapPromise() wrapPromise()
} }