Ajusta placeholders, formulários e widgets
This commit is contained in:
parent
343f0c8c64
commit
b94cea2f9a
33 changed files with 2122 additions and 462 deletions
4
convex/_generated/api.d.ts
vendored
4
convex/_generated/api.d.ts
vendored
|
|
@ -17,6 +17,7 @@ import type * as companies from "../companies.js";
|
|||
import type * as crons from "../crons.js";
|
||||
import type * as dashboards from "../dashboards.js";
|
||||
import type * as deviceExportTemplates from "../deviceExportTemplates.js";
|
||||
import type * as deviceFieldDefaults from "../deviceFieldDefaults.js";
|
||||
import type * as deviceFields from "../deviceFields.js";
|
||||
import type * as devices from "../devices.js";
|
||||
import type * as fields from "../fields.js";
|
||||
|
|
@ -33,6 +34,7 @@ import type * as seed from "../seed.js";
|
|||
import type * as slas from "../slas.js";
|
||||
import type * as teams from "../teams.js";
|
||||
import type * as ticketFormSettings from "../ticketFormSettings.js";
|
||||
import type * as ticketFormTemplates from "../ticketFormTemplates.js";
|
||||
import type * as ticketNotifications from "../ticketNotifications.js";
|
||||
import type * as tickets from "../tickets.js";
|
||||
import type * as users from "../users.js";
|
||||
|
|
@ -61,6 +63,7 @@ declare const fullApi: ApiFromModules<{
|
|||
crons: typeof crons;
|
||||
dashboards: typeof dashboards;
|
||||
deviceExportTemplates: typeof deviceExportTemplates;
|
||||
deviceFieldDefaults: typeof deviceFieldDefaults;
|
||||
deviceFields: typeof deviceFields;
|
||||
devices: typeof devices;
|
||||
fields: typeof fields;
|
||||
|
|
@ -77,6 +80,7 @@ declare const fullApi: ApiFromModules<{
|
|||
slas: typeof slas;
|
||||
teams: typeof teams;
|
||||
ticketFormSettings: typeof ticketFormSettings;
|
||||
ticketFormTemplates: typeof ticketFormTemplates;
|
||||
ticketNotifications: typeof ticketNotifications;
|
||||
tickets: typeof tickets;
|
||||
users: typeof users;
|
||||
|
|
|
|||
|
|
@ -639,7 +639,9 @@ export const ensureQueueSummaryWidget = mutation({
|
|||
)
|
||||
const widgetKey = generateWidgetKey(dashboardId)
|
||||
const config = normalizeQueueSummaryConfig(undefined)
|
||||
const layout = queueSummaryLayout(widgetKey)
|
||||
const layoutWithKey = queueSummaryLayout(widgetKey)
|
||||
const widgetLayout = { ...layoutWithKey }
|
||||
delete (widgetLayout as { i?: string }).i
|
||||
const widgetId = await ctx.db.insert("dashboardWidgets", {
|
||||
tenantId,
|
||||
dashboardId,
|
||||
|
|
@ -647,7 +649,7 @@ export const ensureQueueSummaryWidget = mutation({
|
|||
title: config.title,
|
||||
type: "queue-summary",
|
||||
config,
|
||||
layout,
|
||||
layout: widgetLayout,
|
||||
order: 0,
|
||||
createdBy: actorId,
|
||||
updatedBy: actorId,
|
||||
|
|
|
|||
|
|
@ -345,3 +345,28 @@ export const setDefault = mutation({
|
|||
})
|
||||
},
|
||||
})
|
||||
|
||||
export const clearCompanyDefault = mutation({
|
||||
args: {
|
||||
tenantId: v.string(),
|
||||
actorId: v.id("users"),
|
||||
companyId: v.id("companies"),
|
||||
},
|
||||
handler: async (ctx, { tenantId, actorId, companyId }) => {
|
||||
await requireAdmin(ctx, actorId, tenantId)
|
||||
const templates = await ctx.db
|
||||
.query("deviceExportTemplates")
|
||||
.withIndex("by_tenant_company", (q) => q.eq("tenantId", tenantId).eq("companyId", companyId))
|
||||
.collect()
|
||||
const now = Date.now()
|
||||
await Promise.all(
|
||||
templates.map((tpl) =>
|
||||
ctx.db.patch(tpl._id, {
|
||||
isDefault: false,
|
||||
updatedAt: now,
|
||||
updatedBy: actorId,
|
||||
})
|
||||
)
|
||||
)
|
||||
},
|
||||
})
|
||||
|
|
|
|||
131
convex/deviceFieldDefaults.ts
Normal file
131
convex/deviceFieldDefaults.ts
Normal file
|
|
@ -0,0 +1,131 @@
|
|||
"use server";
|
||||
|
||||
import type { MutationCtx } from "./_generated/server";
|
||||
import type { Doc } from "./_generated/dataModel";
|
||||
|
||||
const DEFAULT_MOBILE_DEVICE_FIELDS: Array<{
|
||||
key: string;
|
||||
label: string;
|
||||
type: "text" | "select";
|
||||
description?: string;
|
||||
options?: Array<{ value: string; label: string }>;
|
||||
}> = [
|
||||
{
|
||||
key: "mobile_identificacao",
|
||||
label: "Identificação interna",
|
||||
type: "text",
|
||||
description: "Como o time reconhece este dispositivo (ex.: iPhone da Ana).",
|
||||
},
|
||||
{
|
||||
key: "mobile_ram",
|
||||
label: "Memória RAM",
|
||||
type: "text",
|
||||
},
|
||||
{
|
||||
key: "mobile_storage",
|
||||
label: "Armazenamento (HD/SSD)",
|
||||
type: "text",
|
||||
},
|
||||
{
|
||||
key: "mobile_cpu",
|
||||
label: "Processador",
|
||||
type: "text",
|
||||
},
|
||||
{
|
||||
key: "mobile_hostname",
|
||||
label: "Hostname",
|
||||
type: "text",
|
||||
},
|
||||
{
|
||||
key: "mobile_patrimonio",
|
||||
label: "Patrimônio",
|
||||
type: "text",
|
||||
},
|
||||
{
|
||||
key: "mobile_observacoes",
|
||||
label: "Observações",
|
||||
type: "text",
|
||||
},
|
||||
{
|
||||
key: "mobile_situacao",
|
||||
label: "Situação do equipamento",
|
||||
type: "select",
|
||||
options: [
|
||||
{ value: "em_uso", label: "Em uso" },
|
||||
{ value: "reserva", label: "Reserva" },
|
||||
{ value: "manutencao", label: "Em manutenção" },
|
||||
{ value: "inativo", label: "Inativo" },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: "mobile_cargo",
|
||||
label: "Cargo",
|
||||
type: "text",
|
||||
},
|
||||
{
|
||||
key: "mobile_setor",
|
||||
label: "Setor",
|
||||
type: "text",
|
||||
},
|
||||
];
|
||||
|
||||
export async function ensureMobileDeviceFields(ctx: MutationCtx, tenantId: string) {
|
||||
const existingMobileFields = await ctx.db
|
||||
.query("deviceFields")
|
||||
.withIndex("by_tenant_scope", (q) => q.eq("tenantId", tenantId).eq("scope", "mobile"))
|
||||
.collect();
|
||||
const allFields = await ctx.db
|
||||
.query("deviceFields")
|
||||
.withIndex("by_tenant_order", (q) => q.eq("tenantId", tenantId))
|
||||
.collect();
|
||||
|
||||
const existingByKey = new Map<string, Doc<"deviceFields">>();
|
||||
existingMobileFields.forEach((field) => existingByKey.set(field.key, field));
|
||||
|
||||
let order = allFields.reduce((max, field) => Math.max(max, field.order ?? 0), 0);
|
||||
const now = Date.now();
|
||||
|
||||
for (const definition of DEFAULT_MOBILE_DEVICE_FIELDS) {
|
||||
const current = existingByKey.get(definition.key);
|
||||
if (current) {
|
||||
const updates: Partial<Doc<"deviceFields">> = {};
|
||||
if ((current.label ?? "").trim() !== definition.label) {
|
||||
updates.label = definition.label;
|
||||
}
|
||||
if ((current.description ?? "") !== (definition.description ?? "")) {
|
||||
updates.description = definition.description ?? undefined;
|
||||
}
|
||||
const existingOptions = JSON.stringify(current.options ?? null);
|
||||
const desiredOptions = JSON.stringify(definition.options ?? null);
|
||||
if (existingOptions !== desiredOptions) {
|
||||
updates.options = definition.options ?? undefined;
|
||||
}
|
||||
if (current.type !== definition.type) {
|
||||
updates.type = definition.type;
|
||||
}
|
||||
if (Object.keys(updates).length) {
|
||||
await ctx.db.patch(current._id, {
|
||||
...updates,
|
||||
updatedAt: now,
|
||||
});
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
order += 1;
|
||||
await ctx.db.insert("deviceFields", {
|
||||
tenantId,
|
||||
key: definition.key,
|
||||
label: definition.label,
|
||||
description: definition.description ?? undefined,
|
||||
type: definition.type,
|
||||
required: false,
|
||||
options: definition.options ?? undefined,
|
||||
scope: "mobile",
|
||||
companyId: undefined,
|
||||
order,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -4,6 +4,7 @@ import { ConvexError, v } from "convex/values"
|
|||
import type { Id } from "./_generated/dataModel"
|
||||
|
||||
import { requireAdmin, requireUser } from "./rbac"
|
||||
import { ensureMobileDeviceFields } from "./deviceFieldDefaults"
|
||||
|
||||
const FIELD_TYPES = ["text", "number", "select", "multiselect", "date", "boolean"] as const
|
||||
type FieldType = (typeof FIELD_TYPES)[number]
|
||||
|
|
@ -269,3 +270,15 @@ export const reorder = mutation({
|
|||
)
|
||||
},
|
||||
})
|
||||
|
||||
export const ensureDefaults = mutation({
|
||||
args: {
|
||||
tenantId: v.string(),
|
||||
actorId: v.id("users"),
|
||||
},
|
||||
handler: async (ctx, { tenantId, actorId }) => {
|
||||
await requireAdmin(ctx, actorId, tenantId)
|
||||
await ensureMobileDeviceFields(ctx, tenantId)
|
||||
return { ok: true }
|
||||
},
|
||||
})
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import type { Doc, Id } from "./_generated/dataModel"
|
|||
import type { MutationCtx, QueryCtx } from "./_generated/server"
|
||||
import { normalizeStatus } from "./tickets"
|
||||
import { requireAdmin } from "./rbac"
|
||||
import { ensureMobileDeviceFields } from "./deviceFieldDefaults"
|
||||
|
||||
const DEFAULT_TENANT_ID = "tenant-atlas"
|
||||
const DEFAULT_TOKEN_TTL_MS = 1000 * 60 * 60 * 24 * 30 // 30 dias
|
||||
|
|
@ -1634,6 +1635,7 @@ export const saveDeviceProfile = mutation({
|
|||
},
|
||||
handler: async (ctx, args) => {
|
||||
await requireAdmin(ctx, args.actorId, args.tenantId)
|
||||
await ensureMobileDeviceFields(ctx, args.tenantId)
|
||||
const displayName = args.displayName.trim()
|
||||
if (!displayName) {
|
||||
throw new ConvexError("Informe o nome do dispositivo")
|
||||
|
|
|
|||
|
|
@ -725,6 +725,143 @@ export const agentProductivity = query({
|
|||
handler: agentProductivityHandler,
|
||||
})
|
||||
|
||||
type CategoryAgentAccumulator = {
|
||||
id: Id<"ticketCategories"> | null
|
||||
name: string
|
||||
total: number
|
||||
resolved: number
|
||||
agents: Map<string, { agentId: Id<"users"> | null; name: string | null; total: number }>
|
||||
}
|
||||
|
||||
export async function ticketCategoryInsightsHandler(
|
||||
ctx: QueryCtx,
|
||||
{
|
||||
tenantId,
|
||||
viewerId,
|
||||
range,
|
||||
companyId,
|
||||
}: { tenantId: string; viewerId: Id<"users">; range?: string; companyId?: Id<"companies"> }
|
||||
) {
|
||||
const viewer = await requireStaff(ctx, viewerId, tenantId)
|
||||
const days = range === "7d" ? 7 : range === "30d" ? 30 : 90
|
||||
const end = new Date()
|
||||
end.setUTCHours(0, 0, 0, 0)
|
||||
const endMs = end.getTime() + ONE_DAY_MS
|
||||
const startMs = endMs - days * ONE_DAY_MS
|
||||
|
||||
const inRange = await fetchScopedTicketsByCreatedRange(ctx, tenantId, viewer, startMs, endMs, companyId)
|
||||
const categories = await ctx.db
|
||||
.query("ticketCategories")
|
||||
.withIndex("by_tenant", (q) => q.eq("tenantId", tenantId))
|
||||
.collect()
|
||||
|
||||
const categoriesById = new Map<Id<"ticketCategories">, Doc<"ticketCategories">>()
|
||||
for (const category of categories) {
|
||||
categoriesById.set(category._id, category)
|
||||
}
|
||||
|
||||
const stats = new Map<string, CategoryAgentAccumulator>()
|
||||
|
||||
for (const ticket of inRange) {
|
||||
const categoryKey = ticket.categoryId ? String(ticket.categoryId) : "uncategorized"
|
||||
let stat = stats.get(categoryKey)
|
||||
if (!stat) {
|
||||
const categoryDoc = ticket.categoryId ? categoriesById.get(ticket.categoryId) : null
|
||||
stat = {
|
||||
id: ticket.categoryId ?? null,
|
||||
name: categoryDoc?.name ?? (ticket.categoryId ? "Categoria removida" : "Sem categoria"),
|
||||
total: 0,
|
||||
resolved: 0,
|
||||
agents: new Map(),
|
||||
}
|
||||
stats.set(categoryKey, stat)
|
||||
}
|
||||
stat.total += 1
|
||||
if (typeof ticket.resolvedAt === "number" && ticket.resolvedAt >= startMs && ticket.resolvedAt < endMs) {
|
||||
stat.resolved += 1
|
||||
}
|
||||
|
||||
const agentKey = ticket.assigneeId ? String(ticket.assigneeId) : "unassigned"
|
||||
let agent = stat.agents.get(agentKey)
|
||||
if (!agent) {
|
||||
const snapshotName = ticket.assigneeSnapshot?.name ?? null
|
||||
const fallbackName = ticket.assigneeId ? null : "Sem responsável"
|
||||
agent = {
|
||||
agentId: ticket.assigneeId ?? null,
|
||||
name: snapshotName ?? fallbackName ?? "Agente",
|
||||
total: 0,
|
||||
}
|
||||
stat.agents.set(agentKey, agent)
|
||||
}
|
||||
agent.total += 1
|
||||
}
|
||||
|
||||
const categoriesData = Array.from(stats.values())
|
||||
.map((stat) => {
|
||||
const agents = Array.from(stat.agents.values()).sort((a, b) => b.total - a.total)
|
||||
const topAgent = agents[0] ?? null
|
||||
return {
|
||||
id: stat.id ? String(stat.id) : null,
|
||||
name: stat.name,
|
||||
total: stat.total,
|
||||
resolved: stat.resolved,
|
||||
topAgent: topAgent
|
||||
? {
|
||||
id: topAgent.agentId ? String(topAgent.agentId) : null,
|
||||
name: topAgent.name,
|
||||
total: topAgent.total,
|
||||
}
|
||||
: null,
|
||||
agents: agents.slice(0, 5).map((agent) => ({
|
||||
id: agent.agentId ? String(agent.agentId) : null,
|
||||
name: agent.name,
|
||||
total: agent.total,
|
||||
})),
|
||||
}
|
||||
})
|
||||
.sort((a, b) => b.total - a.total)
|
||||
|
||||
const spotlight = categoriesData.reduce<
|
||||
| null
|
||||
| {
|
||||
categoryId: string | null
|
||||
categoryName: string
|
||||
agentId: string | null
|
||||
agentName: string | null
|
||||
tickets: number
|
||||
}
|
||||
>((best, current) => {
|
||||
if (!current.topAgent) return best
|
||||
if (!best || current.topAgent.total > best.tickets) {
|
||||
return {
|
||||
categoryId: current.id,
|
||||
categoryName: current.name,
|
||||
agentId: current.topAgent.id,
|
||||
agentName: current.topAgent.name,
|
||||
tickets: current.topAgent.total,
|
||||
}
|
||||
}
|
||||
return best
|
||||
}, null)
|
||||
|
||||
return {
|
||||
rangeDays: days,
|
||||
totalTickets: inRange.length,
|
||||
categories: categoriesData,
|
||||
spotlight,
|
||||
}
|
||||
}
|
||||
|
||||
export const categoryInsights = query({
|
||||
args: {
|
||||
tenantId: v.string(),
|
||||
viewerId: v.id("users"),
|
||||
range: v.optional(v.string()),
|
||||
companyId: v.optional(v.id("companies")),
|
||||
},
|
||||
handler: ticketCategoryInsightsHandler,
|
||||
})
|
||||
|
||||
export async function dashboardOverviewHandler(
|
||||
ctx: QueryCtx,
|
||||
{ tenantId, viewerId }: { tenantId: string; viewerId: Id<"users"> }
|
||||
|
|
|
|||
|
|
@ -284,6 +284,7 @@ export default defineSchema({
|
|||
})
|
||||
),
|
||||
formTemplate: v.optional(v.string()),
|
||||
formTemplateLabel: v.optional(v.string()),
|
||||
relatedTicketIds: v.optional(v.array(v.id("tickets"))),
|
||||
resolvedWithTicketId: v.optional(v.id("tickets")),
|
||||
reopenDeadline: v.optional(v.number()),
|
||||
|
|
@ -477,6 +478,25 @@ export default defineSchema({
|
|||
.index("by_tenant_template_user", ["tenantId", "template", "userId"])
|
||||
.index("by_tenant", ["tenantId"]),
|
||||
|
||||
ticketFormTemplates: defineTable({
|
||||
tenantId: v.string(),
|
||||
key: v.string(),
|
||||
label: v.string(),
|
||||
description: v.optional(v.string()),
|
||||
defaultEnabled: v.optional(v.boolean()),
|
||||
baseTemplateKey: v.optional(v.string()),
|
||||
isSystem: v.optional(v.boolean()),
|
||||
isArchived: v.optional(v.boolean()),
|
||||
order: v.number(),
|
||||
createdAt: v.number(),
|
||||
updatedAt: v.number(),
|
||||
createdBy: v.optional(v.id("users")),
|
||||
updatedBy: v.optional(v.id("users")),
|
||||
})
|
||||
.index("by_tenant", ["tenantId"])
|
||||
.index("by_tenant_key", ["tenantId", "key"])
|
||||
.index("by_tenant_active", ["tenantId", "isArchived"]),
|
||||
|
||||
userInvites: defineTable({
|
||||
tenantId: v.string(),
|
||||
inviteId: v.string(),
|
||||
|
|
|
|||
|
|
@ -4,18 +4,11 @@ import { ConvexError, v } from "convex/values"
|
|||
import type { Id } from "./_generated/dataModel"
|
||||
|
||||
import { requireAdmin } from "./rbac"
|
||||
import { getTemplateByKey, normalizeFormTemplateKey } from "./ticketFormTemplates"
|
||||
import { TICKET_FORM_CONFIG } from "./ticketForms.config"
|
||||
|
||||
const KNOWN_TEMPLATES = new Set(["admissao", "desligamento"])
|
||||
const VALID_SCOPES = new Set(["tenant", "company", "user"])
|
||||
|
||||
function normalizeTemplate(input: string) {
|
||||
const normalized = input.trim().toLowerCase()
|
||||
if (!KNOWN_TEMPLATES.has(normalized)) {
|
||||
throw new ConvexError("Template desconhecido")
|
||||
}
|
||||
return normalized
|
||||
}
|
||||
|
||||
function normalizeScope(input: string) {
|
||||
const normalized = input.trim().toLowerCase()
|
||||
if (!VALID_SCOPES.has(normalized)) {
|
||||
|
|
@ -24,6 +17,22 @@ function normalizeScope(input: string) {
|
|||
return normalized
|
||||
}
|
||||
|
||||
async function ensureTemplateExists(ctx: MutationCtx | QueryCtx, tenantId: string, template: string) {
|
||||
const normalized = normalizeFormTemplateKey(template)
|
||||
if (!normalized) {
|
||||
throw new ConvexError("Template desconhecido")
|
||||
}
|
||||
const existing = await getTemplateByKey(ctx, tenantId, normalized)
|
||||
if (existing && existing.isArchived !== true) {
|
||||
return normalized
|
||||
}
|
||||
const fallback = TICKET_FORM_CONFIG.find((tpl) => tpl.key === normalized)
|
||||
if (fallback) {
|
||||
return normalized
|
||||
}
|
||||
throw new ConvexError("Template desconhecido")
|
||||
}
|
||||
|
||||
export const list = query({
|
||||
args: {
|
||||
tenantId: v.string(),
|
||||
|
|
@ -32,7 +41,7 @@ export const list = query({
|
|||
},
|
||||
handler: async (ctx, { tenantId, viewerId, template }) => {
|
||||
await requireAdmin(ctx, viewerId, tenantId)
|
||||
const normalizedTemplate = template ? normalizeTemplate(template) : null
|
||||
const normalizedTemplate = template ? normalizeFormTemplateKey(template) : null
|
||||
const settings = await ctx.db
|
||||
.query("ticketFormSettings")
|
||||
.withIndex("by_tenant", (q) => q.eq("tenantId", tenantId))
|
||||
|
|
@ -65,7 +74,7 @@ export const upsert = mutation({
|
|||
},
|
||||
handler: async (ctx, { tenantId, actorId, template, scope, companyId, userId, enabled }) => {
|
||||
await requireAdmin(ctx, actorId, tenantId)
|
||||
const normalizedTemplate = normalizeTemplate(template)
|
||||
const normalizedTemplate = await ensureTemplateExists(ctx, tenantId, template)
|
||||
const normalizedScope = normalizeScope(scope)
|
||||
|
||||
if (normalizedScope === "company" && !companyId) {
|
||||
|
|
|
|||
280
convex/ticketFormTemplates.ts
Normal file
280
convex/ticketFormTemplates.ts
Normal file
|
|
@ -0,0 +1,280 @@
|
|||
"use server";
|
||||
|
||||
import { mutation, query } from "./_generated/server";
|
||||
import type { MutationCtx, QueryCtx } from "./_generated/server";
|
||||
import { ConvexError, v } from "convex/values";
|
||||
import type { Doc, Id } from "./_generated/dataModel";
|
||||
|
||||
import { requireAdmin, requireStaff } from "./rbac";
|
||||
import { TICKET_FORM_CONFIG } from "./ticketForms.config";
|
||||
|
||||
type AnyCtx = MutationCtx | QueryCtx;
|
||||
|
||||
function slugify(input: string) {
|
||||
return input
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.normalize("NFD")
|
||||
.replace(/[^\w\s-]/g, "")
|
||||
.replace(/\s+/g, "-")
|
||||
.replace(/-+/g, "-")
|
||||
.replace(/^-|-$/g, "");
|
||||
}
|
||||
|
||||
export function normalizeFormTemplateKey(input: string | null | undefined): string | null {
|
||||
if (!input) return null;
|
||||
const normalized = slugify(input);
|
||||
return normalized || null;
|
||||
}
|
||||
|
||||
async function templateKeyExists(ctx: AnyCtx, tenantId: string, key: string) {
|
||||
const existing = await ctx.db
|
||||
.query("ticketFormTemplates")
|
||||
.withIndex("by_tenant_key", (q) => q.eq("tenantId", tenantId).eq("key", key))
|
||||
.first();
|
||||
return Boolean(existing);
|
||||
}
|
||||
|
||||
export async function ensureTicketFormTemplatesForTenant(ctx: MutationCtx, tenantId: string) {
|
||||
const existing = await ctx.db
|
||||
.query("ticketFormTemplates")
|
||||
.withIndex("by_tenant", (q) => q.eq("tenantId", tenantId))
|
||||
.collect();
|
||||
let order = existing.reduce((max, tpl) => Math.max(max, tpl.order ?? 0), 0);
|
||||
const now = Date.now();
|
||||
for (const template of TICKET_FORM_CONFIG) {
|
||||
const match = existing.find((tpl) => tpl.key === template.key);
|
||||
if (match) {
|
||||
const updates: Partial<Doc<"ticketFormTemplates">> = {};
|
||||
if (!match.baseTemplateKey) {
|
||||
updates.baseTemplateKey = template.key;
|
||||
}
|
||||
if (match.isSystem !== true) {
|
||||
updates.isSystem = true;
|
||||
}
|
||||
if (typeof match.defaultEnabled === "undefined") {
|
||||
updates.defaultEnabled = template.defaultEnabled;
|
||||
}
|
||||
if (Object.keys(updates).length) {
|
||||
await ctx.db.patch(match._id, {
|
||||
...updates,
|
||||
updatedAt: now,
|
||||
});
|
||||
}
|
||||
continue;
|
||||
}
|
||||
order += 1;
|
||||
await ctx.db.insert("ticketFormTemplates", {
|
||||
tenantId,
|
||||
key: template.key,
|
||||
label: template.label,
|
||||
description: template.description ?? undefined,
|
||||
defaultEnabled: template.defaultEnabled,
|
||||
baseTemplateKey: template.key,
|
||||
isSystem: true,
|
||||
isArchived: false,
|
||||
order,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export async function getTemplateByKey(ctx: AnyCtx, tenantId: string, key: string): Promise<Doc<"ticketFormTemplates"> | null> {
|
||||
return ctx.db
|
||||
.query("ticketFormTemplates")
|
||||
.withIndex("by_tenant_key", (q) => q.eq("tenantId", tenantId).eq("key", key))
|
||||
.first();
|
||||
}
|
||||
|
||||
async function generateTemplateKey(ctx: MutationCtx, tenantId: string, label: string) {
|
||||
const base = slugify(label) || `template-${Date.now()}`;
|
||||
let candidate = base;
|
||||
let suffix = 1;
|
||||
while (await templateKeyExists(ctx, tenantId, candidate)) {
|
||||
candidate = `${base}-${suffix}`;
|
||||
suffix += 1;
|
||||
}
|
||||
return candidate;
|
||||
}
|
||||
|
||||
async function cloneFieldsFromTemplate(ctx: MutationCtx, tenantId: string, sourceKey: string, targetKey: string) {
|
||||
const sourceFields = await ctx.db
|
||||
.query("ticketFields")
|
||||
.withIndex("by_tenant_scope", (q) => q.eq("tenantId", tenantId).eq("scope", sourceKey))
|
||||
.collect();
|
||||
if (sourceFields.length === 0) return;
|
||||
const ordered = await ctx.db
|
||||
.query("ticketFields")
|
||||
.withIndex("by_tenant_order", (q) => q.eq("tenantId", tenantId))
|
||||
.collect();
|
||||
let order = ordered.reduce((max, field) => Math.max(max, field.order ?? 0), 0);
|
||||
const now = Date.now();
|
||||
for (const field of sourceFields.sort((a, b) => (a.order ?? 0) - (b.order ?? 0))) {
|
||||
order += 1;
|
||||
await ctx.db.insert("ticketFields", {
|
||||
tenantId,
|
||||
key: field.key,
|
||||
label: field.label,
|
||||
description: field.description ?? undefined,
|
||||
type: field.type,
|
||||
required: field.required,
|
||||
options: field.options ?? undefined,
|
||||
scope: targetKey,
|
||||
order,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function mapTemplate(template: Doc<"ticketFormTemplates">) {
|
||||
return {
|
||||
id: template._id,
|
||||
key: template.key,
|
||||
label: template.label,
|
||||
description: template.description ?? "",
|
||||
defaultEnabled: template.defaultEnabled ?? true,
|
||||
baseTemplateKey: template.baseTemplateKey ?? null,
|
||||
isSystem: Boolean(template.isSystem),
|
||||
isArchived: Boolean(template.isArchived),
|
||||
order: template.order ?? 0,
|
||||
createdAt: template.createdAt,
|
||||
updatedAt: template.updatedAt,
|
||||
};
|
||||
}
|
||||
|
||||
export const list = query({
|
||||
args: {
|
||||
tenantId: v.string(),
|
||||
viewerId: v.id("users"),
|
||||
includeArchived: v.optional(v.boolean()),
|
||||
},
|
||||
handler: async (ctx, { tenantId, viewerId, includeArchived }) => {
|
||||
await requireAdmin(ctx, viewerId, tenantId);
|
||||
const templates = await ctx.db
|
||||
.query("ticketFormTemplates")
|
||||
.withIndex("by_tenant", (q) => q.eq("tenantId", tenantId))
|
||||
.collect();
|
||||
return templates
|
||||
.filter((tpl) => includeArchived || tpl.isArchived !== true)
|
||||
.sort((a, b) => (a.order ?? 0) - (b.order ?? 0) || a.label.localeCompare(b.label, "pt-BR"))
|
||||
.map(mapTemplate);
|
||||
},
|
||||
});
|
||||
|
||||
export const listActive = query({
|
||||
args: {
|
||||
tenantId: v.string(),
|
||||
viewerId: v.id("users"),
|
||||
},
|
||||
handler: async (ctx, { tenantId, viewerId }) => {
|
||||
await requireStaff(ctx, viewerId, tenantId);
|
||||
const templates = await ctx.db
|
||||
.query("ticketFormTemplates")
|
||||
.withIndex("by_tenant", (q) => q.eq("tenantId", tenantId))
|
||||
.collect();
|
||||
return templates
|
||||
.filter((tpl) => tpl.isArchived !== true)
|
||||
.sort((a, b) => (a.order ?? 0) - (b.order ?? 0) || a.label.localeCompare(b.label, "pt-BR"))
|
||||
.map(mapTemplate);
|
||||
},
|
||||
});
|
||||
|
||||
export const create = mutation({
|
||||
args: {
|
||||
tenantId: v.string(),
|
||||
actorId: v.id("users"),
|
||||
label: v.string(),
|
||||
description: v.optional(v.string()),
|
||||
baseTemplateKey: v.optional(v.string()),
|
||||
cloneFields: v.optional(v.boolean()),
|
||||
},
|
||||
handler: async (ctx, { tenantId, actorId, label, description, baseTemplateKey, cloneFields }) => {
|
||||
await requireAdmin(ctx, actorId, tenantId);
|
||||
const trimmedLabel = label.trim();
|
||||
if (trimmedLabel.length < 3) {
|
||||
throw new ConvexError("Informe um nome com pelo menos 3 caracteres");
|
||||
}
|
||||
const key = await generateTemplateKey(ctx, tenantId, trimmedLabel);
|
||||
const templates = await ctx.db
|
||||
.query("ticketFormTemplates")
|
||||
.withIndex("by_tenant", (q) => q.eq("tenantId", tenantId))
|
||||
.collect();
|
||||
const order = (templates.reduce((max, tpl) => Math.max(max, tpl.order ?? 0), 0) ?? 0) + 1;
|
||||
const now = Date.now();
|
||||
const templateId = await ctx.db.insert("ticketFormTemplates", {
|
||||
tenantId,
|
||||
key,
|
||||
label: trimmedLabel,
|
||||
description: description?.trim() || undefined,
|
||||
defaultEnabled: true,
|
||||
baseTemplateKey: baseTemplateKey ?? undefined,
|
||||
isSystem: false,
|
||||
isArchived: false,
|
||||
order,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
createdBy: actorId,
|
||||
updatedBy: actorId,
|
||||
});
|
||||
if (baseTemplateKey && cloneFields) {
|
||||
await cloneFieldsFromTemplate(ctx, tenantId, baseTemplateKey, key);
|
||||
}
|
||||
return templateId;
|
||||
},
|
||||
});
|
||||
|
||||
export const update = mutation({
|
||||
args: {
|
||||
tenantId: v.string(),
|
||||
actorId: v.id("users"),
|
||||
templateId: v.id("ticketFormTemplates"),
|
||||
label: v.string(),
|
||||
description: v.optional(v.string()),
|
||||
isArchived: v.optional(v.boolean()),
|
||||
defaultEnabled: v.optional(v.boolean()),
|
||||
order: v.optional(v.number()),
|
||||
},
|
||||
handler: async (ctx, { tenantId, actorId, templateId, label, description, isArchived, defaultEnabled, order }) => {
|
||||
await requireAdmin(ctx, actorId, tenantId);
|
||||
const template = await ctx.db.get(templateId);
|
||||
if (!template || template.tenantId !== tenantId) {
|
||||
throw new ConvexError("Template não encontrado");
|
||||
}
|
||||
const trimmedLabel = label.trim();
|
||||
if (trimmedLabel.length < 3) {
|
||||
throw new ConvexError("Informe um nome com pelo menos 3 caracteres");
|
||||
}
|
||||
await ctx.db.patch(templateId, {
|
||||
label: trimmedLabel,
|
||||
description: description?.trim() || undefined,
|
||||
isArchived: typeof isArchived === "boolean" ? isArchived : template.isArchived ?? false,
|
||||
defaultEnabled: typeof defaultEnabled === "boolean" ? defaultEnabled : template.defaultEnabled ?? true,
|
||||
order: typeof order === "number" ? order : template.order ?? 0,
|
||||
updatedAt: Date.now(),
|
||||
updatedBy: actorId,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
export const archive = mutation({
|
||||
args: {
|
||||
tenantId: v.string(),
|
||||
actorId: v.id("users"),
|
||||
templateId: v.id("ticketFormTemplates"),
|
||||
archived: v.boolean(),
|
||||
},
|
||||
handler: async (ctx, { tenantId, actorId, templateId, archived }) => {
|
||||
await requireAdmin(ctx, actorId, tenantId);
|
||||
const template = await ctx.db.get(templateId);
|
||||
if (!template || template.tenantId !== tenantId) {
|
||||
throw new ConvexError("Template não encontrado");
|
||||
}
|
||||
await ctx.db.patch(templateId, {
|
||||
isArchived: archived,
|
||||
updatedAt: Date.now(),
|
||||
updatedBy: actorId,
|
||||
});
|
||||
},
|
||||
});
|
||||
127
convex/ticketForms.config.ts
Normal file
127
convex/ticketForms.config.ts
Normal file
|
|
@ -0,0 +1,127 @@
|
|||
"use server";
|
||||
|
||||
export type TicketFormFieldSeed = {
|
||||
key: string;
|
||||
label: string;
|
||||
type: "text" | "number" | "date" | "select" | "boolean";
|
||||
required?: boolean;
|
||||
description?: string;
|
||||
options?: Array<{ value: string; label: string }>;
|
||||
};
|
||||
|
||||
export const TICKET_FORM_CONFIG = [
|
||||
{
|
||||
key: "admissao" as const,
|
||||
label: "Admissão de colaborador",
|
||||
description: "Coleta dados completos para novos colaboradores, incluindo informações pessoais e provisionamento de acesso.",
|
||||
defaultEnabled: true,
|
||||
},
|
||||
{
|
||||
key: "desligamento" as const,
|
||||
label: "Desligamento de colaborador",
|
||||
description: "Checklist de desligamento com orientações para revogar acessos e coletar equipamentos.",
|
||||
defaultEnabled: true,
|
||||
},
|
||||
];
|
||||
|
||||
export const OPTIONAL_ADMISSION_FIELD_KEYS = [
|
||||
"colaborador_observacoes",
|
||||
"colaborador_permissoes_pasta",
|
||||
"colaborador_equipamento",
|
||||
"colaborador_grupos_email",
|
||||
"colaborador_cpf",
|
||||
"colaborador_rg",
|
||||
"colaborador_patrimonio",
|
||||
];
|
||||
|
||||
export const TICKET_FORM_DEFAULT_FIELDS: Record<string, TicketFormFieldSeed[]> = {
|
||||
admissao: [
|
||||
{ key: "solicitante_nome", label: "Nome do solicitante", type: "text", required: true, description: "Quem está solicitando a admissão." },
|
||||
{ key: "solicitante_telefone", label: "Telefone do solicitante", type: "text", required: true },
|
||||
{ key: "solicitante_ramal", label: "Ramal", type: "text" },
|
||||
{
|
||||
key: "solicitante_email",
|
||||
label: "E-mail do solicitante",
|
||||
type: "text",
|
||||
required: true,
|
||||
description: "Informe um e-mail válido para retornarmos atualizações.",
|
||||
},
|
||||
{ key: "colaborador_nome", label: "Nome do colaborador", type: "text", required: true },
|
||||
{ key: "colaborador_email_desejado", label: "E-mail do colaborador", type: "text", required: true, description: "Endereço de e-mail que deverá ser criado." },
|
||||
{ key: "colaborador_data_nascimento", label: "Data de nascimento", type: "date", required: true },
|
||||
{ key: "colaborador_rg", label: "RG", type: "text", required: false },
|
||||
{ key: "colaborador_cpf", label: "CPF", type: "text", required: false },
|
||||
{ key: "colaborador_data_inicio", label: "Data de início", type: "date", required: true },
|
||||
{ key: "colaborador_departamento", label: "Departamento", type: "text", required: true },
|
||||
{
|
||||
key: "colaborador_nova_contratacao",
|
||||
label: "O colaborador é uma nova contratação?",
|
||||
type: "select",
|
||||
required: true,
|
||||
description: "Informe se é uma nova contratação ou substituição.",
|
||||
options: [
|
||||
{ value: "nova", label: "Sim, nova contratação" },
|
||||
{ value: "substituicao", label: "Não, irá substituir alguém" },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: "colaborador_substituicao",
|
||||
label: "Quem será substituído?",
|
||||
type: "text",
|
||||
description: "Preencha somente se for uma substituição.",
|
||||
},
|
||||
{
|
||||
key: "colaborador_grupos_email",
|
||||
label: "Grupos de e-mail necessários",
|
||||
type: "text",
|
||||
required: false,
|
||||
description: "Liste os grupos ou escreva 'Não se aplica'.",
|
||||
},
|
||||
{
|
||||
key: "colaborador_equipamento",
|
||||
label: "Equipamento disponível",
|
||||
type: "text",
|
||||
required: false,
|
||||
description: "Informe se já existe equipamento ou qual deverá ser disponibilizado.",
|
||||
},
|
||||
{
|
||||
key: "colaborador_permissoes_pasta",
|
||||
label: "Permissões de pastas",
|
||||
type: "text",
|
||||
required: false,
|
||||
description: "Indique quais pastas ou qual colaborador servirá de referência.",
|
||||
},
|
||||
{
|
||||
key: "colaborador_observacoes",
|
||||
label: "Observações adicionais",
|
||||
type: "text",
|
||||
required: false,
|
||||
},
|
||||
{
|
||||
key: "colaborador_patrimonio",
|
||||
label: "Patrimônio do computador (se houver)",
|
||||
type: "text",
|
||||
required: false,
|
||||
},
|
||||
],
|
||||
desligamento: [
|
||||
{ key: "contato_nome", label: "Contato responsável", type: "text", required: true },
|
||||
{ key: "contato_email", label: "E-mail do contato", type: "text", required: true },
|
||||
{ key: "contato_telefone", label: "Telefone do contato", type: "text" },
|
||||
{ key: "colaborador_nome", label: "Nome do colaborador", type: "text", required: true },
|
||||
{ key: "colaborador_departamento", label: "Departamento do colaborador", type: "text", required: true },
|
||||
{
|
||||
key: "colaborador_email",
|
||||
label: "E-mail do colaborador",
|
||||
type: "text",
|
||||
required: true,
|
||||
description: "Informe o e-mail que deve ser desativado.",
|
||||
},
|
||||
{
|
||||
key: "colaborador_patrimonio",
|
||||
label: "Patrimônio do computador",
|
||||
type: "text",
|
||||
description: "Informe o patrimônio se houver equipamento vinculado.",
|
||||
},
|
||||
],
|
||||
};
|
||||
|
|
@ -7,6 +7,17 @@ import { Id, type Doc, type DataModel } from "./_generated/dataModel";
|
|||
import type { NamedTableInfo, Query as ConvexQuery } from "convex/server";
|
||||
|
||||
import { requireAdmin, requireStaff, requireUser } from "./rbac";
|
||||
import {
|
||||
OPTIONAL_ADMISSION_FIELD_KEYS,
|
||||
TICKET_FORM_CONFIG,
|
||||
TICKET_FORM_DEFAULT_FIELDS,
|
||||
type TicketFormFieldSeed,
|
||||
} from "./ticketForms.config";
|
||||
import {
|
||||
ensureTicketFormTemplatesForTenant,
|
||||
getTemplateByKey,
|
||||
normalizeFormTemplateKey,
|
||||
} from "./ticketFormTemplates";
|
||||
|
||||
const STAFF_ROLES = new Set(["ADMIN", "MANAGER", "AGENT"]);
|
||||
const INTERNAL_STAFF_ROLES = new Set(["ADMIN", "AGENT"]);
|
||||
|
|
@ -45,120 +56,13 @@ const MAX_COMMENT_CHARS = 20000;
|
|||
const DEFAULT_REOPEN_DAYS = 7;
|
||||
const MAX_REOPEN_DAYS = 14;
|
||||
|
||||
const TICKET_FORM_CONFIG = [
|
||||
{
|
||||
key: "admissao" as const,
|
||||
label: "Admissão de colaborador",
|
||||
description: "Coleta dados completos para novos colaboradores, incluindo informações pessoais e provisionamento de acesso.",
|
||||
defaultEnabled: true,
|
||||
},
|
||||
{
|
||||
key: "desligamento" as const,
|
||||
label: "Desligamento de colaborador",
|
||||
description: "Checklist de desligamento com orientações para revogar acessos e coletar equipamentos.",
|
||||
defaultEnabled: true,
|
||||
},
|
||||
];
|
||||
type AnyCtx = QueryCtx | MutationCtx;
|
||||
|
||||
type TicketFormFieldSeed = {
|
||||
type TemplateSummary = {
|
||||
key: string;
|
||||
label: string;
|
||||
type: "text" | "number" | "date" | "select" | "boolean";
|
||||
required?: boolean;
|
||||
description?: string;
|
||||
options?: Array<{ value: string; label: string }>;
|
||||
};
|
||||
|
||||
const TICKET_FORM_DEFAULT_FIELDS: Record<string, TicketFormFieldSeed[]> = {
|
||||
admissao: [
|
||||
{ key: "solicitante_nome", label: "Nome do solicitante", type: "text", required: true, description: "Quem está solicitando a admissão." },
|
||||
{ key: "solicitante_telefone", label: "Telefone do solicitante", type: "text", required: true },
|
||||
{ key: "solicitante_ramal", label: "Ramal", type: "text" },
|
||||
{
|
||||
key: "solicitante_email",
|
||||
label: "E-mail do solicitante",
|
||||
type: "text",
|
||||
required: true,
|
||||
description: "Informe um e-mail válido para retornarmos atualizações.",
|
||||
},
|
||||
{ key: "colaborador_nome", label: "Nome do colaborador", type: "text", required: true },
|
||||
{ key: "colaborador_email_desejado", label: "E-mail do colaborador", type: "text", required: true, description: "Endereço de e-mail que deverá ser criado." },
|
||||
{ key: "colaborador_data_nascimento", label: "Data de nascimento", type: "date", required: true },
|
||||
{ key: "colaborador_rg", label: "RG", type: "text", required: true },
|
||||
{ key: "colaborador_cpf", label: "CPF", type: "text", required: true },
|
||||
{ key: "colaborador_data_inicio", label: "Data de início", type: "date", required: true },
|
||||
{ key: "colaborador_departamento", label: "Departamento", type: "text", required: true },
|
||||
{
|
||||
key: "colaborador_nova_contratacao",
|
||||
label: "O colaborador é uma nova contratação?",
|
||||
type: "select",
|
||||
required: true,
|
||||
description: "Informe se é uma nova contratação ou substituição.",
|
||||
options: [
|
||||
{ value: "nova", label: "Sim, nova contratação" },
|
||||
{ value: "substituicao", label: "Não, irá substituir alguém" },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: "colaborador_substituicao",
|
||||
label: "Quem será substituído?",
|
||||
type: "text",
|
||||
description: "Preencha somente se for uma substituição.",
|
||||
},
|
||||
{
|
||||
key: "colaborador_grupos_email",
|
||||
label: "Grupos de e-mail necessários",
|
||||
type: "text",
|
||||
required: true,
|
||||
description: "Liste os grupos ou escreva 'Não se aplica'.",
|
||||
},
|
||||
{
|
||||
key: "colaborador_equipamento",
|
||||
label: "Equipamento disponível",
|
||||
type: "text",
|
||||
required: true,
|
||||
description: "Informe se já existe equipamento ou qual deverá ser disponibilizado.",
|
||||
},
|
||||
{
|
||||
key: "colaborador_permissoes_pasta",
|
||||
label: "Permissões de pastas",
|
||||
type: "text",
|
||||
required: true,
|
||||
description: "Indique quais pastas ou qual colaborador servirá de referência.",
|
||||
},
|
||||
{
|
||||
key: "colaborador_observacoes",
|
||||
label: "Observações adicionais",
|
||||
type: "text",
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
key: "colaborador_patrimonio",
|
||||
label: "Patrimônio do computador (se houver)",
|
||||
type: "text",
|
||||
required: false,
|
||||
},
|
||||
],
|
||||
desligamento: [
|
||||
{ key: "contato_nome", label: "Contato responsável", type: "text", required: true },
|
||||
{ key: "contato_email", label: "E-mail do contato", type: "text", required: true },
|
||||
{ key: "contato_telefone", label: "Telefone do contato", type: "text" },
|
||||
{ key: "colaborador_nome", label: "Nome do colaborador", type: "text", required: true },
|
||||
{ key: "colaborador_departamento", label: "Departamento do colaborador", type: "text", required: true },
|
||||
{
|
||||
key: "colaborador_email",
|
||||
label: "E-mail do colaborador",
|
||||
type: "text",
|
||||
required: true,
|
||||
description: "Informe o e-mail que deve ser desativado.",
|
||||
},
|
||||
{
|
||||
key: "colaborador_patrimonio",
|
||||
label: "Patrimônio do computador",
|
||||
type: "text",
|
||||
description: "Informe o patrimônio se houver equipamento vinculado.",
|
||||
},
|
||||
],
|
||||
description: string;
|
||||
defaultEnabled: boolean;
|
||||
};
|
||||
|
||||
function plainTextLength(html: string): number {
|
||||
|
|
@ -182,20 +86,6 @@ function escapeHtml(input: string): string {
|
|||
.replace(/'/g, "'");
|
||||
}
|
||||
|
||||
function normalizeFormTemplateKey(input: string | null | undefined): string | null {
|
||||
if (!input) return null;
|
||||
const trimmed = input.trim();
|
||||
if (!trimmed) return null;
|
||||
const normalized = trimmed
|
||||
.normalize("NFD")
|
||||
.replace(/[^\w\s-]/g, "")
|
||||
.replace(/\s+/g, "-")
|
||||
.replace(/-+/g, "-")
|
||||
.replace(/^-|-$/g, "")
|
||||
.toLowerCase();
|
||||
return normalized || null;
|
||||
}
|
||||
|
||||
function resolveReopenWindowDays(input?: number | null): number {
|
||||
if (typeof input !== "number" || !Number.isFinite(input)) {
|
||||
return DEFAULT_REOPEN_DAYS;
|
||||
|
|
@ -286,7 +176,32 @@ function resolveFormEnabled(
|
|||
return baseEnabled
|
||||
}
|
||||
|
||||
async function fetchTemplateSummaries(ctx: AnyCtx, tenantId: string): Promise<TemplateSummary[]> {
|
||||
const templates = await ctx.db
|
||||
.query("ticketFormTemplates")
|
||||
.withIndex("by_tenant", (q) => q.eq("tenantId", tenantId))
|
||||
.collect();
|
||||
if (!templates.length) {
|
||||
return TICKET_FORM_CONFIG.map((template) => ({
|
||||
key: template.key,
|
||||
label: template.label,
|
||||
description: template.description,
|
||||
defaultEnabled: template.defaultEnabled,
|
||||
}));
|
||||
}
|
||||
return templates
|
||||
.filter((tpl) => tpl.isArchived !== true)
|
||||
.sort((a, b) => (a.order ?? 0) - (b.order ?? 0) || a.label.localeCompare(b.label, "pt-BR"))
|
||||
.map((tpl) => ({
|
||||
key: tpl.key,
|
||||
label: tpl.label,
|
||||
description: tpl.description ?? "",
|
||||
defaultEnabled: tpl.defaultEnabled ?? true,
|
||||
}));
|
||||
}
|
||||
|
||||
async function ensureTicketFormDefaultsForTenant(ctx: MutationCtx, tenantId: string) {
|
||||
await ensureTicketFormTemplatesForTenant(ctx, tenantId);
|
||||
const now = Date.now();
|
||||
for (const template of TICKET_FORM_CONFIG) {
|
||||
const defaults = TICKET_FORM_DEFAULT_FIELDS[template.key] ?? [];
|
||||
|
|
@ -297,18 +212,23 @@ async function ensureTicketFormDefaultsForTenant(ctx: MutationCtx, tenantId: str
|
|||
.query("ticketFields")
|
||||
.withIndex("by_tenant_scope", (q) => q.eq("tenantId", tenantId).eq("scope", template.key))
|
||||
.collect();
|
||||
// Hotfix: garantir que "Patrimônio do computador (se houver)" seja opcional na admissão
|
||||
if (template.key === "admissao") {
|
||||
const patrimonio = existing.find((f) => f.key === "colaborador_patrimonio");
|
||||
if (patrimonio) {
|
||||
const shouldBeOptional = false;
|
||||
const needsRequiredFix = Boolean(patrimonio.required) !== shouldBeOptional;
|
||||
const desiredLabel = "Patrimônio do computador (se houver)";
|
||||
const needsLabelFix = (patrimonio.label ?? "").trim() !== desiredLabel;
|
||||
if (needsRequiredFix || needsLabelFix) {
|
||||
await ctx.db.patch(patrimonio._id, {
|
||||
required: shouldBeOptional,
|
||||
label: desiredLabel,
|
||||
for (const key of OPTIONAL_ADMISSION_FIELD_KEYS) {
|
||||
const field = existing.find((f) => f.key === key);
|
||||
if (!field) continue;
|
||||
const updates: Partial<Doc<"ticketFields">> = {};
|
||||
if (field.required) {
|
||||
updates.required = false;
|
||||
}
|
||||
if (key === "colaborador_patrimonio") {
|
||||
const desiredLabel = "Patrimônio do computador (se houver)";
|
||||
if ((field.label ?? "").trim() !== desiredLabel) {
|
||||
updates.label = desiredLabel;
|
||||
}
|
||||
}
|
||||
if (Object.keys(updates).length) {
|
||||
await ctx.db.patch(field._id, {
|
||||
...updates,
|
||||
updatedAt: now,
|
||||
});
|
||||
}
|
||||
|
|
@ -1242,6 +1162,7 @@ export const list = query({
|
|||
csatRatedAt: t.csatRatedAt ?? null,
|
||||
csatRatedBy: t.csatRatedBy ? String(t.csatRatedBy) : null,
|
||||
formTemplate: t.formTemplate ?? null,
|
||||
formTemplateLabel: t.formTemplateLabel ?? null,
|
||||
company: company
|
||||
? { id: company._id, name: company.name, isAvulso: company.isAvulso ?? false }
|
||||
: t.companyId || t.companySnapshot
|
||||
|
|
@ -1551,6 +1472,7 @@ export const getById = query({
|
|||
})),
|
||||
},
|
||||
formTemplate: t.formTemplate ?? null,
|
||||
formTemplateLabel: t.formTemplateLabel ?? null,
|
||||
chatEnabled: Boolean(t.chatEnabled),
|
||||
relatedTicketIds: Array.isArray(t.relatedTicketIds) ? t.relatedTicketIds.map((id) => String(id)) : [],
|
||||
resolvedWithTicketId: t.resolvedWithTicketId ? String(t.resolvedWithTicketId) : null,
|
||||
|
|
@ -1668,7 +1590,21 @@ export const create = mutation({
|
|||
machineDoc = machine
|
||||
}
|
||||
|
||||
const formTemplateKey = normalizeFormTemplateKey(args.formTemplate ?? null);
|
||||
let formTemplateKey = normalizeFormTemplateKey(args.formTemplate ?? null);
|
||||
let formTemplateLabel: string | null = null;
|
||||
if (formTemplateKey) {
|
||||
const templateDoc = await getTemplateByKey(ctx, args.tenantId, formTemplateKey);
|
||||
if (templateDoc && templateDoc.isArchived !== true) {
|
||||
formTemplateLabel = templateDoc.label;
|
||||
} else {
|
||||
const fallbackTemplate = TICKET_FORM_CONFIG.find((tpl) => tpl.key === formTemplateKey);
|
||||
if (fallbackTemplate) {
|
||||
formTemplateLabel = fallbackTemplate.label;
|
||||
} else {
|
||||
formTemplateKey = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
const chatEnabled = typeof args.chatEnabled === "boolean" ? args.chatEnabled : true;
|
||||
const normalizedCustomFields = await normalizeCustomFieldValues(
|
||||
ctx,
|
||||
|
|
@ -1752,6 +1688,7 @@ export const create = mutation({
|
|||
}
|
||||
: undefined,
|
||||
formTemplate: formTemplateKey ?? undefined,
|
||||
formTemplateLabel: formTemplateLabel ?? undefined,
|
||||
chatEnabled,
|
||||
working: false,
|
||||
activeSessionId: undefined,
|
||||
|
|
@ -2470,6 +2407,7 @@ export const listTicketForms = query({
|
|||
fieldsByScope.get(scope)!.push(definition)
|
||||
}
|
||||
|
||||
const templates = await fetchTemplateSummaries(ctx, tenantId)
|
||||
const forms = [] as Array<{
|
||||
key: string
|
||||
label: string
|
||||
|
|
@ -2485,7 +2423,7 @@ export const listTicketForms = query({
|
|||
}>
|
||||
}>
|
||||
|
||||
for (const template of TICKET_FORM_CONFIG) {
|
||||
for (const template of templates) {
|
||||
let enabled = resolveFormEnabled(template.key, template.defaultEnabled, settings as Doc<"ticketFormSettings">[], {
|
||||
companyId: viewerCompanyId,
|
||||
userId: viewer.user._id,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue