feat: surface ticket work metrics and refresh list layout
Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
This commit is contained in:
parent
744d5933d4
commit
55511f3a0e
20 changed files with 1102 additions and 357 deletions
|
|
@ -57,6 +57,8 @@ export default defineSchema({
|
||||||
updatedAt: v.number(),
|
updatedAt: v.number(),
|
||||||
createdAt: v.number(),
|
createdAt: v.number(),
|
||||||
tags: v.optional(v.array(v.string())),
|
tags: v.optional(v.array(v.string())),
|
||||||
|
totalWorkedMs: v.optional(v.number()),
|
||||||
|
activeSessionId: v.optional(v.id("ticketWorkSessions")),
|
||||||
})
|
})
|
||||||
.index("by_tenant_status", ["tenantId", "status"])
|
.index("by_tenant_status", ["tenantId", "status"])
|
||||||
.index("by_tenant_queue", ["tenantId", "queueId"])
|
.index("by_tenant_queue", ["tenantId", "queueId"])
|
||||||
|
|
@ -89,4 +91,14 @@ export default defineSchema({
|
||||||
payload: v.optional(v.any()),
|
payload: v.optional(v.any()),
|
||||||
createdAt: v.number(),
|
createdAt: v.number(),
|
||||||
}).index("by_ticket", ["ticketId"]),
|
}).index("by_ticket", ["ticketId"]),
|
||||||
|
|
||||||
|
ticketWorkSessions: defineTable({
|
||||||
|
ticketId: v.id("tickets"),
|
||||||
|
agentId: v.id("users"),
|
||||||
|
startedAt: v.number(),
|
||||||
|
stoppedAt: v.optional(v.number()),
|
||||||
|
durationMs: v.optional(v.number()),
|
||||||
|
})
|
||||||
|
.index("by_ticket", ["ticketId"])
|
||||||
|
.index("by_ticket_agent", ["ticketId", "agentId"]),
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { internalMutation, mutation, query } from "./_generated/server";
|
import { internalMutation, mutation, query } from "./_generated/server";
|
||||||
import { v } from "convex/values";
|
import { ConvexError, v } from "convex/values";
|
||||||
import { Id, type Doc } from "./_generated/dataModel";
|
import { Id, type Doc } from "./_generated/dataModel";
|
||||||
|
|
||||||
const PRIORITY_ORDER = ["URGENT", "HIGH", "MEDIUM", "LOW"] as const;
|
const PRIORITY_ORDER = ["URGENT", "HIGH", "MEDIUM", "LOW"] as const;
|
||||||
|
|
@ -53,6 +53,7 @@ export const list = query({
|
||||||
const requester = (await ctx.db.get(t.requesterId)) as Doc<"users"> | null;
|
const requester = (await ctx.db.get(t.requesterId)) as Doc<"users"> | null;
|
||||||
const assignee = t.assigneeId ? ((await ctx.db.get(t.assigneeId)) as Doc<"users"> | null) : null;
|
const assignee = t.assigneeId ? ((await ctx.db.get(t.assigneeId)) as Doc<"users"> | null) : null;
|
||||||
const queue = t.queueId ? ((await ctx.db.get(t.queueId)) as Doc<"queues"> | null) : null;
|
const queue = t.queueId ? ((await ctx.db.get(t.queueId)) as Doc<"queues"> | null) : null;
|
||||||
|
const activeSession = t.activeSessionId ? await ctx.db.get(t.activeSessionId) : null;
|
||||||
return {
|
return {
|
||||||
id: t._id,
|
id: t._id,
|
||||||
reference: t.reference,
|
reference: t.reference,
|
||||||
|
|
@ -88,6 +89,16 @@ export const list = query({
|
||||||
tags: t.tags ?? [],
|
tags: t.tags ?? [],
|
||||||
lastTimelineEntry: null,
|
lastTimelineEntry: null,
|
||||||
metrics: null,
|
metrics: null,
|
||||||
|
workSummary: {
|
||||||
|
totalWorkedMs: t.totalWorkedMs ?? 0,
|
||||||
|
activeSession: activeSession
|
||||||
|
? {
|
||||||
|
id: activeSession._id,
|
||||||
|
agentId: activeSession.agentId,
|
||||||
|
startedAt: activeSession.startedAt,
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
@ -121,6 +132,7 @@ export const getById = query({
|
||||||
id: att.storageId,
|
id: att.storageId,
|
||||||
name: att.name,
|
name: att.name,
|
||||||
size: att.size,
|
size: att.size,
|
||||||
|
type: att.type,
|
||||||
url: await ctx.storage.getUrl(att.storageId),
|
url: await ctx.storage.getUrl(att.storageId),
|
||||||
}))
|
}))
|
||||||
);
|
);
|
||||||
|
|
@ -142,6 +154,8 @@ export const getById = query({
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const activeSession = t.activeSessionId ? await ctx.db.get(t.activeSessionId) : null;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: t._id,
|
id: t._id,
|
||||||
reference: t.reference,
|
reference: t.reference,
|
||||||
|
|
@ -177,6 +191,16 @@ export const getById = query({
|
||||||
tags: t.tags ?? [],
|
tags: t.tags ?? [],
|
||||||
lastTimelineEntry: null,
|
lastTimelineEntry: null,
|
||||||
metrics: null,
|
metrics: null,
|
||||||
|
workSummary: {
|
||||||
|
totalWorkedMs: t.totalWorkedMs ?? 0,
|
||||||
|
activeSession: activeSession
|
||||||
|
? {
|
||||||
|
id: activeSession._id,
|
||||||
|
agentId: activeSession.agentId,
|
||||||
|
startedAt: activeSession.startedAt,
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
},
|
||||||
description: undefined,
|
description: undefined,
|
||||||
customFields: {},
|
customFields: {},
|
||||||
timeline: timeline.map((ev) => ({
|
timeline: timeline.map((ev) => ({
|
||||||
|
|
@ -201,6 +225,10 @@ export const create = mutation({
|
||||||
requesterId: v.id("users"),
|
requesterId: v.id("users"),
|
||||||
},
|
},
|
||||||
handler: async (ctx, args) => {
|
handler: async (ctx, args) => {
|
||||||
|
const subject = args.subject.trim();
|
||||||
|
if (subject.length < 3) {
|
||||||
|
throw new ConvexError("Informe um assunto com pelo menos 3 caracteres");
|
||||||
|
}
|
||||||
// compute next reference (simple monotonic counter per tenant)
|
// compute next reference (simple monotonic counter per tenant)
|
||||||
const existing = await ctx.db
|
const existing = await ctx.db
|
||||||
.query("tickets")
|
.query("tickets")
|
||||||
|
|
@ -212,8 +240,8 @@ export const create = mutation({
|
||||||
const id = await ctx.db.insert("tickets", {
|
const id = await ctx.db.insert("tickets", {
|
||||||
tenantId: args.tenantId,
|
tenantId: args.tenantId,
|
||||||
reference: nextRef,
|
reference: nextRef,
|
||||||
subject: args.subject,
|
subject,
|
||||||
summary: args.summary,
|
summary: args.summary?.trim() || undefined,
|
||||||
status: "NEW",
|
status: "NEW",
|
||||||
priority: args.priority,
|
priority: args.priority,
|
||||||
channel: args.channel,
|
channel: args.channel,
|
||||||
|
|
@ -221,6 +249,8 @@ export const create = mutation({
|
||||||
requesterId: args.requesterId,
|
requesterId: args.requesterId,
|
||||||
assigneeId: undefined,
|
assigneeId: undefined,
|
||||||
working: false,
|
working: false,
|
||||||
|
activeSessionId: undefined,
|
||||||
|
totalWorkedMs: 0,
|
||||||
createdAt: now,
|
createdAt: now,
|
||||||
updatedAt: now,
|
updatedAt: now,
|
||||||
firstResponseAt: undefined,
|
firstResponseAt: undefined,
|
||||||
|
|
@ -282,6 +312,51 @@ export const addComment = mutation({
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const removeCommentAttachment = mutation({
|
||||||
|
args: {
|
||||||
|
ticketId: v.id("tickets"),
|
||||||
|
commentId: v.id("ticketComments"),
|
||||||
|
attachmentId: v.id("_storage"),
|
||||||
|
actorId: v.id("users"),
|
||||||
|
},
|
||||||
|
handler: async (ctx, { ticketId, commentId, attachmentId, actorId }) => {
|
||||||
|
const comment = await ctx.db.get(commentId);
|
||||||
|
if (!comment || comment.ticketId !== ticketId) {
|
||||||
|
throw new ConvexError("Comentário não encontrado");
|
||||||
|
}
|
||||||
|
|
||||||
|
const attachments = comment.attachments ?? [];
|
||||||
|
const target = attachments.find((att) => att.storageId === attachmentId);
|
||||||
|
if (!target) {
|
||||||
|
throw new ConvexError("Anexo não encontrado");
|
||||||
|
}
|
||||||
|
|
||||||
|
await ctx.storage.delete(attachmentId);
|
||||||
|
|
||||||
|
const now = Date.now();
|
||||||
|
await ctx.db.patch(commentId, {
|
||||||
|
attachments: attachments.filter((att) => att.storageId !== attachmentId),
|
||||||
|
updatedAt: now,
|
||||||
|
});
|
||||||
|
|
||||||
|
const actor = (await ctx.db.get(actorId)) as Doc<"users"> | null;
|
||||||
|
await ctx.db.insert("ticketEvents", {
|
||||||
|
ticketId,
|
||||||
|
type: "ATTACHMENT_REMOVED",
|
||||||
|
payload: {
|
||||||
|
attachmentId,
|
||||||
|
attachmentName: target.name,
|
||||||
|
actorId,
|
||||||
|
actorName: actor?.name,
|
||||||
|
actorAvatar: actor?.avatarUrl,
|
||||||
|
},
|
||||||
|
createdAt: now,
|
||||||
|
});
|
||||||
|
|
||||||
|
await ctx.db.patch(ticketId, { updatedAt: now });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
export const updateStatus = mutation({
|
export const updateStatus = mutation({
|
||||||
args: { ticketId: v.id("tickets"), status: v.string(), actorId: v.id("users") },
|
args: { ticketId: v.id("tickets"), status: v.string(), actorId: v.id("users") },
|
||||||
handler: async (ctx, { ticketId, status, actorId }) => {
|
handler: async (ctx, { ticketId, status, actorId }) => {
|
||||||
|
|
@ -334,6 +409,27 @@ export const changeQueue = mutation({
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const workSummary = query({
|
||||||
|
args: { ticketId: v.id("tickets") },
|
||||||
|
handler: async (ctx, { ticketId }) => {
|
||||||
|
const ticket = await ctx.db.get(ticketId)
|
||||||
|
if (!ticket) return null
|
||||||
|
|
||||||
|
const activeSession = ticket.activeSessionId ? await ctx.db.get(ticket.activeSessionId) : null
|
||||||
|
return {
|
||||||
|
ticketId,
|
||||||
|
totalWorkedMs: ticket.totalWorkedMs ?? 0,
|
||||||
|
activeSession: activeSession
|
||||||
|
? {
|
||||||
|
id: activeSession._id,
|
||||||
|
agentId: activeSession.agentId,
|
||||||
|
startedAt: activeSession.startedAt,
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
export const updatePriority = mutation({
|
export const updatePriority = mutation({
|
||||||
args: { ticketId: v.id("tickets"), priority: v.string(), actorId: v.id("users") },
|
args: { ticketId: v.id("tickets"), priority: v.string(), actorId: v.id("users") },
|
||||||
handler: async (ctx, { ticketId, priority, actorId }) => {
|
handler: async (ctx, { ticketId, priority, actorId }) => {
|
||||||
|
|
@ -349,22 +445,89 @@ export const updatePriority = mutation({
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
export const toggleWork = mutation({
|
export const startWork = mutation({
|
||||||
args: { ticketId: v.id("tickets"), actorId: v.id("users") },
|
args: { ticketId: v.id("tickets"), actorId: v.id("users") },
|
||||||
handler: async (ctx, { ticketId, actorId }) => {
|
handler: async (ctx, { ticketId, actorId }) => {
|
||||||
const t = await ctx.db.get(ticketId)
|
const ticket = await ctx.db.get(ticketId)
|
||||||
if (!t) return
|
if (!ticket) {
|
||||||
|
throw new ConvexError("Ticket não encontrado")
|
||||||
|
}
|
||||||
|
if (ticket.activeSessionId) {
|
||||||
|
return { status: "already_started", sessionId: ticket.activeSessionId }
|
||||||
|
}
|
||||||
|
|
||||||
const now = Date.now()
|
const now = Date.now()
|
||||||
const next = !(t.working ?? false)
|
const sessionId = await ctx.db.insert("ticketWorkSessions", {
|
||||||
await ctx.db.patch(ticketId, { working: next, updatedAt: now })
|
ticketId,
|
||||||
|
agentId: actorId,
|
||||||
|
startedAt: now,
|
||||||
|
})
|
||||||
|
|
||||||
|
await ctx.db.patch(ticketId, {
|
||||||
|
working: true,
|
||||||
|
activeSessionId: sessionId,
|
||||||
|
updatedAt: now,
|
||||||
|
})
|
||||||
|
|
||||||
const actor = (await ctx.db.get(actorId)) as Doc<"users"> | null
|
const actor = (await ctx.db.get(actorId)) as Doc<"users"> | null
|
||||||
await ctx.db.insert("ticketEvents", {
|
await ctx.db.insert("ticketEvents", {
|
||||||
ticketId,
|
ticketId,
|
||||||
type: next ? "WORK_STARTED" : "WORK_PAUSED",
|
type: "WORK_STARTED",
|
||||||
payload: { actorId, actorName: actor?.name, actorAvatar: actor?.avatarUrl },
|
payload: { actorId, actorName: actor?.name, actorAvatar: actor?.avatarUrl, sessionId },
|
||||||
createdAt: now,
|
createdAt: now,
|
||||||
})
|
})
|
||||||
return next
|
|
||||||
|
return { status: "started", sessionId, startedAt: now }
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
export const pauseWork = mutation({
|
||||||
|
args: { ticketId: v.id("tickets"), actorId: v.id("users") },
|
||||||
|
handler: async (ctx, { ticketId, actorId }) => {
|
||||||
|
const ticket = await ctx.db.get(ticketId)
|
||||||
|
if (!ticket) {
|
||||||
|
throw new ConvexError("Ticket não encontrado")
|
||||||
|
}
|
||||||
|
if (!ticket.activeSessionId) {
|
||||||
|
return { status: "already_paused" }
|
||||||
|
}
|
||||||
|
|
||||||
|
const session = await ctx.db.get(ticket.activeSessionId)
|
||||||
|
if (!session) {
|
||||||
|
await ctx.db.patch(ticketId, { activeSessionId: undefined, working: false })
|
||||||
|
return { status: "session_missing" }
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = Date.now()
|
||||||
|
const durationMs = now - session.startedAt
|
||||||
|
|
||||||
|
await ctx.db.patch(ticket.activeSessionId, {
|
||||||
|
stoppedAt: now,
|
||||||
|
durationMs,
|
||||||
|
})
|
||||||
|
|
||||||
|
await ctx.db.patch(ticketId, {
|
||||||
|
working: false,
|
||||||
|
activeSessionId: undefined,
|
||||||
|
totalWorkedMs: (ticket.totalWorkedMs ?? 0) + durationMs,
|
||||||
|
updatedAt: now,
|
||||||
|
})
|
||||||
|
|
||||||
|
const actor = (await ctx.db.get(actorId)) as Doc<"users"> | null
|
||||||
|
await ctx.db.insert("ticketEvents", {
|
||||||
|
ticketId,
|
||||||
|
type: "WORK_PAUSED",
|
||||||
|
payload: {
|
||||||
|
actorId,
|
||||||
|
actorName: actor?.name,
|
||||||
|
actorAvatar: actor?.avatarUrl,
|
||||||
|
sessionId: session._id,
|
||||||
|
sessionDurationMs: durationMs,
|
||||||
|
},
|
||||||
|
createdAt: now,
|
||||||
|
})
|
||||||
|
|
||||||
|
return { status: "paused", durationMs }
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -374,12 +537,16 @@ export const updateSubject = mutation({
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
const t = await ctx.db.get(ticketId);
|
const t = await ctx.db.get(ticketId);
|
||||||
if (!t) return;
|
if (!t) return;
|
||||||
await ctx.db.patch(ticketId, { subject, updatedAt: now });
|
const trimmed = subject.trim();
|
||||||
|
if (trimmed.length < 3) {
|
||||||
|
throw new ConvexError("Informe um assunto com pelo menos 3 caracteres");
|
||||||
|
}
|
||||||
|
await ctx.db.patch(ticketId, { subject: trimmed, updatedAt: now });
|
||||||
const actor = await ctx.db.get(actorId);
|
const actor = await ctx.db.get(actorId);
|
||||||
await ctx.db.insert("ticketEvents", {
|
await ctx.db.insert("ticketEvents", {
|
||||||
ticketId,
|
ticketId,
|
||||||
type: "SUBJECT_CHANGED",
|
type: "SUBJECT_CHANGED",
|
||||||
payload: { from: t.subject, to: subject, actorId, actorName: (actor as Doc<"users"> | null)?.name, actorAvatar: (actor as Doc<"users"> | null)?.avatarUrl },
|
payload: { from: t.subject, to: trimmed, actorId, actorName: (actor as Doc<"users"> | null)?.name, actorAvatar: (actor as Doc<"users"> | null)?.avatarUrl },
|
||||||
createdAt: now,
|
createdAt: now,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -43,74 +43,74 @@
|
||||||
--radius-xl: calc(var(--radius) + 4px);
|
--radius-xl: calc(var(--radius) + 4px);
|
||||||
}
|
}
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
--radius: 0.75rem;
|
--radius: 0.75rem;
|
||||||
--background: oklch(0.99 0.004 95.08);
|
--background: #f7f8fb;
|
||||||
--foreground: oklch(0.28 0.02 254.6);
|
--foreground: #0f172a;
|
||||||
--card: oklch(1 0 0);
|
--card: #ffffff;
|
||||||
--card-foreground: oklch(0.22 0.02 254.6);
|
--card-foreground: #0f172a;
|
||||||
--popover: oklch(1 0 0);
|
--popover: #ffffff;
|
||||||
--popover-foreground: oklch(0.22 0.02 254.6);
|
--popover-foreground: #0f172a;
|
||||||
--primary: oklch(0.63 0.16 254.6);
|
--primary: #00e8ff;
|
||||||
--primary-foreground: oklch(0.99 0.004 95.08);
|
--primary-foreground: #020617;
|
||||||
--secondary: oklch(0.96 0.022 254.6);
|
--secondary: #0f172a;
|
||||||
--secondary-foreground: oklch(0.3 0.03 254.6);
|
--secondary-foreground: #f8fafc;
|
||||||
--muted: oklch(0.96 0.01 254.6);
|
--muted: #e2e8f0;
|
||||||
--muted-foreground: oklch(0.55 0.03 254.6);
|
--muted-foreground: #475569;
|
||||||
--accent: oklch(0.96 0.01 254.6);
|
--accent: #dff7fb;
|
||||||
--accent-foreground: oklch(0.3 0.03 254.6);
|
--accent-foreground: #0f172a;
|
||||||
--destructive: oklch(0.55 0.23 23.3);
|
--destructive: #ef4444;
|
||||||
--border: oklch(0.9 0.01 254.6);
|
--border: #d6d8de;
|
||||||
--input: oklch(0.92 0.008 254.6);
|
--input: #e4e7ec;
|
||||||
--ring: oklch(0.63 0.16 254.6);
|
--ring: #00d6eb;
|
||||||
--chart-1: oklch(0.63 0.16 254.6);
|
--chart-1: #00d6eb;
|
||||||
--chart-2: oklch(0.66 0.11 200.43);
|
--chart-2: #0891b2;
|
||||||
--chart-3: oklch(0.73 0.14 146.75);
|
--chart-3: #0e7490;
|
||||||
--chart-4: oklch(0.7 0.17 95.21);
|
--chart-4: #155e75;
|
||||||
--chart-5: oklch(0.73 0.1 18.06);
|
--chart-5: #0f4c5c;
|
||||||
--sidebar: oklch(0.985 0.004 95.08);
|
--sidebar: #f2f5f7;
|
||||||
--sidebar-foreground: oklch(0.28 0.02 254.6);
|
--sidebar-foreground: #0f172a;
|
||||||
--sidebar-primary: oklch(0.63 0.16 254.6);
|
--sidebar-primary: #00e8ff;
|
||||||
--sidebar-primary-foreground: oklch(0.99 0.004 95.08);
|
--sidebar-primary-foreground: #020617;
|
||||||
--sidebar-accent: oklch(0.95 0.008 254.6);
|
--sidebar-accent: #c4eef6;
|
||||||
--sidebar-accent-foreground: oklch(0.28 0.02 254.6);
|
--sidebar-accent-foreground: #0f172a;
|
||||||
--sidebar-border: oklch(0.9 0.01 254.6);
|
--sidebar-border: #cbd5e1;
|
||||||
--sidebar-ring: oklch(0.63 0.16 254.6);
|
--sidebar-ring: #00d6eb;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dark {
|
.dark {
|
||||||
--background: oklch(0.16 0.02 254.6);
|
--background: #020617;
|
||||||
--foreground: oklch(0.96 0.02 254.6);
|
--foreground: #f8fafc;
|
||||||
--card: oklch(0.19 0.02 254.6);
|
--card: #0b1120;
|
||||||
--card-foreground: oklch(0.96 0.02 254.6);
|
--card-foreground: #f8fafc;
|
||||||
--popover: oklch(0.22 0.02 254.6);
|
--popover: #0b1120;
|
||||||
--popover-foreground: oklch(0.96 0.02 254.6);
|
--popover-foreground: #f8fafc;
|
||||||
--primary: oklch(0.71 0.15 254.6);
|
--primary: #00d6eb;
|
||||||
--primary-foreground: oklch(0.1 0.01 254.6);
|
--primary-foreground: #041019;
|
||||||
--secondary: oklch(0.32 0.02 254.6);
|
--secondary: #1f2937;
|
||||||
--secondary-foreground: oklch(0.96 0.02 254.6);
|
--secondary-foreground: #f9fafb;
|
||||||
--muted: oklch(0.3 0.02 254.6);
|
--muted: #1e293b;
|
||||||
--muted-foreground: oklch(0.68 0.02 254.6);
|
--muted-foreground: #cbd5f5;
|
||||||
--accent: oklch(0.29 0.02 254.6);
|
--accent: #083344;
|
||||||
--accent-foreground: oklch(0.96 0.02 254.6);
|
--accent-foreground: #f1f5f9;
|
||||||
--destructive: oklch(0.6 0.21 23.3);
|
--destructive: #f87171;
|
||||||
--border: oklch(0.32 0.02 254.6);
|
--border: #1f2933;
|
||||||
--input: oklch(0.32 0.02 254.6);
|
--input: #1e2933;
|
||||||
--ring: oklch(0.71 0.15 254.6);
|
--ring: #00e6ff;
|
||||||
--chart-1: oklch(0.71 0.15 254.6);
|
--chart-1: #00e6ff;
|
||||||
--chart-2: oklch(0.63 0.12 200.43);
|
--chart-2: #0891b2;
|
||||||
--chart-3: oklch(0.62 0.14 146.75);
|
--chart-3: #0e7490;
|
||||||
--chart-4: oklch(0.6 0.17 95.21);
|
--chart-4: #155e75;
|
||||||
--chart-5: oklch(0.64 0.1 18.06);
|
--chart-5: #0f4c5c;
|
||||||
--sidebar: oklch(0.18 0.02 254.6);
|
--sidebar: #050c16;
|
||||||
--sidebar-foreground: oklch(0.96 0.02 254.6);
|
--sidebar-foreground: #f8fafc;
|
||||||
--sidebar-primary: oklch(0.71 0.15 254.6);
|
--sidebar-primary: #00d6eb;
|
||||||
--sidebar-primary-foreground: oklch(0.1 0.01 254.6);
|
--sidebar-primary-foreground: #041019;
|
||||||
--sidebar-accent: oklch(0.26 0.02 254.6);
|
--sidebar-accent: #083344;
|
||||||
--sidebar-accent-foreground: oklch(0.96 0.02 254.6);
|
--sidebar-accent-foreground: #f8fafc;
|
||||||
--sidebar-border: oklch(0.26 0.02 254.6);
|
--sidebar-border: #0f1b2a;
|
||||||
--sidebar-ring: oklch(0.71 0.15 254.6);
|
--sidebar-ring: #00e6ff;
|
||||||
}
|
}
|
||||||
|
|
||||||
@layer base {
|
@layer base {
|
||||||
* {
|
* {
|
||||||
|
|
@ -127,7 +127,7 @@
|
||||||
@apply text-foreground;
|
@apply text-foreground;
|
||||||
}
|
}
|
||||||
.rich-text p { @apply my-2; }
|
.rich-text p { @apply my-2; }
|
||||||
.rich-text a { @apply text-primary underline; }
|
.rich-text a { @apply text-[#00e8ff] underline; }
|
||||||
.rich-text ul { @apply my-2 list-disc ps-5; }
|
.rich-text ul { @apply my-2 list-disc ps-5; }
|
||||||
.rich-text ol { @apply my-2 list-decimal ps-5; }
|
.rich-text ol { @apply my-2 list-decimal ps-5; }
|
||||||
.rich-text li { @apply my-1; }
|
.rich-text li { @apply my-1; }
|
||||||
|
|
|
||||||
|
|
@ -21,7 +21,7 @@ export default async function TicketDetailPage({ params }: TicketDetailPageProps
|
||||||
title={`Ticket #${id}`}
|
title={`Ticket #${id}`}
|
||||||
lead={"Detalhes do ticket"}
|
lead={"Detalhes do ticket"}
|
||||||
secondaryAction={<SiteHeader.SecondaryButton>Compartilhar</SiteHeader.SecondaryButton>}
|
secondaryAction={<SiteHeader.SecondaryButton>Compartilhar</SiteHeader.SecondaryButton>}
|
||||||
primaryAction={<SiteHeader.PrimaryButton>Adicionar comentario</SiteHeader.PrimaryButton>}
|
primaryAction={<SiteHeader.PrimaryButton>Adicionar comentário</SiteHeader.PrimaryButton>}
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
|
|
|
||||||
|
|
@ -1,122 +1,230 @@
|
||||||
"use client";
|
"use client"
|
||||||
|
|
||||||
import { useMemo, useState } from "react";
|
import { useMemo, useState } from "react"
|
||||||
import type { Id } from "@/convex/_generated/dataModel";
|
import { useRouter } from "next/navigation"
|
||||||
import type { TicketQueueSummary } from "@/lib/schemas/ticket";
|
import { useMutation, useQuery } from "convex/react"
|
||||||
import { useRouter } from "next/navigation";
|
import { toast } from "sonner"
|
||||||
import { useMutation, useQuery } from "convex/react";
|
import type { Id } from "@/convex/_generated/dataModel"
|
||||||
|
import type { TicketPriority, TicketQueueSummary } from "@/lib/schemas/ticket"
|
||||||
|
import { DEFAULT_TENANT_ID } from "@/lib/constants"
|
||||||
|
import { useAuth } from "@/lib/auth-client"
|
||||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
import { api } from "@/convex/_generated/api";
|
import { api } from "@/convex/_generated/api"
|
||||||
import { DEFAULT_TENANT_ID } from "@/lib/constants";
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
|
||||||
import { useAuth } from "@/lib/auth-client";
|
import { Button } from "@/components/ui/button"
|
||||||
import { RichTextEditor } from "@/components/ui/rich-text-editor";
|
import { Input } from "@/components/ui/input"
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||||
|
import { RichTextEditor } from "@/components/ui/rich-text-editor"
|
||||||
|
import { Spinner } from "@/components/ui/spinner"
|
||||||
|
import { Badge } from "@/components/ui/badge"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import {
|
||||||
|
PriorityIcon,
|
||||||
|
priorityBadgeClass,
|
||||||
|
priorityItemClass,
|
||||||
|
priorityStyles,
|
||||||
|
priorityTriggerClass,
|
||||||
|
} from "@/components/tickets/priority-select"
|
||||||
|
|
||||||
export default function NewTicketPage() {
|
export default function NewTicketPage() {
|
||||||
const router = useRouter();
|
const router = useRouter()
|
||||||
const { userId } = useAuth();
|
const { userId } = useAuth()
|
||||||
const queues = (useQuery(api.queues.summary, { tenantId: DEFAULT_TENANT_ID }) as TicketQueueSummary[] | undefined) ?? [];
|
const queues = (useQuery(api.queues.summary, { tenantId: DEFAULT_TENANT_ID }) as TicketQueueSummary[] | undefined) ?? []
|
||||||
const create = useMutation(api.tickets.create);
|
const create = useMutation(api.tickets.create)
|
||||||
const addComment = useMutation(api.tickets.addComment);
|
const addComment = useMutation(api.tickets.addComment)
|
||||||
const ensureDefaults = useMutation(api.bootstrap.ensureDefaults);
|
const ensureDefaults = useMutation(api.bootstrap.ensureDefaults)
|
||||||
|
|
||||||
const [subject, setSubject] = useState("");
|
const [subject, setSubject] = useState("")
|
||||||
const [summary, setSummary] = useState("");
|
const [summary, setSummary] = useState("")
|
||||||
const [priority, setPriority] = useState("MEDIUM");
|
const [priority, setPriority] = useState<TicketPriority>("MEDIUM")
|
||||||
const [channel, setChannel] = useState("MANUAL");
|
const [channel, setChannel] = useState("MANUAL")
|
||||||
const [queueName, setQueueName] = useState<string | null>(null);
|
const [queueName, setQueueName] = useState<string | null>(null)
|
||||||
const [description, setDescription] = useState("");
|
const [description, setDescription] = useState("")
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [subjectError, setSubjectError] = useState<string | null>(null)
|
||||||
|
|
||||||
const queueOptions = useMemo(() => queues.map((q) => q.name), [queues]);
|
const queueOptions = useMemo(() => queues.map((q) => q.name), [queues])
|
||||||
|
|
||||||
async function submit(e: React.FormEvent) {
|
async function submit(event: React.FormEvent) {
|
||||||
e.preventDefault();
|
event.preventDefault()
|
||||||
if (!userId) return;
|
if (!userId || loading) return
|
||||||
if (queues.length === 0) await ensureDefaults({ tenantId: DEFAULT_TENANT_ID });
|
|
||||||
// Encontrar a fila pelo nome (simples)
|
const trimmedSubject = subject.trim()
|
||||||
const selQueue = queues.find((q) => q.name === queueName);
|
if (trimmedSubject.length < 3) {
|
||||||
const queueId = selQueue ? (selQueue.id as Id<"queues">) : undefined;
|
setSubjectError("Informe um assunto com pelo menos 3 caracteres.")
|
||||||
const id = await create({
|
return
|
||||||
tenantId: DEFAULT_TENANT_ID,
|
}
|
||||||
subject,
|
setSubjectError(null)
|
||||||
summary,
|
|
||||||
priority,
|
setLoading(true)
|
||||||
channel,
|
toast.loading("Criando ticket...", { id: "create-ticket" })
|
||||||
queueId,
|
try {
|
||||||
requesterId: userId as Id<"users">,
|
if (queues.length === 0) await ensureDefaults({ tenantId: DEFAULT_TENANT_ID })
|
||||||
});
|
const selQueue = queues.find((q) => q.name === queueName)
|
||||||
const hasDescription = description.replace(/<[^>]*>/g, "").trim().length > 0
|
const queueId = selQueue ? (selQueue.id as Id<"queues">) : undefined
|
||||||
if (hasDescription) {
|
const id = await create({
|
||||||
await addComment({ ticketId: id as Id<"tickets">, authorId: userId as Id<"users">, visibility: "PUBLIC", body: description, attachments: [] })
|
tenantId: DEFAULT_TENANT_ID,
|
||||||
|
subject: trimmedSubject,
|
||||||
|
summary: summary.trim() || undefined,
|
||||||
|
priority,
|
||||||
|
channel,
|
||||||
|
queueId,
|
||||||
|
requesterId: userId as Id<"users">,
|
||||||
|
})
|
||||||
|
const plainDescription = description.replace(/<[^>]*>/g, "").trim()
|
||||||
|
if (plainDescription.length > 0) {
|
||||||
|
await addComment({
|
||||||
|
ticketId: id as Id<"tickets">,
|
||||||
|
authorId: userId as Id<"users">,
|
||||||
|
visibility: "PUBLIC",
|
||||||
|
body: description,
|
||||||
|
attachments: [],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
toast.success("Ticket criado!", { id: "create-ticket" })
|
||||||
|
router.replace(`/tickets/${id}`)
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error)
|
||||||
|
toast.error("Não foi possível criar o ticket.", { id: "create-ticket" })
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
}
|
}
|
||||||
router.replace(`/tickets/${id}`);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const selectTriggerClass = "flex h-8 w-full items-center justify-between rounded-full border border-slate-300 bg-white px-3 text-sm font-medium text-neutral-800 shadow-sm focus:ring-0 data-[state=open]:border-[#00d6eb]"
|
||||||
|
const selectItemClass = "flex items-center gap-2 rounded-md px-2 py-2 text-sm text-neutral-800 transition data-[state=checked]:bg-[#00e8ff]/15 data-[state=checked]:text-neutral-900 focus:bg-[#00e8ff]/10"
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto max-w-2xl p-6">
|
<div className="mx-auto max-w-3xl px-6 py-8">
|
||||||
<h1 className="mb-4 text-xl font-semibold">Novo ticket</h1>
|
<Card className="rounded-2xl border border-slate-200 shadow-sm">
|
||||||
<form onSubmit={submit} className="space-y-4 rounded-xl border bg-card p-6">
|
<CardHeader className="space-y-2">
|
||||||
<div className="space-y-2">
|
<CardTitle className="text-2xl font-semibold text-neutral-900">Novo ticket</CardTitle>
|
||||||
<label className="text-sm">Assunto</label>
|
<CardDescription className="text-sm text-neutral-600">Preencha as informações básicas para abrir um chamado.</CardDescription>
|
||||||
<input className="w-full rounded-md border bg-background px-3 py-2" value={subject} onChange={(e) => setSubject(e.target.value)} required />
|
</CardHeader>
|
||||||
</div>
|
<CardContent>
|
||||||
<div className="space-y-2">
|
<form onSubmit={submit} className="space-y-6">
|
||||||
<label className="text-sm">Resumo</label>
|
<div className="space-y-2">
|
||||||
<textarea className="w-full rounded-md border bg-background px-3 py-2" value={summary} onChange={(e) => setSummary(e.target.value)} rows={3} />
|
<label className="text-sm font-medium text-neutral-700" htmlFor="subject">
|
||||||
</div>
|
Assunto
|
||||||
<div className="space-y-2">
|
</label>
|
||||||
<label className="text-sm">Descrição</label>
|
<Input
|
||||||
<RichTextEditor value={description} onChange={setDescription} placeholder="Detalhe o problema, passos para reproduzir, links, etc." />
|
id="subject"
|
||||||
</div>
|
value={subject}
|
||||||
<div className="grid gap-4 sm:grid-cols-2">
|
onChange={(event) => {
|
||||||
<div className="space-y-2">
|
setSubject(event.target.value)
|
||||||
<label className="text-sm">Prioridade</label>
|
if (subjectError) setSubjectError(null)
|
||||||
<select className="w-full rounded-md border bg-background px-3 py-2" value={priority} onChange={(e) => setPriority(e.target.value)}>
|
}}
|
||||||
{[
|
placeholder="Ex.: Erro 500 no portal"
|
||||||
["LOW", "Baixa"],
|
aria-invalid={subjectError ? "true" : undefined}
|
||||||
["MEDIUM", "Media"],
|
/>
|
||||||
["HIGH", "Alta"],
|
{subjectError ? <p className="text-xs font-medium text-red-500">{subjectError}</p> : null}
|
||||||
["URGENT", "Urgente"],
|
</div>
|
||||||
].map(([v, l]) => (
|
<div className="space-y-2">
|
||||||
<option key={v} value={v}>
|
<label className="text-sm font-medium text-neutral-700" htmlFor="summary">
|
||||||
{l}
|
Resumo
|
||||||
</option>
|
</label>
|
||||||
))}
|
<textarea
|
||||||
</select>
|
id="summary"
|
||||||
</div>
|
className="min-h-[96px] w-full rounded-lg border border-slate-300 bg-white px-3 py-2 text-sm text-neutral-800 shadow-sm outline-none transition-colors focus-visible:border-[#00d6eb] focus-visible:ring-[3px] focus-visible:ring-[#00e8ff]/20"
|
||||||
<div className="space-y-2">
|
value={summary}
|
||||||
<label className="text-sm">Canal</label>
|
onChange={(event) => setSummary(event.target.value)}
|
||||||
<select className="w-full rounded-md border bg-background px-3 py-2" value={channel} onChange={(e) => setChannel(e.target.value)}>
|
/>
|
||||||
{[
|
</div>
|
||||||
["EMAIL", "E-mail"],
|
<div className="space-y-2">
|
||||||
["WHATSAPP", "WhatsApp"],
|
<label className="text-sm font-medium text-neutral-700">Descrição</label>
|
||||||
["CHAT", "Chat"],
|
<RichTextEditor value={description} onChange={setDescription} placeholder="Detalhe o problema, passos para reproduzir, links, etc." />
|
||||||
["PHONE", "Telefone"],
|
</div>
|
||||||
["API", "API"],
|
<div className="grid gap-4 sm:grid-cols-3">
|
||||||
["MANUAL", "Manual"],
|
<div className="space-y-2">
|
||||||
].map(([v, l]) => (
|
<span className="text-sm font-medium text-neutral-700">Prioridade</span>
|
||||||
<option key={v} value={v}>
|
<Select value={priority} onValueChange={(value) => setPriority(value as TicketPriority)}>
|
||||||
{l}
|
<SelectTrigger className={cn(priorityTriggerClass, "w-full justify-between") }>
|
||||||
</option>
|
<SelectValue>
|
||||||
))}
|
<Badge className={cn(priorityBadgeClass, priorityStyles[priority]?.badgeClass)}>
|
||||||
</select>
|
<PriorityIcon value={priority} />
|
||||||
</div>
|
{priorityStyles[priority]?.label ?? priority}
|
||||||
</div>
|
</Badge>
|
||||||
<div className="space-y-2">
|
</SelectValue>
|
||||||
<label className="text-sm">Fila</label>
|
</SelectTrigger>
|
||||||
<select className="w-full rounded-md border bg-background px-3 py-2" value={queueName ?? ""} onChange={(e) => setQueueName(e.target.value || null)}>
|
<SelectContent className="rounded-xl border border-slate-200 bg-white text-neutral-800 shadow-md">
|
||||||
<option value="">Sem fila</option>
|
{(["LOW", "MEDIUM", "HIGH", "URGENT"] as const).map((option) => (
|
||||||
{queueOptions.map((q) => (
|
<SelectItem key={option} value={option} className={priorityItemClass}>
|
||||||
<option key={q} value={q}>
|
<span className="inline-flex items-center gap-2">
|
||||||
{q}
|
<PriorityIcon value={option} />
|
||||||
</option>
|
{priorityStyles[option].label}
|
||||||
))}
|
</span>
|
||||||
</select>
|
</SelectItem>
|
||||||
</div>
|
))}
|
||||||
<div className="flex justify-end">
|
</SelectContent>
|
||||||
<button type="submit" className="inline-flex items-center justify-center rounded-md bg-primary px-3 py-2 text-sm font-medium text-primary-foreground">Criar</button>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
<div className="space-y-2">
|
||||||
|
<span className="text-sm font-medium text-neutral-700">Canal</span>
|
||||||
|
<Select value={channel} onValueChange={setChannel}>
|
||||||
|
<SelectTrigger className={selectTriggerClass}>
|
||||||
|
<SelectValue placeholder="Canal" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent className="rounded-xl border border-slate-200 bg-white text-neutral-800 shadow-md">
|
||||||
|
<SelectItem value="EMAIL" className={selectItemClass}>
|
||||||
|
E-mail
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem value="WHATSAPP" className={selectItemClass}>
|
||||||
|
WhatsApp
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem value="CHAT" className={selectItemClass}>
|
||||||
|
Chat
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem value="PHONE" className={selectItemClass}>
|
||||||
|
Telefone
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem value="API" className={selectItemClass}>
|
||||||
|
API
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem value="MANUAL" className={selectItemClass}>
|
||||||
|
Manual
|
||||||
|
</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<span className="text-sm font-medium text-neutral-700">Fila</span>
|
||||||
|
<Select value={queueName ?? "NONE"} onValueChange={(value) => setQueueName(value === "NONE" ? null : value)}>
|
||||||
|
<SelectTrigger className={selectTriggerClass}>
|
||||||
|
<SelectValue placeholder="Sem fila" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent className="rounded-xl border border-slate-200 bg-white text-neutral-800 shadow-md">
|
||||||
|
<SelectItem value="NONE" className={selectItemClass}>
|
||||||
|
Sem fila
|
||||||
|
</SelectItem>
|
||||||
|
{queueOptions.map((name) => (
|
||||||
|
<SelectItem key={name} value={name} className={selectItemClass}>
|
||||||
|
{name}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
className="min-w-[120px] rounded-lg border border-black bg-black px-4 py-2 text-sm font-semibold text-white transition hover:bg-[#18181b]/85 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#18181b]/30"
|
||||||
|
disabled={loading}
|
||||||
|
>
|
||||||
|
{loading ? (
|
||||||
|
<>
|
||||||
|
<Spinner className="me-2" />
|
||||||
|
Criando...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
"Criar"
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
);
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -33,8 +33,8 @@ import {
|
||||||
SidebarRail,
|
SidebarRail,
|
||||||
} from "@/components/ui/sidebar"
|
} from "@/components/ui/sidebar"
|
||||||
|
|
||||||
const navigation = {
|
const navigation = {
|
||||||
versions: ["MVP", "Beta", "Roadmap"],
|
versions: ["0.0.1"],
|
||||||
navMain: [
|
navMain: [
|
||||||
{
|
{
|
||||||
title: "Operação",
|
title: "Operação",
|
||||||
|
|
@ -82,7 +82,7 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
|
||||||
<Sidebar {...props}>
|
<Sidebar {...props}>
|
||||||
<SidebarHeader className="gap-3">
|
<SidebarHeader className="gap-3">
|
||||||
<VersionSwitcher
|
<VersionSwitcher
|
||||||
label="Release"
|
label="Sistema de chamados"
|
||||||
versions={[...navigation.versions]}
|
versions={[...navigation.versions]}
|
||||||
defaultVersion={navigation.versions[0]}
|
defaultVersion={navigation.versions[0]}
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
|
|
@ -38,8 +38,12 @@ export function DeleteTicketDialog({ ticketId }: { ticketId: Id<"tickets"> }) {
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={setOpen}>
|
<Dialog open={open} onOpenChange={setOpen}>
|
||||||
<DialogTrigger asChild>
|
<DialogTrigger asChild>
|
||||||
<Button variant="ghost" size="sm" className="gap-2 text-destructive hover:bg-destructive/10">
|
<Button
|
||||||
<Trash2 className="size-4" /> Excluir
|
size="icon"
|
||||||
|
aria-label="Excluir ticket"
|
||||||
|
className="border border-[#fca5a5] bg-[#fecaca] text-[#7f1d1d] shadow-sm transition hover:bg-[#fca5a5] focus-visible:ring-[#fca5a5]/30"
|
||||||
|
>
|
||||||
|
<Trash2 className="size-4 text-current" />
|
||||||
</Button>
|
</Button>
|
||||||
</DialogTrigger>
|
</DialogTrigger>
|
||||||
<DialogContent className="max-w-md">
|
<DialogContent className="max-w-md">
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@
|
||||||
import { z } from "zod"
|
import { z } from "zod"
|
||||||
import { useState } from "react"
|
import { useState } from "react"
|
||||||
import type { Id } from "@/convex/_generated/dataModel"
|
import type { Id } from "@/convex/_generated/dataModel"
|
||||||
import type { TicketQueueSummary } from "@/lib/schemas/ticket"
|
import type { TicketPriority, TicketQueueSummary } from "@/lib/schemas/ticket"
|
||||||
import { useMutation, useQuery } from "convex/react"
|
import { useMutation, useQuery } from "convex/react"
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
import { api } from "@/convex/_generated/api"
|
import { api } from "@/convex/_generated/api"
|
||||||
|
|
@ -20,6 +20,15 @@ import { toast } from "sonner"
|
||||||
import { Spinner } from "@/components/ui/spinner"
|
import { Spinner } from "@/components/ui/spinner"
|
||||||
import { Dropzone } from "@/components/ui/dropzone"
|
import { Dropzone } from "@/components/ui/dropzone"
|
||||||
import { RichTextEditor } from "@/components/ui/rich-text-editor"
|
import { RichTextEditor } from "@/components/ui/rich-text-editor"
|
||||||
|
import { Badge } from "@/components/ui/badge"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import {
|
||||||
|
PriorityIcon,
|
||||||
|
priorityBadgeClass,
|
||||||
|
priorityItemClass,
|
||||||
|
priorityStyles,
|
||||||
|
priorityTriggerClass,
|
||||||
|
} from "@/components/tickets/priority-select"
|
||||||
|
|
||||||
const schema = z.object({
|
const schema = z.object({
|
||||||
subject: z.string().min(3, "Informe um assunto"),
|
subject: z.string().min(3, "Informe um assunto"),
|
||||||
|
|
@ -43,6 +52,11 @@ export function NewTicketDialog() {
|
||||||
const create = useMutation(api.tickets.create)
|
const create = useMutation(api.tickets.create)
|
||||||
const addComment = useMutation(api.tickets.addComment)
|
const addComment = useMutation(api.tickets.addComment)
|
||||||
const [attachments, setAttachments] = useState<Array<{ storageId: string; name: string; size?: number; type?: string }>>([])
|
const [attachments, setAttachments] = useState<Array<{ storageId: string; name: string; size?: number; type?: string }>>([])
|
||||||
|
const priorityValue = form.watch("priority") as TicketPriority
|
||||||
|
const channelValue = form.watch("channel")
|
||||||
|
const queueValue = form.watch("queueName") ?? "NONE"
|
||||||
|
const selectTriggerClass = "flex h-8 w-full items-center justify-between rounded-full border border-slate-300 bg-white px-3 text-sm font-medium text-neutral-800 shadow-sm focus:ring-0 data-[state=open]:border-[#00d6eb]"
|
||||||
|
const selectItemClass = "flex items-center gap-2 rounded-md px-2 py-2 text-sm text-neutral-800 transition data-[state=checked]:bg-[#00e8ff]/15 data-[state=checked]:text-neutral-900 focus:bg-[#00e8ff]/10"
|
||||||
|
|
||||||
async function submit(values: z.infer<typeof schema>) {
|
async function submit(values: z.infer<typeof schema>) {
|
||||||
if (!userId) return
|
if (!userId) return
|
||||||
|
|
@ -91,7 +105,12 @@ export function NewTicketDialog() {
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={setOpen}>
|
<Dialog open={open} onOpenChange={setOpen}>
|
||||||
<DialogTrigger asChild>
|
<DialogTrigger asChild>
|
||||||
<Button size="sm">Novo ticket</Button>
|
<Button
|
||||||
|
size="sm"
|
||||||
|
className="rounded-lg border border-black bg-black px-3 py-1.5 text-sm font-semibold text-white transition hover:bg-[#18181b]/85 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#18181b]/30"
|
||||||
|
>
|
||||||
|
Novo ticket
|
||||||
|
</Button>
|
||||||
</DialogTrigger>
|
</DialogTrigger>
|
||||||
<DialogContent>
|
<DialogContent>
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
|
|
@ -126,53 +145,90 @@ export function NewTicketDialog() {
|
||||||
<div className="grid gap-3 sm:grid-cols-3">
|
<div className="grid gap-3 sm:grid-cols-3">
|
||||||
<Field>
|
<Field>
|
||||||
<FieldLabel>Prioridade</FieldLabel>
|
<FieldLabel>Prioridade</FieldLabel>
|
||||||
<Select value={form.watch("priority")} onValueChange={(v) => form.setValue("priority", v as z.infer<typeof schema>["priority"])}>
|
<Select value={priorityValue} onValueChange={(v) => form.setValue("priority", v as z.infer<typeof schema>["priority"])}>
|
||||||
<SelectTrigger><SelectValue placeholder="Prioridade" /></SelectTrigger>
|
<SelectTrigger className={cn(priorityTriggerClass, "w-full justify-between")}>
|
||||||
<SelectContent>
|
<SelectValue>
|
||||||
<SelectItem value="LOW">Baixa</SelectItem>
|
<Badge className={cn(priorityBadgeClass, priorityStyles[priorityValue]?.badgeClass)}>
|
||||||
<SelectItem value="MEDIUM">Média</SelectItem>
|
<PriorityIcon value={priorityValue} />
|
||||||
<SelectItem value="HIGH">Alta</SelectItem>
|
{priorityStyles[priorityValue]?.label ?? priorityValue}
|
||||||
<SelectItem value="URGENT">Urgente</SelectItem>
|
</Badge>
|
||||||
|
</SelectValue>
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent className="rounded-xl border border-slate-200 bg-white text-neutral-800 shadow-md">
|
||||||
|
{(["LOW", "MEDIUM", "HIGH", "URGENT"] as const).map((option) => (
|
||||||
|
<SelectItem key={option} value={option} className={priorityItemClass}>
|
||||||
|
<span className="inline-flex items-center gap-2">
|
||||||
|
<PriorityIcon value={option} />
|
||||||
|
{priorityStyles[option].label}
|
||||||
|
</span>
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</Field>
|
</Field>
|
||||||
<Field>
|
<Field>
|
||||||
<FieldLabel>Canal</FieldLabel>
|
<FieldLabel>Canal</FieldLabel>
|
||||||
<Select value={form.watch("channel")} onValueChange={(v) => form.setValue("channel", v as z.infer<typeof schema>["channel"])}>
|
<Select value={channelValue} onValueChange={(v) => form.setValue("channel", v as z.infer<typeof schema>["channel"])}>
|
||||||
<SelectTrigger><SelectValue placeholder="Canal" /></SelectTrigger>
|
<SelectTrigger className={selectTriggerClass}>
|
||||||
<SelectContent>
|
<SelectValue placeholder="Canal" />
|
||||||
<SelectItem value="EMAIL">E-mail</SelectItem>
|
</SelectTrigger>
|
||||||
<SelectItem value="WHATSAPP">WhatsApp</SelectItem>
|
<SelectContent className="rounded-xl border border-slate-200 bg-white text-neutral-800 shadow-md">
|
||||||
<SelectItem value="CHAT">Chat</SelectItem>
|
<SelectItem value="EMAIL" className={selectItemClass}>
|
||||||
<SelectItem value="PHONE">Telefone</SelectItem>
|
E-mail
|
||||||
<SelectItem value="API">API</SelectItem>
|
</SelectItem>
|
||||||
<SelectItem value="MANUAL">Manual</SelectItem>
|
<SelectItem value="WHATSAPP" className={selectItemClass}>
|
||||||
|
WhatsApp
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem value="CHAT" className={selectItemClass}>
|
||||||
|
Chat
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem value="PHONE" className={selectItemClass}>
|
||||||
|
Telefone
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem value="API" className={selectItemClass}>
|
||||||
|
API
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem value="MANUAL" className={selectItemClass}>
|
||||||
|
Manual
|
||||||
|
</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</Field>
|
||||||
|
<Field>
|
||||||
|
<FieldLabel>Fila</FieldLabel>
|
||||||
|
<Select value={queueValue} onValueChange={(v) => form.setValue("queueName", v === "NONE" ? null : v)}>
|
||||||
|
<SelectTrigger className={selectTriggerClass}>
|
||||||
|
<SelectValue placeholder="Sem fila" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent className="rounded-xl border border-slate-200 bg-white text-neutral-800 shadow-md">
|
||||||
|
<SelectItem value="NONE" className={selectItemClass}>
|
||||||
|
Sem fila
|
||||||
|
</SelectItem>
|
||||||
|
{queues.map((q) => (
|
||||||
|
<SelectItem key={q.id} value={q.name} className={selectItemClass}>
|
||||||
|
{q.name}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</Field>
|
</Field>
|
||||||
<Field>
|
|
||||||
<FieldLabel>Fila</FieldLabel>
|
|
||||||
{(() => {
|
|
||||||
const NONE = "NONE";
|
|
||||||
const current = form.watch("queueName") ?? NONE;
|
|
||||||
return (
|
|
||||||
<Select value={current} onValueChange={(v) => form.setValue("queueName", v === NONE ? null : v)}>
|
|
||||||
<SelectTrigger><SelectValue placeholder="Sem fila" /></SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value={NONE}>Sem fila</SelectItem>
|
|
||||||
{queues.map((q) => (
|
|
||||||
<SelectItem key={q.id} value={q.name}>{q.name}</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
)
|
|
||||||
})()}
|
|
||||||
</Field>
|
|
||||||
</div>
|
</div>
|
||||||
</FieldGroup>
|
</FieldGroup>
|
||||||
</FieldSet>
|
</FieldSet>
|
||||||
<div className="flex justify-end">
|
<div className="flex justify-end">
|
||||||
<Button type="submit" disabled={loading}>{loading ? (<><Spinner className="me-2" /> Criando…</>) : "Criar"}</Button>
|
<Button
|
||||||
|
type="submit"
|
||||||
|
disabled={loading}
|
||||||
|
className="rounded-lg border border-black bg-black px-4 py-2 text-sm font-semibold text-white transition hover:bg-[#18181b]/85 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#18181b]/30 disabled:opacity-60"
|
||||||
|
>
|
||||||
|
{loading ? (
|
||||||
|
<>
|
||||||
|
<Spinner className="me-2" /> Criando…
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
"Criar"
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
|
|
|
||||||
|
|
@ -1,20 +1,15 @@
|
||||||
import { type TicketPriority } from "@/lib/schemas/ticket"
|
import { type TicketPriority } from "@/lib/schemas/ticket"
|
||||||
import { Badge } from "@/components/ui/badge"
|
import { Badge } from "@/components/ui/badge"
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
|
import { PriorityIcon, priorityStyles } from "@/components/tickets/priority-select"
|
||||||
|
|
||||||
const priorityStyles: Record<TicketPriority, { label: string; className: string }> = {
|
const baseClass = "inline-flex items-center gap-1.5 rounded-full px-3 py-0.5 text-xs font-semibold"
|
||||||
LOW: { label: "Baixa", className: "border border-slate-200 bg-slate-100 text-slate-700" },
|
|
||||||
MEDIUM: { label: "Média", className: "border border-slate-200 bg-[#dff1fb] text-[#0a4760]" },
|
|
||||||
HIGH: { label: "Alta", className: "border border-slate-200 bg-[#fde8d1] text-[#7d3b05]" },
|
|
||||||
URGENT: { label: "Urgente", className: "border border-slate-200 bg-[#fbd9dd] text-[#8b0f1c]" },
|
|
||||||
}
|
|
||||||
|
|
||||||
const baseClass = "inline-flex items-center rounded-full px-3 py-0.5 text-xs font-semibold"
|
|
||||||
|
|
||||||
export function TicketPriorityPill({ priority }: { priority: TicketPriority }) {
|
export function TicketPriorityPill({ priority }: { priority: TicketPriority }) {
|
||||||
const styles = priorityStyles[priority]
|
const styles = priorityStyles[priority]
|
||||||
return (
|
return (
|
||||||
<Badge className={cn(baseClass, styles?.className)}>
|
<Badge className={cn(baseClass, styles?.badgeClass)}>
|
||||||
|
<PriorityIcon value={priority} />
|
||||||
{styles?.label ?? priority}
|
{styles?.label ?? priority}
|
||||||
</Badge>
|
</Badge>
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -13,19 +13,19 @@ import { toast } from "sonner"
|
||||||
import { ArrowDown, ArrowRight, ArrowUp, ChevronsUp } from "lucide-react"
|
import { ArrowDown, ArrowRight, ArrowUp, ChevronsUp } from "lucide-react"
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
const priorityStyles: Record<TicketPriority, { label: string; badgeClass: string }> = {
|
export const priorityStyles: Record<TicketPriority, { label: string; badgeClass: string }> = {
|
||||||
LOW: { label: "Baixa", badgeClass: "border border-slate-200 bg-slate-100 text-slate-700" },
|
LOW: { label: "Baixa", badgeClass: "border border-slate-200 bg-slate-100 text-slate-700" },
|
||||||
MEDIUM: { label: "Média", badgeClass: "border border-slate-200 bg-[#dff1fb] text-[#0a4760]" },
|
MEDIUM: { label: "Média", badgeClass: "border border-slate-200 bg-[#dff1fb] text-[#0a4760]" },
|
||||||
HIGH: { label: "Alta", badgeClass: "border border-slate-200 bg-[#fde8d1] text-[#7d3b05]" },
|
HIGH: { label: "Alta", badgeClass: "border border-slate-200 bg-[#fde8d1] text-[#7d3b05]" },
|
||||||
URGENT: { label: "Urgente", badgeClass: "border border-slate-200 bg-[#fbd9dd] text-[#8b0f1c]" },
|
URGENT: { label: "Urgente", badgeClass: "border border-slate-200 bg-[#fbd9dd] text-[#8b0f1c]" },
|
||||||
}
|
}
|
||||||
|
|
||||||
const triggerClass = "h-8 w-[160px] rounded-full border border-slate-300 bg-white px-3 text-left text-sm font-medium text-neutral-800 shadow-sm focus:ring-0 data-[state=open]:border-[#00d6eb]"
|
export const priorityTriggerClass = "h-8 w-[160px] rounded-full border border-slate-300 bg-white px-3 text-left text-sm font-medium text-neutral-800 shadow-sm focus:ring-0 data-[state=open]:border-[#00d6eb]"
|
||||||
const itemClass = "flex items-center gap-2 rounded-md px-2 py-2 text-sm text-neutral-800 transition data-[state=checked]:bg-[#00e8ff]/15 data-[state=checked]:text-neutral-900 focus:bg-[#00e8ff]/10"
|
export const priorityItemClass = "flex items-center gap-2 rounded-md px-2 py-2 text-sm text-neutral-800 transition data-[state=checked]:bg-[#00e8ff]/15 data-[state=checked]:text-neutral-900 focus:bg-[#00e8ff]/10"
|
||||||
const iconClass = "size-4 text-neutral-700"
|
const iconClass = "size-4 text-neutral-700"
|
||||||
const baseBadgeClass = "inline-flex items-center gap-2 rounded-full px-3 py-0.5 text-xs font-semibold"
|
export const priorityBadgeClass = "inline-flex items-center gap-2 rounded-full px-3 py-0.5 text-xs font-semibold"
|
||||||
|
|
||||||
function PriorityIcon({ value }: { value: TicketPriority }) {
|
export function PriorityIcon({ value }: { value: TicketPriority }) {
|
||||||
if (value === "LOW") return <ArrowDown className={iconClass} />
|
if (value === "LOW") return <ArrowDown className={iconClass} />
|
||||||
if (value === "MEDIUM") return <ArrowRight className={iconClass} />
|
if (value === "MEDIUM") return <ArrowRight className={iconClass} />
|
||||||
if (value === "HIGH") return <ArrowUp className={iconClass} />
|
if (value === "HIGH") return <ArrowUp className={iconClass} />
|
||||||
|
|
@ -55,9 +55,9 @@ export function PrioritySelect({ ticketId, value }: { ticketId: string; value: T
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<SelectTrigger className={triggerClass}>
|
<SelectTrigger className={priorityTriggerClass}>
|
||||||
<SelectValue>
|
<SelectValue>
|
||||||
<Badge className={cn(baseBadgeClass, priorityStyles[priority]?.badgeClass)}>
|
<Badge className={cn(priorityBadgeClass, priorityStyles[priority]?.badgeClass)}>
|
||||||
<PriorityIcon value={priority} />
|
<PriorityIcon value={priority} />
|
||||||
{priorityStyles[priority]?.label ?? priority}
|
{priorityStyles[priority]?.label ?? priority}
|
||||||
</Badge>
|
</Badge>
|
||||||
|
|
@ -65,7 +65,7 @@ export function PrioritySelect({ ticketId, value }: { ticketId: string; value: T
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent className="rounded-lg border border-slate-200 bg-white text-neutral-800 shadow-sm">
|
<SelectContent className="rounded-lg border border-slate-200 bg-white text-neutral-800 shadow-sm">
|
||||||
{(["LOW", "MEDIUM", "HIGH", "URGENT"] as const).map((option) => (
|
{(["LOW", "MEDIUM", "HIGH", "URGENT"] as const).map((option) => (
|
||||||
<SelectItem key={option} value={option} className={itemClass}>
|
<SelectItem key={option} value={option} className={priorityItemClass}>
|
||||||
<span className="inline-flex items-center gap-2">
|
<span className="inline-flex items-center gap-2">
|
||||||
<PriorityIcon value={option} />
|
<PriorityIcon value={option} />
|
||||||
{priorityStyles[option].label}
|
{priorityStyles[option].label}
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ import { useMemo, useState } from "react"
|
||||||
import { formatDistanceToNow } from "date-fns"
|
import { formatDistanceToNow } from "date-fns"
|
||||||
import { ptBR } from "date-fns/locale"
|
import { ptBR } from "date-fns/locale"
|
||||||
import { IconLock, IconMessage } from "@tabler/icons-react"
|
import { IconLock, IconMessage } from "@tabler/icons-react"
|
||||||
import { Download, FileIcon } from "lucide-react"
|
import { FileIcon, Trash2, X } from "lucide-react"
|
||||||
import { useMutation } from "convex/react"
|
import { useMutation } from "convex/react"
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
import { api } from "@/convex/_generated/api"
|
import { api } from "@/convex/_generated/api"
|
||||||
|
|
@ -18,7 +18,7 @@ import { Button } from "@/components/ui/button"
|
||||||
import { toast } from "sonner"
|
import { toast } from "sonner"
|
||||||
import { Dropzone } from "@/components/ui/dropzone"
|
import { Dropzone } from "@/components/ui/dropzone"
|
||||||
import { RichTextEditor, RichTextContent, sanitizeEditorHtml } from "@/components/ui/rich-text-editor"
|
import { RichTextEditor, RichTextContent, sanitizeEditorHtml } from "@/components/ui/rich-text-editor"
|
||||||
import { Dialog, DialogContent } from "@/components/ui/dialog"
|
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog"
|
||||||
import { Select, SelectTrigger, SelectValue, SelectContent, SelectItem } from "@/components/ui/select"
|
import { Select, SelectTrigger, SelectValue, SelectContent, SelectItem } from "@/components/ui/select"
|
||||||
import { Empty, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from "@/components/ui/empty"
|
import { Empty, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from "@/components/ui/empty"
|
||||||
|
|
||||||
|
|
@ -28,16 +28,20 @@ interface TicketCommentsProps {
|
||||||
|
|
||||||
const badgeInternal = "gap-1 rounded-full border border-slate-300 bg-neutral-900 px-2 py-0.5 text-xs font-semibold uppercase tracking-wide text-white"
|
const badgeInternal = "gap-1 rounded-full border border-slate-300 bg-neutral-900 px-2 py-0.5 text-xs font-semibold uppercase tracking-wide text-white"
|
||||||
const selectTriggerClass = "h-8 w-[140px] rounded-lg border border-slate-300 bg-white px-3 text-left text-sm font-medium text-neutral-800 shadow-sm focus:ring-0 data-[state=open]:border-[#00d6eb]"
|
const selectTriggerClass = "h-8 w-[140px] rounded-lg border border-slate-300 bg-white px-3 text-left text-sm font-medium text-neutral-800 shadow-sm focus:ring-0 data-[state=open]:border-[#00d6eb]"
|
||||||
const submitButtonClass = "inline-flex items-center gap-2 rounded-lg border border-black bg-[#00e8ff] px-3 py-2 text-sm font-semibold text-black transition hover:bg-[#00d6eb]"
|
const submitButtonClass =
|
||||||
|
"inline-flex items-center gap-2 rounded-lg border border-black bg-black px-3 py-2 text-sm font-semibold text-white transition hover:bg-[#18181b]/85 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#18181b]/30"
|
||||||
|
|
||||||
export function TicketComments({ ticket }: TicketCommentsProps) {
|
export function TicketComments({ ticket }: TicketCommentsProps) {
|
||||||
const { userId } = useAuth()
|
const { userId } = useAuth()
|
||||||
const addComment = useMutation(api.tickets.addComment)
|
const addComment = useMutation(api.tickets.addComment)
|
||||||
|
const removeAttachment = useMutation(api.tickets.removeCommentAttachment)
|
||||||
const [body, setBody] = useState("")
|
const [body, setBody] = useState("")
|
||||||
const [attachmentsToSend, setAttachmentsToSend] = useState<Array<{ storageId: string; name: string; size?: number; type?: string; previewUrl?: string }>>([])
|
const [attachmentsToSend, setAttachmentsToSend] = useState<Array<{ storageId: string; name: string; size?: number; type?: string; previewUrl?: string }>>([])
|
||||||
const [preview, setPreview] = useState<string | null>(null)
|
const [preview, setPreview] = useState<string | null>(null)
|
||||||
const [pending, setPending] = useState<Pick<TicketWithDetails["comments"][number], "id" | "author" | "visibility" | "body" | "attachments" | "createdAt" | "updatedAt">[]>([])
|
const [pending, setPending] = useState<Pick<TicketWithDetails["comments"][number], "id" | "author" | "visibility" | "body" | "attachments" | "createdAt" | "updatedAt">[]>([])
|
||||||
const [visibility, setVisibility] = useState<"PUBLIC" | "INTERNAL">("PUBLIC")
|
const [visibility, setVisibility] = useState<"PUBLIC" | "INTERNAL">("PUBLIC")
|
||||||
|
const [attachmentToRemove, setAttachmentToRemove] = useState<{ commentId: string; attachmentId: string; name: string } | null>(null)
|
||||||
|
const [removingAttachment, setRemovingAttachment] = useState(false)
|
||||||
|
|
||||||
const commentsAll = useMemo(() => {
|
const commentsAll = useMemo(() => {
|
||||||
return [...pending, ...ticket.comments]
|
return [...pending, ...ticket.comments]
|
||||||
|
|
@ -47,7 +51,10 @@ export function TicketComments({ ticket }: TicketCommentsProps) {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
if (!userId) return
|
if (!userId) return
|
||||||
const now = new Date()
|
const now = new Date()
|
||||||
const attachments = attachmentsToSend
|
const attachments = attachmentsToSend.map((item) => ({ ...item }))
|
||||||
|
const previewsToRevoke = attachments
|
||||||
|
.map((attachment) => attachment.previewUrl)
|
||||||
|
.filter((previewUrl): previewUrl is string => Boolean(previewUrl && previewUrl.startsWith("blob:")))
|
||||||
const optimistic = {
|
const optimistic = {
|
||||||
id: `temp-${now.getTime()}`,
|
id: `temp-${now.getTime()}`,
|
||||||
author: ticket.requester,
|
author: ticket.requester,
|
||||||
|
|
@ -56,6 +63,7 @@ export function TicketComments({ ticket }: TicketCommentsProps) {
|
||||||
attachments: attachments.map((attachment) => ({
|
attachments: attachments.map((attachment) => ({
|
||||||
id: attachment.storageId,
|
id: attachment.storageId,
|
||||||
name: attachment.name,
|
name: attachment.name,
|
||||||
|
type: attachment.type,
|
||||||
url: attachment.previewUrl,
|
url: attachment.previewUrl,
|
||||||
})),
|
})),
|
||||||
createdAt: now,
|
createdAt: now,
|
||||||
|
|
@ -87,6 +95,34 @@ export function TicketComments({ ticket }: TicketCommentsProps) {
|
||||||
setPending([])
|
setPending([])
|
||||||
toast.error("Falha ao enviar comentário.", { id: "comment" })
|
toast.error("Falha ao enviar comentário.", { id: "comment" })
|
||||||
}
|
}
|
||||||
|
previewsToRevoke.forEach((previewUrl) => {
|
||||||
|
try {
|
||||||
|
URL.revokeObjectURL(previewUrl)
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to revoke preview URL", error)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleRemoveAttachment() {
|
||||||
|
if (!attachmentToRemove || !userId) return
|
||||||
|
setRemovingAttachment(true)
|
||||||
|
toast.loading("Removendo anexo...", { id: "remove-attachment" })
|
||||||
|
try {
|
||||||
|
await removeAttachment({
|
||||||
|
ticketId: ticket.id as unknown as Id<"tickets">,
|
||||||
|
commentId: attachmentToRemove.commentId as Id<"ticketComments">,
|
||||||
|
attachmentId: attachmentToRemove.attachmentId as Id<"_storage">,
|
||||||
|
actorId: userId as Id<"users">,
|
||||||
|
})
|
||||||
|
toast.success("Anexo removido.", { id: "remove-attachment" })
|
||||||
|
setAttachmentToRemove(null)
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error)
|
||||||
|
toast.error("Não foi possível remover o anexo.", { id: "remove-attachment" })
|
||||||
|
} finally {
|
||||||
|
setRemovingAttachment(false)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -114,6 +150,9 @@ export function TicketComments({ ticket }: TicketCommentsProps) {
|
||||||
.slice(0, 2)
|
.slice(0, 2)
|
||||||
.map((part) => part[0]?.toUpperCase())
|
.map((part) => part[0]?.toUpperCase())
|
||||||
.join("")
|
.join("")
|
||||||
|
const bodyHtml = comment.body ?? ""
|
||||||
|
const bodyPlain = bodyHtml.replace(/<[^>]*>/g, "").trim()
|
||||||
|
const hasBody = bodyPlain.length > 0
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={comment.id} className="flex gap-3">
|
<div key={comment.id} className="flex gap-3">
|
||||||
|
|
@ -133,39 +172,62 @@ export function TicketComments({ ticket }: TicketCommentsProps) {
|
||||||
{formatDistanceToNow(comment.createdAt, { addSuffix: true, locale: ptBR })}
|
{formatDistanceToNow(comment.createdAt, { addSuffix: true, locale: ptBR })}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="break-words rounded-xl border border-slate-200 bg-white px-3 py-2 text-sm leading-relaxed text-neutral-700">
|
{hasBody ? (
|
||||||
<RichTextContent html={comment.body} />
|
<div className="break-words rounded-xl border border-slate-200 bg-white px-3 py-2 text-sm leading-relaxed text-neutral-700">
|
||||||
</div>
|
<RichTextContent html={bodyHtml} />
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
{comment.attachments?.length ? (
|
{comment.attachments?.length ? (
|
||||||
<div className="grid max-w-xl grid-cols-[repeat(auto-fill,minmax(96px,1fr))] gap-3">
|
<div className="grid max-w-xl grid-cols-[repeat(auto-fill,minmax(96px,1fr))] gap-3">
|
||||||
{comment.attachments.map((attachment) => {
|
{comment.attachments.map((attachment) => {
|
||||||
const isImage = (attachment?.url ?? "").match(/\.(png|jpe?g|gif|webp|svg)$/i)
|
const name = attachment?.name ?? ""
|
||||||
if (isImage && attachment.url) {
|
const url = attachment?.url
|
||||||
return (
|
const type = attachment?.type ?? ""
|
||||||
<button
|
const isImage =
|
||||||
key={attachment.id}
|
(!!type && type.startsWith("image/")) ||
|
||||||
type="button"
|
/\.(png|jpe?g|gif|webp|svg)$/i.test(name) ||
|
||||||
onClick={() => setPreview(attachment.url || null)}
|
/\.(png|jpe?g|gif|webp|svg)$/i.test(url ?? "")
|
||||||
className="group overflow-hidden rounded-lg border border-slate-200 bg-white p-0.5 hover:border-slate-400"
|
const openRemovalModal = (event: React.MouseEvent) => {
|
||||||
>
|
event.preventDefault()
|
||||||
<img src={attachment.url} alt={attachment.name} className="h-24 w-24 rounded-md object-cover" />
|
event.stopPropagation()
|
||||||
<div className="mt-1 line-clamp-1 w-24 text-ellipsis text-center text-[11px] text-neutral-500">
|
setAttachmentToRemove({ commentId: comment.id, attachmentId: attachment.id, name })
|
||||||
{attachment.name}
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<a
|
<div
|
||||||
key={attachment.id}
|
key={attachment.id}
|
||||||
href={attachment.url}
|
className="group relative overflow-hidden rounded-lg border border-slate-200 bg-white p-0.5 shadow-sm"
|
||||||
download={attachment.name}
|
|
||||||
target="_blank"
|
|
||||||
className="flex items-center gap-2 rounded-md border border-slate-200 px-2 py-1 text-xs text-neutral-800 hover:border-slate-400"
|
|
||||||
>
|
>
|
||||||
<FileIcon className="size-3.5 text-neutral-700" /> {attachment.name}
|
{isImage && url ? (
|
||||||
{attachment.url ? <Download className="size-3.5 text-neutral-700" /> : null}
|
<button
|
||||||
</a>
|
type="button"
|
||||||
|
onClick={() => setPreview(url || null)}
|
||||||
|
className="block w-full overflow-hidden rounded-md"
|
||||||
|
>
|
||||||
|
<img src={url} alt={name} className="h-24 w-full rounded-md object-cover" />
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<a
|
||||||
|
href={url ?? undefined}
|
||||||
|
download={name || undefined}
|
||||||
|
target="_blank"
|
||||||
|
className="flex h-24 w-full flex-col items-center justify-center gap-2 rounded-md bg-slate-50 text-xs text-neutral-700 transition hover:bg-slate-100"
|
||||||
|
>
|
||||||
|
<FileIcon className="size-5 text-neutral-600" />
|
||||||
|
{url ? <span className="font-medium">Baixar</span> : <span>Pendente</span>}
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={openRemovalModal}
|
||||||
|
aria-label={`Remover ${name}`}
|
||||||
|
className="absolute right-1.5 top-1.5 inline-flex size-7 items-center justify-center rounded-full border border-black bg-black text-white opacity-0 transition hover:bg-[#18181b]/85 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-black/30 focus-visible:opacity-100 group-hover:opacity-100"
|
||||||
|
>
|
||||||
|
<X className="size-3.5" />
|
||||||
|
</button>
|
||||||
|
<div className="mt-1 line-clamp-1 w-full text-ellipsis text-center text-[11px] text-neutral-500">
|
||||||
|
{name}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -178,6 +240,55 @@ export function TicketComments({ ticket }: TicketCommentsProps) {
|
||||||
<form onSubmit={handleSubmit} className="mt-4 space-y-3">
|
<form onSubmit={handleSubmit} className="mt-4 space-y-3">
|
||||||
<RichTextEditor value={body} onChange={setBody} placeholder="Escreva um comentário..." />
|
<RichTextEditor value={body} onChange={setBody} placeholder="Escreva um comentário..." />
|
||||||
<Dropzone onUploaded={(files) => setAttachmentsToSend((prev) => [...prev, ...files])} />
|
<Dropzone onUploaded={(files) => setAttachmentsToSend((prev) => [...prev, ...files])} />
|
||||||
|
{attachmentsToSend.length > 0 ? (
|
||||||
|
<div className="grid max-w-xl grid-cols-[repeat(auto-fill,minmax(96px,1fr))] gap-3">
|
||||||
|
{attachmentsToSend.map((attachment, index) => {
|
||||||
|
const name = attachment.name
|
||||||
|
const previewUrl = attachment.previewUrl
|
||||||
|
const isImage =
|
||||||
|
(attachment.type ?? "").startsWith("image/") ||
|
||||||
|
/\.(png|jpe?g|gif|webp|svg)$/i.test(name)
|
||||||
|
return (
|
||||||
|
<div key={`${attachment.storageId}-${index}`} className="group relative overflow-hidden rounded-lg border border-slate-200 bg-white p-0.5">
|
||||||
|
{isImage && previewUrl ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setPreview(previewUrl || null)}
|
||||||
|
className="block w-full overflow-hidden rounded-md"
|
||||||
|
>
|
||||||
|
<img src={previewUrl} alt={name} className="h-24 w-full rounded-md object-cover" />
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<div className="flex h-24 w-full flex-col items-center justify-center gap-2 rounded-md bg-slate-50 text-xs text-neutral-600">
|
||||||
|
<FileIcon className="size-4" />
|
||||||
|
<span className="line-clamp-2 px-2 text-center">{name}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() =>
|
||||||
|
setAttachmentsToSend((prev) => {
|
||||||
|
const next = [...prev]
|
||||||
|
const removed = next.splice(index, 1)[0]
|
||||||
|
if (removed?.previewUrl?.startsWith("blob:")) {
|
||||||
|
URL.revokeObjectURL(removed.previewUrl)
|
||||||
|
}
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
}
|
||||||
|
className="absolute right-1.5 top-1.5 inline-flex size-7 items-center justify-center rounded-full border border-black bg-black text-white transition hover:bg-[#18181b]/85 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-black/30"
|
||||||
|
aria-label={`Remover ${name}`}
|
||||||
|
>
|
||||||
|
<X className="size-3.5" />
|
||||||
|
</button>
|
||||||
|
<div className="mt-1 line-clamp-1 w-full text-ellipsis text-center text-[11px] text-neutral-500">
|
||||||
|
{name}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
<div className="flex items-center justify-between gap-2">
|
<div className="flex items-center justify-between gap-2">
|
||||||
<div className="flex items-center gap-2 text-xs text-neutral-600">
|
<div className="flex items-center gap-2 text-xs text-neutral-600">
|
||||||
Visibilidade:
|
Visibilidade:
|
||||||
|
|
@ -196,6 +307,36 @@ export function TicketComments({ ticket }: TicketCommentsProps) {
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
<Dialog open={!!attachmentToRemove} onOpenChange={(open) => { if (!open && !removingAttachment) setAttachmentToRemove(null) }}>
|
||||||
|
<DialogContent className="max-w-sm space-y-4">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Remover anexo</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Tem certeza de que deseja remover "{attachmentToRemove?.name}" deste comentário?
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => setAttachmentToRemove(null)}
|
||||||
|
className="rounded-lg border border-slate-200 px-4 py-2 text-sm font-medium text-neutral-700 hover:bg-slate-100"
|
||||||
|
disabled={removingAttachment}
|
||||||
|
>
|
||||||
|
Cancelar
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
onClick={handleRemoveAttachment}
|
||||||
|
disabled={removingAttachment}
|
||||||
|
className="inline-flex items-center gap-2 rounded-lg border border-black bg-black px-4 py-2 text-sm font-semibold text-white transition hover:bg-[#18181b]/85 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#18181b]/30 disabled:opacity-60"
|
||||||
|
>
|
||||||
|
<Trash2 className="size-4" />
|
||||||
|
{removingAttachment ? "Removendo..." : "Excluir"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
<Dialog open={!!preview} onOpenChange={(open) => !open && setPreview(null)}>
|
<Dialog open={!!preview} onOpenChange={(open) => !open && setPreview(null)}>
|
||||||
<DialogContent className="max-w-3xl p-0">
|
<DialogContent className="max-w-3xl p-0">
|
||||||
{preview ? <img src={preview} alt="Preview" className="h-auto w-full rounded-xl" /> : null}
|
{preview ? <img src={preview} alt="Preview" className="h-auto w-full rounded-xl" /> : null}
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { useMemo, useState } from "react"
|
import { useEffect, useMemo, useState } from "react"
|
||||||
import { format } from "date-fns"
|
import { format, formatDistanceToNow } from "date-fns"
|
||||||
import { ptBR } from "date-fns/locale"
|
import { ptBR } from "date-fns/locale"
|
||||||
import { IconPlayerPause, IconPlayerPlay } from "@tabler/icons-react"
|
import { IconPlayerPause, IconPlayerPlay } from "@tabler/icons-react"
|
||||||
import { useMutation, useQuery } from "convex/react"
|
import { useMutation, useQuery } from "convex/react"
|
||||||
|
|
@ -26,15 +26,35 @@ interface TicketHeaderProps {
|
||||||
ticket: TicketWithDetails
|
ticket: TicketWithDetails
|
||||||
}
|
}
|
||||||
|
|
||||||
const cardClass = "space-y-4 rounded-2xl border border-slate-200 bg-white p-6 shadow-sm"
|
const cardClass = "relative space-y-4 rounded-2xl border border-slate-200 bg-white p-6 shadow-sm"
|
||||||
const referenceBadgeClass = "inline-flex items-center gap-1 rounded-full border border-slate-200 bg-white px-3 py-1 text-xs font-semibold text-neutral-700"
|
const referenceBadgeClass = "inline-flex items-center gap-1 rounded-full border border-slate-200 bg-white px-3 py-1 text-xs font-semibold text-neutral-700"
|
||||||
const startButtonClass = "inline-flex items-center gap-1 rounded-lg border border-black bg-[#00e8ff] px-3 py-1.5 text-sm font-semibold text-black transition hover:bg-[#00d6eb]"
|
const startButtonClass =
|
||||||
const pauseButtonClass = "inline-flex items-center gap-1 rounded-lg border border-black bg-black px-3 py-1.5 text-sm font-semibold text-white transition hover:bg-black/90"
|
"inline-flex items-center gap-1 rounded-lg border border-black bg-black px-3 py-1.5 text-sm font-semibold text-white transition hover:bg-[#18181b]/85 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-black/30"
|
||||||
const editButtonClass = "inline-flex items-center gap-1 rounded-lg border border-black bg-black px-3 py-1.5 text-sm font-semibold text-white transition hover:bg-black/90"
|
const pauseButtonClass =
|
||||||
|
"inline-flex items-center gap-1 rounded-lg border border-black bg-black px-3 py-1.5 text-sm font-semibold text-white transition hover:bg-[#18181b]/85 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#18181b]/30"
|
||||||
|
const editButtonClass =
|
||||||
|
"inline-flex items-center gap-1 rounded-lg border border-black bg-black px-3 py-1.5 text-sm font-semibold text-white transition hover:bg-[#18181b]/85 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#18181b]/30"
|
||||||
const selectTriggerClass = "h-8 w-[220px] rounded-lg border border-slate-300 bg-white px-3 text-left text-sm font-medium text-neutral-800 shadow-sm focus:ring-0 data-[state=open]:border-[#00d6eb]"
|
const selectTriggerClass = "h-8 w-[220px] rounded-lg border border-slate-300 bg-white px-3 text-left text-sm font-medium text-neutral-800 shadow-sm focus:ring-0 data-[state=open]:border-[#00d6eb]"
|
||||||
const smallSelectTriggerClass = "h-8 w-[180px] rounded-lg border border-slate-300 bg-white px-3 text-left text-sm font-medium text-neutral-800 shadow-sm focus:ring-0 data-[state=open]:border-[#00d6eb]"
|
const smallSelectTriggerClass = "h-8 w-[180px] rounded-lg border border-slate-300 bg-white px-3 text-left text-sm font-medium text-neutral-800 shadow-sm focus:ring-0 data-[state=open]:border-[#00d6eb]"
|
||||||
const sectionLabelClass = "text-xs font-semibold uppercase tracking-wide text-neutral-500"
|
const sectionLabelClass = "text-xs font-semibold uppercase tracking-wide text-neutral-500"
|
||||||
const sectionValueClass = "font-medium text-neutral-900"
|
const sectionValueClass = "font-medium text-neutral-900"
|
||||||
|
const subtleBadgeClass =
|
||||||
|
"inline-flex items-center rounded-full border border-slate-200 bg-slate-50 px-2.5 py-0.5 text-[11px] font-medium text-neutral-600"
|
||||||
|
|
||||||
|
function formatDuration(durationMs: number) {
|
||||||
|
if (durationMs <= 0) return "0s"
|
||||||
|
const totalSeconds = Math.floor(durationMs / 1000)
|
||||||
|
const hours = Math.floor(totalSeconds / 3600)
|
||||||
|
const minutes = Math.floor((totalSeconds % 3600) / 60)
|
||||||
|
const seconds = totalSeconds % 60
|
||||||
|
if (hours > 0) {
|
||||||
|
return `${hours}h ${minutes.toString().padStart(2, "0")}m`
|
||||||
|
}
|
||||||
|
if (minutes > 0) {
|
||||||
|
return `${minutes}m ${seconds.toString().padStart(2, "0")}s`
|
||||||
|
}
|
||||||
|
return `${seconds}s`
|
||||||
|
}
|
||||||
|
|
||||||
export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
|
export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
|
||||||
const { userId } = useAuth()
|
const { userId } = useAuth()
|
||||||
|
|
@ -42,10 +62,19 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
|
||||||
const changeQueue = useMutation(api.tickets.changeQueue)
|
const changeQueue = useMutation(api.tickets.changeQueue)
|
||||||
const updateSubject = useMutation(api.tickets.updateSubject)
|
const updateSubject = useMutation(api.tickets.updateSubject)
|
||||||
const updateSummary = useMutation(api.tickets.updateSummary)
|
const updateSummary = useMutation(api.tickets.updateSummary)
|
||||||
const toggleWork = useMutation(api.tickets.toggleWork)
|
const startWork = useMutation(api.tickets.startWork)
|
||||||
|
const pauseWork = useMutation(api.tickets.pauseWork)
|
||||||
const agents = (useQuery(api.users.listAgents, { tenantId: ticket.tenantId }) as Doc<"users">[] | undefined) ?? []
|
const agents = (useQuery(api.users.listAgents, { tenantId: ticket.tenantId }) as Doc<"users">[] | undefined) ?? []
|
||||||
const queues = (useQuery(api.queues.summary, { tenantId: ticket.tenantId }) as TicketQueueSummary[] | undefined) ?? []
|
const queues = (useQuery(api.queues.summary, { tenantId: ticket.tenantId }) as TicketQueueSummary[] | undefined) ?? []
|
||||||
const [status] = useState<TicketStatus>(ticket.status)
|
const [status] = useState<TicketStatus>(ticket.status)
|
||||||
|
const workSummaryRemote = useQuery(api.tickets.workSummary, { ticketId: ticket.id as Id<"tickets"> }) as
|
||||||
|
| {
|
||||||
|
ticketId: Id<"tickets">
|
||||||
|
totalWorkedMs: number
|
||||||
|
activeSession: { id: Id<"ticketWorkSessions">; agentId: Id<"users">; startedAt: number } | null
|
||||||
|
}
|
||||||
|
| null
|
||||||
|
| undefined
|
||||||
|
|
||||||
const [editing, setEditing] = useState(false)
|
const [editing, setEditing] = useState(false)
|
||||||
const [subject, setSubject] = useState(ticket.subject)
|
const [subject, setSubject] = useState(ticket.subject)
|
||||||
|
|
@ -78,11 +107,53 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
|
||||||
setEditing(false)
|
setEditing(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
const lastWork = [...ticket.timeline].reverse().find((e) => e.type === "WORK_STARTED" || e.type === "WORK_PAUSED")
|
const workSummary = useMemo(() => {
|
||||||
const isPlaying = lastWork?.type === "WORK_STARTED"
|
if (workSummaryRemote !== undefined) return workSummaryRemote ?? null
|
||||||
|
if (!ticket.workSummary) return null
|
||||||
|
return {
|
||||||
|
ticketId: ticket.id as Id<"tickets">,
|
||||||
|
totalWorkedMs: ticket.workSummary.totalWorkedMs,
|
||||||
|
activeSession: ticket.workSummary.activeSession
|
||||||
|
? {
|
||||||
|
id: ticket.workSummary.activeSession.id as Id<"ticketWorkSessions">,
|
||||||
|
agentId: ticket.workSummary.activeSession.agentId as Id<"users">,
|
||||||
|
startedAt: ticket.workSummary.activeSession.startedAt.getTime(),
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
}
|
||||||
|
}, [ticket.id, ticket.workSummary, workSummaryRemote])
|
||||||
|
|
||||||
|
const isPlaying = Boolean(workSummary?.activeSession)
|
||||||
|
const [now, setNow] = useState(() => Date.now())
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!workSummary?.activeSession) return
|
||||||
|
const interval = setInterval(() => {
|
||||||
|
setNow(Date.now())
|
||||||
|
}, 1000)
|
||||||
|
return () => clearInterval(interval)
|
||||||
|
}, [workSummary?.activeSession])
|
||||||
|
|
||||||
|
const currentSessionMs = workSummary?.activeSession ? Math.max(0, now - workSummary.activeSession.startedAt) : 0
|
||||||
|
const totalWorkedMs = workSummary ? workSummary.totalWorkedMs + currentSessionMs : 0
|
||||||
|
|
||||||
|
const formattedTotalWorked = useMemo(() => formatDuration(totalWorkedMs), [totalWorkedMs])
|
||||||
|
const formattedCurrentSession = useMemo(() => formatDuration(currentSessionMs), [currentSessionMs])
|
||||||
|
const updatedRelative = useMemo(
|
||||||
|
() => formatDistanceToNow(ticket.updatedAt, { addSuffix: true, locale: ptBR }),
|
||||||
|
[ticket.updatedAt]
|
||||||
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={cardClass}>
|
<div className={cardClass}>
|
||||||
|
<div className="absolute right-6 top-6 flex items-center gap-2">
|
||||||
|
{!editing ? (
|
||||||
|
<Button size="sm" className={editButtonClass} onClick={() => setEditing(true)}>
|
||||||
|
Editar
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
<DeleteTicketDialog ticketId={ticket.id as Id<"tickets">} />
|
||||||
|
</div>
|
||||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<div className="flex flex-wrap items-center gap-3">
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
|
|
@ -94,9 +165,27 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
|
||||||
className={isPlaying ? pauseButtonClass : startButtonClass}
|
className={isPlaying ? pauseButtonClass : startButtonClass}
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
if (!userId) return
|
if (!userId) return
|
||||||
const next = await toggleWork({ ticketId: ticket.id as Id<"tickets">, actorId: userId as Id<"users"> })
|
toast.dismiss("work")
|
||||||
if (next) toast.success("Atendimento iniciado", { id: "work" })
|
toast.loading(isPlaying ? "Pausando atendimento..." : "Iniciando atendimento...", { id: "work" })
|
||||||
else toast.success("Atendimento pausado", { id: "work" })
|
try {
|
||||||
|
if (isPlaying) {
|
||||||
|
const result = await pauseWork({ ticketId: ticket.id as Id<"tickets">, actorId: userId as Id<"users"> })
|
||||||
|
if (result?.status === "already_paused") {
|
||||||
|
toast.info("O atendimento já estava pausado", { id: "work" })
|
||||||
|
} else {
|
||||||
|
toast.success("Atendimento pausado", { id: "work" })
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const result = await startWork({ ticketId: ticket.id as Id<"tickets">, actorId: userId as Id<"users"> })
|
||||||
|
if (result?.status === "already_started") {
|
||||||
|
toast.info("O atendimento já estava em andamento", { id: "work" })
|
||||||
|
} else {
|
||||||
|
toast.success("Atendimento iniciado", { id: "work" })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
toast.error("Não foi possível atualizar o atendimento", { id: "work" })
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{isPlaying ? (
|
{isPlaying ? (
|
||||||
|
|
@ -105,11 +194,23 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<IconPlayerPlay className="size-4 text-black" /> Iniciar
|
<IconPlayerPlay className="size-4 text-white" /> Iniciar
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
{workSummary ? (
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<Badge className="inline-flex items-center gap-1 rounded-full border border-slate-200 bg-white px-3 py-1 text-xs font-semibold text-neutral-700">
|
||||||
|
Tempo total: {formattedTotalWorked}
|
||||||
|
</Badge>
|
||||||
|
{isPlaying ? (
|
||||||
|
<Badge className="inline-flex items-center gap-1 rounded-full border border-black bg-black px-3 py-1 text-xs font-semibold text-white">
|
||||||
|
Sessão atual: {formattedCurrentSession}
|
||||||
|
</Badge>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
{editing ? (
|
{editing ? (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Input
|
<Input
|
||||||
|
|
@ -131,23 +232,16 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
|
||||||
{summary ? <p className="max-w-2xl text-sm text-neutral-600">{summary}</p> : null}
|
{summary ? <p className="max-w-2xl text-sm text-neutral-600">{summary}</p> : null}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="flex items-center gap-2">
|
{editing ? (
|
||||||
{editing ? (
|
<div className="flex items-center gap-2">
|
||||||
<>
|
<Button variant="ghost" size="sm" className="text-sm font-semibold text-neutral-700" onClick={handleCancel}>
|
||||||
<Button variant="ghost" size="sm" className="text-sm font-semibold text-neutral-700" onClick={handleCancel}>
|
Cancelar
|
||||||
Cancelar
|
|
||||||
</Button>
|
|
||||||
<Button size="sm" className={startButtonClass} onClick={handleSave} disabled={!dirty}>
|
|
||||||
Salvar
|
|
||||||
</Button>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<Button size="sm" className={editButtonClass} onClick={() => setEditing(true)}>
|
|
||||||
Editar
|
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
<Button size="sm" className={startButtonClass} onClick={handleSave} disabled={!dirty}>
|
||||||
<DeleteTicketDialog ticketId={ticket.id as Id<"tickets">} />
|
Salvar
|
||||||
</div>
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Separator className="bg-slate-200" />
|
<Separator className="bg-slate-200" />
|
||||||
|
|
@ -214,7 +308,10 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1">
|
||||||
<span className={sectionLabelClass}>Atualizado em</span>
|
<span className={sectionLabelClass}>Atualizado em</span>
|
||||||
<span className={sectionValueClass}>{format(ticket.updatedAt, "dd/MM/yyyy HH:mm", { locale: ptBR })}</span>
|
<div className="flex items-center gap-2">
|
||||||
|
<span className={sectionValueClass}>{format(ticket.updatedAt, "dd/MM/yyyy HH:mm", { locale: ptBR })}</span>
|
||||||
|
<span className={subtleBadgeClass}>{updatedRelative}</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1">
|
||||||
<span className={sectionLabelClass}>Criado em</span>
|
<span className={sectionLabelClass}>Criado em</span>
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import { ptBR } from "date-fns/locale"
|
||||||
import {
|
import {
|
||||||
IconClockHour4,
|
IconClockHour4,
|
||||||
IconNote,
|
IconNote,
|
||||||
|
IconPaperclip,
|
||||||
IconSquareCheck,
|
IconSquareCheck,
|
||||||
IconUserCircle,
|
IconUserCircle,
|
||||||
} from "@tabler/icons-react"
|
} from "@tabler/icons-react"
|
||||||
|
|
@ -23,6 +24,8 @@ const timelineIcons: Record<string, ComponentType<{ className?: string }>> = {
|
||||||
SUBJECT_CHANGED: IconNote,
|
SUBJECT_CHANGED: IconNote,
|
||||||
SUMMARY_CHANGED: IconNote,
|
SUMMARY_CHANGED: IconNote,
|
||||||
QUEUE_CHANGED: IconSquareCheck,
|
QUEUE_CHANGED: IconSquareCheck,
|
||||||
|
PRIORITY_CHANGED: IconSquareCheck,
|
||||||
|
ATTACHMENT_REMOVED: IconPaperclip,
|
||||||
}
|
}
|
||||||
|
|
||||||
const timelineLabels: Record<string, string> = {
|
const timelineLabels: Record<string, string> = {
|
||||||
|
|
@ -35,6 +38,8 @@ const timelineLabels: Record<string, string> = {
|
||||||
SUBJECT_CHANGED: "Assunto atualizado",
|
SUBJECT_CHANGED: "Assunto atualizado",
|
||||||
SUMMARY_CHANGED: "Resumo atualizado",
|
SUMMARY_CHANGED: "Resumo atualizado",
|
||||||
QUEUE_CHANGED: "Fila alterada",
|
QUEUE_CHANGED: "Fila alterada",
|
||||||
|
PRIORITY_CHANGED: "Prioridade alterada",
|
||||||
|
ATTACHMENT_REMOVED: "Anexo removido",
|
||||||
}
|
}
|
||||||
|
|
||||||
interface TicketTimelineProps {
|
interface TicketTimelineProps {
|
||||||
|
|
@ -42,6 +47,21 @@ interface TicketTimelineProps {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function TicketTimeline({ ticket }: TicketTimelineProps) {
|
export function TicketTimeline({ ticket }: TicketTimelineProps) {
|
||||||
|
const formatDuration = (durationMs: number) => {
|
||||||
|
if (!durationMs || durationMs <= 0) return "0s"
|
||||||
|
const totalSeconds = Math.floor(durationMs / 1000)
|
||||||
|
const hours = Math.floor(totalSeconds / 3600)
|
||||||
|
const minutes = Math.floor((totalSeconds % 3600) / 60)
|
||||||
|
const seconds = totalSeconds % 60
|
||||||
|
if (hours > 0) {
|
||||||
|
return `${hours}h ${minutes.toString().padStart(2, "0")}m`
|
||||||
|
}
|
||||||
|
if (minutes > 0) {
|
||||||
|
return `${minutes}m ${seconds.toString().padStart(2, "0")}s`
|
||||||
|
}
|
||||||
|
return `${seconds}s`
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card className="rounded-2xl border border-slate-200 bg-white shadow-sm">
|
<Card className="rounded-2xl border border-slate-200 bg-white shadow-sm">
|
||||||
<CardContent className="space-y-5 px-4 pb-6">
|
<CardContent className="space-y-5 px-4 pb-6">
|
||||||
|
|
@ -88,6 +108,8 @@ export function TicketTimeline({ ticket }: TicketTimelineProps) {
|
||||||
authorName?: string
|
authorName?: string
|
||||||
authorId?: string
|
authorId?: string
|
||||||
from?: string
|
from?: string
|
||||||
|
attachmentName?: string
|
||||||
|
sessionDurationMs?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
let message: string | null = null
|
let message: string | null = null
|
||||||
|
|
@ -100,6 +122,9 @@ export function TicketTimeline({ ticket }: TicketTimelineProps) {
|
||||||
if (entry.type === "QUEUE_CHANGED" && (payload.queueName || payload.queueId)) {
|
if (entry.type === "QUEUE_CHANGED" && (payload.queueName || payload.queueId)) {
|
||||||
message = "Fila alterada" + (payload.queueName ? " para " + payload.queueName : "")
|
message = "Fila alterada" + (payload.queueName ? " para " + payload.queueName : "")
|
||||||
}
|
}
|
||||||
|
if (entry.type === "PRIORITY_CHANGED" && (payload.toLabel || payload.to)) {
|
||||||
|
message = "Prioridade alterada para " + (payload.toLabel || payload.to)
|
||||||
|
}
|
||||||
if (entry.type === "CREATED" && payload.requesterName) {
|
if (entry.type === "CREATED" && payload.requesterName) {
|
||||||
message = "Criado por " + payload.requesterName
|
message = "Criado por " + payload.requesterName
|
||||||
}
|
}
|
||||||
|
|
@ -112,6 +137,12 @@ export function TicketTimeline({ ticket }: TicketTimelineProps) {
|
||||||
if (entry.type === "SUMMARY_CHANGED") {
|
if (entry.type === "SUMMARY_CHANGED") {
|
||||||
message = "Resumo atualizado"
|
message = "Resumo atualizado"
|
||||||
}
|
}
|
||||||
|
if (entry.type === "ATTACHMENT_REMOVED" && payload.attachmentName) {
|
||||||
|
message = `Anexo removido: ${payload.attachmentName}`
|
||||||
|
}
|
||||||
|
if (entry.type === "WORK_PAUSED" && typeof payload.sessionDurationMs === "number") {
|
||||||
|
message = `Tempo registrado: ${formatDuration(payload.sessionDurationMs)}`
|
||||||
|
}
|
||||||
if (!message) return null
|
if (!message) return null
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,6 @@
|
||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react"
|
||||||
import Link from "next/link"
|
import Link from "next/link"
|
||||||
import { formatDistanceToNow } from "date-fns"
|
import { formatDistanceToNow } from "date-fns"
|
||||||
import { ptBR } from "date-fns/locale"
|
import { ptBR } from "date-fns/locale"
|
||||||
|
|
@ -29,10 +32,26 @@ const channelLabel: Record<string, string> = {
|
||||||
MANUAL: "Manual",
|
MANUAL: "Manual",
|
||||||
}
|
}
|
||||||
|
|
||||||
const cellClass = "py-4 align-top"
|
const cellClass = "px-6 py-5 align-top text-sm text-neutral-700 first:pl-8 last:pr-8"
|
||||||
const queueBadgeClass = "inline-flex items-center rounded-full border border-slate-200 bg-white px-2.5 py-1 text-xs font-semibold text-neutral-700"
|
const queueBadgeClass = "inline-flex items-center rounded-full border border-slate-200 bg-slate-50 px-3 py-1 text-xs font-semibold text-neutral-700"
|
||||||
const channelBadgeClass = "inline-flex items-center gap-2 rounded-full border border-[#00c4d7]/40 bg-[#00e8ff]/15 px-2.5 py-1 text-xs font-semibold text-neutral-900"
|
const channelBadgeClass = "inline-flex items-center gap-2 rounded-full border border-slate-200 bg-slate-50 px-3 py-1 text-xs font-semibold text-neutral-700"
|
||||||
const tagBadgeClass = "inline-flex items-center rounded-full border border-slate-200 bg-slate-100 px-2 py-0.5 text-[11px] font-medium text-neutral-700"
|
const tagBadgeClass = "inline-flex items-center rounded-full border border-slate-200 bg-slate-50 px-2.5 py-0.5 text-[11px] font-medium text-neutral-600"
|
||||||
|
const tableRowClass = "group border-b border-slate-100 text-sm transition-colors hover:bg-slate-100/70 last:border-none"
|
||||||
|
|
||||||
|
function formatDuration(ms?: number) {
|
||||||
|
if (!ms || ms <= 0) return "—"
|
||||||
|
const totalSeconds = Math.floor(ms / 1000)
|
||||||
|
const hours = Math.floor(totalSeconds / 3600)
|
||||||
|
const minutes = Math.floor((totalSeconds % 3600) / 60)
|
||||||
|
const seconds = totalSeconds % 60
|
||||||
|
if (hours > 0) {
|
||||||
|
return `${hours}h ${minutes.toString().padStart(2, "0")}m`
|
||||||
|
}
|
||||||
|
if (minutes > 0) {
|
||||||
|
return `${minutes}m ${seconds.toString().padStart(2, "0")}s`
|
||||||
|
}
|
||||||
|
return `${seconds}s`
|
||||||
|
}
|
||||||
|
|
||||||
function AssigneeCell({ ticket }: { ticket: Ticket }) {
|
function AssigneeCell({ ticket }: { ticket: Ticket }) {
|
||||||
if (!ticket.assignee) {
|
if (!ticket.assignee) {
|
||||||
|
|
@ -68,30 +87,67 @@ export type TicketsTableProps = {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function TicketsTable({ tickets = ticketsMock }: TicketsTableProps) {
|
export function TicketsTable({ tickets = ticketsMock }: TicketsTableProps) {
|
||||||
|
const [now, setNow] = useState(() => Date.now())
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const interval = setInterval(() => {
|
||||||
|
setNow(Date.now())
|
||||||
|
}, 1000)
|
||||||
|
return () => clearInterval(interval)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const getWorkedMs = (ticket: Ticket) => {
|
||||||
|
const base = ticket.workSummary?.totalWorkedMs ?? 0
|
||||||
|
const activeStart = ticket.workSummary?.activeSession?.startedAt
|
||||||
|
if (activeStart instanceof Date) {
|
||||||
|
return base + Math.max(0, now - activeStart.getTime())
|
||||||
|
}
|
||||||
|
return base
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card className="rounded-2xl border border-slate-200 bg-white shadow-sm">
|
<Card className="rounded-3xl border border-slate-200 bg-white shadow-sm">
|
||||||
<CardContent className="px-4 py-4 sm:px-6">
|
<CardContent className="p-0">
|
||||||
<Table className="min-w-full">
|
<Table className="min-w-full overflow-hidden rounded-3xl">
|
||||||
<TableHeader>
|
<TableHeader className="bg-slate-100/80">
|
||||||
<TableRow className="text-[11px] uppercase tracking-wide text-neutral-500">
|
<TableRow className="bg-transparent text-[11px] uppercase tracking-wide text-neutral-600">
|
||||||
<TableHead className="w-[110px]">Ticket</TableHead>
|
<TableHead className="w-[120px] px-6 py-4 text-left text-[11px] font-semibold uppercase tracking-wide text-neutral-600 first:pl-8 last:pr-8">
|
||||||
<TableHead>Assunto</TableHead>
|
Ticket
|
||||||
<TableHead className="hidden lg:table-cell">Fila</TableHead>
|
</TableHead>
|
||||||
<TableHead className="hidden md:table-cell">Canal</TableHead>
|
<TableHead className="px-6 py-4 text-left text-[11px] font-semibold uppercase tracking-wide text-neutral-600 first:pl-8 last:pr-8">
|
||||||
<TableHead className="hidden md:table-cell">Prioridade</TableHead>
|
Assunto
|
||||||
<TableHead>Status</TableHead>
|
</TableHead>
|
||||||
<TableHead className="hidden xl:table-cell">Responsável</TableHead>
|
<TableHead className="hidden px-6 py-4 text-left text-[11px] font-semibold uppercase tracking-wide text-neutral-600 first:pl-8 last:pr-8 lg:table-cell">
|
||||||
<TableHead className="w-[140px]">Atualizado</TableHead>
|
Fila
|
||||||
|
</TableHead>
|
||||||
|
<TableHead className="hidden px-6 py-4 text-left text-[11px] font-semibold uppercase tracking-wide text-neutral-600 first:pl-8 last:pr-8 md:table-cell">
|
||||||
|
Canal
|
||||||
|
</TableHead>
|
||||||
|
<TableHead className="hidden px-6 py-4 text-left text-[11px] font-semibold uppercase tracking-wide text-neutral-600 first:pl-8 last:pr-8 md:table-cell">
|
||||||
|
Prioridade
|
||||||
|
</TableHead>
|
||||||
|
<TableHead className="px-6 py-4 text-left text-[11px] font-semibold uppercase tracking-wide text-neutral-600 first:pl-8 last:pr-8">
|
||||||
|
Status
|
||||||
|
</TableHead>
|
||||||
|
<TableHead className="hidden px-6 py-4 text-left text-[11px] font-semibold uppercase tracking-wide text-neutral-600 first:pl-8 last:pr-8 lg:table-cell">
|
||||||
|
Tempo
|
||||||
|
</TableHead>
|
||||||
|
<TableHead className="hidden px-6 py-4 text-left text-[11px] font-semibold uppercase tracking-wide text-neutral-600 first:pl-8 last:pr-8 xl:table-cell">
|
||||||
|
Responsável
|
||||||
|
</TableHead>
|
||||||
|
<TableHead className="px-6 py-4 text-left text-[11px] font-semibold uppercase tracking-wide text-neutral-600 first:pl-8 last:pr-8">
|
||||||
|
Atualizado
|
||||||
|
</TableHead>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{tickets.map((ticket) => (
|
{tickets.map((ticket) => (
|
||||||
<TableRow key={ticket.id} className="group border-b border-slate-100 transition hover:bg-[#00e8ff]/8">
|
<TableRow key={ticket.id} className={tableRowClass}>
|
||||||
<TableCell className={cellClass}>
|
<TableCell className={cellClass}>
|
||||||
<div className="flex flex-col gap-0.5">
|
<div className="flex flex-col gap-1">
|
||||||
<Link
|
<Link
|
||||||
href={`/tickets/${ticket.id}`}
|
href={`/tickets/${ticket.id}`}
|
||||||
className="font-semibold tracking-tight text-neutral-900 hover:text-[#00b8ce]"
|
className="font-semibold tracking-tight text-neutral-900 transition hover:text-neutral-700"
|
||||||
>
|
>
|
||||||
#{ticket.reference}
|
#{ticket.reference}
|
||||||
</Link>
|
</Link>
|
||||||
|
|
@ -101,18 +157,18 @@ export function TicketsTable({ tickets = ticketsMock }: TicketsTableProps) {
|
||||||
</div>
|
</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className={cellClass}>
|
<TableCell className={cellClass}>
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1.5">
|
||||||
<Link
|
<Link
|
||||||
href={`/tickets/${ticket.id}`}
|
href={`/tickets/${ticket.id}`}
|
||||||
className="line-clamp-1 font-semibold text-neutral-900 hover:text-[#00b8ce]"
|
className="line-clamp-1 text-[15px] font-semibold text-neutral-900 transition hover:text-neutral-700"
|
||||||
>
|
>
|
||||||
{ticket.subject}
|
{ticket.subject}
|
||||||
</Link>
|
</Link>
|
||||||
<span className="line-clamp-1 text-sm text-neutral-600">
|
<span className="line-clamp-1 text-sm text-neutral-600">
|
||||||
{ticket.summary ?? "Sem resumo"}
|
{ticket.summary ?? "Sem resumo"}
|
||||||
</span>
|
</span>
|
||||||
<div className="flex flex-wrap gap-2 text-xs text-neutral-500">
|
<div className="flex flex-wrap items-center gap-2 text-xs text-neutral-500">
|
||||||
<span className="font-medium text-neutral-900">{ticket.requester.name}</span>
|
<span className="font-semibold text-neutral-700">{ticket.requester.name}</span>
|
||||||
{ticket.tags?.map((tag) => (
|
{ticket.tags?.map((tag) => (
|
||||||
<Badge key={tag} className={tagBadgeClass}>
|
<Badge key={tag} className={tagBadgeClass}>
|
||||||
{tag}
|
{tag}
|
||||||
|
|
@ -126,7 +182,7 @@ export function TicketsTable({ tickets = ticketsMock }: TicketsTableProps) {
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className={`${cellClass} hidden md:table-cell`}>
|
<TableCell className={`${cellClass} hidden md:table-cell`}>
|
||||||
<Badge className={channelBadgeClass}>
|
<Badge className={channelBadgeClass}>
|
||||||
<span className="inline-block size-2 rounded-full border border-[#009bb1] bg-[#00e8ff]" />
|
<span className="inline-block size-2 rounded-full bg-[#00d6eb]" />
|
||||||
{channelLabel[ticket.channel] ?? ticket.channel}
|
{channelLabel[ticket.channel] ?? ticket.channel}
|
||||||
</Badge>
|
</Badge>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
|
|
@ -143,11 +199,19 @@ export function TicketsTable({ tickets = ticketsMock }: TicketsTableProps) {
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
|
<TableCell className={`${cellClass} hidden lg:table-cell`}>
|
||||||
|
<div className="flex flex-col gap-1 text-sm text-neutral-600">
|
||||||
|
<span className="font-semibold text-neutral-800">{formatDuration(getWorkedMs(ticket))}</span>
|
||||||
|
{ticket.workSummary?.activeSession ? (
|
||||||
|
<span className="text-xs text-neutral-500">Em andamento</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
<TableCell className={`${cellClass} hidden xl:table-cell`}>
|
<TableCell className={`${cellClass} hidden xl:table-cell`}>
|
||||||
<AssigneeCell ticket={ticket} />
|
<AssigneeCell ticket={ticket} />
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className={cellClass}>
|
<TableCell className={cellClass}>
|
||||||
<span className="text-sm text-neutral-500">
|
<span className="text-sm text-neutral-600">
|
||||||
{formatDistanceToNow(ticket.updatedAt, { addSuffix: true, locale: ptBR })}
|
{formatDistanceToNow(ticket.updatedAt, { addSuffix: true, locale: ptBR })}
|
||||||
</span>
|
</span>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
|
|
|
||||||
|
|
@ -75,26 +75,43 @@ export function Dropzone({
|
||||||
return (
|
return (
|
||||||
<div className={cn("space-y-3", className)}>
|
<div className={cn("space-y-3", className)}>
|
||||||
<div
|
<div
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
className={cn(
|
className={cn(
|
||||||
"relative rounded-lg border border-dashed p-6 text-center transition-colors",
|
"group relative rounded-xl border border-dashed border-black/30 bg-white p-6 text-center transition-all focus:outline-none focus:ring-2 focus:ring-[#00d6eb]/40 focus:ring-offset-2",
|
||||||
drag ? "border-primary bg-primary/5" : "border-muted-foreground/25 hover:border-muted-foreground/50"
|
drag ? "border-black bg-black/5" : "hover:border-black hover:bg-black/5"
|
||||||
)}
|
)}
|
||||||
|
onClick={() => inputRef.current?.click()}
|
||||||
|
onKeyDown={(event) => {
|
||||||
|
if (event.key === "Enter" || event.key === " ") {
|
||||||
|
event.preventDefault()
|
||||||
|
inputRef.current?.click()
|
||||||
|
}
|
||||||
|
}}
|
||||||
onDragEnter={(e) => { e.preventDefault(); setDrag(true); }}
|
onDragEnter={(e) => { e.preventDefault(); setDrag(true); }}
|
||||||
onDragOver={(e) => { e.preventDefault(); setDrag(true); }}
|
onDragOver={(e) => { e.preventDefault(); setDrag(true); }}
|
||||||
onDragLeave={(e) => { e.preventDefault(); setDrag(false); }}
|
onDragLeave={(e) => { e.preventDefault(); setDrag(false); }}
|
||||||
onDrop={(e) => { e.preventDefault(); setDrag(false); startUpload(e.dataTransfer.files); }}
|
onDrop={(e) => { e.preventDefault(); setDrag(false); startUpload(e.dataTransfer.files); }}
|
||||||
>
|
>
|
||||||
<input ref={inputRef} type="file" className="sr-only" multiple={multiple} onChange={(e) => e.target.files && startUpload(e.target.files)} />
|
<input
|
||||||
|
ref={inputRef}
|
||||||
|
type="file"
|
||||||
|
className="sr-only"
|
||||||
|
multiple={multiple}
|
||||||
|
onChange={(e) => e.target.files && startUpload(e.target.files)}
|
||||||
|
/>
|
||||||
<div className="flex flex-col items-center gap-3">
|
<div className="flex flex-col items-center gap-3">
|
||||||
<div className="flex h-12 w-12 items-center justify-center rounded-full bg-muted">
|
<div className="flex h-12 w-12 items-center justify-center rounded-full bg-[#e5f9fc] text-[#0f172a] shadow-sm transition group-hover:bg-black group-hover:text-white">
|
||||||
{items.some((it) => it.status === "uploading") ? (
|
{items.some((it) => it.status === "uploading") ? (
|
||||||
<Spinner />
|
<Spinner />
|
||||||
) : (
|
) : (
|
||||||
<Upload className="size-5 text-muted-foreground" />
|
<Upload className="size-5" />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm">Arraste arquivos aqui ou <button type="button" className="text-primary underline" onClick={() => inputRef.current?.click()}>selecione</button></p>
|
<p className="text-sm text-neutral-800">
|
||||||
<p className="text-xs text-muted-foreground">Máximo {Math.round(maxSize/1024/1024)}MB • Até {maxFiles} arquivos</p>
|
Arraste arquivos aqui ou <span className="font-semibold text-black underline decoration-dotted">selecione</span>
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-neutral-500">Máximo {Math.round(maxSize/1024/1024)}MB • Até {maxFiles} arquivos</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{items.length > 0 && (
|
{items.length > 0 && (
|
||||||
|
|
@ -102,9 +119,9 @@ export function Dropzone({
|
||||||
{items.map((it) => (
|
{items.map((it) => (
|
||||||
<div key={it.id} className="flex items-center justify-between gap-3 rounded-md border p-2 text-sm">
|
<div key={it.id} className="flex items-center justify-between gap-3 rounded-md border p-2 text-sm">
|
||||||
<span className="truncate">{it.name}</span>
|
<span className="truncate">{it.name}</span>
|
||||||
<div className="flex items-center gap-2 min-w-[140px]">
|
<div className="flex min-w-[140px] items-center gap-2">
|
||||||
<Progress value={it.progress} className="h-1.5 w-24" />
|
<Progress value={it.progress} className="h-1.5 w-24" />
|
||||||
<span className="text-xs text-muted-foreground w-10 text-right">{it.progress}%</span>
|
<span className="w-10 text-right text-xs text-neutral-500">{it.progress}%</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|
|
||||||
|
|
@ -8,9 +8,8 @@ function Input({ className, type, ...props }: React.ComponentProps<"input">) {
|
||||||
type={type}
|
type={type}
|
||||||
data-slot="input"
|
data-slot="input"
|
||||||
className={cn(
|
className={cn(
|
||||||
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
"placeholder:text-neutral-400 selection:bg-neutral-900 selection:text-white aria-invalid:border-red-500/80 aria-invalid:ring-red-500/20 h-9 w-full min-w-0 rounded-lg border border-slate-300 bg-white px-3 py-1.5 text-base text-neutral-800 shadow-sm transition-colors outline-none file:inline-flex file:h-7 file:rounded-md file:border file:border-black file:bg-black file:px-3 file:text-sm file:font-semibold file:text-white disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-60 md:text-sm",
|
||||||
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
|
"focus-visible:border-[#00d6eb] focus-visible:ring-[3px] focus-visible:ring-[#00e8ff]/20",
|
||||||
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
|
|
|
||||||
|
|
@ -37,7 +37,7 @@ function SelectTrigger({
|
||||||
data-slot="select-trigger"
|
data-slot="select-trigger"
|
||||||
data-size={size}
|
data-size={size}
|
||||||
className={cn(
|
className={cn(
|
||||||
"border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 flex w-fit items-center justify-between gap-2 rounded-md border bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
"data-[placeholder]:text-neutral-400 [&_svg:not([class*='text-'])]:text-neutral-500 aria-invalid:border-red-500/80 aria-invalid:ring-red-500/20 flex w-fit items-center justify-between gap-2 whitespace-nowrap rounded-lg border border-slate-300 bg-white px-3 py-2 text-sm font-medium text-neutral-800 shadow-sm outline-none transition-all disabled:cursor-not-allowed disabled:opacity-60 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 focus-visible:ring-[3px] focus-visible:ring-[#00e8ff]/20 focus-visible:border-[#00d6eb] data-[state=open]:border-[#00d6eb] data-[state=open]:shadow-[0_0_0_3px_rgba(0,232,255,0.12)]",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
|
|
@ -61,9 +61,9 @@ function SelectContent({
|
||||||
<SelectPrimitive.Content
|
<SelectPrimitive.Content
|
||||||
data-slot="select-content"
|
data-slot="select-content"
|
||||||
className={cn(
|
className={cn(
|
||||||
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border shadow-md",
|
"relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-lg border border-slate-200 bg-white text-neutral-800 shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||||
position === "popper" &&
|
position === "popper" &&
|
||||||
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
|
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
position={position}
|
position={position}
|
||||||
|
|
@ -107,14 +107,14 @@ function SelectItem({
|
||||||
<SelectPrimitive.Item
|
<SelectPrimitive.Item
|
||||||
data-slot="select-item"
|
data-slot="select-item"
|
||||||
className={cn(
|
className={cn(
|
||||||
"focus:bg-accent focus:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
|
"relative flex w-full select-none items-center gap-2 rounded-md px-2.5 py-2 text-sm text-neutral-700 outline-hidden transition-colors data-[disabled]:pointer-events-none data-[disabled]:opacity-40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2 data-[highlighted]:bg-[#00e8ff]/15 data-[highlighted]:text-neutral-900 data-[state=checked]:bg-[#00e8ff]/25 data-[state=checked]:text-neutral-900",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
<span className="absolute right-2 flex size-3.5 items-center justify-center">
|
<span className="absolute right-2 flex size-3.5 items-center justify-center">
|
||||||
<SelectPrimitive.ItemIndicator>
|
<SelectPrimitive.ItemIndicator>
|
||||||
<CheckIcon className="size-4" />
|
<CheckIcon className="size-4 text-[#009bb1]" />
|
||||||
</SelectPrimitive.ItemIndicator>
|
</SelectPrimitive.ItemIndicator>
|
||||||
</span>
|
</span>
|
||||||
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
||||||
|
|
|
||||||
|
|
@ -12,12 +12,17 @@ const Toaster = ({ ...props }: ToasterProps) => {
|
||||||
className="toaster group"
|
className="toaster group"
|
||||||
toastOptions={{
|
toastOptions={{
|
||||||
classNames: {
|
classNames: {
|
||||||
toast: "border border-black bg-black text-white shadow-md",
|
toast: "border border-black bg-black text-white shadow-lg rounded-xl px-4 py-3 text-sm font-semibold",
|
||||||
|
success: "border border-black bg-black text-white",
|
||||||
|
error: "border border-black bg-black text-white",
|
||||||
|
info: "border border-black bg-black text-white",
|
||||||
|
warning: "border border-black bg-black text-white",
|
||||||
|
loading: "border border-black bg-black text-white",
|
||||||
title: "font-medium",
|
title: "font-medium",
|
||||||
description: "text-white/80",
|
description: "text-white/80",
|
||||||
icon: "text-cyan-400",
|
icon: "text-[#00e8ff]",
|
||||||
actionButton: "bg-white text-black border border-black",
|
actionButton: "bg-white text-black border border-black rounded-lg",
|
||||||
cancelButton: "bg-transparent text-white border border-white/40",
|
cancelButton: "bg-transparent text-white border border-white/40 rounded-lg",
|
||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
style={
|
style={
|
||||||
|
|
|
||||||
|
|
@ -32,6 +32,19 @@ const serverTicketSchema = z.object({
|
||||||
tags: z.array(z.string()).default([]).optional(),
|
tags: z.array(z.string()).default([]).optional(),
|
||||||
lastTimelineEntry: z.string().nullable().optional(),
|
lastTimelineEntry: z.string().nullable().optional(),
|
||||||
metrics: z.any().nullable().optional(),
|
metrics: z.any().nullable().optional(),
|
||||||
|
workSummary: z
|
||||||
|
.object({
|
||||||
|
totalWorkedMs: z.number(),
|
||||||
|
activeSession: z
|
||||||
|
.object({
|
||||||
|
id: z.string(),
|
||||||
|
agentId: z.string(),
|
||||||
|
startedAt: z.number(),
|
||||||
|
})
|
||||||
|
.nullable(),
|
||||||
|
})
|
||||||
|
.nullable()
|
||||||
|
.optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
const serverAttachmentSchema = z.object({
|
const serverAttachmentSchema = z.object({
|
||||||
|
|
@ -75,6 +88,17 @@ export function mapTicketFromServer(input: unknown) {
|
||||||
dueAt: s.dueAt ? new Date(s.dueAt) : null,
|
dueAt: s.dueAt ? new Date(s.dueAt) : null,
|
||||||
firstResponseAt: s.firstResponseAt ? new Date(s.firstResponseAt) : null,
|
firstResponseAt: s.firstResponseAt ? new Date(s.firstResponseAt) : null,
|
||||||
resolvedAt: s.resolvedAt ? new Date(s.resolvedAt) : null,
|
resolvedAt: s.resolvedAt ? new Date(s.resolvedAt) : null,
|
||||||
|
workSummary: s.workSummary
|
||||||
|
? {
|
||||||
|
totalWorkedMs: s.workSummary.totalWorkedMs,
|
||||||
|
activeSession: s.workSummary.activeSession
|
||||||
|
? {
|
||||||
|
...s.workSummary.activeSession,
|
||||||
|
startedAt: new Date(s.workSummary.activeSession.startedAt),
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
};
|
};
|
||||||
return ui as unknown as z.infer<typeof ticketSchema>;
|
return ui as unknown as z.infer<typeof ticketSchema>;
|
||||||
}
|
}
|
||||||
|
|
@ -100,6 +124,17 @@ export function mapTicketWithDetailsFromServer(input: unknown) {
|
||||||
createdAt: new Date(c.createdAt),
|
createdAt: new Date(c.createdAt),
|
||||||
updatedAt: new Date(c.updatedAt),
|
updatedAt: new Date(c.updatedAt),
|
||||||
})),
|
})),
|
||||||
|
workSummary: s.workSummary
|
||||||
|
? {
|
||||||
|
totalWorkedMs: s.workSummary.totalWorkedMs,
|
||||||
|
activeSession: s.workSummary.activeSession
|
||||||
|
? {
|
||||||
|
...s.workSummary.activeSession,
|
||||||
|
startedAt: new Date(s.workSummary.activeSession.startedAt),
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
};
|
};
|
||||||
return ui as unknown as z.infer<typeof ticketWithDetailsSchema>;
|
return ui as unknown as z.infer<typeof ticketWithDetailsSchema>;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -47,6 +47,7 @@ export const ticketCommentSchema = z.object({
|
||||||
id: z.string(),
|
id: z.string(),
|
||||||
name: z.string(),
|
name: z.string(),
|
||||||
size: z.number().optional(),
|
size: z.number().optional(),
|
||||||
|
type: z.string().optional(),
|
||||||
url: z.string().url().optional(),
|
url: z.string().url().optional(),
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
|
|
@ -97,6 +98,19 @@ export const ticketSchema = z.object({
|
||||||
timeOpenedMinutes: z.number().nullable(),
|
timeOpenedMinutes: z.number().nullable(),
|
||||||
})
|
})
|
||||||
.nullable(),
|
.nullable(),
|
||||||
|
workSummary: z
|
||||||
|
.object({
|
||||||
|
totalWorkedMs: z.number(),
|
||||||
|
activeSession: z
|
||||||
|
.object({
|
||||||
|
id: z.string(),
|
||||||
|
agentId: z.string(),
|
||||||
|
startedAt: z.coerce.date(),
|
||||||
|
})
|
||||||
|
.nullable(),
|
||||||
|
})
|
||||||
|
.nullable()
|
||||||
|
.optional(),
|
||||||
})
|
})
|
||||||
export type Ticket = z.infer<typeof ticketSchema>
|
export type Ticket = z.infer<typeof ticketSchema>
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue