feat: export reports as xlsx and add machine inventory
This commit is contained in:
parent
29b865885c
commit
714b199879
34 changed files with 2304 additions and 245 deletions
|
|
@ -30,6 +30,15 @@ const serverUserSchema = z.object({
|
|||
teams: z.array(z.string()).optional(),
|
||||
});
|
||||
|
||||
const serverMachineSummarySchema = z.object({
|
||||
id: z.string().nullable().optional(),
|
||||
hostname: z.string().nullable().optional(),
|
||||
persona: z.string().nullable().optional(),
|
||||
assignedUserName: z.string().nullable().optional(),
|
||||
assignedUserEmail: z.string().nullable().optional(),
|
||||
status: z.string().nullable().optional(),
|
||||
});
|
||||
|
||||
const serverTicketSchema = z.object({
|
||||
id: z.string(),
|
||||
reference: z.number(),
|
||||
|
|
@ -46,6 +55,7 @@ const serverTicketSchema = z.object({
|
|||
.object({ id: z.string(), name: z.string(), isAvulso: z.boolean().optional() })
|
||||
.optional()
|
||||
.nullable(),
|
||||
machine: serverMachineSummarySchema.optional().nullable(),
|
||||
slaPolicy: z.any().nullable().optional(),
|
||||
dueAt: z.number().nullable().optional(),
|
||||
firstResponseAt: z.number().nullable().optional(),
|
||||
|
|
@ -151,6 +161,16 @@ export function mapTicketFromServer(input: unknown) {
|
|||
company: s.company
|
||||
? { id: s.company.id, name: s.company.name, isAvulso: s.company.isAvulso ?? false }
|
||||
: undefined,
|
||||
machine: s.machine
|
||||
? {
|
||||
id: s.machine.id ?? null,
|
||||
hostname: s.machine.hostname ?? null,
|
||||
persona: s.machine.persona ?? null,
|
||||
assignedUserName: s.machine.assignedUserName ?? null,
|
||||
assignedUserEmail: s.machine.assignedUserEmail ?? null,
|
||||
status: s.machine.status ?? null,
|
||||
}
|
||||
: null,
|
||||
category: s.category ?? undefined,
|
||||
subcategory: s.subcategory ?? undefined,
|
||||
lastTimelineEntry: s.lastTimelineEntry ?? undefined,
|
||||
|
|
@ -223,6 +243,16 @@ export function mapTicketWithDetailsFromServer(input: unknown) {
|
|||
firstResponseAt: s.firstResponseAt ? new Date(s.firstResponseAt) : null,
|
||||
resolvedAt: s.resolvedAt ? new Date(s.resolvedAt) : null,
|
||||
company: s.company ? { id: s.company.id, name: s.company.name, isAvulso: s.company.isAvulso ?? false } : undefined,
|
||||
machine: s.machine
|
||||
? {
|
||||
id: s.machine.id ?? null,
|
||||
hostname: s.machine.hostname ?? null,
|
||||
persona: s.machine.persona ?? null,
|
||||
assignedUserName: s.machine.assignedUserName ?? null,
|
||||
assignedUserEmail: s.machine.assignedUserEmail ?? null,
|
||||
status: s.machine.status ?? null,
|
||||
}
|
||||
: null,
|
||||
timeline: s.timeline.map((e) => ({ ...e, createdAt: new Date(e.createdAt) })),
|
||||
comments: s.comments.map((c) => ({
|
||||
...c,
|
||||
|
|
|
|||
|
|
@ -41,6 +41,16 @@ export const ticketCompanySummarySchema = z.object({
|
|||
})
|
||||
export type TicketCompanySummary = z.infer<typeof ticketCompanySummarySchema>
|
||||
|
||||
export const ticketMachineSummarySchema = z.object({
|
||||
id: z.string().nullable(),
|
||||
hostname: z.string().nullable().optional(),
|
||||
persona: z.string().nullable().optional(),
|
||||
assignedUserName: z.string().nullable().optional(),
|
||||
assignedUserEmail: z.string().nullable().optional(),
|
||||
status: z.string().nullable().optional(),
|
||||
})
|
||||
export type TicketMachineSummary = z.infer<typeof ticketMachineSummarySchema>
|
||||
|
||||
export const ticketCategorySummarySchema = z.object({
|
||||
id: z.string(),
|
||||
name: z.string(),
|
||||
|
|
@ -118,6 +128,7 @@ export const ticketSchema = z.object({
|
|||
requester: userSummarySchema,
|
||||
assignee: userSummarySchema.nullable(),
|
||||
company: ticketCompanySummarySchema.optional().nullable(),
|
||||
machine: ticketMachineSummarySchema.nullable().optional(),
|
||||
slaPolicy: z
|
||||
.object({
|
||||
id: z.string(),
|
||||
|
|
|
|||
327
src/lib/xlsx.ts
Normal file
327
src/lib/xlsx.ts
Normal file
|
|
@ -0,0 +1,327 @@
|
|||
import { TextEncoder } from "util"
|
||||
|
||||
type WorksheetRow = Array<unknown>
|
||||
|
||||
export type WorksheetConfig = {
|
||||
name: string
|
||||
headers: string[]
|
||||
rows: WorksheetRow[]
|
||||
}
|
||||
|
||||
type ZipEntry = {
|
||||
path: string
|
||||
data: Buffer
|
||||
}
|
||||
|
||||
const XML_DECLARATION = '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
|
||||
|
||||
function escapeXml(value: string): string {
|
||||
return value
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/\u0008/g, "")
|
||||
.replace(/\u000B/g, "")
|
||||
.replace(/\u000C/g, "")
|
||||
.replace(/\u0000/g, "")
|
||||
}
|
||||
|
||||
function columnRef(index: number): string {
|
||||
let col = ""
|
||||
let n = index + 1
|
||||
while (n > 0) {
|
||||
const remainder = (n - 1) % 26
|
||||
col = String.fromCharCode(65 + remainder) + col
|
||||
n = Math.floor((n - 1) / 26)
|
||||
}
|
||||
return col
|
||||
}
|
||||
|
||||
function formatCell(value: unknown, colIndex: number, rowIndex: number): string {
|
||||
const ref = `${columnRef(colIndex)}${rowIndex + 1}`
|
||||
if (value === null || value === undefined || value === "") {
|
||||
return `<c r="${ref}"/>`
|
||||
}
|
||||
|
||||
if (value instanceof Date) {
|
||||
return `<c r="${ref}" t="inlineStr"><is><t>${escapeXml(value.toISOString())}</t></is></c>`
|
||||
}
|
||||
|
||||
if (typeof value === "number" && Number.isFinite(value)) {
|
||||
return `<c r="${ref}"><v>${value}</v></c>`
|
||||
}
|
||||
|
||||
if (typeof value === "boolean") {
|
||||
return `<c r="${ref}"><v>${value ? 1 : 0}</v></c>`
|
||||
}
|
||||
|
||||
let text: string
|
||||
if (typeof value === "string") {
|
||||
text = value
|
||||
} else {
|
||||
text = JSON.stringify(value)
|
||||
}
|
||||
return `<c r="${ref}" t="inlineStr"><is><t>${escapeXml(text)}</t></is></c>`
|
||||
}
|
||||
|
||||
function buildWorksheetXml(config: WorksheetConfig): string {
|
||||
const rows: string[] = []
|
||||
const headerRow = config.headers.map((header, idx) => formatCell(header, idx, 0)).join("")
|
||||
rows.push(`<row r="1">${headerRow}</row>`)
|
||||
|
||||
config.rows.forEach((rowData, rowIdx) => {
|
||||
const cells = config.headers.map((_, colIdx) => formatCell(rowData[colIdx], colIdx, rowIdx + 1)).join("")
|
||||
rows.push(`<row r="${rowIdx + 2}">${cells}</row>`)
|
||||
})
|
||||
|
||||
return [
|
||||
XML_DECLARATION,
|
||||
'<worksheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships">',
|
||||
" <sheetData>",
|
||||
rows.map((row) => ` ${row}`).join("\n"),
|
||||
" </sheetData>",
|
||||
"</worksheet>",
|
||||
].join("\n")
|
||||
}
|
||||
|
||||
const CRC32_TABLE = (() => {
|
||||
const table = new Uint32Array(256)
|
||||
for (let i = 0; i < 256; i += 1) {
|
||||
let c = i
|
||||
for (let k = 0; k < 8; k += 1) {
|
||||
c = (c & 1) !== 0 ? 0xedb88320 ^ (c >>> 1) : c >>> 1
|
||||
}
|
||||
table[i] = c >>> 0
|
||||
}
|
||||
return table
|
||||
})()
|
||||
|
||||
function crc32(buffer: Buffer): number {
|
||||
let crc = 0 ^ -1
|
||||
for (let i = 0; i < buffer.length; i += 1) {
|
||||
crc = (crc >>> 8) ^ CRC32_TABLE[(crc ^ buffer[i]!) & 0xff]
|
||||
}
|
||||
return (crc ^ -1) >>> 0
|
||||
}
|
||||
|
||||
function toDosTime(date: Date): { time: number; date: number } {
|
||||
const year = date.getFullYear()
|
||||
const month = date.getMonth() + 1
|
||||
const day = date.getDate()
|
||||
const hours = date.getHours()
|
||||
const minutes = date.getMinutes()
|
||||
const seconds = Math.floor(date.getSeconds() / 2)
|
||||
const dosTime = (hours << 11) | (minutes << 5) | seconds
|
||||
const dosDate = ((year - 1980) << 9) | (month << 5) | day
|
||||
return { time: dosTime, date: dosDate }
|
||||
}
|
||||
|
||||
function writeUInt16LE(value: number): Buffer {
|
||||
const buffer = Buffer.alloc(2)
|
||||
buffer.writeUInt16LE(value & 0xffff, 0)
|
||||
return buffer
|
||||
}
|
||||
|
||||
function writeUInt32LE(value: number): Buffer {
|
||||
const buffer = Buffer.alloc(4)
|
||||
buffer.writeUInt32LE(value >>> 0, 0)
|
||||
return buffer
|
||||
}
|
||||
|
||||
function encodeZip(entries: ZipEntry[]): Buffer {
|
||||
const encoder = new TextEncoder()
|
||||
const localParts: Buffer[] = []
|
||||
const centralParts: Buffer[] = []
|
||||
let offset = 0
|
||||
const now = new Date()
|
||||
const { time: dosTime, date: dosDate } = toDosTime(now)
|
||||
|
||||
entries.forEach((entry) => {
|
||||
const filenameBytes = encoder.encode(entry.path)
|
||||
const crc = crc32(entry.data)
|
||||
const compressedSize = entry.data.length
|
||||
const uncompressedSize = entry.data.length
|
||||
|
||||
const localHeader = Buffer.concat([
|
||||
writeUInt32LE(0x04034b50),
|
||||
writeUInt16LE(20),
|
||||
writeUInt16LE(0),
|
||||
writeUInt16LE(0), // store (no compression)
|
||||
writeUInt16LE(dosTime),
|
||||
writeUInt16LE(dosDate),
|
||||
writeUInt32LE(crc),
|
||||
writeUInt32LE(compressedSize),
|
||||
writeUInt32LE(uncompressedSize),
|
||||
writeUInt16LE(filenameBytes.length),
|
||||
writeUInt16LE(0),
|
||||
Buffer.from(filenameBytes),
|
||||
])
|
||||
|
||||
localParts.push(localHeader, entry.data)
|
||||
|
||||
const centralHeader = Buffer.concat([
|
||||
writeUInt32LE(0x02014b50),
|
||||
writeUInt16LE(20),
|
||||
writeUInt16LE(20),
|
||||
writeUInt16LE(0),
|
||||
writeUInt16LE(0),
|
||||
writeUInt16LE(dosTime),
|
||||
writeUInt16LE(dosDate),
|
||||
writeUInt32LE(crc),
|
||||
writeUInt32LE(compressedSize),
|
||||
writeUInt32LE(uncompressedSize),
|
||||
writeUInt16LE(filenameBytes.length),
|
||||
writeUInt16LE(0),
|
||||
writeUInt16LE(0),
|
||||
writeUInt16LE(0),
|
||||
writeUInt16LE(0),
|
||||
writeUInt32LE(0),
|
||||
writeUInt32LE(offset),
|
||||
Buffer.from(filenameBytes),
|
||||
])
|
||||
|
||||
centralParts.push(centralHeader)
|
||||
|
||||
offset += localHeader.length + entry.data.length
|
||||
})
|
||||
|
||||
const centralDirectory = Buffer.concat(centralParts)
|
||||
const endOfCentralDirectory = Buffer.concat([
|
||||
writeUInt32LE(0x06054b50),
|
||||
writeUInt16LE(0),
|
||||
writeUInt16LE(0),
|
||||
writeUInt16LE(entries.length),
|
||||
writeUInt16LE(entries.length),
|
||||
writeUInt32LE(centralDirectory.length),
|
||||
writeUInt32LE(offset),
|
||||
writeUInt16LE(0),
|
||||
])
|
||||
|
||||
return Buffer.concat([...localParts, centralDirectory, endOfCentralDirectory])
|
||||
}
|
||||
|
||||
export function buildXlsxWorkbook(sheets: WorksheetConfig[]): Buffer {
|
||||
if (sheets.length === 0) {
|
||||
throw new Error("Workbook requires at least one sheet")
|
||||
}
|
||||
|
||||
const now = new Date()
|
||||
const timestamp = now.toISOString()
|
||||
const workbookRels: string[] = []
|
||||
const sheetEntries: ZipEntry[] = []
|
||||
|
||||
const sheetRefs = sheets.map((sheet, index) => {
|
||||
const sheetId = index + 1
|
||||
const relId = `rId${sheetId}`
|
||||
const worksheetXml = buildWorksheetXml(sheet)
|
||||
sheetEntries.push({
|
||||
path: `xl/worksheets/sheet${sheetId}.xml`,
|
||||
data: Buffer.from(worksheetXml, "utf8"),
|
||||
})
|
||||
workbookRels.push(
|
||||
`<Relationship Id="${relId}" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet" Target="worksheets/sheet${sheetId}.xml"/>`,
|
||||
)
|
||||
return `<sheet name="${escapeXml(sheet.name)}" sheetId="${sheetId}" r:id="${relId}"/>`
|
||||
})
|
||||
|
||||
const workbookXml = [
|
||||
XML_DECLARATION,
|
||||
'<workbook xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships">',
|
||||
" <sheets>",
|
||||
sheetRefs.map((sheet) => ` ${sheet}`).join("\n"),
|
||||
" </sheets>",
|
||||
"</workbook>",
|
||||
].join("\n")
|
||||
|
||||
workbookRels.push(
|
||||
'<Relationship Id="rIdStyles" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles" Target="styles.xml"/>',
|
||||
)
|
||||
|
||||
const workbookRelsXml = [
|
||||
XML_DECLARATION,
|
||||
'<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">',
|
||||
workbookRels.map((rel) => ` ${rel}`).join("\n"),
|
||||
"</Relationships>",
|
||||
].join("\n")
|
||||
|
||||
const stylesXml = [
|
||||
XML_DECLARATION,
|
||||
'<styleSheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main">',
|
||||
' <fonts count="1"><font><sz val="11"/><color theme="1"/><name val="Calibri"/><family val="2"/></font></fonts>',
|
||||
' <fills count="2"><fill><patternFill patternType="none"/></fill><fill><patternFill patternType="gray125"/></fill></fills>',
|
||||
' <borders count="1"><border><left/><right/><top/><bottom/><diagonal/></border></borders>',
|
||||
' <cellStyleXfs count="1"><xf numFmtId="0" fontId="0" fillId="0" borderId="0"/></cellStyleXfs>',
|
||||
' <cellXfs count="1"><xf numFmtId="0" fontId="0" fillId="0" borderId="0" xfId="0"/></cellXfs>',
|
||||
' <cellStyles count="1"><cellStyle name="Normal" xfId="0" builtinId="0"/></cellStyles>',
|
||||
"</styleSheet>",
|
||||
].join("\n")
|
||||
|
||||
const corePropsXml = [
|
||||
XML_DECLARATION,
|
||||
'<cp:coreProperties xmlns:cp="http://schemas.openxmlformats.org/package/2006/metadata/core-properties" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:dcterms="http://purl.org/dc/terms/" xmlns:dcmitype="http://purl.org/dc/dcmitype/" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">',
|
||||
" <dc:creator>Raven</dc:creator>",
|
||||
" <cp:lastModifiedBy>Raven</cp:lastModifiedBy>",
|
||||
` <dcterms:created xsi:type="dcterms:W3CDTF">${escapeXml(timestamp)}</dcterms:created>`,
|
||||
` <dcterms:modified xsi:type="dcterms:W3CDTF">${escapeXml(timestamp)}</dcterms:modified>`,
|
||||
"</cp:coreProperties>",
|
||||
].join("\n")
|
||||
|
||||
const appPropsXml = [
|
||||
XML_DECLARATION,
|
||||
'<Properties xmlns="http://schemas.openxmlformats.org/officeDocument/2006/extended-properties" xmlns:vt="http://schemas.openxmlformats.org/officeDocument/2006/docPropsVTypes">',
|
||||
" <Application>Raven</Application>",
|
||||
" <DocSecurity>0</DocSecurity>",
|
||||
" <ScaleCrop>false</ScaleCrop>",
|
||||
" <HeadingPairs>",
|
||||
' <vt:vector size="2" baseType="variant">',
|
||||
' <vt:variant><vt:lpstr>Worksheets</vt:lpstr></vt:variant>',
|
||||
` <vt:variant><vt:i4>${sheets.length}</vt:i4></vt:variant>`,
|
||||
" </vt:vector>",
|
||||
" </HeadingPairs>",
|
||||
" <TitlesOfParts>",
|
||||
` <vt:vector size="${sheets.length}" baseType="lpstr">`,
|
||||
sheets.map((sheet) => ` <vt:lpstr>${escapeXml(sheet.name)}</vt:lpstr>`).join("\n"),
|
||||
" </vt:vector>",
|
||||
" </TitlesOfParts>",
|
||||
"</Properties>",
|
||||
].join("\n")
|
||||
|
||||
const contentTypes = [
|
||||
XML_DECLARATION,
|
||||
'<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">',
|
||||
' <Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>',
|
||||
' <Default Extension="xml" ContentType="application/xml"/>',
|
||||
' <Override PartName="/xl/workbook.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml"/>',
|
||||
' <Override PartName="/xl/styles.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.styles+xml"/>',
|
||||
' <Override PartName="/docProps/core.xml" ContentType="application/vnd.openxmlformats-package.core-properties+xml"/>',
|
||||
' <Override PartName="/docProps/app.xml" ContentType="application/vnd.openxmlformats-officedocument.extended-properties+xml"/>',
|
||||
...sheets.map(
|
||||
(_sheet, index) =>
|
||||
` <Override PartName="/xl/worksheets/sheet${index + 1}.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"/>`,
|
||||
),
|
||||
"</Types>",
|
||||
].join("\n")
|
||||
|
||||
const rootRelsXml = [
|
||||
XML_DECLARATION,
|
||||
'<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">',
|
||||
' <Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="xl/workbook.xml"/>',
|
||||
' <Relationship Id="rId2" Type="http://schemas.openxmlformats.org/package/2006/relationships/metadata/core-properties" Target="docProps/core.xml"/>',
|
||||
' <Relationship Id="rId3" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/extended-properties" Target="docProps/app.xml"/>',
|
||||
"</Relationships>",
|
||||
].join("\n")
|
||||
|
||||
const entries: ZipEntry[] = [
|
||||
{ path: "[Content_Types].xml", data: Buffer.from(contentTypes, "utf8") },
|
||||
{ path: "_rels/.rels", data: Buffer.from(rootRelsXml, "utf8") },
|
||||
{ path: "docProps/core.xml", data: Buffer.from(corePropsXml, "utf8") },
|
||||
{ path: "docProps/app.xml", data: Buffer.from(appPropsXml, "utf8") },
|
||||
{ path: "xl/workbook.xml", data: Buffer.from(workbookXml, "utf8") },
|
||||
{ path: "xl/_rels/workbook.xml.rels", data: Buffer.from(workbookRelsXml, "utf8") },
|
||||
{ path: "xl/styles.xml", data: Buffer.from(stylesXml, "utf8") },
|
||||
...sheetEntries,
|
||||
]
|
||||
|
||||
return encodeZip(entries)
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue