feat: agenda polish, SLA sync, filters
This commit is contained in:
parent
7fb6c65d9a
commit
6ab8a6ce89
40 changed files with 2771 additions and 154 deletions
3
bun.lock
3
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=="],
|
||||
|
|
|
|||
2
convex/_generated/api.d.ts
vendored
2
convex/_generated/api.d.ts
vendored
|
|
@ -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;
|
||||
|
|
|
|||
169
convex/categorySlas.ts
Normal file
169
convex/categorySlas.ts
Normal file
|
|
@ -0,0 +1,169 @@
|
|||
import { mutation, query } from "./_generated/server"
|
||||
import { ConvexError, v } from "convex/values"
|
||||
|
||||
import { requireAdmin } from "./rbac"
|
||||
|
||||
const PRIORITY_VALUES = ["URGENT", "HIGH", "MEDIUM", "LOW", "DEFAULT"] as const
|
||||
const VALID_STATUSES = ["PENDING", "AWAITING_ATTENDANCE", "PAUSED", "RESOLVED"] as const
|
||||
const VALID_TIME_MODES = ["business", "calendar"] as const
|
||||
|
||||
type CategorySlaRuleInput = {
|
||||
priority: string
|
||||
responseTargetMinutes?: number | null
|
||||
responseMode?: string | null
|
||||
solutionTargetMinutes?: number | null
|
||||
solutionMode?: string | null
|
||||
alertThreshold?: number | null
|
||||
pauseStatuses?: string[] | null
|
||||
calendarType?: string | null
|
||||
}
|
||||
|
||||
const ruleInput = v.object({
|
||||
priority: v.string(),
|
||||
responseTargetMinutes: v.optional(v.number()),
|
||||
responseMode: v.optional(v.string()),
|
||||
solutionTargetMinutes: v.optional(v.number()),
|
||||
solutionMode: v.optional(v.string()),
|
||||
alertThreshold: v.optional(v.number()),
|
||||
pauseStatuses: v.optional(v.array(v.string())),
|
||||
calendarType: v.optional(v.string()),
|
||||
})
|
||||
|
||||
function normalizePriority(value: string) {
|
||||
const upper = value.trim().toUpperCase()
|
||||
return PRIORITY_VALUES.includes(upper as (typeof PRIORITY_VALUES)[number]) ? upper : "DEFAULT"
|
||||
}
|
||||
|
||||
function sanitizeTime(value?: number | null) {
|
||||
if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) return undefined
|
||||
return Math.round(value)
|
||||
}
|
||||
|
||||
function normalizeMode(value?: string | null) {
|
||||
if (!value) return "calendar"
|
||||
const normalized = value.toLowerCase()
|
||||
return VALID_TIME_MODES.includes(normalized as (typeof VALID_TIME_MODES)[number]) ? normalized : "calendar"
|
||||
}
|
||||
|
||||
function normalizeThreshold(value?: number | null) {
|
||||
if (typeof value !== "number" || Number.isNaN(value)) {
|
||||
return 0.8
|
||||
}
|
||||
const clamped = Math.min(Math.max(value, 0.1), 0.95)
|
||||
return Math.round(clamped * 100) / 100
|
||||
}
|
||||
|
||||
function normalizePauseStatuses(value?: string[] | null) {
|
||||
if (!Array.isArray(value)) return ["PAUSED"]
|
||||
const normalized = new Set<string>()
|
||||
for (const status of value) {
|
||||
if (typeof status !== "string") continue
|
||||
const upper = status.trim().toUpperCase()
|
||||
if (VALID_STATUSES.includes(upper as (typeof VALID_STATUSES)[number])) {
|
||||
normalized.add(upper)
|
||||
}
|
||||
}
|
||||
if (normalized.size === 0) {
|
||||
normalized.add("PAUSED")
|
||||
}
|
||||
return Array.from(normalized)
|
||||
}
|
||||
|
||||
export const get = query({
|
||||
args: {
|
||||
tenantId: v.string(),
|
||||
viewerId: v.id("users"),
|
||||
categoryId: v.id("ticketCategories"),
|
||||
},
|
||||
handler: async (ctx, { tenantId, viewerId, categoryId }) => {
|
||||
await requireAdmin(ctx, viewerId, tenantId)
|
||||
const category = await ctx.db.get(categoryId)
|
||||
if (!category || category.tenantId !== tenantId) {
|
||||
throw new ConvexError("Categoria não encontrada")
|
||||
}
|
||||
const records = await ctx.db
|
||||
.query("categorySlaSettings")
|
||||
.withIndex("by_tenant_category", (q) => q.eq("tenantId", tenantId).eq("categoryId", categoryId))
|
||||
.collect()
|
||||
|
||||
return {
|
||||
categoryId,
|
||||
categoryName: category.name,
|
||||
rules: records.map((record) => ({
|
||||
priority: record.priority,
|
||||
responseTargetMinutes: record.responseTargetMinutes ?? null,
|
||||
responseMode: record.responseMode ?? "calendar",
|
||||
solutionTargetMinutes: record.solutionTargetMinutes ?? null,
|
||||
solutionMode: record.solutionMode ?? "calendar",
|
||||
alertThreshold: record.alertThreshold ?? 0.8,
|
||||
pauseStatuses: record.pauseStatuses ?? ["PAUSED"],
|
||||
})),
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
export const save = mutation({
|
||||
args: {
|
||||
tenantId: v.string(),
|
||||
actorId: v.id("users"),
|
||||
categoryId: v.id("ticketCategories"),
|
||||
rules: v.array(ruleInput),
|
||||
},
|
||||
handler: async (ctx, { tenantId, actorId, categoryId, rules }) => {
|
||||
await requireAdmin(ctx, actorId, tenantId)
|
||||
const category = await ctx.db.get(categoryId)
|
||||
if (!category || category.tenantId !== tenantId) {
|
||||
throw new ConvexError("Categoria não encontrada")
|
||||
}
|
||||
const sanitized = sanitizeRules(rules)
|
||||
const existing = await ctx.db
|
||||
.query("categorySlaSettings")
|
||||
.withIndex("by_tenant_category", (q) => q.eq("tenantId", tenantId).eq("categoryId", categoryId))
|
||||
.collect()
|
||||
await Promise.all(existing.map((record) => ctx.db.delete(record._id)))
|
||||
|
||||
const now = Date.now()
|
||||
for (const rule of sanitized) {
|
||||
await ctx.db.insert("categorySlaSettings", {
|
||||
tenantId,
|
||||
categoryId,
|
||||
priority: rule.priority,
|
||||
responseTargetMinutes: rule.responseTargetMinutes,
|
||||
responseMode: rule.responseMode,
|
||||
solutionTargetMinutes: rule.solutionTargetMinutes,
|
||||
solutionMode: rule.solutionMode,
|
||||
alertThreshold: rule.alertThreshold,
|
||||
pauseStatuses: rule.pauseStatuses,
|
||||
calendarType: rule.calendarType ?? undefined,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
actorId,
|
||||
})
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
function sanitizeRules(rules: CategorySlaRuleInput[]) {
|
||||
const normalized: Record<string, ReturnType<typeof buildRule>> = {}
|
||||
for (const rule of rules) {
|
||||
const built = buildRule(rule)
|
||||
normalized[built.priority] = built
|
||||
}
|
||||
return Object.values(normalized)
|
||||
}
|
||||
|
||||
function buildRule(rule: CategorySlaRuleInput) {
|
||||
const priority = normalizePriority(rule.priority)
|
||||
const responseTargetMinutes = sanitizeTime(rule.responseTargetMinutes)
|
||||
const solutionTargetMinutes = sanitizeTime(rule.solutionTargetMinutes)
|
||||
return {
|
||||
priority,
|
||||
responseTargetMinutes,
|
||||
responseMode: normalizeMode(rule.responseMode),
|
||||
solutionTargetMinutes,
|
||||
solutionMode: normalizeMode(rule.solutionMode),
|
||||
alertThreshold: normalizeThreshold(rule.alertThreshold),
|
||||
pauseStatuses: normalizePauseStatuses(rule.pauseStatuses),
|
||||
calendarType: rule.calendarType ?? null,
|
||||
}
|
||||
}
|
||||
|
|
@ -75,6 +75,67 @@ function pruneUndefined<T extends Record<string, unknown>>(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<ExportedSlaSnapshot>({
|
||||
categoryId: snapshot.categoryId ? String(snapshot.categoryId) : undefined,
|
||||
categoryName: snapshot.categoryName,
|
||||
priority: snapshot.priority,
|
||||
responseTargetMinutes: snapshot.responseTargetMinutes,
|
||||
responseMode: snapshot.responseMode,
|
||||
solutionTargetMinutes: snapshot.solutionTargetMinutes,
|
||||
solutionMode: snapshot.solutionMode,
|
||||
alertThreshold: snapshot.alertThreshold,
|
||||
pauseStatuses: snapshot.pauseStatuses && snapshot.pauseStatuses.length > 0 ? snapshot.pauseStatuses : undefined,
|
||||
})
|
||||
return Object.keys(exported).length > 0 ? exported : undefined
|
||||
}
|
||||
|
||||
function normalizeImportedSlaSnapshot(snapshot: unknown): TicketSlaSnapshotRecord | undefined {
|
||||
if (!snapshot || typeof snapshot !== "object") return undefined
|
||||
const record = snapshot as Record<string, unknown>
|
||||
const pauseStatuses = Array.isArray(record.pauseStatuses)
|
||||
? record.pauseStatuses.filter((value): value is string => typeof value === "string")
|
||||
: undefined
|
||||
|
||||
const normalized = pruneUndefined<TicketSlaSnapshotRecord>({
|
||||
categoryName: typeof record.categoryName === "string" ? record.categoryName : undefined,
|
||||
priority: typeof record.priority === "string" ? record.priority : undefined,
|
||||
responseTargetMinutes: typeof record.responseTargetMinutes === "number" ? record.responseTargetMinutes : undefined,
|
||||
responseMode: typeof record.responseMode === "string" ? record.responseMode : undefined,
|
||||
solutionTargetMinutes: typeof record.solutionTargetMinutes === "number" ? record.solutionTargetMinutes : undefined,
|
||||
solutionMode: typeof record.solutionMode === "string" ? record.solutionMode : undefined,
|
||||
alertThreshold: typeof record.alertThreshold === "number" ? record.alertThreshold : undefined,
|
||||
pauseStatuses: pauseStatuses && pauseStatuses.length > 0 ? pauseStatuses : undefined,
|
||||
})
|
||||
|
||||
return Object.keys(normalized).length > 0 ? normalized : undefined
|
||||
}
|
||||
|
||||
async function ensureUser(
|
||||
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,
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -48,6 +48,19 @@ const LEGACY_STATUS_MAP: Record<string, TicketStatusNormalized> = {
|
|||
CLOSED: "RESOLVED",
|
||||
};
|
||||
|
||||
function normalizePriorityFilter(input: string | string[] | null | undefined): string[] {
|
||||
if (!input) return [];
|
||||
const list = Array.isArray(input) ? input : [input];
|
||||
const set = new Set<string>();
|
||||
for (const entry of list) {
|
||||
if (typeof entry !== "string") continue;
|
||||
const normalized = entry.trim().toUpperCase();
|
||||
if (!normalized) continue;
|
||||
set.add(normalized);
|
||||
}
|
||||
return Array.from(set);
|
||||
}
|
||||
|
||||
const missingRequesterLogCache = new Set<string>();
|
||||
const missingCommentAuthorLogCache = new Set<string>();
|
||||
|
||||
|
|
@ -80,6 +93,249 @@ function plainTextLength(html: string): number {
|
|||
}
|
||||
}
|
||||
|
||||
const SLA_DEFAULT_ALERT_THRESHOLD = 0.8;
|
||||
const BUSINESS_DAY_START_HOUR = 8;
|
||||
const BUSINESS_DAY_END_HOUR = 18;
|
||||
|
||||
type SlaTimeMode = "business" | "calendar";
|
||||
|
||||
type TicketSlaSnapshot = {
|
||||
categoryId?: Id<"ticketCategories">;
|
||||
categoryName?: string;
|
||||
priority: string;
|
||||
responseTargetMinutes?: number;
|
||||
responseMode: SlaTimeMode;
|
||||
solutionTargetMinutes?: number;
|
||||
solutionMode: SlaTimeMode;
|
||||
alertThreshold: number;
|
||||
pauseStatuses: TicketStatusNormalized[];
|
||||
};
|
||||
|
||||
type SlaStatusValue = "pending" | "met" | "breached" | "n/a";
|
||||
|
||||
function normalizeSlaMode(input?: string | null): SlaTimeMode {
|
||||
if (!input) return "calendar";
|
||||
return input.toLowerCase() === "business" ? "business" : "calendar";
|
||||
}
|
||||
|
||||
function normalizeSnapshotPauseStatuses(statuses?: string[] | null): TicketStatusNormalized[] {
|
||||
if (!Array.isArray(statuses)) {
|
||||
return ["PAUSED"];
|
||||
}
|
||||
const set = new Set<TicketStatusNormalized>();
|
||||
for (const value of statuses) {
|
||||
if (typeof value !== "string") continue;
|
||||
const normalized = normalizeStatus(value);
|
||||
set.add(normalized);
|
||||
}
|
||||
if (set.size === 0) {
|
||||
set.add("PAUSED");
|
||||
}
|
||||
return Array.from(set);
|
||||
}
|
||||
|
||||
async function resolveTicketSlaSnapshot(
|
||||
ctx: AnyCtx,
|
||||
tenantId: string,
|
||||
category: Doc<"ticketCategories"> | null,
|
||||
priority: string
|
||||
): Promise<TicketSlaSnapshot | null> {
|
||||
if (!category) {
|
||||
return null;
|
||||
}
|
||||
const normalizedPriority = priority.trim().toUpperCase();
|
||||
const rule =
|
||||
(await ctx.db
|
||||
.query("categorySlaSettings")
|
||||
.withIndex("by_tenant_category_priority", (q) =>
|
||||
q.eq("tenantId", tenantId).eq("categoryId", category._id).eq("priority", normalizedPriority)
|
||||
)
|
||||
.first()) ??
|
||||
(await ctx.db
|
||||
.query("categorySlaSettings")
|
||||
.withIndex("by_tenant_category_priority", (q) =>
|
||||
q.eq("tenantId", tenantId).eq("categoryId", category._id).eq("priority", "DEFAULT")
|
||||
)
|
||||
.first());
|
||||
if (!rule) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
categoryId: category._id,
|
||||
categoryName: category.name,
|
||||
priority: normalizedPriority,
|
||||
responseTargetMinutes: rule.responseTargetMinutes ?? undefined,
|
||||
responseMode: normalizeSlaMode(rule.responseMode),
|
||||
solutionTargetMinutes: rule.solutionTargetMinutes ?? undefined,
|
||||
solutionMode: normalizeSlaMode(rule.solutionMode),
|
||||
alertThreshold:
|
||||
typeof rule.alertThreshold === "number" && Number.isFinite(rule.alertThreshold)
|
||||
? rule.alertThreshold
|
||||
: SLA_DEFAULT_ALERT_THRESHOLD,
|
||||
pauseStatuses: normalizeSnapshotPauseStatuses(rule.pauseStatuses),
|
||||
};
|
||||
}
|
||||
|
||||
function computeSlaDueDates(snapshot: TicketSlaSnapshot, startAt: number) {
|
||||
return {
|
||||
responseDueAt: addMinutesWithMode(startAt, snapshot.responseTargetMinutes, snapshot.responseMode),
|
||||
solutionDueAt: addMinutesWithMode(startAt, snapshot.solutionTargetMinutes, snapshot.solutionMode),
|
||||
};
|
||||
}
|
||||
|
||||
function addMinutesWithMode(startAt: number, minutes: number | null | undefined, mode: SlaTimeMode): number | null {
|
||||
if (minutes === null || minutes === undefined || minutes <= 0) {
|
||||
return null;
|
||||
}
|
||||
if (mode === "calendar") {
|
||||
return startAt + minutes * 60000;
|
||||
}
|
||||
|
||||
let remaining = minutes;
|
||||
let cursor = alignToBusinessStart(new Date(startAt));
|
||||
|
||||
while (remaining > 0) {
|
||||
if (!isBusinessDay(cursor)) {
|
||||
cursor = advanceToNextBusinessStart(cursor);
|
||||
continue;
|
||||
}
|
||||
const endOfDay = new Date(cursor);
|
||||
endOfDay.setHours(BUSINESS_DAY_END_HOUR, 0, 0, 0);
|
||||
const minutesAvailable = (endOfDay.getTime() - cursor.getTime()) / 60000;
|
||||
if (minutesAvailable >= remaining) {
|
||||
cursor = new Date(cursor.getTime() + remaining * 60000);
|
||||
remaining = 0;
|
||||
} else {
|
||||
remaining -= minutesAvailable;
|
||||
cursor = advanceToNextBusinessStart(endOfDay);
|
||||
}
|
||||
}
|
||||
|
||||
return cursor.getTime();
|
||||
}
|
||||
|
||||
function alignToBusinessStart(date: Date): Date {
|
||||
let result = new Date(date);
|
||||
if (!isBusinessDay(result)) {
|
||||
return advanceToNextBusinessStart(result);
|
||||
}
|
||||
if (result.getHours() >= BUSINESS_DAY_END_HOUR) {
|
||||
return advanceToNextBusinessStart(result);
|
||||
}
|
||||
if (result.getHours() < BUSINESS_DAY_START_HOUR) {
|
||||
result.setHours(BUSINESS_DAY_START_HOUR, 0, 0, 0);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function advanceToNextBusinessStart(date: Date): Date {
|
||||
const next = new Date(date);
|
||||
next.setHours(BUSINESS_DAY_START_HOUR, 0, 0, 0);
|
||||
next.setDate(next.getDate() + 1);
|
||||
while (!isBusinessDay(next)) {
|
||||
next.setDate(next.getDate() + 1);
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
function isBusinessDay(date: Date) {
|
||||
const day = date.getDay();
|
||||
return day !== 0 && day !== 6;
|
||||
}
|
||||
|
||||
function applySlaSnapshot(snapshot: TicketSlaSnapshot | null, now: number) {
|
||||
if (!snapshot) return {};
|
||||
const { responseDueAt, solutionDueAt } = computeSlaDueDates(snapshot, now);
|
||||
return {
|
||||
slaSnapshot: snapshot,
|
||||
slaResponseDueAt: responseDueAt ?? undefined,
|
||||
slaSolutionDueAt: solutionDueAt ?? undefined,
|
||||
slaResponseStatus: responseDueAt ? ("pending" as SlaStatusValue) : ("n/a" as SlaStatusValue),
|
||||
slaSolutionStatus: solutionDueAt ? ("pending" as SlaStatusValue) : ("n/a" as SlaStatusValue),
|
||||
dueAt: solutionDueAt ?? undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function buildSlaStatusPatch(ticketDoc: Doc<"tickets">, nextStatus: TicketStatusNormalized, now: number) {
|
||||
const snapshot = ticketDoc.slaSnapshot as TicketSlaSnapshot | undefined;
|
||||
if (!snapshot) return {};
|
||||
const pauseSet = new Set(snapshot.pauseStatuses);
|
||||
const currentlyPaused = typeof ticketDoc.slaPausedAt === "number";
|
||||
|
||||
if (pauseSet.has(nextStatus)) {
|
||||
if (currentlyPaused) {
|
||||
return {};
|
||||
}
|
||||
return {
|
||||
slaPausedAt: now,
|
||||
slaPausedBy: nextStatus,
|
||||
};
|
||||
}
|
||||
|
||||
if (currentlyPaused) {
|
||||
const pauseStart = ticketDoc.slaPausedAt ?? now;
|
||||
const delta = Math.max(0, now - pauseStart);
|
||||
const patch: Record<string, unknown> = {
|
||||
slaPausedAt: undefined,
|
||||
slaPausedBy: undefined,
|
||||
slaPausedMs: (ticketDoc.slaPausedMs ?? 0) + delta,
|
||||
};
|
||||
if (ticketDoc.slaResponseDueAt && ticketDoc.slaResponseStatus !== "met" && ticketDoc.slaResponseStatus !== "breached") {
|
||||
patch.slaResponseDueAt = ticketDoc.slaResponseDueAt + delta;
|
||||
}
|
||||
if (ticketDoc.slaSolutionDueAt && ticketDoc.slaSolutionStatus !== "met" && ticketDoc.slaSolutionStatus !== "breached") {
|
||||
patch.slaSolutionDueAt = ticketDoc.slaSolutionDueAt + delta;
|
||||
patch.dueAt = ticketDoc.slaSolutionDueAt + delta;
|
||||
}
|
||||
return patch;
|
||||
}
|
||||
|
||||
return {};
|
||||
}
|
||||
|
||||
function mergeTicketState(ticketDoc: Doc<"tickets">, patch: Record<string, unknown>): Doc<"tickets"> {
|
||||
const merged = { ...ticketDoc } as Record<string, unknown>;
|
||||
for (const [key, value] of Object.entries(patch)) {
|
||||
if (value === undefined) {
|
||||
delete merged[key];
|
||||
} else {
|
||||
merged[key] = value;
|
||||
}
|
||||
}
|
||||
return merged as Doc<"tickets">;
|
||||
}
|
||||
|
||||
function buildResponseCompletionPatch(ticketDoc: Doc<"tickets">, now: number) {
|
||||
if (ticketDoc.firstResponseAt) {
|
||||
return {};
|
||||
}
|
||||
if (!ticketDoc.slaResponseDueAt) {
|
||||
return {
|
||||
firstResponseAt: now,
|
||||
slaResponseStatus: "n/a",
|
||||
};
|
||||
}
|
||||
const status = now <= ticketDoc.slaResponseDueAt ? "met" : "breached";
|
||||
return {
|
||||
firstResponseAt: now,
|
||||
slaResponseStatus: status,
|
||||
};
|
||||
}
|
||||
|
||||
function buildSolutionCompletionPatch(ticketDoc: Doc<"tickets">, now: number) {
|
||||
if (ticketDoc.slaSolutionStatus === "met" || ticketDoc.slaSolutionStatus === "breached") {
|
||||
return {};
|
||||
}
|
||||
if (!ticketDoc.slaSolutionDueAt) {
|
||||
return { slaSolutionStatus: "n/a" };
|
||||
}
|
||||
const status = now <= ticketDoc.slaSolutionDueAt ? "met" : "breached";
|
||||
return {
|
||||
slaSolutionStatus: status,
|
||||
};
|
||||
}
|
||||
|
||||
function resolveFormTemplateLabel(
|
||||
templateKey: string | null | undefined,
|
||||
storedLabel: string | null | undefined
|
||||
|
|
@ -223,51 +479,77 @@ async function fetchTicketFieldsByScopes(
|
|||
tenantId: string,
|
||||
scopes: string[]
|
||||
): Promise<TicketFieldScopeMap> {
|
||||
const uniqueScopes = Array.from(new Set(scopes));
|
||||
const uniqueScopes = Array.from(new Set(scopes.filter((scope) => Boolean(scope))));
|
||||
if (uniqueScopes.length === 0) {
|
||||
return new Map();
|
||||
}
|
||||
const scopeSet = new Set(uniqueScopes);
|
||||
const result: TicketFieldScopeMap = new Map();
|
||||
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<Doc<"ticketFormSettings">[]> {
|
||||
const tenantSettingsPromise = ctx.db
|
||||
): Promise<Map<string, Doc<"ticketFormSettings">[]>> {
|
||||
const uniqueTemplates = Array.from(new Set(templateKeys));
|
||||
if (uniqueTemplates.length === 0) {
|
||||
return new Map();
|
||||
}
|
||||
const keySet = new Set(uniqueTemplates);
|
||||
const viewerIdStr = String(viewerId);
|
||||
const viewerCompanyIdStr = viewerCompanyId ? String(viewerCompanyId) : null;
|
||||
const scopedMap = new Map<string, Doc<"ticketFormSettings">[]>();
|
||||
|
||||
const allSettings = await ctx.db
|
||||
.query("ticketFormSettings")
|
||||
.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<Doc<"ticketFormSettings">[]>([]);
|
||||
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<string, unknown>;
|
||||
const normalized: Record<string, unknown> = {};
|
||||
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<string, Doc<"ticketFormSettings">[]>()
|
||||
|
||||
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<string, Doc<"ticketFormSettings">[]>()
|
||||
: 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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
31
docs/alteracoes-2025-11-08.md
Normal file
31
docs/alteracoes-2025-11-08.md
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
# Alterações — 08/11/2025
|
||||
|
||||
## Concluído
|
||||
|
||||
- **Agenda (Resumo & Calendário)** — nova rota `/agenda` (AppShell + Tabs) com filtros persistentes (`AgendaFilters`), visão Resumo com KPIs por status/SLA e cards por seção, e Calendário mensal com eventos coloridos por SLA. Dataset derivado em `src/lib/agenda-utils.ts` normaliza tickets Convex → blocos (upcoming, overdue, unscheduled, completed) e gera eventos sintéticos até conectarmos ao modelo definitivo de agendamentos.
|
||||
- **Sidebar & navegação** — link “Agenda” habilitado no `AppSidebar`, replicando permissões das páginas de tickets.
|
||||
- **Datas de admissão/desligamento** — `use-local-time-zone` + `Calendar` atualizados; todos os fluxos (novo ticket, edição dentro do ticket, portal) aplicam o mesmo picker e normalizam valores em UTC, eliminando o deslocamento -1/-2 dias para nascimento e início.
|
||||
- **Layout dos campos personalizados** — seção “Editar campos personalizados” reutiliza o grid/style do modal de criação, mantendo labels compactos, espaçamento consistente e colunas responsivas semelhantes ao layout do portal.
|
||||
- **CSAT no ticket individual** — `ticket-csat-card.tsx` mantém a experiência para colaboradores, mas oculta a avaliação/“Obrigado pelo feedback!” de agentes/gestores. Também bloqueia o card inteiro para agentes (somente admins visualizam a nota rapidamente).
|
||||
- **Toast global** — `src/lib/toast-patch.ts` higieniza títulos/descrições removendo pontuação final (`!`, `.`, reticências). Patch tipado evita `any` e replica o comportamento em todos os métodos (`success`, `error`, `loading`, `promise`, etc.).
|
||||
- **Linha do tempo mais útil** — mutations de campos personalizados ignoram saves sem alteração e registram apenas os campos realmente modificados, reduzindo spam como “Campos personalizados atualizados (Nome do solicitante, …)” quando nada mudou.
|
||||
- **SLA por categoria/prioridade**
|
||||
- Convex: tabela `categorySlaSettings`, helpers (`categorySlas.ts`) e drawer na UI de categorias permitem definir alvos por prioridade (resposta/solução, modo business/calendar, pausas e alert threshold).
|
||||
- Tickets: snapshot (`ticket.slaSnapshot`) no momento da criação inclui regra aplicada; `computeSlaDueDates` trata horas úteis (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.
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -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?
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
121
src/app/agenda/agenda-page-client.tsx
Normal file
121
src/app/agenda/agenda-page-client.tsx
Normal file
|
|
@ -0,0 +1,121 @@
|
|||
"use client"
|
||||
|
||||
import { useEffect, useMemo, useState } from "react"
|
||||
import { useQuery } from "convex/react"
|
||||
import { format } from "date-fns"
|
||||
import { ptBR } from "date-fns/locale/pt-BR"
|
||||
|
||||
import { api } from "@/convex/_generated/api"
|
||||
import type { Id } from "@/convex/_generated/dataModel"
|
||||
import { DEFAULT_TENANT_ID } from "@/lib/constants"
|
||||
import { mapTicketsFromServerList } from "@/lib/mappers/ticket"
|
||||
import type { Ticket } from "@/lib/schemas/ticket"
|
||||
import { AppShell } from "@/components/app-shell"
|
||||
import { SiteHeader } from "@/components/site-header"
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { CalendarPlus } from "lucide-react"
|
||||
import { useAuth } from "@/lib/auth-client"
|
||||
import { AgendaFilters, AgendaFilterState, AgendaPeriod, defaultAgendaFilters } from "@/components/agenda/agenda-filters"
|
||||
import { AgendaSummaryView } from "@/components/agenda/agenda-summary-view"
|
||||
import { AgendaCalendarView } from "@/components/agenda/agenda-calendar-view"
|
||||
import { buildAgendaDataset, type AgendaDataset } from "@/lib/agenda-utils"
|
||||
|
||||
export function AgendaPageClient() {
|
||||
const [activeTab, setActiveTab] = useState<"summary" | "calendar">("summary")
|
||||
const [filters, setFilters] = useState<AgendaFilterState>(defaultAgendaFilters)
|
||||
|
||||
const { convexUserId, session } = useAuth()
|
||||
const userId = convexUserId as Id<"users"> | null
|
||||
const tenantId = session?.user?.tenantId ?? DEFAULT_TENANT_ID
|
||||
|
||||
const ticketsArgs = userId
|
||||
? {
|
||||
tenantId,
|
||||
viewerId: userId,
|
||||
status: undefined,
|
||||
priority: filters.priorities.length ? filters.priorities : undefined,
|
||||
queueId: undefined,
|
||||
channel: undefined,
|
||||
assigneeId: filters.onlyMyTickets ? userId : undefined,
|
||||
search: undefined,
|
||||
}
|
||||
: "skip"
|
||||
|
||||
const ticketsRaw = useQuery(api.tickets.list, ticketsArgs)
|
||||
const mappedTickets = useMemo<Ticket[] | null>(() => {
|
||||
if (!Array.isArray(ticketsRaw)) return null
|
||||
return mapTicketsFromServerList(ticketsRaw as unknown[])
|
||||
}, [ticketsRaw])
|
||||
|
||||
const [cachedTickets, setCachedTickets] = useState<Ticket[]>([])
|
||||
|
||||
useEffect(() => {
|
||||
if (mappedTickets) {
|
||||
setCachedTickets(mappedTickets)
|
||||
}
|
||||
}, [mappedTickets])
|
||||
|
||||
const effectiveTickets = mappedTickets ?? cachedTickets
|
||||
const isInitialLoading = !mappedTickets && cachedTickets.length === 0 && ticketsArgs !== "skip"
|
||||
|
||||
const dataset: AgendaDataset = useMemo(
|
||||
() => buildAgendaDataset(effectiveTickets, filters),
|
||||
[effectiveTickets, filters]
|
||||
)
|
||||
|
||||
const greeting = getGreetingMessage()
|
||||
const firstName = session?.user?.name?.split(" ")[0] ?? session?.user?.email?.split("@")[0] ?? "equipe"
|
||||
const rangeDescription = formatRangeDescription(filters.period, dataset.range)
|
||||
const headerLead = `${greeting}, ${firstName}! ${rangeDescription}`
|
||||
|
||||
return (
|
||||
<AppShell
|
||||
header={
|
||||
<SiteHeader
|
||||
title="Agenda"
|
||||
lead={headerLead}
|
||||
secondaryAction={
|
||||
<Button variant="secondary" size="sm" className="gap-2" disabled>
|
||||
<CalendarPlus className="size-4" />
|
||||
Em breve: novo compromisso
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<div className="flex flex-col gap-6 px-4 lg:px-6">
|
||||
<AgendaFilters filters={filters} onChange={setFilters} queues={dataset.availableQueues} />
|
||||
<Tabs value={activeTab} onValueChange={(value) => setActiveTab(value as typeof activeTab)}>
|
||||
<TabsList className="mb-4">
|
||||
<TabsTrigger value="summary">Resumo</TabsTrigger>
|
||||
<TabsTrigger value="calendar">Calendário</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="summary" className="focus-visible:outline-none">
|
||||
<AgendaSummaryView data={dataset} isLoading={isInitialLoading} />
|
||||
</TabsContent>
|
||||
<TabsContent value="calendar" className="focus-visible:outline-none">
|
||||
<AgendaCalendarView events={dataset.calendarEvents} range={dataset.range} />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
</AppShell>
|
||||
)
|
||||
}
|
||||
|
||||
function getGreetingMessage(date: Date = new Date()) {
|
||||
const hour = date.getHours()
|
||||
if (hour < 12) return "Bom dia"
|
||||
if (hour < 18) return "Boa tarde"
|
||||
return "Boa noite"
|
||||
}
|
||||
|
||||
function formatRangeDescription(period: AgendaPeriod, range: { start: Date; end: Date }) {
|
||||
if (period === "today") {
|
||||
return `Hoje é ${format(range.start, "eeee, d 'de' MMMM", { locale: ptBR })}`
|
||||
}
|
||||
if (period === "week") {
|
||||
return `Semana de ${format(range.start, "d MMM", { locale: ptBR })} a ${format(range.end, "d MMM", { locale: ptBR })}`
|
||||
}
|
||||
return `Mês de ${format(range.start, "MMMM 'de' yyyy", { locale: ptBR })}`
|
||||
}
|
||||
9
src/app/agenda/page.tsx
Normal file
9
src/app/agenda/page.tsx
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
import { requireAuthenticatedSession } from "@/lib/auth-server"
|
||||
|
||||
import { AgendaPageClient } from "./agenda-page-client"
|
||||
|
||||
export default async function AgendaPage() {
|
||||
await requireAuthenticatedSession()
|
||||
return <AgendaPageClient />
|
||||
}
|
||||
|
||||
|
|
@ -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() {
|
||||
await requireAuthenticatedSession()
|
||||
return <TicketsPageClient />
|
||||
type TicketsPageProps = {
|
||||
searchParams?: Record<string, string | string[] | undefined>
|
||||
}
|
||||
|
||||
export default async function TicketsPage({ searchParams }: TicketsPageProps) {
|
||||
await requireAuthenticatedSession()
|
||||
const initialFilters = deriveInitialFilters(searchParams ?? {})
|
||||
return <TicketsPageClient initialFilters={initialFilters} />
|
||||
}
|
||||
|
||||
function getParamValue(value: string | string[] | undefined): string | undefined {
|
||||
if (Array.isArray(value)) {
|
||||
return value[0]
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
function deriveInitialFilters(params: Record<string, string | string[] | undefined>): Partial<TicketFiltersState> {
|
||||
const initial: Partial<TicketFiltersState> = {}
|
||||
const view = getParamValue(params.view)
|
||||
if (view === "completed" || view === "active") {
|
||||
initial.view = view
|
||||
}
|
||||
const status = getParamValue(params.status)
|
||||
if (status && ["PENDING", "AWAITING_ATTENDANCE", "PAUSED", "RESOLVED"].includes(status)) {
|
||||
initial.status = status as TicketStatus
|
||||
}
|
||||
const priority = getParamValue(params.priority)
|
||||
if (priority) initial.priority = priority
|
||||
const queue = getParamValue(params.queue)
|
||||
if (queue) initial.queue = queue
|
||||
const channel = getParamValue(params.channel)
|
||||
if (channel) initial.channel = channel
|
||||
const company = getParamValue(params.company)
|
||||
if (company) initial.company = company
|
||||
const assigneeId = getParamValue(params.assignee)
|
||||
if (assigneeId) initial.assigneeId = assigneeId
|
||||
const focus = getParamValue(params.focus)
|
||||
if (focus === "visits") {
|
||||
initial.focusVisits = true
|
||||
}
|
||||
return initial
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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<TicketFiltersState>
|
||||
}
|
||||
|
||||
export function TicketsPageClient({ initialFilters }: TicketsPageClientProps = {}) {
|
||||
return (
|
||||
<AppShell
|
||||
header={
|
||||
|
|
@ -44,7 +49,7 @@ export function TicketsPageClient() {
|
|||
<div className="px-4 lg:px-6">
|
||||
<TicketQueueSummaryCards />
|
||||
</div>
|
||||
<TicketsView />
|
||||
<TicketsView initialFilters={initialFilters} />
|
||||
</div>
|
||||
</AppShell>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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<T extends "category" | "subcategory"> =
|
||||
| { type: T; targetId: string; reason: string }
|
||||
|
|
@ -36,6 +38,7 @@ export function CategoriesManager() {
|
|||
const [subcategoryDraft, setSubcategoryDraft] = useState("")
|
||||
const [subcategoryList, setSubcategoryList] = useState<string[]>([])
|
||||
const [deleteState, setDeleteState] = useState<DeleteState<"category" | "subcategory">>(null)
|
||||
const [slaCategory, setSlaCategory] = useState<TicketCategory | null>(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 (
|
||||
<div className="space-y-6">
|
||||
|
|
@ -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() {
|
|||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
<CategorySlaDrawer
|
||||
category={slaCategory}
|
||||
tenantId={tenantId}
|
||||
viewerId={viewerId}
|
||||
onClose={() => setSlaCategory(null)}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -385,6 +396,7 @@ interface CategoryItemProps {
|
|||
onCreateSubcategory: (categoryId: string, payload: { name: string }) => Promise<void>
|
||||
onUpdateSubcategory: (subcategory: TicketSubcategory, name: string) => Promise<void>
|
||||
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({
|
|||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-2">
|
||||
<Button size="sm" variant="secondary" onClick={onConfigureSla} disabled={disabled}>
|
||||
Configurar SLA
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" onClick={() => setIsEditing(true)} disabled={disabled}>
|
||||
Editar
|
||||
</Button>
|
||||
|
|
@ -552,3 +568,360 @@ function SubcategoryItem({ subcategory, disabled, onUpdate, onDelete }: Subcateg
|
|||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
type RuleFormState = {
|
||||
responseValue: string
|
||||
responseUnit: "minutes" | "hours" | "days"
|
||||
responseMode: "business" | "calendar"
|
||||
solutionValue: string
|
||||
solutionUnit: "minutes" | "hours" | "days"
|
||||
solutionMode: "business" | "calendar"
|
||||
alertThreshold: number
|
||||
pauseStatuses: string[]
|
||||
}
|
||||
|
||||
const PRIORITY_ROWS = [
|
||||
{ value: "URGENT", label: "Crítico" },
|
||||
{ value: "HIGH", label: "Alta" },
|
||||
{ value: "MEDIUM", label: "Média" },
|
||||
{ value: "LOW", label: "Baixa" },
|
||||
{ value: "DEFAULT", label: "Sem prioridade" },
|
||||
] as const
|
||||
|
||||
const TIME_UNITS: Array<{ value: RuleFormState["responseUnit"]; label: string; factor: number }> = [
|
||||
{ value: "minutes", label: "Minutos", factor: 1 },
|
||||
{ value: "hours", label: "Horas", factor: 60 },
|
||||
{ value: "days", label: "Dias", factor: 1440 },
|
||||
]
|
||||
|
||||
const MODE_OPTIONS: Array<{ value: RuleFormState["responseMode"]; label: string }> = [
|
||||
{ value: "calendar", label: "Horas corridas" },
|
||||
{ value: "business", label: "Horas úteis" },
|
||||
]
|
||||
|
||||
const PAUSE_STATUS_OPTIONS = [
|
||||
{ value: "PENDING", label: "Pendente" },
|
||||
{ value: "AWAITING_ATTENDANCE", label: "Em atendimento" },
|
||||
{ value: "PAUSED", label: "Pausado" },
|
||||
] as const
|
||||
|
||||
const DEFAULT_RULE_STATE: RuleFormState = {
|
||||
responseValue: "",
|
||||
responseUnit: "hours",
|
||||
responseMode: "calendar",
|
||||
solutionValue: "",
|
||||
solutionUnit: "hours",
|
||||
solutionMode: "calendar",
|
||||
alertThreshold: 80,
|
||||
pauseStatuses: ["PAUSED"],
|
||||
}
|
||||
|
||||
type CategorySlaDrawerProps = {
|
||||
category: TicketCategory | null
|
||||
tenantId: string
|
||||
viewerId: Id<"users"> | null
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
function CategorySlaDrawer({ category, tenantId, viewerId, onClose }: CategorySlaDrawerProps) {
|
||||
const [rules, setRules] = useState<Record<string, RuleFormState>>(() => buildDefaultRuleState())
|
||||
const [saving, setSaving] = useState(false)
|
||||
const drawerOpen = Boolean(category)
|
||||
|
||||
const canLoad = Boolean(category && viewerId)
|
||||
const existing = useQuery(
|
||||
api.categorySlas.get,
|
||||
canLoad
|
||||
? {
|
||||
tenantId,
|
||||
viewerId: viewerId as Id<"users">,
|
||||
categoryId: category!.id as Id<"ticketCategories">,
|
||||
}
|
||||
: "skip"
|
||||
) as { rules: Array<{ priority: string; responseTargetMinutes: number | null; responseMode?: string | null; solutionTargetMinutes: number | null; solutionMode?: string | null; alertThreshold?: number | null; pauseStatuses?: string[] | null }> } | undefined
|
||||
|
||||
const saveSla = useMutation(api.categorySlas.save)
|
||||
|
||||
useEffect(() => {
|
||||
if (!existing?.rules) {
|
||||
setRules(buildDefaultRuleState())
|
||||
return
|
||||
}
|
||||
const next = buildDefaultRuleState()
|
||||
for (const rule of existing.rules) {
|
||||
const priority = rule.priority?.toUpperCase() ?? "DEFAULT"
|
||||
next[priority] = convertRuleToForm(rule)
|
||||
}
|
||||
setRules(next)
|
||||
}, [existing, category?.id])
|
||||
|
||||
const handleChange = (priority: string, patch: Partial<RuleFormState>) => {
|
||||
setRules((current) => ({
|
||||
...current,
|
||||
[priority]: {
|
||||
...current[priority],
|
||||
...patch,
|
||||
},
|
||||
}))
|
||||
}
|
||||
|
||||
const togglePause = (priority: string, status: string) => {
|
||||
setRules((current) => {
|
||||
const selected = new Set(current[priority].pauseStatuses)
|
||||
if (selected.has(status)) {
|
||||
selected.delete(status)
|
||||
} else {
|
||||
selected.add(status)
|
||||
}
|
||||
if (selected.size === 0) {
|
||||
selected.add("PAUSED")
|
||||
}
|
||||
return {
|
||||
...current,
|
||||
[priority]: {
|
||||
...current[priority],
|
||||
pauseStatuses: Array.from(selected),
|
||||
},
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!category || !viewerId) return
|
||||
setSaving(true)
|
||||
toast.loading("Salvando SLA...", { id: "category-sla" })
|
||||
try {
|
||||
const payload = PRIORITY_ROWS.map((row) => {
|
||||
const form = rules[row.value]
|
||||
return {
|
||||
priority: row.value,
|
||||
responseTargetMinutes: convertToMinutes(form.responseValue, form.responseUnit),
|
||||
responseMode: form.responseMode,
|
||||
solutionTargetMinutes: convertToMinutes(form.solutionValue, form.solutionUnit),
|
||||
solutionMode: form.solutionMode,
|
||||
alertThreshold: Math.min(Math.max(form.alertThreshold, 5), 95) / 100,
|
||||
pauseStatuses: form.pauseStatuses,
|
||||
}
|
||||
})
|
||||
await saveSla({
|
||||
tenantId,
|
||||
actorId: viewerId,
|
||||
categoryId: category.id as Id<"ticketCategories">,
|
||||
rules: payload,
|
||||
})
|
||||
toast.success("SLA atualizado", { id: "category-sla" })
|
||||
onClose()
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
toast.error("Não foi possível salvar as regras de SLA.", { id: "category-sla" })
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={drawerOpen}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
onClose()
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DialogContent className="max-w-4xl overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Configurar SLA — {category?.name ?? ""}</DialogTitle>
|
||||
<DialogDescription>
|
||||
Defina metas de resposta e resolução para cada prioridade. Os prazos em horas úteis consideram apenas
|
||||
segunda a sexta, das 8h às 18h.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4 py-2">
|
||||
{PRIORITY_ROWS.map((row) => {
|
||||
const form = rules[row.value]
|
||||
return (
|
||||
<div key={row.value} className="space-y-3 rounded-2xl border border-slate-200 bg-white/80 p-4 shadow-sm">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-neutral-900">{row.label}</p>
|
||||
<p className="text-xs text-neutral-500">
|
||||
{row.value === "DEFAULT" ? "Aplicado quando o ticket não tem prioridade definida." : "Aplica-se aos tickets desta prioridade."}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<SlaInputGroup
|
||||
title="Tempo de resposta"
|
||||
amount={form.responseValue}
|
||||
unit={form.responseUnit}
|
||||
mode={form.responseMode}
|
||||
onAmountChange={(value) => handleChange(row.value, { responseValue: value })}
|
||||
onUnitChange={(value) => handleChange(row.value, { responseUnit: value as RuleFormState["responseUnit"] })}
|
||||
onModeChange={(value) => handleChange(row.value, { responseMode: value as RuleFormState["responseMode"] })}
|
||||
/>
|
||||
<SlaInputGroup
|
||||
title="Tempo de solução"
|
||||
amount={form.solutionValue}
|
||||
unit={form.solutionUnit}
|
||||
mode={form.solutionMode}
|
||||
onAmountChange={(value) => handleChange(row.value, { solutionValue: value })}
|
||||
onUnitChange={(value) => handleChange(row.value, { solutionUnit: value as RuleFormState["solutionUnit"] })}
|
||||
onModeChange={(value) => handleChange(row.value, { solutionMode: value as RuleFormState["solutionMode"] })}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div>
|
||||
<p className="text-xs font-semibold uppercase tracking-wide text-neutral-500">Alertar quando</p>
|
||||
<div className="mt-2 flex items-center gap-2">
|
||||
<Input
|
||||
type="number"
|
||||
min={10}
|
||||
max={95}
|
||||
step={5}
|
||||
value={form.alertThreshold}
|
||||
onChange={(event) => handleChange(row.value, { alertThreshold: Number(event.target.value) || 0 })}
|
||||
/>
|
||||
<span className="text-xs text-neutral-500">% do tempo for consumido.</span>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs font-semibold uppercase tracking-wide text-neutral-500">Estados que pausam</p>
|
||||
<div className="mt-2 flex flex-wrap gap-2">
|
||||
{PAUSE_STATUS_OPTIONS.map((option) => {
|
||||
const selected = form.pauseStatuses.includes(option.value)
|
||||
return (
|
||||
<button
|
||||
key={option.value}
|
||||
type="button"
|
||||
onClick={() => togglePause(row.value, option.value)}
|
||||
className={cn(
|
||||
"rounded-full border px-3 py-1 text-xs font-semibold transition",
|
||||
selected ? "border-primary bg-primary text-primary-foreground" : "border-slate-200 bg-white text-neutral-600"
|
||||
)}
|
||||
>
|
||||
{option.label}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
<DialogFooter className="gap-2">
|
||||
<Button variant="outline" onClick={onClose}>
|
||||
Cancelar
|
||||
</Button>
|
||||
<Button onClick={handleSave} disabled={saving || !viewerId}>
|
||||
{saving ? "Salvando..." : "Salvar"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
function buildDefaultRuleState() {
|
||||
return PRIORITY_ROWS.reduce<Record<string, RuleFormState>>((acc, row) => {
|
||||
acc[row.value] = { ...DEFAULT_RULE_STATE }
|
||||
return acc
|
||||
}, {})
|
||||
}
|
||||
|
||||
function convertRuleToForm(rule: {
|
||||
priority: string
|
||||
responseTargetMinutes: number | null
|
||||
responseMode?: string | null
|
||||
solutionTargetMinutes: number | null
|
||||
solutionMode?: string | null
|
||||
alertThreshold?: number | null
|
||||
pauseStatuses?: string[] | null
|
||||
}): RuleFormState {
|
||||
const response = minutesToForm(rule.responseTargetMinutes)
|
||||
const solution = minutesToForm(rule.solutionTargetMinutes)
|
||||
return {
|
||||
responseValue: response.amount,
|
||||
responseUnit: response.unit,
|
||||
responseMode: (rule.responseMode ?? "calendar") as RuleFormState["responseMode"],
|
||||
solutionValue: solution.amount,
|
||||
solutionUnit: solution.unit,
|
||||
solutionMode: (rule.solutionMode ?? "calendar") as RuleFormState["solutionMode"],
|
||||
alertThreshold: Math.round(((rule.alertThreshold ?? 0.8) * 100)),
|
||||
pauseStatuses: rule.pauseStatuses && rule.pauseStatuses.length > 0 ? rule.pauseStatuses : ["PAUSED"],
|
||||
}
|
||||
}
|
||||
|
||||
function minutesToForm(input?: number | null) {
|
||||
if (!input || input <= 0) {
|
||||
return { amount: "", unit: "hours" as RuleFormState["responseUnit"] }
|
||||
}
|
||||
for (const option of [...TIME_UNITS].reverse()) {
|
||||
if (input % option.factor === 0) {
|
||||
return { amount: String(Math.round(input / option.factor)), unit: option.value }
|
||||
}
|
||||
}
|
||||
return { amount: String(input), unit: "minutes" as RuleFormState["responseUnit"] }
|
||||
}
|
||||
|
||||
function convertToMinutes(value: string, unit: RuleFormState["responseUnit"]) {
|
||||
const numeric = Number(value)
|
||||
if (!Number.isFinite(numeric) || numeric <= 0) {
|
||||
return undefined
|
||||
}
|
||||
const factor = TIME_UNITS.find((item) => item.value === unit)?.factor ?? 1
|
||||
return Math.round(numeric * factor)
|
||||
}
|
||||
|
||||
type SlaInputGroupProps = {
|
||||
title: string
|
||||
amount: string
|
||||
unit: RuleFormState["responseUnit"]
|
||||
mode: RuleFormState["responseMode"]
|
||||
onAmountChange: (value: string) => void
|
||||
onUnitChange: (value: string) => void
|
||||
onModeChange: (value: string) => void
|
||||
}
|
||||
|
||||
function SlaInputGroup({ title, amount, unit, mode, onAmountChange, onUnitChange, onModeChange }: SlaInputGroupProps) {
|
||||
return (
|
||||
<div className="space-y-2 rounded-xl border border-slate-200 bg-white px-3 py-3">
|
||||
<p className="text-xs font-semibold uppercase tracking-wide text-neutral-500">{title}</p>
|
||||
<div className="flex flex-col gap-2 md:flex-row">
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
step={1}
|
||||
value={amount}
|
||||
onChange={(event) => onAmountChange(event.target.value)}
|
||||
placeholder="0"
|
||||
/>
|
||||
<Select value={unit} onValueChange={onUnitChange}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Unidade" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{TIME_UNITS.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<Select value={mode} onValueChange={onModeChange}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Tipo de contagem" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{MODE_OPTIONS.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
372
src/components/agenda/agenda-calendar-view.tsx
Normal file
372
src/components/agenda/agenda-calendar-view.tsx
Normal file
|
|
@ -0,0 +1,372 @@
|
|||
"use client"
|
||||
|
||||
import Link from "next/link"
|
||||
import { Fragment, useMemo, useState } from "react"
|
||||
import {
|
||||
addDays,
|
||||
addWeeks,
|
||||
addMonths,
|
||||
endOfMonth,
|
||||
endOfWeek,
|
||||
format,
|
||||
isSameDay,
|
||||
isSameMonth,
|
||||
isToday,
|
||||
startOfMonth,
|
||||
startOfWeek,
|
||||
} from "date-fns"
|
||||
import { ptBR } from "date-fns/locale/pt-BR"
|
||||
import { ChevronLeft, ChevronRight, CalendarDays, AlertCircle } from "lucide-react"
|
||||
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"
|
||||
import { ScrollArea } from "@/components/ui/scroll-area"
|
||||
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"
|
||||
import { priorityStyles } from "@/lib/ticket-priority-style"
|
||||
import type { AgendaCalendarEvent } from "@/lib/agenda-utils"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
type AgendaCalendarViewProps = {
|
||||
events: AgendaCalendarEvent[]
|
||||
range: { start: Date; end: Date }
|
||||
}
|
||||
|
||||
const weekdayLabels = ["Seg", "Ter", "Qua", "Qui", "Sex", "Sáb", "Dom"]
|
||||
|
||||
const slaColors: Record<AgendaCalendarEvent["slaStatus"], string> = {
|
||||
on_track: "border-emerald-200 bg-emerald-50 text-emerald-800",
|
||||
at_risk: "border-amber-200 bg-amber-50 text-amber-800",
|
||||
breached: "border-rose-200 bg-rose-50 text-rose-800",
|
||||
met: "border-primary/20 bg-primary/5 text-primary",
|
||||
}
|
||||
|
||||
export function AgendaCalendarView({ events, range }: AgendaCalendarViewProps) {
|
||||
const [viewMode, setViewMode] = useState<"month" | "week">("month")
|
||||
const [currentMonth, setCurrentMonth] = useState<Date>(startOfMonth(range.start))
|
||||
const [currentWeekStart, setCurrentWeekStart] = useState<Date>(startOfWeek(range.start, { weekStartsOn: 1 }))
|
||||
|
||||
const monthMatrix = useMemo(() => buildCalendarMatrix(currentMonth), [currentMonth])
|
||||
const availableYears = useMemo(() => {
|
||||
const years = new Set<number>()
|
||||
years.add(new Date().getFullYear())
|
||||
years.add(currentMonth.getFullYear())
|
||||
years.add(currentWeekStart.getFullYear())
|
||||
events.forEach((event) => {
|
||||
years.add(event.start.getFullYear())
|
||||
years.add(event.end.getFullYear())
|
||||
})
|
||||
return Array.from(years).sort((a, b) => a - b)
|
||||
}, [currentMonth, currentWeekStart, events])
|
||||
const eventsByDay = useMemo(() => {
|
||||
const map = new Map<string, AgendaCalendarEvent[]>()
|
||||
for (const event of events) {
|
||||
const key = format(event.start, "yyyy-MM-dd")
|
||||
if (!map.has(key)) {
|
||||
map.set(key, [])
|
||||
}
|
||||
map.get(key)!.push(event)
|
||||
}
|
||||
for (const value of map.values()) {
|
||||
value.sort((a, b) => a.start.getTime() - b.start.getTime())
|
||||
}
|
||||
return map
|
||||
}, [events])
|
||||
|
||||
const weekDays = useMemo(() => {
|
||||
const start = currentWeekStart
|
||||
return Array.from({ length: 7 }, (_, index) => addDays(start, index))
|
||||
}, [currentWeekStart])
|
||||
|
||||
const handlePrev = () => {
|
||||
if (viewMode === "month") {
|
||||
setCurrentMonth(addMonths(currentMonth, -1))
|
||||
} else {
|
||||
setCurrentWeekStart(addWeeks(currentWeekStart, -1))
|
||||
}
|
||||
}
|
||||
|
||||
const handleNext = () => {
|
||||
if (viewMode === "month") {
|
||||
setCurrentMonth(addMonths(currentMonth, 1))
|
||||
} else {
|
||||
setCurrentWeekStart(addWeeks(currentWeekStart, 1))
|
||||
}
|
||||
}
|
||||
|
||||
const handleToday = () => {
|
||||
const today = new Date()
|
||||
setCurrentMonth(startOfMonth(today))
|
||||
setCurrentWeekStart(startOfWeek(today, { weekStartsOn: 1 }))
|
||||
}
|
||||
|
||||
const handleSelectMonth = (year: number, monthIndex: number) => {
|
||||
const nextMonth = startOfMonth(new Date(year, monthIndex, 1))
|
||||
setCurrentMonth(nextMonth)
|
||||
setCurrentWeekStart(startOfWeek(nextMonth, { weekStartsOn: 1 }))
|
||||
}
|
||||
|
||||
const currentLabel =
|
||||
viewMode === "month"
|
||||
? format(currentMonth, "MMMM 'de' yyyy", { locale: ptBR })
|
||||
: `${format(currentWeekStart, "d MMM", { locale: ptBR })} – ${format(
|
||||
addDays(currentWeekStart, 6),
|
||||
"d MMM",
|
||||
{ locale: ptBR },
|
||||
)}`
|
||||
|
||||
return (
|
||||
<div className="rounded-2xl border border-border/60 bg-card/70 p-4 shadow-sm">
|
||||
<div className="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
|
||||
<div className="space-y-1">
|
||||
<h3 className="text-lg font-semibold text-foreground">Calendário operacional</h3>
|
||||
<p className="text-sm text-muted-foreground">{currentLabel}</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<ToggleGroup
|
||||
type="single"
|
||||
value={viewMode}
|
||||
onValueChange={(value) => value && setViewMode(value as "month" | "week")}
|
||||
className="rounded-xl border border-border/70 bg-background/60 p-0.5"
|
||||
>
|
||||
<ToggleGroupItem value="month" className="rounded-lg px-4 py-2 text-sm font-medium data-[state=on]:bg-primary data-[state=on]:text-primary-foreground">
|
||||
Mês
|
||||
</ToggleGroupItem>
|
||||
<ToggleGroupItem value="week" className="rounded-lg px-4 py-2 text-sm font-medium data-[state=on]:bg-primary data-[state=on]:text-primary-foreground">
|
||||
Semana
|
||||
</ToggleGroupItem>
|
||||
</ToggleGroup>
|
||||
<div className="flex flex-wrap items-center gap-2 rounded-2xl bg-white/80 px-2 py-1.5 shadow-sm">
|
||||
<Button variant="ghost" size="icon" onClick={handlePrev}>
|
||||
<ChevronLeft className="size-4" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" onClick={handleNext}>
|
||||
<ChevronRight className="size-4" />
|
||||
</Button>
|
||||
<Button variant="secondary" onClick={handleToday}>
|
||||
Hoje
|
||||
</Button>
|
||||
<YearPopover years={availableYears} onSelectMonth={handleSelectMonth} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{viewMode === "month" ? (
|
||||
<MonthView
|
||||
monthMatrix={monthMatrix}
|
||||
eventsByDay={eventsByDay}
|
||||
currentMonth={currentMonth}
|
||||
/>
|
||||
) : (
|
||||
<WeekView weekDays={weekDays} eventsByDay={eventsByDay} />
|
||||
)}
|
||||
{events.length === 0 ? (
|
||||
<div className="mt-6 flex items-center gap-3 rounded-lg border border-dashed border-border/60 bg-background/60 p-4 text-sm text-muted-foreground">
|
||||
<AlertCircle className="size-4" />
|
||||
Nenhum compromisso previsto para o período filtrado.
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function MonthView({
|
||||
monthMatrix,
|
||||
eventsByDay,
|
||||
currentMonth,
|
||||
}: {
|
||||
monthMatrix: Date[][]
|
||||
eventsByDay: Map<string, AgendaCalendarEvent[]>
|
||||
currentMonth: Date
|
||||
}) {
|
||||
return (
|
||||
<div className="mt-6">
|
||||
<div className="grid grid-cols-7 text-center text-xs font-medium text-muted-foreground">
|
||||
{weekdayLabels.map((label) => (
|
||||
<div key={label} className="pb-2">
|
||||
{label}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="grid grid-cols-7 gap-1 text-sm">
|
||||
{monthMatrix.map((week, weekIndex) => (
|
||||
<Fragment key={`week-${weekIndex}`}>
|
||||
{week.map((day) => {
|
||||
const dayKey = format(day, "yyyy-MM-dd")
|
||||
const dayEvents = eventsByDay.get(dayKey) ?? []
|
||||
const isCurrent = isSameMonth(day, currentMonth)
|
||||
return (
|
||||
<div
|
||||
key={dayKey}
|
||||
className={cn(
|
||||
"min-h-[110px] rounded-xl border bg-background/70 p-2",
|
||||
isCurrent ? "border-border/80" : "border-border/40 bg-muted/30",
|
||||
isToday(day) ? "ring-2 ring-primary/60" : ""
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center justify-between text-xs font-semibold text-foreground">
|
||||
<span className={isCurrent ? "text-foreground" : "text-muted-foreground"}>{day.getDate()}</span>
|
||||
{dayEvents.length > 0 ? (
|
||||
<Badge variant="secondary" className="text-[10px]">
|
||||
{dayEvents.length}
|
||||
</Badge>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="mt-2 space-y-1">
|
||||
{dayEvents.slice(0, 3).map((event) => (
|
||||
<CalendarEventBadge key={event.id} event={event} />
|
||||
))}
|
||||
{dayEvents.length > 3 ? (
|
||||
<p className="text-[10px] text-muted-foreground">+{dayEvents.length - 3} mais</p>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</Fragment>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function WeekView({
|
||||
weekDays,
|
||||
eventsByDay,
|
||||
}: {
|
||||
weekDays: Date[]
|
||||
eventsByDay: Map<string, AgendaCalendarEvent[]>
|
||||
}) {
|
||||
return (
|
||||
<div className="mt-6">
|
||||
<div className="grid grid-cols-7 text-center text-xs font-medium text-muted-foreground">
|
||||
{weekDays.map((day, index) => (
|
||||
<div key={`label-${index}`} className="pb-2">
|
||||
{weekdayLabels[index]} <span className="ml-1 font-semibold text-foreground">{day.getDate()}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="grid grid-cols-7 gap-2 text-sm">
|
||||
{weekDays.map((day) => {
|
||||
const dayKey = format(day, "yyyy-MM-dd")
|
||||
const dayEvents = eventsByDay.get(dayKey) ?? []
|
||||
return (
|
||||
<div
|
||||
key={`week-${dayKey}`}
|
||||
className={cn(
|
||||
"min-h-[150px] rounded-2xl border border-border/70 bg-background/70 p-3",
|
||||
isToday(day) ? "ring-2 ring-primary/60" : ""
|
||||
)}
|
||||
>
|
||||
<div className="space-y-2">
|
||||
{dayEvents.length === 0 ? (
|
||||
<p className="text-[11px] text-muted-foreground">Sem eventos</p>
|
||||
) : (
|
||||
dayEvents.map((event) => (
|
||||
<CalendarEventBadge key={event.id} event={event} />
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function YearPopover({
|
||||
years,
|
||||
onSelectMonth,
|
||||
}: {
|
||||
years: number[]
|
||||
onSelectMonth: (year: number, monthIndex: number) => void
|
||||
}) {
|
||||
const monthLabels = ["Jan", "Fev", "Mar", "Abr", "Mai", "Jun", "Jul", "Ago", "Set", "Out", "Nov", "Dez"]
|
||||
const sortedYears = years.length > 0 ? years : [new Date().getFullYear()]
|
||||
|
||||
return (
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="gap-2 rounded-full border border-slate-200 px-3 text-sm font-medium text-neutral-700 hover:border-slate-300 hover:bg-white"
|
||||
>
|
||||
Selecionar mês/ano
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-72 p-0" align="end">
|
||||
<ScrollArea className="h-64">
|
||||
<div className="divide-y divide-slate-100">
|
||||
{sortedYears.map((year) => (
|
||||
<div key={year} className="px-3 py-2">
|
||||
<p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">{year}</p>
|
||||
<div className="mt-2 grid grid-cols-3 gap-2">
|
||||
{monthLabels.map((label, index) => (
|
||||
<Button
|
||||
key={`${year}-${label}`}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-8 rounded-full text-xs"
|
||||
onClick={() => onSelectMonth(year, index)}
|
||||
>
|
||||
{label}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
|
||||
function CalendarEventBadge({ event }: { event: AgendaCalendarEvent }) {
|
||||
const priorityStyle = priorityStyles[event.priority]
|
||||
const slaColor = slaColors[event.slaStatus]
|
||||
return (
|
||||
<Link
|
||||
href={event.href}
|
||||
prefetch={false}
|
||||
className={cn(
|
||||
"block rounded-lg border px-1.5 py-1 text-[10px] transition hover:ring-2 hover:ring-primary/40 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/40",
|
||||
slaColor
|
||||
)}
|
||||
aria-label={`Abrir ticket #${event.reference}`}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-1">
|
||||
<span className="truncate font-semibold">#{event.reference}</span>
|
||||
<span className="text-[9px] text-muted-foreground">{format(event.start, "HH:mm")}</span>
|
||||
</div>
|
||||
<p className="truncate text-[10px] text-foreground">{event.title}</p>
|
||||
<div className="mt-0.5 flex flex-wrap items-center gap-1">
|
||||
{event.queue ? (
|
||||
<Badge variant="outline" className="rounded-full px-1.5 text-[9px]">
|
||||
{event.queue}
|
||||
</Badge>
|
||||
) : null}
|
||||
<Badge className={cn("rounded-full px-1.5 text-[9px]", priorityStyle?.badgeClass ?? "")}>
|
||||
{priorityStyle?.label ?? event.priority}
|
||||
</Badge>
|
||||
</div>
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
|
||||
function buildCalendarMatrix(currentMonth: Date): Date[][] {
|
||||
const start = startOfWeek(startOfMonth(currentMonth), { weekStartsOn: 1 })
|
||||
const end = endOfWeek(endOfMonth(currentMonth), { weekStartsOn: 1 })
|
||||
const matrix: Date[][] = []
|
||||
let cursor = start
|
||||
while (cursor <= end) {
|
||||
const week: Date[] = []
|
||||
for (let i = 0; i < 7; i += 1) {
|
||||
week.push(cursor)
|
||||
cursor = addDays(cursor, 1)
|
||||
}
|
||||
matrix.push(week)
|
||||
}
|
||||
return matrix
|
||||
}
|
||||
201
src/components/agenda/agenda-filters.tsx
Normal file
201
src/components/agenda/agenda-filters.tsx
Normal file
|
|
@ -0,0 +1,201 @@
|
|||
"use client"
|
||||
|
||||
import { useMemo } from "react"
|
||||
import { SlidersHorizontal } from "lucide-react"
|
||||
|
||||
import { Button } from "@/components/ui/button"
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu"
|
||||
import { Switch } from "@/components/ui/switch"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { priorityStyles } from "@/lib/ticket-priority-style"
|
||||
import { cn } from "@/lib/utils"
|
||||
import type { TicketPriority } from "@/lib/schemas/ticket"
|
||||
|
||||
export type AgendaPeriod = "today" | "week" | "month"
|
||||
|
||||
export type AgendaFilterState = {
|
||||
period: AgendaPeriod
|
||||
queues: string[]
|
||||
priorities: TicketPriority[]
|
||||
onlyMyTickets: boolean
|
||||
focusVisits: boolean
|
||||
}
|
||||
|
||||
export const defaultAgendaFilters: AgendaFilterState = {
|
||||
period: "week",
|
||||
queues: [],
|
||||
priorities: [],
|
||||
onlyMyTickets: true,
|
||||
focusVisits: false,
|
||||
}
|
||||
|
||||
type AgendaFiltersProps = {
|
||||
filters: AgendaFilterState
|
||||
queues: string[]
|
||||
onChange: (next: AgendaFilterState) => void
|
||||
}
|
||||
|
||||
export function AgendaFilters({ filters, queues, onChange }: AgendaFiltersProps) {
|
||||
const periodOptions: Array<{ value: AgendaPeriod; label: string }> = [
|
||||
{ value: "today", label: "Hoje" },
|
||||
{ value: "week", label: "Semana" },
|
||||
{ value: "month", label: "Mês" },
|
||||
]
|
||||
|
||||
const queueLabel = useMemo(() => {
|
||||
if (filters.queues.length === 0) return "Todas as filas"
|
||||
if (filters.queues.length === 1) return filters.queues[0]
|
||||
return `${filters.queues[0]} +${filters.queues.length - 1}`
|
||||
}, [filters.queues])
|
||||
|
||||
const priorityLabel = useMemo(() => {
|
||||
if (filters.priorities.length === 0) return "Todas prioridades"
|
||||
if (filters.priorities.length === 1) {
|
||||
return priorityStyles[filters.priorities[0]].label
|
||||
}
|
||||
return `${priorityStyles[filters.priorities[0]].label} +${filters.priorities.length - 1}`
|
||||
}, [filters.priorities])
|
||||
|
||||
const updateFilters = (partial: Partial<AgendaFilterState>) => {
|
||||
onChange({ ...filters, ...partial })
|
||||
}
|
||||
|
||||
const handleQueueToggle = (queue: string, checked: boolean) => {
|
||||
const set = new Set(filters.queues)
|
||||
if (checked) {
|
||||
set.add(queue)
|
||||
} else {
|
||||
set.delete(queue)
|
||||
}
|
||||
updateFilters({ queues: Array.from(set) })
|
||||
}
|
||||
|
||||
const handlePriorityToggle = (priority: TicketPriority, checked: boolean) => {
|
||||
const set = new Set(filters.priorities)
|
||||
if (checked) {
|
||||
set.add(priority)
|
||||
} else {
|
||||
set.delete(priority)
|
||||
}
|
||||
updateFilters({ priorities: Array.from(set) })
|
||||
}
|
||||
|
||||
const resetFilters = () => {
|
||||
onChange(defaultAgendaFilters)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3 rounded-2xl border border-border/70 bg-card/70 p-4 shadow-sm sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<div className="flex flex-wrap items-center gap-2 rounded-2xl border border-slate-200 bg-white/80 px-2 py-1.5 shadow-sm">
|
||||
{periodOptions.map((option) => {
|
||||
const isActive = filters.period === option.value
|
||||
return (
|
||||
<Button
|
||||
key={option.value}
|
||||
type="button"
|
||||
size="sm"
|
||||
variant={isActive ? "secondary" : "ghost"}
|
||||
onClick={() => updateFilters({ period: option.value })}
|
||||
className={cn(
|
||||
"gap-2 rounded-full border px-3 text-sm font-medium transition",
|
||||
isActive
|
||||
? "border-slate-200 bg-slate-900 text-white"
|
||||
: "border-slate-200 text-neutral-700 hover:border-slate-300 hover:bg-white"
|
||||
)}
|
||||
>
|
||||
{option.label}
|
||||
</Button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="outline" className="gap-2">
|
||||
{queueLabel}
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent className="w-56" align="start">
|
||||
<DropdownMenuLabel>Filtrar filas</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
{queues.length === 0 ? (
|
||||
<div className="px-2 py-1.5 text-sm text-muted-foreground">Nenhuma fila disponível.</div>
|
||||
) : (
|
||||
queues.map((queue) => (
|
||||
<DropdownMenuCheckboxItem
|
||||
key={queue}
|
||||
checked={filters.queues.includes(queue)}
|
||||
onCheckedChange={(checked) => handleQueueToggle(queue, Boolean(checked))}
|
||||
>
|
||||
{queue}
|
||||
</DropdownMenuCheckboxItem>
|
||||
))
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="outline" className="gap-2">
|
||||
{priorityLabel}
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent className="w-64" align="start">
|
||||
<DropdownMenuLabel>Prioridades</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
{(Object.keys(priorityStyles) as TicketPriority[]).map((priority) => (
|
||||
<DropdownMenuCheckboxItem
|
||||
key={priority}
|
||||
checked={filters.priorities.includes(priority)}
|
||||
onCheckedChange={(checked) => handlePriorityToggle(priority, Boolean(checked))}
|
||||
className="gap-2"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge className={cn("rounded-full px-2.5 py-0.5 text-xs font-semibold", priorityStyles[priority].badgeClass ?? "")}>
|
||||
{priorityStyles[priority].label}
|
||||
</Badge>
|
||||
<span className="text-sm text-foreground">{priorityStyles[priority].label}</span>
|
||||
</div>
|
||||
</DropdownMenuCheckboxItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Switch
|
||||
id="onlyMe"
|
||||
checked={filters.onlyMyTickets}
|
||||
onCheckedChange={(checked) => updateFilters({ onlyMyTickets: checked })}
|
||||
className="data-[state=checked]:border-sidebar-ring data-[state=checked]:bg-sidebar-accent"
|
||||
/>
|
||||
<Label htmlFor="onlyMe" className="text-sm text-muted-foreground">
|
||||
Somente meus tickets
|
||||
</Label>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Switch
|
||||
id="visitsOnly"
|
||||
checked={filters.focusVisits}
|
||||
onCheckedChange={(checked) => updateFilters({ focusVisits: checked })}
|
||||
className="data-[state=checked]:border-sidebar-ring data-[state=checked]:bg-sidebar-accent"
|
||||
/>
|
||||
<Label htmlFor="visitsOnly" className="text-sm text-muted-foreground">
|
||||
Apenas visitas
|
||||
</Label>
|
||||
</div>
|
||||
<Button variant="ghost" size="sm" className="gap-2" onClick={resetFilters}>
|
||||
<SlidersHorizontal className="size-4" />
|
||||
Resetar
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
191
src/components/agenda/agenda-summary-view.tsx
Normal file
191
src/components/agenda/agenda-summary-view.tsx
Normal file
|
|
@ -0,0 +1,191 @@
|
|||
import Link from "next/link"
|
||||
import { Fragment } from "react"
|
||||
import { AlertTriangle, CalendarClock, Clock, CheckCircle2, Circle } from "lucide-react"
|
||||
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
import { priorityStyles } from "@/lib/ticket-priority-style"
|
||||
import { cn } from "@/lib/utils"
|
||||
import type { AgendaDataset, AgendaTicketSummary } from "@/lib/agenda-utils"
|
||||
|
||||
type AgendaSummaryViewProps = {
|
||||
data: AgendaDataset
|
||||
isLoading?: boolean
|
||||
}
|
||||
|
||||
const statusIndicator: Record<string, { label: string; icon: React.ComponentType<{ className?: string }>; className: string }> = {
|
||||
on_track: { label: "No prazo", icon: Circle, className: "text-emerald-500" },
|
||||
at_risk: { label: "Em risco", icon: AlertTriangle, className: "text-amber-500" },
|
||||
breached: { label: "Violado", icon: AlertTriangle, className: "text-rose-500" },
|
||||
met: { label: "Concluído", icon: CheckCircle2, className: "text-emerald-500" },
|
||||
}
|
||||
|
||||
export function AgendaSummaryView({ data, isLoading }: AgendaSummaryViewProps) {
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="space-y-4 py-6">
|
||||
<Skeleton className="h-8 w-48" />
|
||||
<Skeleton className="h-24 w-full rounded-xl" />
|
||||
<Skeleton className="h-24 w-full rounded-xl" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
const sections: Array<{
|
||||
key: keyof AgendaDataset["sections"]
|
||||
title: string
|
||||
empty: string
|
||||
icon: React.ComponentType<{ className?: string }>
|
||||
iconClass?: string
|
||||
}> = [
|
||||
{ key: "upcoming", title: "Próximas", empty: "Nenhum compromisso para o período selecionado.", icon: CalendarClock, iconClass: "text-foreground" },
|
||||
{ key: "overdue", title: "Atrasadas", empty: "Sem pendências atrasadas — ótimo trabalho!", icon: AlertTriangle, iconClass: "text-amber-500" },
|
||||
{ key: "unscheduled", title: "Sem agendamento", empty: "Todos os tickets críticos têm agenda.", icon: Clock, iconClass: "text-foreground" },
|
||||
{ key: "completed", title: "Concluídas", empty: "Ainda não há visitas concluídas neste período.", icon: CheckCircle2, iconClass: "text-foreground" },
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<KpiGrid data={data} />
|
||||
<div className="grid gap-6 lg:grid-cols-2">
|
||||
{sections.map((section) => {
|
||||
const items = data.sections[section.key]
|
||||
const SectionIcon = section.icon
|
||||
const visibleItems = items.slice(0, 3)
|
||||
const viewAllHref = buildSectionLink(section.key)
|
||||
return (
|
||||
<Card key={section.key} className="min-h-[320px]">
|
||||
<CardHeader className="flex flex-row items-center justify-between gap-2">
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<SectionIcon className={cn("size-4", section.iconClass ?? "text-foreground")} />
|
||||
{section.title}
|
||||
{items.length > 0 ? (
|
||||
<Badge variant="secondary" className="ml-2">
|
||||
{items.length}
|
||||
</Badge>
|
||||
) : null}
|
||||
</CardTitle>
|
||||
<Link
|
||||
href={viewAllHref}
|
||||
className="text-sm font-semibold text-[#006879] underline-offset-4 transition-colors hover:text-[#004d5a] hover:underline"
|
||||
>
|
||||
Ver todos
|
||||
</Link>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{items.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">{section.empty}</p>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{visibleItems.map((item) => (
|
||||
<AgendaSummaryRow key={`${section.key}-${item.id}`} item={item} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function KpiGrid({ data }: { data: AgendaDataset }) {
|
||||
const kpis = [
|
||||
{ label: "Pendentes", value: data.kpis.pending, description: "Aguardando início" },
|
||||
{ label: "Em andamento", value: data.kpis.inProgress, description: "Atividades em curso" },
|
||||
{ label: "Pausados", value: data.kpis.paused, description: "Dependem de cliente/terceiros" },
|
||||
{ label: "% fora do SLA", value: data.kpis.outsideSla, description: "Chamados com risco/violação" },
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
|
||||
{kpis.map((item) => (
|
||||
<Card
|
||||
key={item.label}
|
||||
className="rounded-2xl border border-border/60 bg-gradient-to-br from-white/95 via-white to-primary/5 shadow-sm"
|
||||
>
|
||||
<CardContent className="space-y-3 px-5 py-5">
|
||||
<p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground/80">{item.label}</p>
|
||||
<p className="text-4xl font-semibold leading-tight text-neutral-900">{item.value}</p>
|
||||
<p className="text-sm text-muted-foreground">{item.description}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function AgendaSummaryRow({ item }: { item: AgendaTicketSummary }) {
|
||||
const status = statusIndicator[item.slaStatus ?? "on_track"] ?? statusIndicator.on_track
|
||||
|
||||
return (
|
||||
<Link
|
||||
href={item.href}
|
||||
prefetch={false}
|
||||
className="block rounded-xl border border-border/60 bg-background/70 p-3 shadow-sm transition hover:border-primary/60 hover:shadow-lg focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/40"
|
||||
aria-label={`Abrir ticket #${item.reference}`}
|
||||
>
|
||||
<div className="flex flex-col gap-2 lg:flex-row lg:items-center lg:justify-between">
|
||||
<div>
|
||||
<div className="flex items-center gap-2 text-sm font-semibold text-foreground">
|
||||
<span>
|
||||
#{item.reference} · {item.subject}
|
||||
</span>
|
||||
{item.queue ? (
|
||||
<Badge variant="outline" className="rounded-full border-dashed px-2.5 py-0.5 text-[11px] font-medium">
|
||||
{item.queue}
|
||||
</Badge>
|
||||
) : null}
|
||||
<Badge className={cn("rounded-full px-2.5 py-0.5 text-[11px] font-semibold", priorityStyles[item.priority]?.badgeClass ?? "")}>
|
||||
{priorityStyles[item.priority]?.label ?? item.priority}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="mt-1 flex flex-wrap items-center gap-3 text-xs text-muted-foreground">
|
||||
{item.company ? <span>{item.company}</span> : null}
|
||||
{item.location ? <span className="flex items-center gap-1"><Clock className="size-3" /> {item.location}</span> : null}
|
||||
{item.startAt ? (
|
||||
<span className="flex items-center gap-1">
|
||||
<CalendarClock className="size-3" />
|
||||
{formatDateRange(item.startAt, item.endAt)}
|
||||
</span>
|
||||
) : (
|
||||
<span className="flex items-center gap-1 text-foreground">
|
||||
<AlertTriangle className="size-3" />
|
||||
Não agendado
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-xs font-semibold uppercase tracking-wide">
|
||||
<status.icon className={cn("size-4", status.className)} />
|
||||
<span className={status.className}>{status.label}</span>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
|
||||
function formatDateRange(start?: Date | null, end?: Date | null) {
|
||||
if (!start) return "Sem data"
|
||||
if (!end) return new Intl.DateTimeFormat("pt-BR", { dateStyle: "medium", timeStyle: "short" }).format(start)
|
||||
const sameDay = start.toDateString() === end.toDateString()
|
||||
if (sameDay) {
|
||||
return `${new Intl.DateTimeFormat("pt-BR", { dateStyle: "short" }).format(start)} · ${new Intl.DateTimeFormat("pt-BR", { timeStyle: "short" }).format(start)} - ${new Intl.DateTimeFormat("pt-BR", { timeStyle: "short" }).format(end)}`
|
||||
}
|
||||
return `${new Intl.DateTimeFormat("pt-BR", { dateStyle: "short", timeStyle: "short" }).format(start)} → ${new Intl.DateTimeFormat("pt-BR", { dateStyle: "short", timeStyle: "short" }).format(end)}`
|
||||
}
|
||||
|
||||
function buildSectionLink(section: keyof AgendaDataset["sections"]) {
|
||||
const params = new URLSearchParams()
|
||||
params.set("focus", "visits")
|
||||
params.set("view", section === "completed" ? "completed" : "active")
|
||||
if (section === "completed") {
|
||||
params.set("status", "RESOLVED")
|
||||
}
|
||||
return `/tickets?${params.toString()}`
|
||||
}
|
||||
|
|
@ -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" },
|
||||
],
|
||||
},
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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<string>("default")
|
||||
const [customFieldValues, setCustomFieldValues] = useState<Record<string, unknown>>({})
|
||||
const [openCalendarField, setOpenCalendarField] = useState<string | null>(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}
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
|
|
|||
|
|
@ -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() {
|
|||
})}
|
||||
</div>
|
||||
</section>
|
||||
{isStaff ? (
|
||||
<section id="custom-fields" className="space-y-4">
|
||||
<div>
|
||||
<h2 className="text-base font-semibold text-neutral-900">Campos personalizados</h2>
|
||||
<p className="text-sm text-neutral-600">
|
||||
Ajuste os campos de admissão, desligamento e demais metadados diretamente pelo painel administrativo.
|
||||
</p>
|
||||
</div>
|
||||
<FieldsManager />
|
||||
</section>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<z.infer<typeof schema>>({
|
||||
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}
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
</CardDescription>
|
||||
</div>
|
||||
{hasRating && !viewerIsAdmin ? (
|
||||
{hasRating && collaboratorCanView ? (
|
||||
<div className="flex items-center gap-1 rounded-full bg-emerald-50 px-3 py-1 text-xs font-medium text-emerald-700">
|
||||
Obrigado pelo feedback!
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -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
|
|||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
{hasConfiguredFields ? (
|
||||
<div className="rounded-2xl border border-slate-200 bg-white/70 px-4 py-4">
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
{selectedForm.fields.map((field) => renderFieldEditor(field))}
|
||||
</div>
|
||||
<div className="grid gap-4 rounded-2xl border border-slate-200 bg-white px-4 py-4 sm:grid-cols-2">
|
||||
<p className="text-sm font-semibold text-neutral-800 sm:col-span-2">Informações adicionais</p>
|
||||
{selectedForm.fields.map((field) => renderFieldEditor(field))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-neutral-500">Nenhum campo configurado ainda.</p>
|
||||
|
|
@ -473,7 +474,7 @@ function renderFieldEditor(field: TicketFormFieldDefinition) {
|
|||
|
||||
if (field.type === "select") {
|
||||
return (
|
||||
<Field key={field.id} className={spanClass}>
|
||||
<Field key={field.id} className={cn("gap-1.5", spanClass)}>
|
||||
<FieldLabel className="flex items-center gap-1">
|
||||
{field.label} {field.required ? <span className="text-destructive">*</span> : null}
|
||||
</FieldLabel>
|
||||
|
|
@ -501,7 +502,7 @@ function renderFieldEditor(field: TicketFormFieldDefinition) {
|
|||
|
||||
if (field.type === "number") {
|
||||
return (
|
||||
<Field key={field.id} className={spanClass}>
|
||||
<Field key={field.id} className={cn("gap-1.5", spanClass)}>
|
||||
<FieldLabel className="flex items-center gap-1">
|
||||
{field.label} {field.required ? <span className="text-destructive">*</span> : null}
|
||||
</FieldLabel>
|
||||
|
|
@ -521,7 +522,7 @@ function renderFieldEditor(field: TicketFormFieldDefinition) {
|
|||
const isoValue = toIsoDateString(value)
|
||||
const parsedDate = isoValue ? parseIsoDate(isoValue) : null
|
||||
return (
|
||||
<Field key={field.id} className={cn("flex flex-col gap-2", spanClass)}>
|
||||
<Field key={field.id} className={cn("flex flex-col gap-1.5", spanClass)}>
|
||||
<FieldLabel className="flex items-center gap-1">
|
||||
{field.label} {field.required ? <span className="text-destructive">*</span> : null}
|
||||
</FieldLabel>
|
||||
|
|
@ -554,6 +555,7 @@ function renderFieldEditor(field: TicketFormFieldDefinition) {
|
|||
handleFieldChange(field, date ? format(date, "yyyy-MM-dd") : "")
|
||||
setOpenCalendarField(null)
|
||||
}}
|
||||
timeZone={calendarTimeZone}
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
|
@ -564,7 +566,7 @@ function renderFieldEditor(field: TicketFormFieldDefinition) {
|
|||
|
||||
if (field.type === "text" && !isTextarea) {
|
||||
return (
|
||||
<Field key={field.id} className={spanClass}>
|
||||
<Field key={field.id} className={cn("gap-1.5", spanClass)}>
|
||||
<FieldLabel className="flex items-center gap-1">
|
||||
{field.label} {field.required ? <span className="text-destructive">*</span> : null}
|
||||
</FieldLabel>
|
||||
|
|
@ -580,7 +582,7 @@ function renderFieldEditor(field: TicketFormFieldDefinition) {
|
|||
}
|
||||
|
||||
return (
|
||||
<Field key={field.id} className={spanClass}>
|
||||
<Field key={field.id} className={cn("gap-1.5", spanClass)}>
|
||||
<FieldLabel className="flex items-center gap-1">
|
||||
{field.label} {field.required ? <span className="text-destructive">*</span> : null}
|
||||
</FieldLabel>
|
||||
|
|
|
|||
|
|
@ -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<TicketWithDetails["priority"], SummaryTone> = {
|
|||
URGENT: "danger",
|
||||
}
|
||||
|
||||
const slaStatusTone: Record<Exclude<SlaDisplayStatus, "n/a">, { label: string; className: string }> = {
|
||||
on_track: { label: "No prazo", className: "text-emerald-600" },
|
||||
at_risk: { label: "Em risco", className: "text-amber-600" },
|
||||
breached: { label: "Violado", className: "text-rose-600" },
|
||||
met: { label: "Concluído", className: "text-emerald-600" },
|
||||
}
|
||||
|
||||
function formatDuration(ms?: number | null) {
|
||||
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<SlaDisplayStatus, "n/a">]
|
||||
}
|
||||
|
||||
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) {
|
|||
<section className="space-y-3">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<h3 className="text-sm font-semibold text-neutral-900">SLA & métricas</h3>
|
||||
{ticket.slaPolicy ? (
|
||||
<span className="text-xs font-medium text-neutral-500">{ticket.slaPolicy.name}</span>
|
||||
{ticket.slaSnapshot ? (
|
||||
<span className="text-xs font-medium text-neutral-500">
|
||||
{ticket.slaSnapshot.categoryName ?? "Configuração personalizada"}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
<div className="rounded-2xl border border-slate-200 bg-white px-4 py-3 shadow-sm">
|
||||
<p className="text-xs font-semibold uppercase tracking-wide text-neutral-500">Política de SLA</p>
|
||||
{ticket.slaPolicy ? (
|
||||
<div className="mt-3 space-y-2 text-sm text-neutral-700">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<span className="text-xs text-neutral-500">Resposta inicial</span>
|
||||
<span className="text-sm font-semibold text-neutral-900">
|
||||
{formatMinutes(ticket.slaPolicy.targetMinutesToFirstResponse)}
|
||||
</span>
|
||||
{ticket.slaSnapshot ? (
|
||||
<div className="mt-3 space-y-4 text-sm text-neutral-700">
|
||||
<div>
|
||||
<span className="text-xs text-neutral-500">Categoria</span>
|
||||
<p className="font-semibold text-neutral-900">{ticket.slaSnapshot.categoryName ?? "Categoria padrão"}</p>
|
||||
<p className="text-xs text-neutral-500">
|
||||
Prioridade: {priorityLabel[ticket.priority] ?? ticket.priority}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<span className="text-xs text-neutral-500">Resolução</span>
|
||||
<span className="text-sm font-semibold text-neutral-900">
|
||||
{formatMinutes(ticket.slaPolicy.targetMinutesToResolution)}
|
||||
</span>
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<SlaMetric
|
||||
label="Resposta"
|
||||
target={formatSlaTarget(ticket.slaSnapshot.responseTargetMinutes, ticket.slaSnapshot.responseMode)}
|
||||
dueDate={responseDue}
|
||||
status={responseStatus}
|
||||
/>
|
||||
<SlaMetric
|
||||
label="Resolução"
|
||||
target={formatSlaTarget(ticket.slaSnapshot.solutionTargetMinutes, ticket.slaSnapshot.solutionMode)}
|
||||
dueDate={solutionDue}
|
||||
status={solutionStatus}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
|
|
@ -289,3 +328,30 @@ function SummaryChip({
|
|||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface SlaMetricProps {
|
||||
label: string
|
||||
target: string
|
||||
dueDate: Date | null
|
||||
status: SlaDisplayStatus
|
||||
}
|
||||
|
||||
function SlaMetric({ label, target, dueDate, status }: SlaMetricProps) {
|
||||
const display = getSlaStatusDisplay(status)
|
||||
return (
|
||||
<div className="rounded-xl border border-slate-200 bg-white px-3 py-3 shadow-sm">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-xs text-neutral-500">{label}</p>
|
||||
<p className="text-sm font-semibold text-neutral-900">{target}</p>
|
||||
{dueDate ? (
|
||||
<p className="text-xs text-neutral-500">{format(dueDate, "dd/MM/yyyy HH:mm", { locale: ptBR })}</p>
|
||||
) : (
|
||||
<p className="text-xs text-neutral-500">Sem prazo calculado</p>
|
||||
)}
|
||||
</div>
|
||||
<span className={cn("text-xs font-semibold uppercase", display.className)}>{display.label}</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1563,10 +1563,10 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
|
|||
<span className={sectionValueClass}>{format(ticket.dueAt, "dd/MM/yyyy HH:mm", { locale: ptBR })}</span>
|
||||
</div>
|
||||
) : null}
|
||||
{ticket.slaPolicy ? (
|
||||
{ticket.slaSnapshot ? (
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className={sectionLabelClass}>Política</span>
|
||||
<span className={sectionValueClass}>{ticket.slaPolicy.name}</span>
|
||||
<span className={sectionValueClass}>{ticket.slaSnapshot.categoryName ?? "Configuração personalizada"}</span>
|
||||
</div>
|
||||
) : null}
|
||||
<TicketCustomFieldsSection ticket={ticket} variant="inline" className="sm:col-span-2 lg:col-span-3" />
|
||||
|
|
|
|||
|
|
@ -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])
|
||||
|
||||
|
|
|
|||
|
|
@ -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<TicketFiltersState>
|
||||
|
|
@ -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<string[]>([])
|
||||
const [enteringIds, setEnteringIds] = useState<Set<string>>(new Set())
|
||||
|
|
|
|||
29
src/components/ui/switch.tsx
Normal file
29
src/components/ui/switch.tsx
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as SwitchPrimitive from "@radix-ui/react-switch"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Switch = React.forwardRef<
|
||||
React.ElementRef<typeof SwitchPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof SwitchPrimitive.Root>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SwitchPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"peer inline-flex h-6 w-11 shrink-0 cursor-pointer items-center rounded-full border border-transparent bg-input transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-sidebar-accent data-[state=checked]:text-sidebar-accent-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<SwitchPrimitive.Thumb
|
||||
className={cn(
|
||||
"pointer-events-none block h-5 w-5 translate-x-0 rounded-full bg-background shadow transition-transform duration-200 data-[state=checked]:translate-x-[20px]",
|
||||
)}
|
||||
/>
|
||||
</SwitchPrimitive.Root>
|
||||
))
|
||||
Switch.displayName = SwitchPrimitive.Root.displayName
|
||||
|
||||
export { Switch }
|
||||
19
src/hooks/use-local-time-zone.ts
Normal file
19
src/hooks/use-local-time-zone.ts
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
import { useEffect, useState } from "react"
|
||||
|
||||
export function useLocalTimeZone(fallback?: string) {
|
||||
const [timeZone, setTimeZone] = useState<string | undefined>(fallback)
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === "undefined") return
|
||||
try {
|
||||
const resolved = Intl.DateTimeFormat().resolvedOptions().timeZone
|
||||
if (resolved) {
|
||||
setTimeZone(resolved)
|
||||
}
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}, [])
|
||||
|
||||
return timeZone
|
||||
}
|
||||
255
src/lib/agenda-utils.ts
Normal file
255
src/lib/agenda-utils.ts
Normal file
|
|
@ -0,0 +1,255 @@
|
|||
import {
|
||||
addMinutes,
|
||||
endOfDay,
|
||||
endOfMonth,
|
||||
endOfWeek,
|
||||
isAfter,
|
||||
isBefore,
|
||||
isWithinInterval,
|
||||
startOfDay,
|
||||
startOfMonth,
|
||||
startOfWeek,
|
||||
} from "date-fns"
|
||||
|
||||
import type { Ticket, TicketPriority } from "@/lib/schemas/ticket"
|
||||
import type { AgendaFilterState, AgendaPeriod } from "@/components/agenda/agenda-filters"
|
||||
import { getSlaDisplayStatus, getSlaDueDate } from "@/lib/sla-utils"
|
||||
import { isVisitTicket } from "@/lib/ticket-matchers"
|
||||
|
||||
export type AgendaSlaStatus = "on_track" | "at_risk" | "breached" | "met"
|
||||
|
||||
export type AgendaTicketSummary = {
|
||||
id: string
|
||||
reference: number
|
||||
subject: string
|
||||
queue: string | null
|
||||
company: string | null
|
||||
priority: TicketPriority
|
||||
location?: string | null
|
||||
startAt: Date | null
|
||||
endAt: Date | null
|
||||
slaStatus: AgendaSlaStatus
|
||||
completedAt?: Date | null
|
||||
href: string
|
||||
}
|
||||
|
||||
export type AgendaCalendarEvent = {
|
||||
id: string
|
||||
ticketId: string
|
||||
reference: number
|
||||
title: string
|
||||
queue: string | null
|
||||
priority: TicketPriority
|
||||
start: Date
|
||||
end: Date
|
||||
slaStatus: AgendaSlaStatus
|
||||
href: string
|
||||
}
|
||||
|
||||
export type AgendaDataset = {
|
||||
range: { start: Date; end: Date }
|
||||
availableQueues: string[]
|
||||
kpis: {
|
||||
pending: number
|
||||
inProgress: number
|
||||
paused: number
|
||||
outsideSla: string
|
||||
}
|
||||
sections: {
|
||||
upcoming: AgendaTicketSummary[]
|
||||
overdue: AgendaTicketSummary[]
|
||||
unscheduled: AgendaTicketSummary[]
|
||||
completed: AgendaTicketSummary[]
|
||||
}
|
||||
calendarEvents: AgendaCalendarEvent[]
|
||||
}
|
||||
|
||||
const DEFAULT_EVENT_DURATION_MINUTES = 60
|
||||
|
||||
export function buildAgendaDataset(tickets: Ticket[], filters: AgendaFilterState): AgendaDataset {
|
||||
const now = new Date()
|
||||
const range = computeRange(filters.period, now)
|
||||
|
||||
const availableQueues = Array.from(
|
||||
new Set(
|
||||
tickets
|
||||
.map((ticket) => ticket.queue?.trim())
|
||||
.filter((queue): queue is string => Boolean(queue))
|
||||
)
|
||||
).sort((a, b) => a.localeCompare(b, "pt-BR"))
|
||||
|
||||
const filteredTickets = tickets
|
||||
.filter((ticket) => matchesFilters(ticket, filters))
|
||||
.filter((ticket) => isVisitTicket(ticket))
|
||||
|
||||
const enriched = filteredTickets.map((ticket) => {
|
||||
const schedule = deriveScheduleWindow(ticket)
|
||||
const slaStatus = computeSlaStatus(ticket, now)
|
||||
return { ticket, schedule, slaStatus }
|
||||
})
|
||||
|
||||
const summarySections = {
|
||||
upcoming: [] as AgendaTicketSummary[],
|
||||
overdue: [] as AgendaTicketSummary[],
|
||||
unscheduled: [] as AgendaTicketSummary[],
|
||||
completed: [] as AgendaTicketSummary[],
|
||||
}
|
||||
|
||||
for (const entry of enriched) {
|
||||
const summary = buildSummary(entry.ticket, entry.schedule, entry.slaStatus)
|
||||
const dueDate = entry.schedule.startAt
|
||||
const createdAt = entry.ticket.createdAt
|
||||
const resolvedAt = entry.ticket.resolvedAt
|
||||
|
||||
if (dueDate && isWithinRange(dueDate, range)) {
|
||||
if (!entry.ticket.resolvedAt && isAfter(dueDate, now)) {
|
||||
summarySections.upcoming.push(summary)
|
||||
}
|
||||
if (!entry.ticket.resolvedAt && isBefore(dueDate, now)) {
|
||||
summarySections.overdue.push(summary)
|
||||
}
|
||||
}
|
||||
|
||||
if (!dueDate && entry.ticket.status !== "RESOLVED" && isWithinRange(createdAt, range)) {
|
||||
summarySections.unscheduled.push(summary)
|
||||
}
|
||||
|
||||
if (resolvedAt && isWithinRange(resolvedAt, range)) {
|
||||
summarySections.completed.push(summary)
|
||||
}
|
||||
}
|
||||
|
||||
summarySections.upcoming.sort((a, b) => compareNullableDate(a.startAt, b.startAt, 1))
|
||||
summarySections.overdue.sort((a, b) => compareNullableDate(a.startAt, b.startAt, -1))
|
||||
summarySections.unscheduled.sort((a, b) => compareByPriorityThenReference(a, b))
|
||||
summarySections.completed.sort((a, b) => compareNullableDate(a.completedAt ?? null, b.completedAt ?? null, -1))
|
||||
|
||||
const calendarEvents = enriched
|
||||
.filter((entry): entry is typeof entry & { schedule: { startAt: Date; endAt: Date } } => Boolean(entry.schedule.startAt && entry.schedule.endAt))
|
||||
.map((entry) => ({
|
||||
id: `${entry.ticket.id}-event`,
|
||||
ticketId: entry.ticket.id,
|
||||
reference: entry.ticket.reference,
|
||||
title: entry.ticket.subject,
|
||||
queue: entry.ticket.queue ?? null,
|
||||
priority: entry.ticket.priority,
|
||||
start: entry.schedule.startAt!,
|
||||
end: entry.schedule.endAt!,
|
||||
slaStatus: entry.slaStatus,
|
||||
href: `/tickets/${entry.ticket.id}`,
|
||||
}))
|
||||
.sort((a, b) => a.start.getTime() - b.start.getTime())
|
||||
|
||||
const outsideSlaCount = enriched.filter((entry) => entry.slaStatus === "breached" || entry.slaStatus === "at_risk").length
|
||||
const outsideSlaPct = filteredTickets.length ? Math.round((outsideSlaCount / filteredTickets.length) * 100) : 0
|
||||
|
||||
const dataset: AgendaDataset = {
|
||||
range,
|
||||
availableQueues,
|
||||
kpis: {
|
||||
pending: countByStatus(filteredTickets, ["PENDING"]),
|
||||
inProgress: countByStatus(filteredTickets, ["AWAITING_ATTENDANCE"]),
|
||||
paused: countByStatus(filteredTickets, ["PAUSED"]),
|
||||
outsideSla: `${outsideSlaPct}%`,
|
||||
},
|
||||
sections: summarySections,
|
||||
calendarEvents,
|
||||
}
|
||||
|
||||
return dataset
|
||||
}
|
||||
|
||||
function matchesFilters(ticket: Ticket, filters: AgendaFilterState) {
|
||||
if (filters.queues.length > 0) {
|
||||
if (!ticket.queue) return false
|
||||
const normalizedQueue = ticket.queue.toLowerCase()
|
||||
const matchesQueue = filters.queues.some((queue) => queue.toLowerCase() === normalizedQueue)
|
||||
if (!matchesQueue) return false
|
||||
}
|
||||
|
||||
if (filters.priorities.length > 0 && !filters.priorities.includes(ticket.priority)) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (filters.focusVisits && !isVisitTicket(ticket)) {
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
function computeRange(period: AgendaPeriod, pivot: Date) {
|
||||
if (period === "today") {
|
||||
return {
|
||||
start: startOfDay(pivot),
|
||||
end: endOfDay(pivot),
|
||||
}
|
||||
}
|
||||
if (period === "month") {
|
||||
return {
|
||||
start: startOfMonth(pivot),
|
||||
end: endOfMonth(pivot),
|
||||
}
|
||||
}
|
||||
return {
|
||||
start: startOfWeek(pivot, { weekStartsOn: 1 }),
|
||||
end: endOfWeek(pivot, { weekStartsOn: 1 }),
|
||||
}
|
||||
}
|
||||
|
||||
function deriveScheduleWindow(ticket: Ticket) {
|
||||
const due = getSlaDueDate(ticket, "solution")
|
||||
if (!due) {
|
||||
return { startAt: null, endAt: null }
|
||||
}
|
||||
const startAt = due
|
||||
const endAt = addMinutes(startAt, DEFAULT_EVENT_DURATION_MINUTES)
|
||||
return { startAt, endAt }
|
||||
}
|
||||
|
||||
function computeSlaStatus(ticket: Ticket, now: Date): AgendaSlaStatus {
|
||||
const status = getSlaDisplayStatus(ticket, "solution", now)
|
||||
if (status === "n/a") {
|
||||
return "on_track"
|
||||
}
|
||||
return status
|
||||
}
|
||||
|
||||
function buildSummary(ticket: Ticket, schedule: { startAt: Date | null; endAt: Date | null }, slaStatus: AgendaSlaStatus): AgendaTicketSummary {
|
||||
return {
|
||||
id: ticket.id,
|
||||
reference: ticket.reference,
|
||||
subject: ticket.subject,
|
||||
queue: ticket.queue ?? null,
|
||||
company: ticket.company?.name ?? null,
|
||||
priority: ticket.priority,
|
||||
location: null,
|
||||
startAt: schedule.startAt,
|
||||
endAt: ticket.resolvedAt ?? schedule.endAt,
|
||||
slaStatus,
|
||||
completedAt: ticket.resolvedAt ?? null,
|
||||
href: `/tickets/${ticket.id}`,
|
||||
}
|
||||
}
|
||||
|
||||
function isWithinRange(date: Date, range: { start: Date; end: Date }) {
|
||||
return isWithinInterval(date, range)
|
||||
}
|
||||
|
||||
function countByStatus(tickets: Ticket[], statuses: Ticket["status"][]): number {
|
||||
const set = new Set(statuses)
|
||||
return tickets.filter((ticket) => set.has(ticket.status)).length
|
||||
}
|
||||
|
||||
function compareNullableDate(a: Date | null, b: Date | null, direction: 1 | -1) {
|
||||
const aTime = a ? a.getTime() : Number.MAX_SAFE_INTEGER
|
||||
const bTime = b ? b.getTime() : Number.MAX_SAFE_INTEGER
|
||||
return (aTime - bTime) * direction
|
||||
}
|
||||
|
||||
function compareByPriorityThenReference(a: AgendaTicketSummary, b: AgendaTicketSummary) {
|
||||
const rank: Record<TicketPriority, number> = { URGENT: 1, HIGH: 2, MEDIUM: 3, LOW: 4 }
|
||||
const diff = (rank[a.priority] ?? 5) - (rank[b.priority] ?? 5)
|
||||
if (diff !== 0) return diff
|
||||
return a.reference - b.reference
|
||||
}
|
||||
|
|
@ -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<string, { label: string; type: string; value?: unknown; displayValue?: string }>
|
||||
>(
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -9,6 +9,23 @@ export const ticketStatusSchema = z.enum([
|
|||
|
||||
export type TicketStatus = z.infer<typeof ticketStatusSchema>
|
||||
|
||||
const slaStatusSchema = z.enum(["pending", "met", "breached", "n/a"])
|
||||
const slaTimeModeSchema = z.enum(["business", "calendar"])
|
||||
|
||||
export const ticketSlaSnapshotSchema = z.object({
|
||||
categoryId: z.string().optional(),
|
||||
categoryName: z.string().optional(),
|
||||
priority: z.string(),
|
||||
responseTargetMinutes: z.number().nullable().optional(),
|
||||
responseMode: slaTimeModeSchema.optional(),
|
||||
solutionTargetMinutes: z.number().nullable().optional(),
|
||||
solutionMode: slaTimeModeSchema.optional(),
|
||||
alertThreshold: z.number().optional(),
|
||||
pauseStatuses: z.array(z.string()).default([]),
|
||||
})
|
||||
|
||||
export type TicketSlaSnapshot = z.infer<typeof ticketSlaSnapshotSchema>
|
||||
|
||||
export const ticketPrioritySchema = z.enum(["LOW", "MEDIUM", "HIGH", "URGENT"])
|
||||
export type TicketPriority = z.infer<typeof ticketPrioritySchema>
|
||||
|
||||
|
|
@ -137,6 +154,14 @@ export const ticketSchema = z.object({
|
|||
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(),
|
||||
|
|
|
|||
66
src/lib/sla-utils.ts
Normal file
66
src/lib/sla-utils.ts
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
import type { Ticket } from "@/lib/schemas/ticket"
|
||||
|
||||
export type SlaTimerType = "response" | "solution"
|
||||
export type SlaDisplayStatus = "on_track" | "at_risk" | "breached" | "met" | "n/a"
|
||||
|
||||
const DEFAULT_ALERT_THRESHOLD = 0.8
|
||||
|
||||
export function getSlaDueDate(ticket: Ticket, type: SlaTimerType): Date | null {
|
||||
if (type === "response") {
|
||||
return ticket.slaResponseDueAt ?? null
|
||||
}
|
||||
return ticket.slaSolutionDueAt ?? ticket.dueAt ?? null
|
||||
}
|
||||
|
||||
export function getSlaDisplayStatus(ticket: Ticket, type: SlaTimerType, now: Date = new Date()): SlaDisplayStatus {
|
||||
const snapshot = ticket.slaSnapshot
|
||||
const dueAt = getSlaDueDate(ticket, type)
|
||||
const finalStatus = type === "response" ? ticket.slaResponseStatus : ticket.slaSolutionStatus
|
||||
|
||||
if (!snapshot || !dueAt) {
|
||||
if (finalStatus === "met" || finalStatus === "breached") {
|
||||
return finalStatus
|
||||
}
|
||||
return "n/a"
|
||||
}
|
||||
|
||||
if (finalStatus === "met" || finalStatus === "breached") {
|
||||
return finalStatus
|
||||
}
|
||||
|
||||
const completedAt = type === "response" ? ticket.firstResponseAt : ticket.resolvedAt
|
||||
if (completedAt) {
|
||||
return completedAt.getTime() <= dueAt.getTime() ? "met" : "breached"
|
||||
}
|
||||
|
||||
const elapsed = getEffectiveElapsedMs(ticket, now)
|
||||
const total = dueAt.getTime() - ticket.createdAt.getTime()
|
||||
if (total <= 0) {
|
||||
return now.getTime() <= dueAt.getTime() ? "on_track" : "breached"
|
||||
}
|
||||
|
||||
if (now.getTime() > dueAt.getTime()) {
|
||||
return "breached"
|
||||
}
|
||||
|
||||
const threshold = snapshot.alertThreshold ?? DEFAULT_ALERT_THRESHOLD
|
||||
const ratio = elapsed / total
|
||||
if (ratio >= 1) {
|
||||
return "breached"
|
||||
}
|
||||
if (ratio >= threshold) {
|
||||
return "at_risk"
|
||||
}
|
||||
return "on_track"
|
||||
}
|
||||
|
||||
function getEffectiveElapsedMs(ticket: Ticket, now: Date) {
|
||||
const pausedMs = ticket.slaPausedMs ?? 0
|
||||
const pausedAt = ticket.slaPausedAt ?? null
|
||||
const createdAt = ticket.createdAt instanceof Date ? ticket.createdAt : new Date(ticket.createdAt)
|
||||
let elapsed = now.getTime() - createdAt.getTime() - pausedMs
|
||||
if (pausedAt) {
|
||||
elapsed -= now.getTime() - pausedAt.getTime()
|
||||
}
|
||||
return Math.max(0, elapsed)
|
||||
}
|
||||
25
src/lib/ticket-filters.ts
Normal file
25
src/lib/ticket-filters.ts
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
import type { TicketStatus } from "@/lib/schemas/ticket"
|
||||
|
||||
export type TicketFiltersState = {
|
||||
search: string
|
||||
status: TicketStatus | null
|
||||
priority: string | null
|
||||
queue: string | null
|
||||
channel: string | null
|
||||
company: string | null
|
||||
assigneeId: string | null
|
||||
view: "active" | "completed"
|
||||
focusVisits: boolean
|
||||
}
|
||||
|
||||
export const defaultTicketFilters: TicketFiltersState = {
|
||||
search: "",
|
||||
status: null,
|
||||
priority: null,
|
||||
queue: null,
|
||||
channel: null,
|
||||
company: null,
|
||||
assigneeId: null,
|
||||
view: "active",
|
||||
focusVisits: false,
|
||||
}
|
||||
12
src/lib/ticket-matchers.ts
Normal file
12
src/lib/ticket-matchers.ts
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
import type { Ticket } from "@/lib/schemas/ticket"
|
||||
|
||||
export const VISIT_KEYWORDS = ["visita", "visitas", "in loco", "laboratório", "laboratorio", "lab"]
|
||||
|
||||
export function isVisitTicket(ticket: Ticket): boolean {
|
||||
const queueName = ticket.queue?.toLowerCase() ?? ""
|
||||
if (VISIT_KEYWORDS.some((keyword) => queueName.includes(keyword))) {
|
||||
return true
|
||||
}
|
||||
const tags = Array.isArray(ticket.tags) ? ticket.tags : []
|
||||
return tags.some((tag) => VISIT_KEYWORDS.some((keyword) => tag.toLowerCase().includes(keyword)))
|
||||
}
|
||||
|
|
@ -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<typeof toast, ToastMethodKey | "promise"> & {
|
||||
__punctuationPatched?: boolean
|
||||
}
|
||||
|
||||
const patchedToast = toast as PatchedToast
|
||||
|
||||
function stripTrailingPunctuation(value: string): string {
|
||||
const trimmed = value.trimEnd()
|
||||
|
|
@ -32,25 +38,27 @@ function sanitizeOptions<T>(options: T): T {
|
|||
}
|
||||
|
||||
function wrapSimpleMethod<K extends ToastMethodKey>(method: K) {
|
||||
const original = toastAny[method] as typeof toast[K]
|
||||
const original = patchedToast[method]
|
||||
if (typeof original !== "function") return
|
||||
const patched = ((...args: Parameters<typeof toast[K]>) => {
|
||||
const nextArgs = args.slice() as Parameters<typeof toast[K]>
|
||||
type ToastFn = (...args: unknown[]) => unknown
|
||||
const callable = original as ToastFn
|
||||
const patched = ((...args: Parameters<ToastFn>) => {
|
||||
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<typeof toast[K]>)
|
||||
}) 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()
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue