diff --git a/backend/src/routes/tickets.js b/backend/src/routes/tickets.js index aac3cfd..1a6024d 100644 --- a/backend/src/routes/tickets.js +++ b/backend/src/routes/tickets.js @@ -86,8 +86,14 @@ router.use(checkCrudPermissions('tickets')); router.post('/', wrapAsync(async (req, res) => { const referer = req.headers.referer || `${req.protocol}://${req.hostname}${req.originalUrl}`; const link = new URL(referer); - await TicketsService.create(req.body.data, req.currentUser, true, link.host); - const payload = true; + const ticket = await TicketsService.create(req.body.data, req.currentUser, true, link.host); + const payload = ticket ? { + id: ticket.id, + ticket_number: ticket.ticket_number, + subject: ticket.subject, + status: ticket.status, + priority: ticket.priority, + } : true; res.status(200).send(payload); })); diff --git a/backend/src/services/tickets.js b/backend/src/services/tickets.js index 4c73c2f..64d2db7 100644 --- a/backend/src/services/tickets.js +++ b/backend/src/services/tickets.js @@ -1,36 +1,43 @@ const db = require('../db/models'); const TicketsDBApi = require('../db/api/tickets'); -const processFile = require("../middlewares/upload"); +const processFile = require('../middlewares/upload'); const ValidationError = require('./notifications/errors/validation'); const csv = require('csv-parser'); -const axios = require('axios'); -const config = require('../config'); const stream = require('stream'); - - - - module.exports = class TicketsService { static async create(data, currentUser) { const transaction = await db.sequelize.transaction(); try { - await TicketsDBApi.create( - data, - { - currentUser, - transaction, - }, - ); + const sanitizedData = { + ...data, + ticket_number: data.ticket_number || `HD-${Date.now().toString().slice(-8)}`, + reported_at: data.reported_at || new Date().toISOString(), + status: data.status || 'new', + }; + + if (!sanitizedData.subject || !String(sanitizedData.subject).trim()) { + throw new ValidationError('errors.validation.message'); + } + + if (!sanitizedData.description || !String(sanitizedData.description).trim()) { + throw new ValidationError('errors.validation.message'); + } + + const ticket = await TicketsDBApi.create(sanitizedData, { + currentUser, + transaction, + }); await transaction.commit(); + return ticket; } catch (error) { await transaction.rollback(); throw error; } - }; + } - static async bulkImport(req, res, sendInvitationEmails = true, host) { + static async bulkImport(req, res) { const transaction = await db.sequelize.transaction(); try { @@ -38,7 +45,7 @@ module.exports = class TicketsService { const bufferStream = new stream.PassThrough(); const results = []; - await bufferStream.end(Buffer.from(req.file.buffer, "utf-8")); // convert Buffer to Stream + await bufferStream.end(Buffer.from(req.file.buffer, 'utf-8')); await new Promise((resolve, reject) => { bufferStream @@ -49,13 +56,13 @@ module.exports = class TicketsService { resolve(); }) .on('error', (error) => reject(error)); - }) + }); await TicketsDBApi.bulkImport(results, { - transaction, - ignoreDuplicates: true, - validate: true, - currentUser: req.currentUser + transaction, + ignoreDuplicates: true, + validate: true, + currentUser: req.currentUser, }); await transaction.commit(); @@ -68,15 +75,13 @@ module.exports = class TicketsService { static async update(data, id, currentUser) { const transaction = await db.sequelize.transaction(); try { - let tickets = await TicketsDBApi.findBy( - {id}, - {transaction}, + const tickets = await TicketsDBApi.findBy( + { id }, + { transaction }, ); if (!tickets) { - throw new ValidationError( - 'ticketsNotFound', - ); + throw new ValidationError('ticketsNotFound'); } const updatedTickets = await TicketsDBApi.update( @@ -90,12 +95,11 @@ module.exports = class TicketsService { await transaction.commit(); return updatedTickets; - } catch (error) { await transaction.rollback(); throw error; } - }; + } static async deleteByIds(ids, currentUser) { const transaction = await db.sequelize.transaction(); @@ -131,8 +135,4 @@ module.exports = class TicketsService { throw error; } } - - }; - - diff --git a/frontend/src/components/NavBarItem.tsx b/frontend/src/components/NavBarItem.tsx index 72935e6..fcbd9b9 100644 --- a/frontend/src/components/NavBarItem.tsx +++ b/frontend/src/components/NavBarItem.tsx @@ -1,6 +1,5 @@ -import React, {useEffect, useRef} from 'react' +import React, { useEffect, useRef, useState } from 'react' import Link from 'next/link' -import { useState } from 'react' import { mdiChevronUp, mdiChevronDown } from '@mdi/js' import BaseDivider from './BaseDivider' import BaseIcon from './BaseIcon' diff --git a/frontend/src/layouts/Authenticated.tsx b/frontend/src/layouts/Authenticated.tsx index 1b9907d..73d8391 100644 --- a/frontend/src/layouts/Authenticated.tsx +++ b/frontend/src/layouts/Authenticated.tsx @@ -1,5 +1,4 @@ -import React, { ReactNode, useEffect } from 'react' -import { useState } from 'react' +import React, { ReactNode, useEffect, useState } from 'react' import jwt from 'jsonwebtoken'; import { mdiForwardburger, mdiBackburger, mdiMenu } from '@mdi/js' import menuAside from '../menuAside' diff --git a/frontend/src/menuAside.ts b/frontend/src/menuAside.ts index 0176b05..9c2b0b8 100644 --- a/frontend/src/menuAside.ts +++ b/frontend/src/menuAside.ts @@ -7,6 +7,14 @@ const menuAside: MenuAsideItem[] = [ icon: icon.mdiViewDashboardOutline, label: 'Dashboard', }, + { + href: '/service-desk', + label: 'Service Desk', + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + icon: 'mdiHeadset' in icon ? icon['mdiHeadset' as keyof typeof icon] : ('mdiTicket' in icon ? icon['mdiTicket' as keyof typeof icon] : icon.mdiTable), + permissions: ['CREATE_TICKETS', 'READ_TICKETS', 'READ_ASSETS', 'READ_REPAIRS'], + }, { href: '/users/users-list', diff --git a/frontend/src/pages/index.tsx b/frontend/src/pages/index.tsx index 7513184..1a6f63b 100644 --- a/frontend/src/pages/index.tsx +++ b/frontend/src/pages/index.tsx @@ -1,166 +1,192 @@ - -import React, { useEffect, useState } from 'react'; +import React from 'react'; import type { ReactElement } from 'react'; import Head from 'next/head'; import Link from 'next/link'; -import BaseButton from '../components/BaseButton'; -import CardBox from '../components/CardBox'; -import SectionFullScreen from '../components/SectionFullScreen'; -import LayoutGuest from '../layouts/Guest'; -import BaseDivider from '../components/BaseDivider'; -import BaseButtons from '../components/BaseButtons'; -import { getPageTitle } from '../config'; -import { useAppSelector } from '../stores/hooks'; -import CardBoxComponentTitle from "../components/CardBoxComponentTitle"; -import { getPexelsImage, getPexelsVideo } from '../helpers/pexels'; +import * as icon from '@mdi/js'; +import BaseButton from '../components/BaseButton'; +import BaseIcon from '../components/BaseIcon'; +import CardBox from '../components/CardBox'; +import LayoutGuest from '../layouts/Guest'; +import SectionFullScreen from '../components/SectionFullScreen'; +import { getPageTitle } from '../config'; + +const workflowCards = [ + { + title: 'Ticket intake', + description: + 'Capture troubleshooting requests with priority, category, location, and asset context in a single guided flow.', + icon: + 'mdiTicket' in icon + ? icon['mdiTicket' as keyof typeof icon] + : icon.mdiChartTimelineVariant, + }, + { + title: 'Repair pipeline', + description: + 'Track recovered equipment through testing, repair, and return-to-service so nothing disappears into email threads.', + icon: + 'mdiWrench' in icon + ? icon['mdiWrench' as keyof typeof icon] + : icon.mdiChartTimelineVariant, + }, + { + title: 'Asset records', + description: + 'Keep the history of new, assigned, recovered, and retired devices together with the tickets that explain why.', + icon: + 'mdiLaptop' in icon + ? icon['mdiLaptop' as keyof typeof icon] + : icon.mdiChartTimelineVariant, + }, +]; + +const operationalHighlights = [ + 'Desktop-first workspace for IT coordinators and managers', + 'Structured lifecycle tracking for equipment under test or repair', + 'Fast hand-off from ticket submission to queue review and detail history', +]; export default function Starter() { - const [illustrationImage, setIllustrationImage] = useState({ - src: undefined, - photographer: undefined, - photographer_url: undefined, - }) - const [illustrationVideo, setIllustrationVideo] = useState({video_files: []}) - const [contentType, setContentType] = useState('video'); - const [contentPosition, setContentPosition] = useState('right'); - const textColor = useAppSelector((state) => state.style.linkColor); - - const title = 'IT Help Desk & Assets' - - // Fetch Pexels image/video - useEffect(() => { - async function fetchData() { - const image = await getPexelsImage(); - const video = await getPexelsVideo(); - setIllustrationImage(image); - setIllustrationVideo(video); - } - fetchData(); - }, []); - - const imageBlock = (image) => ( -
-
- - Photo by {image?.photographer} on Pexels - -
-
- ); - - const videoBlock = (video) => { - if (video?.video_files?.length > 0) { - return ( -
- -
- - Video by {video.user.name} on Pexels - -
-
) - } - }; - return ( -
+ <> - {getPageTitle('Starter Page')} + {getPageTitle('IT Help Desk')} - -
- {contentType === 'image' && contentPosition !== 'background' - ? imageBlock(illustrationImage) - : null} - {contentType === 'video' && contentPosition !== 'background' - ? videoBlock(illustrationVideo) - : null} -
- - - -
-

This is a React.js/Node.js app generated by the Flatlogic Web App Generator

-

For guides and documentation please check - your local README.md and the Flatlogic documentation

+ +
+
+
+
+

+ IT Help Desk & Asset Operations +

+

+ Resolve issues faster. Track every device with confidence. +

+
+
+ + +
- - - +
- - +
+
+
+
+
+ + Built for internal support teams and equipment stewardship + +

+ A clean operations hub for ticket triage, recovery workflows, and searchable IT equipment history. +

+

+ Centralize troubleshooting requests, monitor the active queue, and keep a reliable record of devices that are new, + assigned, recovered from staff, under test, or in repair. +

+ +
+ + + +
+ +
    + {operationalHighlights.map((highlight) => ( +
  • + {highlight} +
  • + ))} +
+
+ + +
+
+
+
+ +
+
+

First delivery

+

Service Desk workspace

+
+
+

+ Launch a real, end-to-end slice today: submit a new ticket, confirm the request instantly, then jump into the live + queue and linked equipment records. +

+
+ +
+ {workflowCards.map((card) => ( +
+
+ +
+

{card.title}

+

{card.description}

+
+ ))} +
+
+
+
+
+ +
+
+ +

01

+

Submit and acknowledge

+

+ Capture enough context up front so the IT team can act without chasing missing details. +

+
+ +

02

+

Triage against assets

+

+ Connect issues to specific devices, locations, and recovery stages to reduce ambiguity. +

+
+ +

03

+

Keep searchable history

+

+ Maintain a dependable record of actions taken, equipment movement, and support outcomes. +

+
+
+
+
+ +
+
+

© 2026 IT Help Desk & Assets. Designed for fast internal support operations.

+
+ + Admin login + + + Privacy Policy + +
+
+
-
-
-

© 2026 {title}. All rights reserved

- - Privacy Policy - -
- -
+ ); } Starter.getLayout = function getLayout(page: ReactElement) { return {page}; }; - diff --git a/frontend/src/pages/service-desk.tsx b/frontend/src/pages/service-desk.tsx new file mode 100644 index 0000000..d0aa936 --- /dev/null +++ b/frontend/src/pages/service-desk.tsx @@ -0,0 +1,739 @@ +import React, { ReactElement, useCallback, useEffect, useMemo, useState } from 'react'; +import Head from 'next/head'; +import Link from 'next/link'; +import axios from 'axios'; +import * as icon from '@mdi/js'; +import { Field, Form, Formik, FormikHelpers } from 'formik'; + +import BaseButton from '../components/BaseButton'; +import BaseDivider from '../components/BaseDivider'; +import BaseIcon from '../components/BaseIcon'; +import BaseButtons from '../components/BaseButtons'; +import CardBox from '../components/CardBox'; +import FormField from '../components/FormField'; +import LoadingSpinner from '../components/LoadingSpinner'; +import SectionMain from '../components/SectionMain'; +import SectionTitleLineWithButton from '../components/SectionTitleLineWithButton'; +import { SelectField } from '../components/SelectField'; +import { SwitchField } from '../components/SwitchField'; +import { getPageTitle } from '../config'; +import { hasPermission } from '../helpers/userPermissions'; +import LayoutAuthenticated from '../layouts/Authenticated'; +import { useAppDispatch, useAppSelector } from '../stores/hooks'; +import { create as createTicket } from '../stores/tickets/ticketsSlice'; + +type TicketFormValues = { + ticket_number: string; + subject: string; + description: string; + category: string; + priority: string; + related_asset: string | null; + location: string | null; + requires_on_site: boolean; +}; + +type TicketSummary = { + id: string; + ticket_number?: string; + subject?: string; + status?: string; + priority?: string; + createdAt?: string; + assignee?: { label?: string } | null; +}; + +type AssetSummary = { + id: string; + asset_tag?: string; + name?: string; + lifecycle_status?: string; + asset_type?: string; + updatedAt?: string; +}; + +type AssetLane = { + rows: AssetSummary[]; + count: number; +}; + +type WorkspaceState = { + recentTickets: TicketSummary[]; + totalTickets: number; + newTickets: number; + inProgressTickets: number; + waitingTickets: number; + recoveredAssets: AssetLane; + testingAssets: AssetLane; + repairAssets: AssetLane; +}; + +type CreatedTicket = { + id: string; + ticket_number?: string; + subject?: string; + status?: string; + priority?: string; +}; + +const initialWorkspaceState: WorkspaceState = { + recentTickets: [], + totalTickets: 0, + newTickets: 0, + inProgressTickets: 0, + waitingTickets: 0, + recoveredAssets: { rows: [], count: 0 }, + testingAssets: { rows: [], count: 0 }, + repairAssets: { rows: [], count: 0 }, +}; + +const ticketCategories = [ + { value: 'hardware', label: 'Hardware' }, + { value: 'software', label: 'Software' }, + { value: 'network', label: 'Network' }, + { value: 'access', label: 'Access' }, + { value: 'email', label: 'Email' }, + { value: 'security', label: 'Security' }, + { value: 'other', label: 'Other' }, +]; + +const ticketPriorities = [ + { value: 'low', label: 'Low' }, + { value: 'medium', label: 'Medium' }, + { value: 'high', label: 'High' }, + { value: 'urgent', label: 'Urgent' }, +]; + +const assetLaneMeta = [ + { key: 'recovered', title: 'Recovered from staff', tone: 'border-amber-200 bg-amber-50 text-amber-800' }, + { key: 'testing', title: 'In testing', tone: 'border-sky-200 bg-sky-50 text-sky-800' }, + { key: 'repair', title: 'In repair', tone: 'border-rose-200 bg-rose-50 text-rose-800' }, +] as const; + +const formatLabel = (value?: string | null) => { + if (!value) { + return 'Not set'; + } + + return value.replace(/_/g, ' '); +}; + +const formatDateTime = (value?: string) => { + if (!value) { + return 'No timestamp'; + } + + return new Date(value).toLocaleString(); +}; + +const buildTicketNumber = () => + `HD-${new Date().toISOString().slice(0, 10).replace(/-/g, '')}-${Math.floor(100 + Math.random() * 900)}`; + +const buildInitialValues = (): TicketFormValues => ({ + ticket_number: buildTicketNumber(), + subject: '', + description: '', + category: 'hardware', + priority: 'medium', + related_asset: null, + location: null, + requires_on_site: false, +}); + +const stripHtml = (value: string) => value.replace(/<[^>]*>/g, ' ').replace(/\s+/g, ' ').trim(); + +const ticketStatusClassName: Record = { + new: 'bg-sky-100 text-sky-700', + triaged: 'bg-indigo-100 text-indigo-700', + in_progress: 'bg-amber-100 text-amber-800', + waiting_on_user: 'bg-violet-100 text-violet-800', + waiting_on_vendor: 'bg-fuchsia-100 text-fuchsia-800', + resolved: 'bg-emerald-100 text-emerald-700', + closed: 'bg-slate-200 text-slate-700', + cancelled: 'bg-rose-100 text-rose-700', +}; + +const priorityClassName: Record = { + low: 'bg-slate-100 text-slate-700', + medium: 'bg-blue-100 text-blue-700', + high: 'bg-orange-100 text-orange-800', + urgent: 'bg-rose-100 text-rose-700', +}; + +const assetStatusClassName: Record = { + recovered: 'bg-amber-100 text-amber-800', + in_testing: 'bg-sky-100 text-sky-700', + in_repair: 'bg-rose-100 text-rose-700', + assigned: 'bg-emerald-100 text-emerald-700', + in_stock: 'bg-slate-100 text-slate-700', +}; + +const getTicketStatusClassName = (status?: string) => ticketStatusClassName[status || ''] || 'bg-slate-100 text-slate-700'; +const getPriorityClassName = (priority?: string) => priorityClassName[priority || ''] || 'bg-slate-100 text-slate-700'; +const getAssetStatusClassName = (status?: string) => assetStatusClassName[status || ''] || 'bg-slate-100 text-slate-700'; + +export default function ServiceDeskPage() { + const dispatch = useAppDispatch(); + const { currentUser } = useAppSelector((state) => state.auth); + const { loading: isCreatingTicket } = useAppSelector((state) => state.tickets); + + const [workspace, setWorkspace] = useState(initialWorkspaceState); + const [isWorkspaceLoading, setIsWorkspaceLoading] = useState(true); + const [workspaceError, setWorkspaceError] = useState(''); + const [submissionError, setSubmissionError] = useState(''); + const [createdTicket, setCreatedTicket] = useState(null); + + const canCreateTickets = hasPermission(currentUser, 'CREATE_TICKETS'); + const canReadTickets = hasPermission(currentUser, 'READ_TICKETS'); + const canReadAssets = hasPermission(currentUser, 'READ_ASSETS'); + const canReadLocations = hasPermission(currentUser, 'READ_LOCATIONS'); + const canCreateAssets = hasPermission(currentUser, 'CREATE_ASSETS'); + const canReadRepairs = hasPermission(currentUser, 'READ_REPAIRS'); + const canReadTestRuns = hasPermission(currentUser, 'READ_TEST_RUNS'); + + const queueCards = useMemo( + () => [ + { + title: 'Total tickets', + value: workspace.totalTickets, + icon: + 'mdiTicket' in icon + ? icon['mdiTicket' as keyof typeof icon] + : icon.mdiChartTimelineVariant, + tone: 'from-slate-900 via-slate-800 to-slate-700 text-white', + }, + { + title: 'New intake', + value: workspace.newTickets, + icon: + 'mdiInboxArrowDown' in icon + ? icon['mdiInboxArrowDown' as keyof typeof icon] + : icon.mdiChartTimelineVariant, + tone: 'from-sky-500 to-cyan-500 text-white', + }, + { + title: 'In progress', + value: workspace.inProgressTickets, + icon: + 'mdiProgressClock' in icon + ? icon['mdiProgressClock' as keyof typeof icon] + : icon.mdiChartTimelineVariant, + tone: 'from-amber-400 to-orange-500 text-white', + }, + { + title: 'Waiting', + value: workspace.waitingTickets, + icon: + 'mdiTimerSand' in icon + ? icon['mdiTimerSand' as keyof typeof icon] + : icon.mdiChartTimelineVariant, + tone: 'from-violet-500 to-fuchsia-500 text-white', + }, + ], + [workspace], + ); + + const loadWorkspace = useCallback(async () => { + setWorkspaceError(''); + setIsWorkspaceLoading(true); + + try { + const ticketResponses = canReadTickets + ? await Promise.all([ + axios.get('tickets?limit=6&page=0&field=createdAt&sort=desc'), + axios.get('tickets?limit=1&page=0&status=new'), + axios.get('tickets?limit=1&page=0&status=in_progress'), + axios.get('tickets?limit=1&page=0&status=waiting_on_user'), + axios.get('tickets?limit=1&page=0&status=waiting_on_vendor'), + ]) + : []; + + const assetResponses = canReadAssets + ? await Promise.all([ + axios.get('assets?limit=4&page=0&field=updatedAt&sort=desc&lifecycle_status=recovered'), + axios.get('assets?limit=4&page=0&field=updatedAt&sort=desc&lifecycle_status=in_testing'), + axios.get('assets?limit=4&page=0&field=updatedAt&sort=desc&lifecycle_status=in_repair'), + ]) + : []; + + setWorkspace({ + recentTickets: canReadTickets ? ticketResponses[0].data.rows || [] : [], + totalTickets: canReadTickets ? ticketResponses[0].data.count || 0 : 0, + newTickets: canReadTickets ? ticketResponses[1].data.count || 0 : 0, + inProgressTickets: canReadTickets ? ticketResponses[2].data.count || 0 : 0, + waitingTickets: canReadTickets ? (ticketResponses[3].data.count || 0) + (ticketResponses[4].data.count || 0) : 0, + recoveredAssets: canReadAssets + ? { rows: assetResponses[0].data.rows || [], count: assetResponses[0].data.count || 0 } + : { rows: [], count: 0 }, + testingAssets: canReadAssets + ? { rows: assetResponses[1].data.rows || [], count: assetResponses[1].data.count || 0 } + : { rows: [], count: 0 }, + repairAssets: canReadAssets + ? { rows: assetResponses[2].data.rows || [], count: assetResponses[2].data.count || 0 } + : { rows: [], count: 0 }, + }); + } catch (error) { + console.error('Failed to load service desk workspace', error); + setWorkspaceError('Unable to load the live queue right now. You can still submit a new ticket.'); + } finally { + setIsWorkspaceLoading(false); + } + }, [canReadAssets, canReadTickets]); + + useEffect(() => { + if (!currentUser?.id) { + return; + } + + loadWorkspace(); + }, [currentUser?.id, loadWorkspace]); + + const validateTicket = (values: TicketFormValues) => { + const errors: Partial> = {}; + + if (!values.subject.trim()) { + errors.subject = 'Please add a short summary for the issue.'; + } else if (values.subject.trim().length < 5) { + errors.subject = 'Use at least 5 characters so the queue is easy to scan.'; + } + + if (!stripHtml(values.description).trim()) { + errors.description = 'Add a few details so IT can troubleshoot quickly.'; + } else if (stripHtml(values.description).trim().length < 10) { + errors.description = 'Please include a little more context about the problem.'; + } + + return errors; + }; + + const handleSubmit = async ( + values: TicketFormValues, + { resetForm, setSubmitting }: FormikHelpers, + ) => { + setSubmissionError(''); + setCreatedTicket(null); + + try { + const payload = { + ...values, + subject: values.subject.trim(), + description: values.description.trim(), + requester: currentUser?.id, + status: 'new', + reported_at: new Date().toISOString(), + }; + + const response = await dispatch(createTicket(payload)).unwrap(); + setCreatedTicket(response); + resetForm({ values: buildInitialValues() }); + await loadWorkspace(); + } catch (error: unknown) { + console.error('Failed to create ticket', error); + const message = + typeof error === 'object' && error !== null && 'message' in error + ? String(error.message) + : 'Unable to submit the ticket right now. Please try again.'; + setSubmissionError(message); + } finally { + setSubmitting(false); + } + }; + + const assetLaneState = [ + { ...assetLaneMeta[0], data: workspace.recoveredAssets }, + { ...assetLaneMeta[1], data: workspace.testingAssets }, + { ...assetLaneMeta[2], data: workspace.repairAssets }, + ]; + + return ( + <> + + {getPageTitle('Service Desk')} + + + + + + {canReadTickets && } + {canReadAssets && } + + + +
+ +
+
+ + IT operations command center + +

+ Submit issues, review the live queue, and keep recovered equipment moving. +

+

+ This first workspace is designed to feel useful immediately: intake on the left, confirmation after submission, + then a quick path into tickets, assets, repairs, and testing records. +

+
+ {canCreateTickets && } + {canCreateAssets && } + {canReadRepairs && } +
+
+ +
+ {queueCards.map((card) => ( +
+
+
+
+

{card.title}

+

{card.value}

+
+
+ +
+
+
+
+ ))} +
+
+
+ + +
+
+

Quick actions

+

Move between help desk workflows

+
+
+ {canReadTickets && ( + +
+
+

Ticket queue

+

Review all requests, priorities, and assignees.

+
+ +
+ + )} + {canCreateAssets && ( + +
+
+

Register equipment

+

Add a new or recovered device into inventory.

+
+ +
+ + )} + {canReadTestRuns && ( + +
+
+

Testing records

+

See validation steps for repaired or recovered assets.

+
+ +
+ + )} +
+
+
+
+ + {workspaceError && ( + +
+ +

{workspaceError}

+
+
+ )} + +
+ +
+
+

Create

+

Log a new support ticket

+

+ Capture just enough detail for triage now. The full record can be expanded later with comments, work logs, and repairs. +

+
+
+ + {!canCreateTickets ? ( +
+ Your role currently does not have permission to create tickets. Ask an administrator to grant ticket intake access if you + need it. +
+ ) : ( + + {({ errors, isSubmitting }) => ( +
+ + + + + + + + {errors.subject &&

{errors.subject}

} + + + + + {errors.description &&

{errors.description}

} + +
+ + + {ticketCategories.map((option) => ( + + ))} + + + + + + {ticketPriorities.map((option) => ( + + ))} + + +
+ +
+ {canReadLocations ? ( + + + + ) : ( +
+ )} + + {canReadAssets ? ( + + + + ) : ( +
+ )} +
+ + + + + + {submissionError && ( +
+ {submissionError} +
+ )} + + + + + + + + )} + + )} + + +
+ {createdTicket && ( + +
+
+ +
+
+

Ticket received

+

+ {createdTicket.ticket_number || 'New help desk ticket'} created successfully +

+

+ {createdTicket.subject || 'Your ticket was submitted.'} +

+
+ {createdTicket.status && ( + + {formatLabel(createdTicket.status)} + + )} + {createdTicket.priority && ( + + {formatLabel(createdTicket.priority)} + + )} +
+
+ {canReadTickets && ( + + )} + {canReadTickets && ( + + )} +
+
+
+
+ )} + + +
+
+

Tickets

+

Recent queue activity

+
+ {canReadTickets && } +
+ + {isWorkspaceLoading ? ( + + ) : !canReadTickets ? ( +
+ Ticket queue visibility is limited for your role. You can still use this page for ticket intake if creation rights are enabled. +
+ ) : workspace.recentTickets.length ? ( +
+ {workspace.recentTickets.map((ticket) => ( + +
+
+

+ {ticket.subject || ticket.ticket_number || 'Untitled ticket'} +

+

+ {ticket.ticket_number || 'No reference'} • Updated {formatDateTime(ticket.createdAt)} +

+ {ticket.assignee?.label && ( +

Assigned to {ticket.assignee.label}

+ )} +
+
+ + {formatLabel(ticket.status)} + + + {formatLabel(ticket.priority)} + +
+
+ + ))} +
+ ) : ( +
+ No tickets yet. Submit the first request from this page to start the queue. +
+ )} +
+
+
+ + +
+
+

Assets

+

Recovery and repair pipeline

+

+ Keep the critical asset states visible while tickets are being processed. +

+
+ {canReadAssets && } +
+ + {isWorkspaceLoading ? ( + + ) : !canReadAssets ? ( +
+ Asset lifecycle data is hidden for your role. Grant asset read permission to expose recovered, testing, and repair records here. +
+ ) : ( +
+ {assetLaneState.map((lane) => ( +
+
+
+

{lane.title}

+

{lane.data.count} tracked assets

+
+ {lane.data.count} +
+ + {lane.data.rows.length ? ( +
+ {lane.data.rows.map((asset) => ( + +
+
+

+ {asset.name || asset.asset_tag || 'Untitled asset'} +

+

+ {asset.asset_tag || 'No asset tag'} • {formatLabel(asset.asset_type)} +

+

Updated {formatDateTime(asset.updatedAt)}

+
+ + {formatLabel(asset.lifecycle_status)} + +
+ + ))} +
+ ) : ( +
+ No assets are currently in this stage. +
+ )} +
+ ))} +
+ )} +
+ + + ); +} + +ServiceDeskPage.getLayout = function getLayout(page: ReactElement) { + return {page}; +};