diff --git a/bun.lock b/bun.lock index 5c16f1b..802f6b0 100644 --- a/bun.lock +++ b/bun.lock @@ -22,6 +22,7 @@ "@radix-ui/react-select": "^2.2.6", "@radix-ui/react-separator": "^1.1.7", "@radix-ui/react-slot": "^1.2.3", + "@radix-ui/react-switch": "^1.2.6", "@radix-ui/react-tabs": "^1.1.13", "@radix-ui/react-toggle": "^1.1.10", "@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-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-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=="], diff --git a/convex/_generated/api.d.ts b/convex/_generated/api.d.ts index 93e829a..9495ab3 100644 --- a/convex/_generated/api.d.ts +++ b/convex/_generated/api.d.ts @@ -12,6 +12,7 @@ import type * as alerts from "../alerts.js"; import type * as alerts_actions from "../alerts_actions.js"; import type * as bootstrap from "../bootstrap.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 companies from "../companies.js"; import type * as crons from "../crons.js"; @@ -58,6 +59,7 @@ declare const fullApi: ApiFromModules<{ alerts_actions: typeof alerts_actions; bootstrap: typeof bootstrap; categories: typeof categories; + categorySlas: typeof categorySlas; commentTemplates: typeof commentTemplates; companies: typeof companies; crons: typeof crons; diff --git a/convex/categorySlas.ts b/convex/categorySlas.ts new file mode 100644 index 0000000..09086a5 --- /dev/null +++ b/convex/categorySlas.ts @@ -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() + 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> = {} + 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, + } +} diff --git a/convex/migrations.ts b/convex/migrations.ts index 16f050b..099ec82 100644 --- a/convex/migrations.ts +++ b/convex/migrations.ts @@ -75,6 +75,67 @@ function pruneUndefined>(input: T): T { 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({ + 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 + const pauseStatuses = Array.isArray(record.pauseStatuses) + ? record.pauseStatuses.filter((value): value is string => typeof value === "string") + : undefined + + const normalized = pruneUndefined({ + 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( ctx: MutationCtx, tenantId: string, @@ -333,6 +394,14 @@ export const exportTenantSnapshot = query({ createdAt: ticket.createdAt, updatedAt: ticket.updatedAt, 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 .map((comment) => { const author = userMap.get(comment.authorId) @@ -446,6 +515,14 @@ export const importPrismaSnapshot = mutation({ createdAt: v.number(), updatedAt: v.number(), 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( v.object({ authorEmail: v.string(), @@ -513,6 +590,9 @@ export const importPrismaSnapshot = mutation({ let eventsInserted = 0 for (const ticket of snapshot.tickets) { + const normalizedSnapshot = normalizeImportedSlaSnapshot(ticket.slaSnapshot) + const slaPausedMs = typeof ticket.slaPausedMs === "number" ? ticket.slaPausedMs : undefined + const requesterId = await ensureUser( ctx, snapshot.tenantId, @@ -567,6 +647,14 @@ export const importPrismaSnapshot = mutation({ updatedAt: ticket.updatedAt, createdAt: ticket.createdAt, 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, totalWorkedMs: undefined, activeSessionId: undefined, diff --git a/convex/schema.ts b/convex/schema.ts index 217972c..35b0445 100644 --- a/convex/schema.ts +++ b/convex/schema.ts @@ -250,6 +250,26 @@ export default defineSchema({ ), working: v.optional(v.boolean()), 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 firstResponseAt: v.optional(v.number()), resolvedAt: v.optional(v.number()), @@ -437,6 +457,24 @@ export default defineSchema({ .index("by_category_slug", ["categoryId", "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({ tenantId: v.string(), key: v.string(), diff --git a/convex/tickets.ts b/convex/tickets.ts index d2c54fb..50bd0d1 100644 --- a/convex/tickets.ts +++ b/convex/tickets.ts @@ -48,6 +48,19 @@ const LEGACY_STATUS_MAP: Record = { CLOSED: "RESOLVED", }; +function normalizePriorityFilter(input: string | string[] | null | undefined): string[] { + if (!input) return []; + const list = Array.isArray(input) ? input : [input]; + const set = new Set(); + 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(); const missingCommentAuthorLogCache = new Set(); @@ -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(); + 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 { + 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 = { + 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): Doc<"tickets"> { + const merged = { ...ticketDoc } as Record; + 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( templateKey: string | null | undefined, storedLabel: string | null | undefined @@ -223,51 +479,77 @@ async function fetchTicketFieldsByScopes( tenantId: string, scopes: string[] ): Promise { - 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(); - for (const scope of uniqueScopes) { - const fields = await ctx.db - .query("ticketFields") - .withIndex("by_tenant_scope", (q) => q.eq("tenantId", tenantId).eq("scope", scope)) - .collect(); - result.set(scope, fields); + const allFields = await ctx.db + .query("ticketFields") + .withIndex("by_tenant", (q) => q.eq("tenantId", tenantId)) + .collect(); + + 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; } -async function fetchScopedFormSettings( +async function fetchViewerScopedFormSettings( ctx: QueryCtx, tenantId: string, - templateKey: string, + templateKeys: string[], viewerId: Id<"users">, viewerCompanyId: Id<"companies"> | null -): Promise[]> { - const tenantSettingsPromise = ctx.db +): Promise[]>> { + 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[]>(); + + const allSettings = await ctx.db .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(); - const companySettingsPromise = viewerCompanyId - ? ctx.db - .query("ticketFormSettings") - .withIndex("by_tenant_template_company", (q) => - q.eq("tenantId", tenantId).eq("template", templateKey).eq("companyId", viewerCompanyId) - ) - .collect() - : Promise.resolve[]>([]); + for (const setting of allSettings) { + if (!keySet.has(setting.template)) { + continue; + } + if (setting.scope === "company") { + if (!viewerCompanyIdStr || !setting.companyId || String(setting.companyId) !== viewerCompanyIdStr) { + continue; + } + } else if (setting.scope === "user") { + if (!setting.userId || String(setting.userId) !== viewerIdStr) { + continue; + } + } else if (setting.scope !== "tenant") { + continue; + } - const userSettingsPromise = ctx.db - .query("ticketFormSettings") - .withIndex("by_tenant_template_user", (q) => q.eq("tenantId", tenantId).eq("template", templateKey).eq("userId", viewerId)) - .collect(); + if (scopedMap.has(setting.template)) { + scopedMap.get(setting.template)!.push(setting); + } else { + scopedMap.set(setting.template, [setting]); + } + } - const [tenantSettings, companySettings, userSettings] = await Promise.all([ - tenantSettingsPromise, - companySettingsPromise, - userSettingsPromise, - ]); - - return [...tenantSettings, ...companySettings, ...userSettings]; + return scopedMap; } 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; -function areValuesEqual(a: unknown, b: unknown): boolean { - if (a === b) return true; - 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 { + return serializeCustomFieldEntry(a) === serializeCustomFieldEntry(b); } -function areCustomFieldEntriesEqual(a: CustomFieldRecordEntry, b: CustomFieldRecordEntry): boolean { - if (!a && !b) return true; - if (!a || !b) return false; - if (!areValuesEqual(a.value ?? null, b.value ?? null)) return false; - const prevDisplay = a.displayValue ?? null; - const nextDisplay = b.displayValue ?? null; - return prevDisplay === nextDisplay; +function serializeCustomFieldEntry(entry: CustomFieldRecordEntry): string { + if (!entry) return "__undefined__"; + return JSON.stringify({ + value: normalizeEntryValue(entry.value), + displayValue: entry.displayValue ?? null, + }); +} + +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; + const normalized: Record = {}; + Object.keys(record) + .sort() + .forEach((key) => { + normalized[key] = normalizeEntryValue(record[key]); + }); + return normalized; + } + return value; } function getCustomFieldRecordEntry( @@ -1066,7 +1350,7 @@ export const list = query({ viewerId: v.optional(v.id("users")), tenantId: 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()), queueId: v.optional(v.id("queues")), assigneeId: v.optional(v.id("users")), @@ -1085,7 +1369,9 @@ export const list = query({ } 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 searchTerm = args.search?.trim().toLowerCase() ?? null; @@ -1098,8 +1384,8 @@ export const list = query({ if (normalizedStatusFilter) { working = working.filter((q) => q.eq(q.field("status"), normalizedStatusFilter)); } - if (normalizedPriorityFilter) { - working = working.filter((q) => q.eq(q.field("priority"), normalizedPriorityFilter)); + if (primaryPriorityFilter) { + working = working.filter((q) => q.eq(q.field("priority"), primaryPriorityFilter)); } if (normalizedChannelFilter) { working = working.filter((q) => q.eq(q.field("channel"), normalizedChannelFilter)); @@ -1188,7 +1474,7 @@ export const list = query({ if (role === "MANAGER") { 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 (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)); @@ -1762,6 +2048,7 @@ export const create = mutation({ avatarUrl: requester.avatarUrl ?? 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 if (!companyDoc && 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", { tenantId: args.tenantId, reference: nextRef, @@ -1837,6 +2125,7 @@ export const create = mutation({ slaPolicyId: undefined, dueAt: undefined, customFields: normalizedCustomFields.length ? normalizedCustomFields : undefined, + ...slaFields, }); await ctx.db.insert("ticketEvents", { ticketId: id, @@ -1972,8 +2261,13 @@ export const addComment = mutation({ payload: { authorId: args.authorId, authorName: author.name, authorAvatar: author.avatarUrl }, createdAt: now, }); - // bump ticket updatedAt - await ctx.db.patch(args.ticketId, { updatedAt: now }); + const isStaffResponder = + 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 try { 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.") } 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", { ticketId, type: "STATUS_CHANGED", @@ -2206,6 +2501,10 @@ export async function resolveTicketHandler( ), ).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, { status: normalizedStatus, resolvedAt: now, @@ -2217,6 +2516,8 @@ export async function resolveTicketHandler( relatedTicketIds: relatedIdList.length ? relatedIdList : undefined, activeSessionId: undefined, working: false, + ...slaPausePatch, + ...slaSolutionPatch, }) 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") } + const slaPatch = buildSlaStatusPatch(ticketDoc, "AWAITING_ATTENDANCE", now) await ctx.db.patch(ticketId, { status: "AWAITING_ATTENDANCE", reopenedAt: now, resolvedAt: undefined, closedAt: undefined, updatedAt: now, + ...slaPatch, }) await ctx.db.insert("ticketEvents", { @@ -2529,16 +2832,9 @@ export const listTicketForms = query({ const fieldsByScope = await fetchTicketFieldsByScopes(ctx, tenantId, scopes) const staffOverride = viewerRole === "ADMIN" || viewerRole === "AGENT" - const settingsByTemplate = new Map[]>() - - if (!staffOverride) { - 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 settingsByTemplate = staffOverride + ? new Map[]>() + : await fetchViewerScopedFormSettings(ctx, tenantId, scopes, viewer.user._id, viewerCompanyId) const forms = [] as Array<{ key: string @@ -3272,11 +3568,13 @@ export const startWork = mutation({ startedAt: now, }) + const slaStartPatch = buildSlaStatusPatch(ticketDoc, "AWAITING_ATTENDANCE", now); await ctx.db.patch(ticketId, { working: true, activeSessionId: sessionId, status: "AWAITING_ATTENDANCE", updatedAt: now, + ...slaStartPatch, }) if (assigneePatched) { @@ -3336,10 +3634,12 @@ export const pauseWork = mutation({ const normalizedStatus = normalizeStatus(ticketDoc.status) if (normalizedStatus === "AWAITING_ATTENDANCE") { const now = Date.now() + const slaPausePatch = buildSlaStatusPatch(ticketDoc, "PAUSED", now) await ctx.db.patch(ticketId, { status: "PAUSED", working: false, updatedAt: now, + ...slaPausePatch, }) await ctx.db.insert("ticketEvents", { ticketId, @@ -3380,6 +3680,7 @@ export const pauseWork = mutation({ const deltaInternal = sessionType === "INTERNAL" ? durationMs : 0 const deltaExternal = sessionType === "EXTERNAL" ? durationMs : 0 + const slaPausePatch = buildSlaStatusPatch(ticketDoc, "PAUSED", now) await ctx.db.patch(ticketId, { working: false, activeSessionId: undefined, @@ -3388,6 +3689,7 @@ export const pauseWork = mutation({ internalWorkedMs: (ticket.internalWorkedMs ?? 0) + deltaInternal, externalWorkedMs: (ticket.externalWorkedMs ?? 0) + deltaExternal, updatedAt: now, + ...slaPausePatch, }) const actor = (await ctx.db.get(actorId)) as Doc<"users"> | null diff --git a/docs/README.md b/docs/README.md index ff2f724..2daf3e5 100644 --- a/docs/README.md +++ b/docs/README.md @@ -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` ## Arquivo (histórico/planejamento) +- `docs/alteracoes-2025-11-08.md` - `docs/archive/operacao-producao.md` (substituído por `docs/operations.md`) - `docs/archive/deploy-runbook.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` Se algum conteúdo arquivado voltar a ser relevante, mova-o de volta, atualizando a data e o escopo. - diff --git a/docs/alteracoes-2025-11-08.md b/docs/alteracoes-2025-11-08.md new file mode 100644 index 0000000..819a413 --- /dev/null +++ b/docs/alteracoes-2025-11-08.md @@ -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 (08h–18h, seg–sex) 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 1 s 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 08h–18h (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. diff --git a/package.json b/package.json index c599f00..1e5a8ca 100644 --- a/package.json +++ b/package.json @@ -43,6 +43,7 @@ "@radix-ui/react-select": "^2.2.6", "@radix-ui/react-separator": "^1.1.7", "@radix-ui/react-slot": "^1.2.3", + "@radix-ui/react-switch": "^1.2.6", "@radix-ui/react-tabs": "^1.1.13", "@radix-ui/react-toggle": "^1.1.10", "@radix-ui/react-toggle-group": "^1.1.11", @@ -54,10 +55,10 @@ "@tiptap/extension-link": "^3.10.0", "@tiptap/extension-mention": "^3.10.0", "@tiptap/extension-placeholder": "^3.10.0", + "@tiptap/markdown": "^3.10.0", "@tiptap/react": "^3.10.0", "@tiptap/starter-kit": "^3.10.0", "@tiptap/suggestion": "^3.10.0", - "@tiptap/markdown": "^3.10.0", "better-auth": "^1.3.26", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", diff --git a/prisma/migrations/20251108042551_add_ticket_sla_fields/migration.sql b/prisma/migrations/20251108042551_add_ticket_sla_fields/migration.sql new file mode 100644 index 0000000..db6b91c --- /dev/null +++ b/prisma/migrations/20251108042551_add_ticket_sla_fields/migration.sql @@ -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; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 3d144e1..de8c42f 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -180,6 +180,14 @@ model Ticket { assigneeId String? slaPolicyId String? companyId String? + slaSnapshot Json? + slaResponseDueAt DateTime? + slaSolutionDueAt DateTime? + slaResponseStatus String? + slaSolutionStatus String? + slaPausedAt DateTime? + slaPausedBy String? + slaPausedMs Int? dueAt DateTime? firstResponseAt DateTime? resolvedAt DateTime? diff --git a/scripts/import-convex-to-prisma.mjs b/scripts/import-convex-to-prisma.mjs index 235760f..d71584d 100644 --- a/scripts/import-convex-to-prisma.mjs +++ b/scripts/import-convex-to-prisma.mjs @@ -71,6 +71,37 @@ function normalizeStatus(status) { 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) { const map = new Map() @@ -260,6 +291,8 @@ async function upsertTickets(snapshotTickets, userMap, queueMap, companyMap) { const desiredAssigneeEmail = defaultAssigneeEmail || normalizeEmail(ticket.assigneeEmail) const assigneeId = desiredAssigneeEmail ? userMap.get(desiredAssigneeEmail) || fallbackAssigneeId || null : fallbackAssigneeId || null + const slaSnapshot = serializeConvexSlaSnapshot(ticket.slaSnapshot) + const existing = await prisma.ticket.findFirst({ where: { tenantId, @@ -283,6 +316,14 @@ async function upsertTickets(snapshotTickets, userMap, queueMap, companyMap) { createdAt: toDate(ticket.createdAt) ?? new Date(), updatedAt: toDate(ticket.updatedAt) ?? new Date(), 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 diff --git a/scripts/sync-prisma-to-convex.mjs b/scripts/sync-prisma-to-convex.mjs index dc37822..9dbbbd4 100644 --- a/scripts/sync-prisma-to-convex.mjs +++ b/scripts/sync-prisma-to-convex.mjs @@ -21,6 +21,29 @@ function slugify(value) { .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() { const tenantId = process.env.SYNC_TENANT_ID || "tenant-atlas" 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(), updatedAt: toMillis(ticket.updatedAt) ?? Date.now(), 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) => ({ authorEmail: comment.author?.email ?? requesterEmail, visibility: comment.visibility, diff --git a/src/app/agenda/agenda-page-client.tsx b/src/app/agenda/agenda-page-client.tsx new file mode 100644 index 0000000..f7e20be --- /dev/null +++ b/src/app/agenda/agenda-page-client.tsx @@ -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(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(() => { + if (!Array.isArray(ticketsRaw)) return null + return mapTicketsFromServerList(ticketsRaw as unknown[]) + }, [ticketsRaw]) + + const [cachedTickets, setCachedTickets] = useState([]) + + 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 ( + + + Em breve: novo compromisso + + } + /> + } + > +
+ + setActiveTab(value as typeof activeTab)}> + + Resumo + Calendário + + + + + + + + +
+
+ ) +} + +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 })}` +} diff --git a/src/app/agenda/page.tsx b/src/app/agenda/page.tsx new file mode 100644 index 0000000..8fd57d3 --- /dev/null +++ b/src/app/agenda/page.tsx @@ -0,0 +1,9 @@ +import { requireAuthenticatedSession } from "@/lib/auth-server" + +import { AgendaPageClient } from "./agenda-page-client" + +export default async function AgendaPage() { + await requireAuthenticatedSession() + return +} + diff --git a/src/app/tickets/page.tsx b/src/app/tickets/page.tsx index 63bd3a7..9585c25 100644 --- a/src/app/tickets/page.tsx +++ b/src/app/tickets/page.tsx @@ -1,8 +1,49 @@ import { TicketsPageClient } from "./tickets-page-client" 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 = { + searchParams?: Record +} + +export default async function TicketsPage({ searchParams }: TicketsPageProps) { await requireAuthenticatedSession() - return + const initialFilters = deriveInitialFilters(searchParams ?? {}) + return +} + +function getParamValue(value: string | string[] | undefined): string | undefined { + if (Array.isArray(value)) { + return value[0] + } + return value +} + +function deriveInitialFilters(params: Record): Partial { + const initial: Partial = {} + 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 } diff --git a/src/app/tickets/tickets-page-client.tsx b/src/app/tickets/tickets-page-client.tsx index bacb255..441c462 100644 --- a/src/app/tickets/tickets-page-client.tsx +++ b/src/app/tickets/tickets-page-client.tsx @@ -4,6 +4,7 @@ import dynamic from "next/dynamic" import { AppShell } from "@/components/app-shell" import { SiteHeader } from "@/components/site-header" +import type { TicketFiltersState } from "@/components/tickets/tickets-filters" const TicketQueueSummaryCards = dynamic( () => @@ -29,7 +30,11 @@ const NewTicketDialog = dynamic( { ssr: false } ) -export function TicketsPageClient() { +type TicketsPageClientProps = { + initialFilters?: Partial +} + +export function TicketsPageClient({ initialFilters }: TicketsPageClientProps = {}) { return ( - + ) diff --git a/src/components/admin/categories/categories-manager.tsx b/src/components/admin/categories/categories-manager.tsx index 730ff32..5f9a427 100644 --- a/src/components/admin/categories/categories-manager.tsx +++ b/src/components/admin/categories/categories-manager.tsx @@ -1,6 +1,6 @@ "use client" -import { useMemo, useState } from "react" +import { useEffect, useMemo, useState } from "react" import { useMutation, useQuery } from "convex/react" import { toast } from "sonner" import { api } from "@/convex/_generated/api" @@ -22,6 +22,8 @@ import { import { Input } from "@/components/ui/input" import { Label } from "@/components/ui/label" import { Textarea } from "@/components/ui/textarea" +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" +import { cn } from "@/lib/utils" type DeleteState = | { type: T; targetId: string; reason: string } @@ -36,6 +38,7 @@ export function CategoriesManager() { const [subcategoryDraft, setSubcategoryDraft] = useState("") const [subcategoryList, setSubcategoryList] = useState([]) const [deleteState, setDeleteState] = useState>(null) + const [slaCategory, setSlaCategory] = useState(null) const createCategory = useMutation(api.categories.createCategory) const deleteCategory = useMutation(api.categories.deleteCategory) const updateCategory = useMutation(api.categories.updateCategory) @@ -196,6 +199,7 @@ export function CategoriesManager() { const pendingDelete = deleteState const isDisabled = !convexUserId + const viewerId = convexUserId as Id<"users"> | null return (
@@ -311,6 +315,7 @@ export function CategoriesManager() { onDeleteSubcategory={(subcategoryId) => setDeleteState({ type: "subcategory", targetId: subcategoryId, reason: "" }) } + onConfigureSla={() => setSlaCategory(category)} disabled={isDisabled} /> )) @@ -373,6 +378,12 @@ export function CategoriesManager() { + setSlaCategory(null)} + />
) } @@ -385,6 +396,7 @@ interface CategoryItemProps { onCreateSubcategory: (categoryId: string, payload: { name: string }) => Promise onUpdateSubcategory: (subcategory: TicketSubcategory, name: string) => Promise onDeleteSubcategory: (subcategoryId: string) => void + onConfigureSla: () => void } function CategoryItem({ @@ -395,6 +407,7 @@ function CategoryItem({ onCreateSubcategory, onUpdateSubcategory, onDeleteSubcategory, + onConfigureSla, }: CategoryItemProps) { const [isEditing, setIsEditing] = useState(false) const [name, setName] = useState(category.name) @@ -448,6 +461,9 @@ function CategoryItem({ ) : (
+ @@ -552,3 +568,360 @@ function SubcategoryItem({ subcategory, disabled, onUpdate, onDelete }: Subcateg
) } + +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>(() => 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) => { + 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 ( + { + if (!open) { + onClose() + } + }} + > + + + Configurar SLA — {category?.name ?? ""} + + Defina metas de resposta e resolução para cada prioridade. Os prazos em horas úteis consideram apenas + segunda a sexta, das 8h às 18h. + + +
+ {PRIORITY_ROWS.map((row) => { + const form = rules[row.value] + return ( +
+
+
+

{row.label}

+

+ {row.value === "DEFAULT" ? "Aplicado quando o ticket não tem prioridade definida." : "Aplica-se aos tickets desta prioridade."} +

+
+
+
+ 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"] })} + /> + 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"] })} + /> +
+
+
+

Alertar quando

+
+ handleChange(row.value, { alertThreshold: Number(event.target.value) || 0 })} + /> + % do tempo for consumido. +
+
+
+

Estados que pausam

+
+ {PAUSE_STATUS_OPTIONS.map((option) => { + const selected = form.pauseStatuses.includes(option.value) + return ( + + ) + })} +
+
+
+
+ ) + })} +
+ + + + +
+
+ ) +} + +function buildDefaultRuleState() { + return PRIORITY_ROWS.reduce>((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 ( +
+

{title}

+
+ onAmountChange(event.target.value)} + placeholder="0" + /> + +
+ +
+ ) +} diff --git a/src/components/agenda/agenda-calendar-view.tsx b/src/components/agenda/agenda-calendar-view.tsx new file mode 100644 index 0000000..5211af4 --- /dev/null +++ b/src/components/agenda/agenda-calendar-view.tsx @@ -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 = { + 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(startOfMonth(range.start)) + const [currentWeekStart, setCurrentWeekStart] = useState(startOfWeek(range.start, { weekStartsOn: 1 })) + + const monthMatrix = useMemo(() => buildCalendarMatrix(currentMonth), [currentMonth]) + const availableYears = useMemo(() => { + const years = new Set() + 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() + 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 ( +
+
+
+

Calendário operacional

+

{currentLabel}

+
+
+ value && setViewMode(value as "month" | "week")} + className="rounded-xl border border-border/70 bg-background/60 p-0.5" + > + + Mês + + + Semana + + +
+ + + + +
+
+
+ {viewMode === "month" ? ( + + ) : ( + + )} + {events.length === 0 ? ( +
+ + Nenhum compromisso previsto para o período filtrado. +
+ ) : null} +
+ ) +} + +function MonthView({ + monthMatrix, + eventsByDay, + currentMonth, +}: { + monthMatrix: Date[][] + eventsByDay: Map + currentMonth: Date +}) { + return ( +
+
+ {weekdayLabels.map((label) => ( +
+ {label} +
+ ))} +
+
+ {monthMatrix.map((week, weekIndex) => ( + + {week.map((day) => { + const dayKey = format(day, "yyyy-MM-dd") + const dayEvents = eventsByDay.get(dayKey) ?? [] + const isCurrent = isSameMonth(day, currentMonth) + return ( +
+
+ {day.getDate()} + {dayEvents.length > 0 ? ( + + {dayEvents.length} + + ) : null} +
+
+ {dayEvents.slice(0, 3).map((event) => ( + + ))} + {dayEvents.length > 3 ? ( +

+{dayEvents.length - 3} mais

+ ) : null} +
+
+ ) + })} +
+ ))} +
+
+ ) +} + +function WeekView({ + weekDays, + eventsByDay, +}: { + weekDays: Date[] + eventsByDay: Map +}) { + return ( +
+
+ {weekDays.map((day, index) => ( +
+ {weekdayLabels[index]} {day.getDate()} +
+ ))} +
+
+ {weekDays.map((day) => { + const dayKey = format(day, "yyyy-MM-dd") + const dayEvents = eventsByDay.get(dayKey) ?? [] + return ( +
+
+ {dayEvents.length === 0 ? ( +

Sem eventos

+ ) : ( + dayEvents.map((event) => ( + + )) + )} +
+
+ ) + })} +
+
+ ) +} + +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 ( + + + + + + +
+ {sortedYears.map((year) => ( +
+

{year}

+
+ {monthLabels.map((label, index) => ( + + ))} +
+
+ ))} +
+
+
+
+ ) +} + +function CalendarEventBadge({ event }: { event: AgendaCalendarEvent }) { + const priorityStyle = priorityStyles[event.priority] + const slaColor = slaColors[event.slaStatus] + return ( + +
+ #{event.reference} + {format(event.start, "HH:mm")} +
+

{event.title}

+
+ {event.queue ? ( + + {event.queue} + + ) : null} + + {priorityStyle?.label ?? event.priority} + +
+ + ) +} + +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 +} diff --git a/src/components/agenda/agenda-filters.tsx b/src/components/agenda/agenda-filters.tsx new file mode 100644 index 0000000..2f6fd99 --- /dev/null +++ b/src/components/agenda/agenda-filters.tsx @@ -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) => { + 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 ( +
+
+
+ {periodOptions.map((option) => { + const isActive = filters.period === option.value + return ( + + ) + })} +
+ + + + + + Filtrar filas + + {queues.length === 0 ? ( +
Nenhuma fila disponível.
+ ) : ( + queues.map((queue) => ( + handleQueueToggle(queue, Boolean(checked))} + > + {queue} + + )) + )} +
+
+ + + + + + Prioridades + + {(Object.keys(priorityStyles) as TicketPriority[]).map((priority) => ( + handlePriorityToggle(priority, Boolean(checked))} + className="gap-2" + > +
+ + {priorityStyles[priority].label} + + {priorityStyles[priority].label} +
+
+ ))} +
+
+
+
+
+ updateFilters({ onlyMyTickets: checked })} + className="data-[state=checked]:border-sidebar-ring data-[state=checked]:bg-sidebar-accent" + /> + +
+
+ updateFilters({ focusVisits: checked })} + className="data-[state=checked]:border-sidebar-ring data-[state=checked]:bg-sidebar-accent" + /> + +
+ +
+
+ ) +} diff --git a/src/components/agenda/agenda-summary-view.tsx b/src/components/agenda/agenda-summary-view.tsx new file mode 100644 index 0000000..eb399ec --- /dev/null +++ b/src/components/agenda/agenda-summary-view.tsx @@ -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; 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 ( + + + + + + + + ) + } + + 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 ( +
+ +
+ {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 ( + + + + + {section.title} + {items.length > 0 ? ( + + {items.length} + + ) : null} + + + Ver todos + + + + {items.length === 0 ? ( +

{section.empty}

+ ) : ( +
+ {visibleItems.map((item) => ( + + ))} +
+ )} +
+
+ ) + })} +
+
+ ) +} + +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 ( +
+ {kpis.map((item) => ( + + +

{item.label}

+

{item.value}

+

{item.description}

+
+
+ ))} +
+ ) +} + +function AgendaSummaryRow({ item }: { item: AgendaTicketSummary }) { + const status = statusIndicator[item.slaStatus ?? "on_track"] ?? statusIndicator.on_track + + return ( + +
+
+
+ + #{item.reference} · {item.subject} + + {item.queue ? ( + + {item.queue} + + ) : null} + + {priorityStyles[item.priority]?.label ?? item.priority} + +
+
+ {item.company ? {item.company} : null} + {item.location ? {item.location} : null} + {item.startAt ? ( + + + {formatDateRange(item.startAt, item.endAt)} + + ) : ( + + + Não agendado + + )} +
+
+
+ + {status.label} +
+
+ + ) +} + +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()}` +} diff --git a/src/components/app-sidebar.tsx b/src/components/app-sidebar.tsx index a8939d0..11d47d0 100644 --- a/src/components/app-sidebar.tsx +++ b/src/components/app-sidebar.tsx @@ -22,6 +22,7 @@ import { ShieldCheck, Users, Layers3, + CalendarDays, } from "lucide-react" import { usePathname } from "next/navigation" import Link from "next/link" @@ -80,6 +81,7 @@ const navigation: NavigationGroup[] = [ }, { title: "Visualizações", url: "/views", icon: PanelsTopLeft, requiredRole: "staff" }, { title: "Modo Play", url: "/play", icon: PlayCircle, requiredRole: "staff" }, + { title: "Agenda", url: "/agenda", icon: CalendarDays, requiredRole: "staff" }, ], }, { diff --git a/src/components/portal/portal-ticket-form.tsx b/src/components/portal/portal-ticket-form.tsx index a8d7a5a..6b422fc 100644 --- a/src/components/portal/portal-ticket-form.tsx +++ b/src/components/portal/portal-ticket-form.tsx @@ -21,6 +21,7 @@ import { RichTextEditor } from "@/components/ui/rich-text-editor" import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" import { normalizeCustomFieldInputs, hasMissingRequiredCustomFields } from "@/lib/ticket-form-helpers" import { cn } from "@/lib/utils" +import { useLocalTimeZone } from "@/hooks/use-local-time-zone" import type { TicketFormDefinition } from "@/lib/ticket-form-types" import { Calendar } from "@/components/ui/calendar" import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover" @@ -69,6 +70,7 @@ export function PortalTicketForm() { const [selectedFormKey, setSelectedFormKey] = useState("default") const [customFieldValues, setCustomFieldValues] = useState>({}) const [openCalendarField, setOpenCalendarField] = useState(null) + const calendarTimeZone = useLocalTimeZone() const hasEnsuredFormsRef = useRef(false) useEffect(() => { @@ -467,6 +469,7 @@ export function PortalTicketForm() { startMonth={new Date(1900, 0)} endMonth={new Date(new Date().getFullYear() + 5, 11)} locale={ptBR} + timeZone={calendarTimeZone} /> diff --git a/src/components/settings/settings-content.tsx b/src/components/settings/settings-content.tsx index b52b0d2..8963383 100644 --- a/src/components/settings/settings-content.tsx +++ b/src/components/settings/settings-content.tsx @@ -12,6 +12,7 @@ import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } import { Separator } from "@/components/ui/separator" import { useAuth, signOut } from "@/lib/auth-client" import { DEFAULT_TENANT_ID } from "@/lib/constants" +import { FieldsManager } from "@/components/admin/fields/fields-manager" import type { LucideIcon } from "lucide-react" @@ -270,6 +271,17 @@ export function SettingsContent() { })} + {isStaff ? ( +
+
+

Campos personalizados

+

+ Ajuste os campos de admissão, desligamento e demais metadados diretamente pelo painel administrativo. +

+
+ +
+ ) : null} ) } diff --git a/src/components/tickets/new-ticket-dialog.tsx b/src/components/tickets/new-ticket-dialog.tsx index 75bb35c..a9371c9 100644 --- a/src/components/tickets/new-ticket-dialog.tsx +++ b/src/components/tickets/new-ticket-dialog.tsx @@ -29,6 +29,7 @@ import { SearchableCombobox, type SearchableComboboxOption } from "@/components/ import { Calendar } from "@/components/ui/calendar" import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover" import { useDefaultQueues } from "@/hooks/use-default-queues" +import { useLocalTimeZone } from "@/hooks/use-local-time-zone" import { cn } from "@/lib/utils" import { priorityStyles } from "@/lib/ticket-priority-style" import { normalizeCustomFieldInputs } from "@/lib/ticket-form-helpers" @@ -115,6 +116,7 @@ const schema = z.object({ export function NewTicketDialog({ triggerClassName }: { triggerClassName?: string } = {}) { const [open, setOpen] = useState(false) const [loading, setLoading] = useState(false) + const calendarTimeZone = useLocalTimeZone() const form = useForm>({ resolver: zodResolver(schema), defaultValues: { @@ -1039,6 +1041,7 @@ export function NewTicketDialog({ triggerClassName }: { triggerClassName?: strin startMonth={new Date(1900, 0)} endMonth={new Date(new Date().getFullYear() + 5, 11)} locale={ptBR} + timeZone={calendarTimeZone} /> diff --git a/src/components/tickets/ticket-csat-card.tsx b/src/components/tickets/ticket-csat-card.tsx index bdfcdc8..ff244db 100644 --- a/src/components/tickets/ticket-csat-card.tsx +++ b/src/components/tickets/ticket-csat-card.tsx @@ -107,11 +107,14 @@ export function TicketCsatCard({ ticket }: TicketCsatCardProps) { const effectiveScore = hasSubmitted ? score : hoverScore ?? score const viewerIsAdmin = viewerRole === "ADMIN" + const viewerIsStaff = + viewerRole === "MANAGER" || viewerRole === "AGENT" || viewerIsAdmin + const collaboratorCanView = !viewerIsStaff && isRequester const adminCanInspect = viewerIsAdmin && ticket.status !== "PENDING" const canSubmit = Boolean(viewerId && viewerRole === "COLLABORATOR" && isRequester && isResolved && !hasSubmitted) const hasRating = hasSubmitted - const showCard = adminCanInspect || isRequester + const showCard = adminCanInspect || collaboratorCanView const ratedAtRelative = useMemo(() => formatRelative(ratedAt), [ratedAt]) @@ -181,7 +184,7 @@ export function TicketCsatCard({ ticket }: TicketCsatCardProps) { Conte como foi sua experiência com este chamado. - {hasRating && !viewerIsAdmin ? ( + {hasRating && collaboratorCanView ? (
Obrigado pelo feedback!
diff --git a/src/components/tickets/ticket-custom-fields.tsx b/src/components/tickets/ticket-custom-fields.tsx index f531f16..8794d0f 100644 --- a/src/components/tickets/ticket-custom-fields.tsx +++ b/src/components/tickets/ticket-custom-fields.tsx @@ -27,6 +27,7 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@ import { Field, FieldLabel } from "@/components/ui/field" import { Textarea } from "@/components/ui/textarea" import { Spinner } from "@/components/ui/spinner" +import { useLocalTimeZone } from "@/hooks/use-local-time-zone" type TicketCustomFieldsListProps = { record?: TicketCustomFieldRecord | null @@ -216,6 +217,7 @@ type TicketCustomFieldsSectionProps = { export function TicketCustomFieldsSection({ ticket, variant = "card", className }: TicketCustomFieldsSectionProps) { const { convexUserId, role } = useAuth() const canEdit = Boolean(convexUserId && (role === "admin" || role === "agent")) + const calendarTimeZone = useLocalTimeZone() const viewerId = convexUserId as Id<"users"> | null const tenantId = ticket.tenantId @@ -368,10 +370,9 @@ export function TicketCustomFieldsSection({ ticket, variant = "card", className {hasConfiguredFields ? ( -
-
- {selectedForm.fields.map((field) => renderFieldEditor(field))} -
+
+

Informações adicionais

+ {selectedForm.fields.map((field) => renderFieldEditor(field))}
) : (

Nenhum campo configurado ainda.

@@ -473,7 +474,7 @@ function renderFieldEditor(field: TicketFormFieldDefinition) { if (field.type === "select") { return ( - + {field.label} {field.required ? * : null} @@ -501,7 +502,7 @@ function renderFieldEditor(field: TicketFormFieldDefinition) { if (field.type === "number") { return ( - + {field.label} {field.required ? * : null} @@ -521,7 +522,7 @@ function renderFieldEditor(field: TicketFormFieldDefinition) { const isoValue = toIsoDateString(value) const parsedDate = isoValue ? parseIsoDate(isoValue) : null return ( - + {field.label} {field.required ? * : null} @@ -554,6 +555,7 @@ function renderFieldEditor(field: TicketFormFieldDefinition) { handleFieldChange(field, date ? format(date, "yyyy-MM-dd") : "") setOpenCalendarField(null) }} + timeZone={calendarTimeZone} /> @@ -564,7 +566,7 @@ function renderFieldEditor(field: TicketFormFieldDefinition) { if (field.type === "text" && !isTextarea) { return ( - + {field.label} {field.required ? * : null} @@ -580,7 +582,7 @@ function renderFieldEditor(field: TicketFormFieldDefinition) { } return ( - + {field.label} {field.required ? * : null} diff --git a/src/components/tickets/ticket-details-panel.tsx b/src/components/tickets/ticket-details-panel.tsx index 4384a22..dfc8388 100644 --- a/src/components/tickets/ticket-details-panel.tsx +++ b/src/components/tickets/ticket-details-panel.tsx @@ -7,6 +7,7 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/com import { getTicketStatusLabel, getTicketStatusSummaryTone } from "@/lib/ticket-status-style" import { Badge } from "@/components/ui/badge" import { cn } from "@/lib/utils" +import { getSlaDisplayStatus, getSlaDueDate, type SlaDisplayStatus } from "@/lib/sla-utils" interface TicketDetailsPanelProps { ticket: TicketWithDetails @@ -28,6 +29,13 @@ const priorityTone: Record = { URGENT: "danger", } +const slaStatusTone: Record, { 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) { if (!ms || ms <= 0) return "0s" const totalSeconds = Math.floor(ms / 1000) @@ -48,6 +56,22 @@ function formatMinutes(value?: number | null) { 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] +} + type SummaryChipConfig = { key: string label: string @@ -59,6 +83,10 @@ type SummaryChipConfig = { export function TicketDetailsPanel({ ticket }: TicketDetailsPanelProps) { const isAvulso = Boolean(ticket.company?.isAvulso) 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 chips: SummaryChipConfig[] = [ @@ -148,26 +176,37 @@ export function TicketDetailsPanel({ ticket }: TicketDetailsPanelProps) {

SLA & métricas

- {ticket.slaPolicy ? ( - {ticket.slaPolicy.name} + {ticket.slaSnapshot ? ( + + {ticket.slaSnapshot.categoryName ?? "Configuração personalizada"} + ) : null}

Política de SLA

- {ticket.slaPolicy ? ( -
-
- Resposta inicial - - {formatMinutes(ticket.slaPolicy.targetMinutesToFirstResponse)} - + {ticket.slaSnapshot ? ( +
+
+ Categoria +

{ticket.slaSnapshot.categoryName ?? "Categoria padrão"}

+

+ Prioridade: {priorityLabel[ticket.priority] ?? ticket.priority} +

-
- Resolução - - {formatMinutes(ticket.slaPolicy.targetMinutesToResolution)} - +
+ +
) : ( @@ -289,3 +328,30 @@ function SummaryChip({
) } + +interface SlaMetricProps { + label: string + target: string + dueDate: Date | null + status: SlaDisplayStatus +} + +function SlaMetric({ label, target, dueDate, status }: SlaMetricProps) { + const display = getSlaStatusDisplay(status) + return ( +
+
+
+

{label}

+

{target}

+ {dueDate ? ( +

{format(dueDate, "dd/MM/yyyy HH:mm", { locale: ptBR })}

+ ) : ( +

Sem prazo calculado

+ )} +
+ {display.label} +
+
+ ) +} diff --git a/src/components/tickets/ticket-summary-header.tsx b/src/components/tickets/ticket-summary-header.tsx index 055329f..7ea3395 100644 --- a/src/components/tickets/ticket-summary-header.tsx +++ b/src/components/tickets/ticket-summary-header.tsx @@ -1563,10 +1563,10 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) { {format(ticket.dueAt, "dd/MM/yyyy HH:mm", { locale: ptBR })}
) : null} - {ticket.slaPolicy ? ( + {ticket.slaSnapshot ? (
Política - {ticket.slaPolicy.name} + {ticket.slaSnapshot.categoryName ?? "Configuração personalizada"}
) : null} diff --git a/src/components/tickets/tickets-filters.tsx b/src/components/tickets/tickets-filters.tsx index 0a2cb6b..b19c3a8 100644 --- a/src/components/tickets/tickets-filters.tsx +++ b/src/components/tickets/tickets-filters.tsx @@ -8,6 +8,11 @@ import { ticketPrioritySchema, type TicketStatus, } 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 { Button } from "@/components/ui/button" import { Input } from "@/components/ui/input" @@ -60,28 +65,6 @@ const channelOptions = ticketChannelSchema.options.map((channel) => ({ 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 { onChange?: (filters: TicketFiltersState) => void queues?: QueueOption[] @@ -127,6 +110,7 @@ export function TicketsFilters({ onChange, queues = [], companies = [], assignee chips.push(`Responsável: ${found?.name ?? filters.assigneeId}`) } if (!filters.status && filters.view === "completed") chips.push("Exibindo concluídos") + if (filters.focusVisits) chips.push("Somente visitas/lab") return chips }, [filters, assignees]) diff --git a/src/components/tickets/tickets-view.tsx b/src/components/tickets/tickets-view.tsx index 820b954..1f1512c 100644 --- a/src/components/tickets/tickets-view.tsx +++ b/src/components/tickets/tickets-view.tsx @@ -15,6 +15,7 @@ import { useAuth } from "@/lib/auth-client" import { useDefaultQueues } from "@/hooks/use-default-queues" import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group" import { LayoutGrid, List } from "lucide-react" +import { isVisitTicket } from "@/lib/ticket-matchers" type TicketsViewProps = { initialFilters?: Partial @@ -163,9 +164,12 @@ export function TicketsView({ initialFilters }: TicketsViewProps = {}) { if (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 - }, [tickets, filters.queue, filters.status, filters.view, filters.company]) + }, [tickets, filters.queue, filters.status, filters.view, filters.company, filters.focusVisits]) const previousIdsRef = useRef([]) const [enteringIds, setEnteringIds] = useState>(new Set()) diff --git a/src/components/ui/switch.tsx b/src/components/ui/switch.tsx new file mode 100644 index 0000000..4ea9461 --- /dev/null +++ b/src/components/ui/switch.tsx @@ -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, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + +)) +Switch.displayName = SwitchPrimitive.Root.displayName + +export { Switch } diff --git a/src/hooks/use-local-time-zone.ts b/src/hooks/use-local-time-zone.ts new file mode 100644 index 0000000..59aa0ef --- /dev/null +++ b/src/hooks/use-local-time-zone.ts @@ -0,0 +1,19 @@ +import { useEffect, useState } from "react" + +export function useLocalTimeZone(fallback?: string) { + const [timeZone, setTimeZone] = useState(fallback) + + useEffect(() => { + if (typeof window === "undefined") return + try { + const resolved = Intl.DateTimeFormat().resolvedOptions().timeZone + if (resolved) { + setTimeZone(resolved) + } + } catch { + /* ignore */ + } + }, []) + + return timeZone +} diff --git a/src/lib/agenda-utils.ts b/src/lib/agenda-utils.ts new file mode 100644 index 0000000..f65c82f --- /dev/null +++ b/src/lib/agenda-utils.ts @@ -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 = { 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 +} diff --git a/src/lib/mappers/ticket.ts b/src/lib/mappers/ticket.ts index 55fa31c..5763ce9 100644 --- a/src/lib/mappers/ticket.ts +++ b/src/lib/mappers/ticket.ts @@ -89,6 +89,27 @@ const serverTicketSchema = z.object({ .nullable(), machine: serverMachineSummarySchema.optional().nullable(), 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(), firstResponseAt: z.number().nullable().optional(), resolvedAt: z.number().nullable().optional(), @@ -200,6 +221,19 @@ export function mapTicketFromServer(input: unknown) { ...base } = serverTicketSchema.parse(input); 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 = { ...base, status: normalizeTicketStatus(s.status), @@ -230,6 +264,14 @@ export function mapTicketFromServer(input: unknown) { csatRatedAt: csatRatedAt ? new Date(csatRatedAt) : null, csatRatedBy: csatRatedBy ?? 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 ? { totalWorkedMs: s.workSummary.totalWorkedMs, @@ -271,6 +313,19 @@ export function mapTicketWithDetailsFromServer(input: unknown) { ...base } = serverTicketWithDetailsSchema.parse(input); 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< Record >( @@ -317,6 +372,14 @@ export function mapTicketWithDetailsFromServer(input: unknown) { status: base.machine.status ?? 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) })), comments: base.comments.map((c) => ({ ...c, diff --git a/src/lib/schemas/ticket.ts b/src/lib/schemas/ticket.ts index ff9ec78..a059af8 100644 --- a/src/lib/schemas/ticket.ts +++ b/src/lib/schemas/ticket.ts @@ -6,8 +6,25 @@ export const ticketStatusSchema = z.enum([ "PAUSED", "RESOLVED", ]) - -export type TicketStatus = z.infer + +export type TicketStatus = z.infer + +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 export const ticketPrioritySchema = z.enum(["LOW", "MEDIUM", "HIGH", "URGENT"]) export type TicketPriority = z.infer @@ -130,15 +147,23 @@ export const ticketSchema = z.object({ company: ticketCompanySummarySchema.optional().nullable(), machine: ticketMachineSummarySchema.nullable().optional(), slaPolicy: z - .object({ - id: z.string(), - name: z.string(), - targetMinutesToFirstResponse: z.number().nullable(), - targetMinutesToResolution: z.number().nullable(), - }) - .nullable(), - dueAt: z.coerce.date().nullable(), - firstResponseAt: z.coerce.date().nullable(), + .object({ + id: z.string(), + name: z.string(), + targetMinutesToFirstResponse: z.number().nullable(), + targetMinutesToResolution: z.number().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(), + firstResponseAt: z.coerce.date().nullable(), resolvedAt: z.coerce.date().nullable(), updatedAt: z.coerce.date(), createdAt: z.coerce.date(), diff --git a/src/lib/sla-utils.ts b/src/lib/sla-utils.ts new file mode 100644 index 0000000..81a63cd --- /dev/null +++ b/src/lib/sla-utils.ts @@ -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) +} diff --git a/src/lib/ticket-filters.ts b/src/lib/ticket-filters.ts new file mode 100644 index 0000000..1785813 --- /dev/null +++ b/src/lib/ticket-filters.ts @@ -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, +} diff --git a/src/lib/ticket-matchers.ts b/src/lib/ticket-matchers.ts new file mode 100644 index 0000000..c6c0e83 --- /dev/null +++ b/src/lib/ticket-matchers.ts @@ -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))) +} diff --git a/src/lib/toast-patch.ts b/src/lib/toast-patch.ts index 8755c7d..44b3eef 100644 --- a/src/lib/toast-patch.ts +++ b/src/lib/toast-patch.ts @@ -4,8 +4,14 @@ import { toast } from "sonner" const METHODS = ["success", "error", "info", "warning", "message", "loading"] as const const TRAILING_PUNCTUATION_REGEX = /[\s!?.…,;:]+$/u -const toastAny = toast as typeof toast & { __punctuationPatched?: boolean } + type ToastMethodKey = (typeof METHODS)[number] +type PatchedToast = typeof toast & + Pick & { + __punctuationPatched?: boolean + } + +const patchedToast = toast as PatchedToast function stripTrailingPunctuation(value: string): string { const trimmed = value.trimEnd() @@ -32,25 +38,27 @@ function sanitizeOptions(options: T): T { } function wrapSimpleMethod(method: K) { - const original = toastAny[method] as typeof toast[K] + const original = patchedToast[method] if (typeof original !== "function") return - const patched = ((...args: Parameters) => { - const nextArgs = args.slice() as Parameters + type ToastFn = (...args: unknown[]) => unknown + const callable = original as ToastFn + const patched = ((...args: Parameters) => { + const nextArgs = args.slice() if (nextArgs.length > 0) { nextArgs[0] = sanitizeContent(nextArgs[0]) } if (nextArgs.length > 1) { nextArgs[1] = sanitizeOptions(nextArgs[1]) } - return original.apply(null, nextArgs as Parameters) - }) as typeof toast[K] - toastAny[method] = patched + return callable(...nextArgs) + }) as typeof patchedToast[K] + patchedToast[method] = patched } function wrapPromise() { - const originalPromise = toastAny.promise + const originalPromise = patchedToast.promise if (typeof originalPromise !== "function") return - toastAny.promise = ((promise, messages) => { + patchedToast.promise = ((promise, messages) => { const normalizedMessages = messages && typeof messages === "object" ? ({ @@ -66,8 +74,8 @@ function wrapPromise() { }) as typeof toast.promise } -if (!toastAny.__punctuationPatched) { - toastAny.__punctuationPatched = true +if (!patchedToast.__punctuationPatched) { + patchedToast.__punctuationPatched = true METHODS.forEach(wrapSimpleMethod) wrapPromise() }