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) => (
-
- );
-
- const videoBlock = (video) => {
- if (video?.video_files?.length > 0) {
- return (
-
-
-
- Your browser does not support the video tag.
-
-
-
)
- }
- };
-
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 {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 && (
+
+
+
+ )}
+
+
+
+
+
+
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 }) => (
+
+ )}
+
+ )}
+
+
+
+ {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} ;
+};