Compare commits
No commits in common. "ai-dev" and "master" have entirely different histories.
@ -86,14 +86,8 @@ 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);
|
||||
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;
|
||||
await TicketsService.create(req.body.data, req.currentUser, true, link.host);
|
||||
const payload = true;
|
||||
res.status(200).send(payload);
|
||||
}));
|
||||
|
||||
|
||||
@ -1,43 +1,36 @@
|
||||
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 {
|
||||
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 TicketsDBApi.create(
|
||||
data,
|
||||
{
|
||||
currentUser,
|
||||
transaction,
|
||||
},
|
||||
);
|
||||
|
||||
await transaction.commit();
|
||||
return ticket;
|
||||
} catch (error) {
|
||||
await transaction.rollback();
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
static async bulkImport(req, res) {
|
||||
static async bulkImport(req, res, sendInvitationEmails = true, host) {
|
||||
const transaction = await db.sequelize.transaction();
|
||||
|
||||
try {
|
||||
@ -45,7 +38,7 @@ module.exports = class TicketsService {
|
||||
const bufferStream = new stream.PassThrough();
|
||||
const results = [];
|
||||
|
||||
await bufferStream.end(Buffer.from(req.file.buffer, 'utf-8'));
|
||||
await bufferStream.end(Buffer.from(req.file.buffer, "utf-8")); // convert Buffer to Stream
|
||||
|
||||
await new Promise((resolve, reject) => {
|
||||
bufferStream
|
||||
@ -56,13 +49,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();
|
||||
@ -75,13 +68,15 @@ module.exports = class TicketsService {
|
||||
static async update(data, id, currentUser) {
|
||||
const transaction = await db.sequelize.transaction();
|
||||
try {
|
||||
const tickets = await TicketsDBApi.findBy(
|
||||
{ id },
|
||||
{ transaction },
|
||||
let tickets = await TicketsDBApi.findBy(
|
||||
{id},
|
||||
{transaction},
|
||||
);
|
||||
|
||||
if (!tickets) {
|
||||
throw new ValidationError('ticketsNotFound');
|
||||
throw new ValidationError(
|
||||
'ticketsNotFound',
|
||||
);
|
||||
}
|
||||
|
||||
const updatedTickets = await TicketsDBApi.update(
|
||||
@ -95,11 +90,12 @@ 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();
|
||||
@ -135,4 +131,8 @@ module.exports = class TicketsService {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
};
|
||||
|
||||
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import React, { useEffect, useRef, useState } from 'react'
|
||||
import React, {useEffect, useRef} 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'
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import React, { ReactNode, useEffect, useState } from 'react'
|
||||
import React, { ReactNode, useEffect } from 'react'
|
||||
import { useState } from 'react'
|
||||
import jwt from 'jsonwebtoken';
|
||||
import { mdiForwardburger, mdiBackburger, mdiMenu } from '@mdi/js'
|
||||
import menuAside from '../menuAside'
|
||||
|
||||
@ -7,14 +7,6 @@ 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',
|
||||
|
||||
@ -1,192 +1,166 @@
|
||||
import React from 'react';
|
||||
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import type { ReactElement } from 'react';
|
||||
import Head from 'next/head';
|
||||
import Link from 'next/link';
|
||||
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 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';
|
||||
|
||||
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) => (
|
||||
<div
|
||||
className='hidden md:flex flex-col justify-end relative flex-grow-0 flex-shrink-0 w-1/3'
|
||||
style={{
|
||||
backgroundImage: `${
|
||||
image
|
||||
? `url(${image?.src?.original})`
|
||||
: 'linear-gradient(rgba(255, 255, 255, 0.5), rgba(255, 255, 255, 0.5))'
|
||||
}`,
|
||||
backgroundSize: 'cover',
|
||||
backgroundPosition: 'left center',
|
||||
backgroundRepeat: 'no-repeat',
|
||||
}}
|
||||
>
|
||||
<div className='flex justify-center w-full bg-blue-300/20'>
|
||||
<a
|
||||
className='text-[8px]'
|
||||
href={image?.photographer_url}
|
||||
target='_blank'
|
||||
rel='noreferrer'
|
||||
>
|
||||
Photo by {image?.photographer} on Pexels
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const videoBlock = (video) => {
|
||||
if (video?.video_files?.length > 0) {
|
||||
return (
|
||||
<div className='hidden md:flex flex-col justify-end relative flex-grow-0 flex-shrink-0 w-1/3'>
|
||||
<video
|
||||
className='absolute top-0 left-0 w-full h-full object-cover'
|
||||
autoPlay
|
||||
loop
|
||||
muted
|
||||
>
|
||||
<source src={video?.video_files[0]?.link} type='video/mp4'/>
|
||||
Your browser does not support the video tag.
|
||||
</video>
|
||||
<div className='flex justify-center w-full bg-blue-300/20 z-10'>
|
||||
<a
|
||||
className='text-[8px]'
|
||||
href={video?.user?.url}
|
||||
target='_blank'
|
||||
rel='noreferrer'
|
||||
>
|
||||
Video by {video.user.name} on Pexels
|
||||
</a>
|
||||
</div>
|
||||
</div>)
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
style={
|
||||
contentPosition === 'background'
|
||||
? {
|
||||
backgroundImage: `${
|
||||
illustrationImage
|
||||
? `url(${illustrationImage.src?.original})`
|
||||
: 'linear-gradient(rgba(255, 255, 255, 0.5), rgba(255, 255, 255, 0.5))'
|
||||
}`,
|
||||
backgroundSize: 'cover',
|
||||
backgroundPosition: 'left center',
|
||||
backgroundRepeat: 'no-repeat',
|
||||
}
|
||||
: {}
|
||||
}
|
||||
>
|
||||
<Head>
|
||||
<title>{getPageTitle('IT Help Desk')}</title>
|
||||
<title>{getPageTitle('Starter Page')}</title>
|
||||
</Head>
|
||||
|
||||
<SectionFullScreen bg="violet">
|
||||
<div className="min-h-screen bg-slate-950 text-white">
|
||||
<header className="border-b border-white/10 bg-slate-950/85 backdrop-blur">
|
||||
<div className="mx-auto flex w-full max-w-7xl items-center justify-between px-6 py-5">
|
||||
<div>
|
||||
<p className="text-xs uppercase tracking-[0.35em] text-cyan-300">
|
||||
IT Help Desk & Asset Operations
|
||||
</p>
|
||||
<h1 className="mt-2 text-2xl font-semibold text-white">
|
||||
Resolve issues faster. Track every device with confidence.
|
||||
</h1>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<BaseButton href="/login" label="Login" color="whiteDark" outline />
|
||||
<BaseButton href="/login" label="Admin interface" color="info" />
|
||||
</div>
|
||||
<SectionFullScreen bg='violet'>
|
||||
<div
|
||||
className={`flex ${
|
||||
contentPosition === 'right' ? 'flex-row-reverse' : 'flex-row'
|
||||
} min-h-screen w-full`}
|
||||
>
|
||||
{contentType === 'image' && contentPosition !== 'background'
|
||||
? imageBlock(illustrationImage)
|
||||
: null}
|
||||
{contentType === 'video' && contentPosition !== 'background'
|
||||
? videoBlock(illustrationVideo)
|
||||
: null}
|
||||
<div className='flex items-center justify-center flex-col space-y-4 w-full lg:w-full'>
|
||||
<CardBox className='w-full md:w-3/5 lg:w-2/3'>
|
||||
<CardBoxComponentTitle title="Welcome to your IT Help Desk & Assets app!"/>
|
||||
|
||||
<div className="space-y-3">
|
||||
<p className='text-center text-gray-500'>This is a React.js/Node.js app generated by the <a className={`${textColor}`} href="https://flatlogic.com/generator">Flatlogic Web App Generator</a></p>
|
||||
<p className='text-center text-gray-500'>For guides and documentation please check
|
||||
your local README.md and the <a className={`${textColor}`} href="https://flatlogic.com/documentation">Flatlogic documentation</a></p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main>
|
||||
<section className="relative overflow-hidden">
|
||||
<div className="absolute inset-0 bg-[radial-gradient(circle_at_top_left,_rgba(34,211,238,0.22),_transparent_36%),radial-gradient(circle_at_bottom_right,_rgba(59,130,246,0.2),_transparent_30%)]" />
|
||||
<div className="relative mx-auto grid w-full max-w-7xl gap-8 px-6 py-20 lg:grid-cols-[1.35fr,0.9fr] lg:items-center">
|
||||
<div>
|
||||
<span className="inline-flex items-center rounded-full border border-cyan-400/30 bg-cyan-400/10 px-4 py-1 text-sm font-medium text-cyan-200">
|
||||
Built for internal support teams and equipment stewardship
|
||||
</span>
|
||||
<h2 className="mt-6 max-w-3xl text-5xl font-semibold leading-tight text-white">
|
||||
A clean operations hub for ticket triage, recovery workflows, and searchable IT equipment history.
|
||||
</h2>
|
||||
<p className="mt-6 max-w-2xl text-lg leading-8 text-slate-300">
|
||||
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.
|
||||
</p>
|
||||
<BaseButtons>
|
||||
<BaseButton
|
||||
href='/login'
|
||||
label='Login'
|
||||
color='info'
|
||||
className='w-full'
|
||||
/>
|
||||
|
||||
<div className="mt-8 flex flex-wrap gap-3">
|
||||
<BaseButton href="/service-desk" label="Open service desk" color="info" />
|
||||
<BaseButton href="/tickets/tickets-list" label="Review ticket queue" color="whiteDark" outline />
|
||||
<BaseButton href="/assets/assets-list" label="Browse assets" color="whiteDark" outline />
|
||||
</div>
|
||||
|
||||
<ul className="mt-10 grid gap-3 text-sm text-slate-300 md:grid-cols-3">
|
||||
{operationalHighlights.map((highlight) => (
|
||||
<li
|
||||
key={highlight}
|
||||
className="rounded-2xl border border-white/10 bg-white/5 px-4 py-4 backdrop-blur"
|
||||
>
|
||||
{highlight}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<CardBox className="border-white/10 bg-white/5 shadow-2xl shadow-cyan-950/30 backdrop-blur">
|
||||
<div className="grid gap-5">
|
||||
<div className="rounded-2xl border border-cyan-400/20 bg-slate-900/70 p-5">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="rounded-2xl bg-cyan-400/10 p-3 text-cyan-300">
|
||||
<BaseIcon path={icon.mdiChartTimelineVariant} size={28} />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm uppercase tracking-[0.28em] text-slate-400">First delivery</p>
|
||||
<h3 className="text-2xl font-semibold text-white">Service Desk workspace</h3>
|
||||
</div>
|
||||
</div>
|
||||
<p className="mt-4 text-sm leading-7 text-slate-300">
|
||||
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.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-3">
|
||||
{workflowCards.map((card) => (
|
||||
<div key={card.title} className="rounded-2xl border border-white/10 bg-slate-900/65 p-5">
|
||||
<div className="inline-flex rounded-xl bg-white/5 p-3 text-cyan-300">
|
||||
<BaseIcon path={card.icon} size={24} />
|
||||
</div>
|
||||
<h4 className="mt-4 text-lg font-semibold text-white">{card.title}</h4>
|
||||
<p className="mt-2 text-sm leading-6 text-slate-300">{card.description}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</CardBox>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="border-y border-white/10 bg-slate-900/70">
|
||||
<div className="mx-auto grid w-full max-w-7xl gap-6 px-6 py-16 md:grid-cols-3">
|
||||
<CardBox className="border-white/10 bg-white/5 backdrop-blur">
|
||||
<p className="text-sm uppercase tracking-[0.28em] text-cyan-300">01</p>
|
||||
<h3 className="mt-3 text-2xl font-semibold">Submit and acknowledge</h3>
|
||||
<p className="mt-3 text-sm leading-7 text-slate-300">
|
||||
Capture enough context up front so the IT team can act without chasing missing details.
|
||||
</p>
|
||||
</CardBox>
|
||||
<CardBox className="border-white/10 bg-white/5 backdrop-blur">
|
||||
<p className="text-sm uppercase tracking-[0.28em] text-cyan-300">02</p>
|
||||
<h3 className="mt-3 text-2xl font-semibold">Triage against assets</h3>
|
||||
<p className="mt-3 text-sm leading-7 text-slate-300">
|
||||
Connect issues to specific devices, locations, and recovery stages to reduce ambiguity.
|
||||
</p>
|
||||
</CardBox>
|
||||
<CardBox className="border-white/10 bg-white/5 backdrop-blur">
|
||||
<p className="text-sm uppercase tracking-[0.28em] text-cyan-300">03</p>
|
||||
<h3 className="mt-3 text-2xl font-semibold">Keep searchable history</h3>
|
||||
<p className="mt-3 text-sm leading-7 text-slate-300">
|
||||
Maintain a dependable record of actions taken, equipment movement, and support outcomes.
|
||||
</p>
|
||||
</CardBox>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<footer className="border-t border-white/10 bg-slate-950">
|
||||
<div className="mx-auto flex w-full max-w-7xl flex-col gap-4 px-6 py-8 text-sm text-slate-400 md:flex-row md:items-center md:justify-between">
|
||||
<p>© 2026 IT Help Desk & Assets. Designed for fast internal support operations.</p>
|
||||
<div className="flex flex-wrap items-center gap-5">
|
||||
<Link className="transition hover:text-white" href="/login">
|
||||
Admin login
|
||||
</Link>
|
||||
<Link className="transition hover:text-white" href="/privacy-policy">
|
||||
Privacy Policy
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
</BaseButtons>
|
||||
</CardBox>
|
||||
</div>
|
||||
</div>
|
||||
</SectionFullScreen>
|
||||
</>
|
||||
<div className='bg-black text-white flex flex-col text-center justify-center md:flex-row'>
|
||||
<p className='py-6 text-sm'>© 2026 <span>{title}</span>. All rights reserved</p>
|
||||
<Link className='py-6 ml-4 text-sm' href='/privacy-policy/'>
|
||||
Privacy Policy
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Starter.getLayout = function getLayout(page: ReactElement) {
|
||||
return <LayoutGuest>{page}</LayoutGuest>;
|
||||
};
|
||||
|
||||
|
||||
@ -1,739 +0,0 @@
|
||||
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<string, string> = {
|
||||
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<string, string> = {
|
||||
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<string, string> = {
|
||||
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<WorkspaceState>(initialWorkspaceState);
|
||||
const [isWorkspaceLoading, setIsWorkspaceLoading] = useState(true);
|
||||
const [workspaceError, setWorkspaceError] = useState('');
|
||||
const [submissionError, setSubmissionError] = useState('');
|
||||
const [createdTicket, setCreatedTicket] = useState<CreatedTicket | null>(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<Record<keyof TicketFormValues, string>> = {};
|
||||
|
||||
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<TicketFormValues>,
|
||||
) => {
|
||||
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 (
|
||||
<>
|
||||
<Head>
|
||||
<title>{getPageTitle('Service Desk')}</title>
|
||||
</Head>
|
||||
|
||||
<SectionMain>
|
||||
<SectionTitleLineWithButton
|
||||
icon={icon.mdiChartTimelineVariant}
|
||||
title="Service Desk"
|
||||
main
|
||||
>
|
||||
<BaseButtons>
|
||||
{canReadTickets && <BaseButton href="/tickets/tickets-list" label="Ticket queue" color="info" />}
|
||||
{canReadAssets && <BaseButton href="/assets/assets-list" label="Assets" color="whiteDark" outline />}
|
||||
</BaseButtons>
|
||||
</SectionTitleLineWithButton>
|
||||
|
||||
<div className="grid gap-6 xl:grid-cols-[1.3fr,0.7fr]">
|
||||
<CardBox className="border-0 bg-gradient-to-br from-slate-950 via-slate-900 to-cyan-700 text-white shadow-2xl shadow-cyan-900/20">
|
||||
<div className="grid gap-8 lg:grid-cols-[1.2fr,0.9fr] lg:items-start">
|
||||
<div>
|
||||
<span className="inline-flex rounded-full border border-white/15 bg-white/10 px-4 py-1 text-sm font-medium text-cyan-100">
|
||||
IT operations command center
|
||||
</span>
|
||||
<h1 className="mt-5 text-4xl font-semibold leading-tight">
|
||||
Submit issues, review the live queue, and keep recovered equipment moving.
|
||||
</h1>
|
||||
<p className="mt-4 max-w-2xl text-base leading-8 text-slate-200">
|
||||
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.
|
||||
</p>
|
||||
<div className="mt-8 flex flex-wrap gap-3">
|
||||
{canCreateTickets && <BaseButton href="#ticket-intake" label="Log a ticket" color="info" />}
|
||||
{canCreateAssets && <BaseButton href="/assets/assets-new" label="Register asset" color="whiteDark" outline />}
|
||||
{canReadRepairs && <BaseButton href="/repairs/repairs-list" label="Repair log" color="whiteDark" outline />}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
{queueCards.map((card) => (
|
||||
<div key={card.title} className={`rounded-2xl bg-gradient-to-br p-[1px] ${card.tone}`}>
|
||||
<div className="h-full rounded-2xl bg-slate-950/75 p-5 backdrop-blur">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<p className="text-sm text-slate-300">{card.title}</p>
|
||||
<p className="mt-2 text-3xl font-semibold text-white">{card.value}</p>
|
||||
</div>
|
||||
<div className="rounded-2xl bg-white/10 p-3">
|
||||
<BaseIcon path={card.icon} size={24} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</CardBox>
|
||||
|
||||
<CardBox>
|
||||
<div className="space-y-5">
|
||||
<div>
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.3em] text-slate-500">Quick actions</p>
|
||||
<h2 className="mt-2 text-2xl font-semibold text-slate-900 dark:text-white">Move between help desk workflows</h2>
|
||||
</div>
|
||||
<div className="grid gap-3">
|
||||
{canReadTickets && (
|
||||
<Link
|
||||
href="/tickets/tickets-list"
|
||||
className="rounded-2xl border border-slate-200 px-4 py-4 transition hover:border-blue-300 hover:bg-blue-50 dark:border-dark-700 dark:hover:bg-dark-800"
|
||||
>
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<p className="font-semibold">Ticket queue</p>
|
||||
<p className="mt-1 text-sm text-slate-500">Review all requests, priorities, and assignees.</p>
|
||||
</div>
|
||||
<BaseIcon path={icon.mdiArrowRight} size={20} className="text-slate-400" />
|
||||
</div>
|
||||
</Link>
|
||||
)}
|
||||
{canCreateAssets && (
|
||||
<Link
|
||||
href="/assets/assets-new"
|
||||
className="rounded-2xl border border-slate-200 px-4 py-4 transition hover:border-emerald-300 hover:bg-emerald-50 dark:border-dark-700 dark:hover:bg-dark-800"
|
||||
>
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<p className="font-semibold">Register equipment</p>
|
||||
<p className="mt-1 text-sm text-slate-500">Add a new or recovered device into inventory.</p>
|
||||
</div>
|
||||
<BaseIcon path={icon.mdiArrowRight} size={20} className="text-slate-400" />
|
||||
</div>
|
||||
</Link>
|
||||
)}
|
||||
{canReadTestRuns && (
|
||||
<Link
|
||||
href="/test_runs/test_runs-list"
|
||||
className="rounded-2xl border border-slate-200 px-4 py-4 transition hover:border-cyan-300 hover:bg-cyan-50 dark:border-dark-700 dark:hover:bg-dark-800"
|
||||
>
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<p className="font-semibold">Testing records</p>
|
||||
<p className="mt-1 text-sm text-slate-500">See validation steps for repaired or recovered assets.</p>
|
||||
</div>
|
||||
<BaseIcon path={icon.mdiArrowRight} size={20} className="text-slate-400" />
|
||||
</div>
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CardBox>
|
||||
</div>
|
||||
|
||||
{workspaceError && (
|
||||
<CardBox className="mt-6 border border-amber-200 bg-amber-50 text-amber-900">
|
||||
<div className="flex items-start gap-3">
|
||||
<BaseIcon path={icon.mdiAlertCircleOutline} size={22} className="mt-0.5 text-amber-600" />
|
||||
<p className="text-sm leading-6">{workspaceError}</p>
|
||||
</div>
|
||||
</CardBox>
|
||||
)}
|
||||
|
||||
<div className="mt-6 grid gap-6 xl:grid-cols-[1.1fr,0.9fr]">
|
||||
<CardBox id="ticket-intake">
|
||||
<div className="mb-6 flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.3em] text-slate-500">Create</p>
|
||||
<h2 className="mt-2 text-2xl font-semibold text-slate-900 dark:text-white">Log a new support ticket</h2>
|
||||
<p className="mt-2 text-sm leading-6 text-slate-500">
|
||||
Capture just enough detail for triage now. The full record can be expanded later with comments, work logs, and repairs.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!canCreateTickets ? (
|
||||
<div className="rounded-2xl border border-dashed border-slate-300 bg-slate-50 px-5 py-8 text-sm leading-7 text-slate-500 dark:border-dark-700 dark:bg-dark-800 dark:text-slate-300">
|
||||
Your role currently does not have permission to create tickets. Ask an administrator to grant ticket intake access if you
|
||||
need it.
|
||||
</div>
|
||||
) : (
|
||||
<Formik initialValues={buildInitialValues()} validate={validateTicket} onSubmit={handleSubmit}>
|
||||
{({ errors, isSubmitting }) => (
|
||||
<Form>
|
||||
<FormField label="Reference" help="A tracking reference is generated automatically for the new request.">
|
||||
<Field name="ticket_number" readOnly />
|
||||
</FormField>
|
||||
|
||||
<FormField label="Issue summary" help="Keep it short and clear so the queue stays readable.">
|
||||
<Field name="subject" placeholder="Example: Laptop will not connect to the office Wi-Fi" />
|
||||
</FormField>
|
||||
{errors.subject && <p className="-mt-4 mb-6 text-sm text-rose-600">{errors.subject}</p>}
|
||||
|
||||
<FormField label="Description" hasTextareaHeight help="Include symptoms, what you expected, and any troubleshooting already tried.">
|
||||
<Field as="textarea" name="description" placeholder="Describe the issue, error messages, and urgency." />
|
||||
</FormField>
|
||||
{errors.description && <p className="-mt-4 mb-6 text-sm text-rose-600">{errors.description}</p>}
|
||||
|
||||
<div className="grid gap-6 md:grid-cols-2">
|
||||
<FormField label="Category">
|
||||
<Field as="select" name="category">
|
||||
{ticketCategories.map((option) => (
|
||||
<option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</Field>
|
||||
</FormField>
|
||||
|
||||
<FormField label="Priority">
|
||||
<Field as="select" name="priority">
|
||||
{ticketPriorities.map((option) => (
|
||||
<option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</Field>
|
||||
</FormField>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6 md:grid-cols-2">
|
||||
{canReadLocations ? (
|
||||
<FormField label="Location" help="Optional: route the issue faster by attaching a site or office.">
|
||||
<Field name="location" component={SelectField} options={[]} itemRef="locations" />
|
||||
</FormField>
|
||||
) : (
|
||||
<div />
|
||||
)}
|
||||
|
||||
{canReadAssets ? (
|
||||
<FormField label="Related asset" help="Optional: connect this ticket directly to a tracked device.">
|
||||
<Field name="related_asset" component={SelectField} options={[]} itemRef="assets" />
|
||||
</FormField>
|
||||
) : (
|
||||
<div />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<FormField label="Requires on-site support" help="Turn this on for desk-side visits, swaps, or collection of faulty equipment.">
|
||||
<Field name="requires_on_site" component={SwitchField} />
|
||||
</FormField>
|
||||
|
||||
{submissionError && (
|
||||
<div className="mb-6 rounded-2xl border border-rose-200 bg-rose-50 px-4 py-3 text-sm text-rose-700">
|
||||
{submissionError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<BaseDivider />
|
||||
<BaseButtons>
|
||||
<BaseButton
|
||||
type="submit"
|
||||
color="info"
|
||||
label={isSubmitting || isCreatingTicket ? 'Submitting...' : 'Submit ticket'}
|
||||
disabled={isSubmitting || isCreatingTicket || !currentUser?.id}
|
||||
/>
|
||||
<BaseButton type="reset" color="whiteDark" outline label="Reset" />
|
||||
</BaseButtons>
|
||||
</Form>
|
||||
)}
|
||||
</Formik>
|
||||
)}
|
||||
</CardBox>
|
||||
|
||||
<div className="grid gap-6">
|
||||
{createdTicket && (
|
||||
<CardBox className="border border-emerald-200 bg-emerald-50">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="rounded-2xl bg-emerald-100 p-3 text-emerald-700">
|
||||
<BaseIcon path={icon.mdiCheckCircleOutline} size={24} />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.3em] text-emerald-700">Ticket received</p>
|
||||
<h3 className="mt-2 text-xl font-semibold text-emerald-950">
|
||||
{createdTicket.ticket_number || 'New help desk ticket'} created successfully
|
||||
</h3>
|
||||
<p className="mt-2 text-sm leading-6 text-emerald-900">
|
||||
{createdTicket.subject || 'Your ticket was submitted.'}
|
||||
</p>
|
||||
<div className="mt-4 flex flex-wrap gap-2">
|
||||
{createdTicket.status && (
|
||||
<span className={`rounded-full px-3 py-1 text-xs font-semibold ${getTicketStatusClassName(createdTicket.status)}`}>
|
||||
{formatLabel(createdTicket.status)}
|
||||
</span>
|
||||
)}
|
||||
{createdTicket.priority && (
|
||||
<span className={`rounded-full px-3 py-1 text-xs font-semibold ${getPriorityClassName(createdTicket.priority)}`}>
|
||||
{formatLabel(createdTicket.priority)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-5 flex flex-wrap gap-3">
|
||||
{canReadTickets && (
|
||||
<BaseButton href={`/tickets/tickets-view/?id=${createdTicket.id}`} label="Open ticket" color="info" />
|
||||
)}
|
||||
{canReadTickets && (
|
||||
<BaseButton href="/tickets/tickets-list" label="View queue" color="whiteDark" outline />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardBox>
|
||||
)}
|
||||
|
||||
<CardBox>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.3em] text-slate-500">Tickets</p>
|
||||
<h2 className="mt-2 text-2xl font-semibold text-slate-900 dark:text-white">Recent queue activity</h2>
|
||||
</div>
|
||||
{canReadTickets && <BaseButton href="/tickets/tickets-list" label="All tickets" color="whiteDark" outline small />}
|
||||
</div>
|
||||
|
||||
{isWorkspaceLoading ? (
|
||||
<LoadingSpinner />
|
||||
) : !canReadTickets ? (
|
||||
<div className="mt-6 rounded-2xl border border-dashed border-slate-300 bg-slate-50 px-5 py-8 text-sm leading-7 text-slate-500 dark:border-dark-700 dark:bg-dark-800 dark:text-slate-300">
|
||||
Ticket queue visibility is limited for your role. You can still use this page for ticket intake if creation rights are enabled.
|
||||
</div>
|
||||
) : workspace.recentTickets.length ? (
|
||||
<div className="mt-6 space-y-3">
|
||||
{workspace.recentTickets.map((ticket) => (
|
||||
<Link
|
||||
key={ticket.id}
|
||||
href={`/tickets/tickets-view/?id=${ticket.id}`}
|
||||
className="block rounded-2xl border border-slate-200 px-4 py-4 transition hover:border-blue-300 hover:bg-blue-50 dark:border-dark-700 dark:hover:bg-dark-800"
|
||||
>
|
||||
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-semibold text-slate-900 dark:text-white">
|
||||
{ticket.subject || ticket.ticket_number || 'Untitled ticket'}
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-slate-500">
|
||||
{ticket.ticket_number || 'No reference'} • Updated {formatDateTime(ticket.createdAt)}
|
||||
</p>
|
||||
{ticket.assignee?.label && (
|
||||
<p className="mt-1 text-xs text-slate-500">Assigned to {ticket.assignee.label}</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<span className={`rounded-full px-3 py-1 text-xs font-semibold ${getTicketStatusClassName(ticket.status)}`}>
|
||||
{formatLabel(ticket.status)}
|
||||
</span>
|
||||
<span className={`rounded-full px-3 py-1 text-xs font-semibold ${getPriorityClassName(ticket.priority)}`}>
|
||||
{formatLabel(ticket.priority)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="mt-6 rounded-2xl border border-dashed border-slate-300 bg-slate-50 px-5 py-8 text-sm leading-7 text-slate-500 dark:border-dark-700 dark:bg-dark-800 dark:text-slate-300">
|
||||
No tickets yet. Submit the first request from this page to start the queue.
|
||||
</div>
|
||||
)}
|
||||
</CardBox>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<CardBox className="mt-6">
|
||||
<div className="flex flex-col gap-3 md:flex-row md:items-end md:justify-between">
|
||||
<div>
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.3em] text-slate-500">Assets</p>
|
||||
<h2 className="mt-2 text-2xl font-semibold text-slate-900 dark:text-white">Recovery and repair pipeline</h2>
|
||||
<p className="mt-2 text-sm leading-6 text-slate-500">
|
||||
Keep the critical asset states visible while tickets are being processed.
|
||||
</p>
|
||||
</div>
|
||||
{canReadAssets && <BaseButton href="/assets/assets-list" label="Asset register" color="whiteDark" outline />}
|
||||
</div>
|
||||
|
||||
{isWorkspaceLoading ? (
|
||||
<LoadingSpinner />
|
||||
) : !canReadAssets ? (
|
||||
<div className="mt-6 rounded-2xl border border-dashed border-slate-300 bg-slate-50 px-5 py-8 text-sm leading-7 text-slate-500 dark:border-dark-700 dark:bg-dark-800 dark:text-slate-300">
|
||||
Asset lifecycle data is hidden for your role. Grant asset read permission to expose recovered, testing, and repair records here.
|
||||
</div>
|
||||
) : (
|
||||
<div className="mt-6 grid gap-4 xl:grid-cols-3">
|
||||
{assetLaneState.map((lane) => (
|
||||
<div key={lane.key} className="rounded-3xl border border-slate-200 bg-slate-50 p-5 dark:border-dark-700 dark:bg-dark-800/70">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-slate-900 dark:text-white">{lane.title}</h3>
|
||||
<p className="mt-1 text-sm text-slate-500">{lane.data.count} tracked assets</p>
|
||||
</div>
|
||||
<span className={`rounded-full border px-3 py-1 text-xs font-semibold ${lane.tone}`}>{lane.data.count}</span>
|
||||
</div>
|
||||
|
||||
{lane.data.rows.length ? (
|
||||
<div className="mt-4 space-y-3">
|
||||
{lane.data.rows.map((asset) => (
|
||||
<Link
|
||||
key={asset.id}
|
||||
href={`/assets/assets-view/?id=${asset.id}`}
|
||||
className="block rounded-2xl border border-white bg-white px-4 py-4 transition hover:border-blue-300 hover:bg-blue-50 dark:border-dark-700 dark:bg-dark-900 dark:hover:bg-dark-700"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<p className="truncate text-sm font-semibold text-slate-900 dark:text-white">
|
||||
{asset.name || asset.asset_tag || 'Untitled asset'}
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-slate-500">
|
||||
{asset.asset_tag || 'No asset tag'} • {formatLabel(asset.asset_type)}
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-slate-500">Updated {formatDateTime(asset.updatedAt)}</p>
|
||||
</div>
|
||||
<span className={`rounded-full px-3 py-1 text-xs font-semibold ${getAssetStatusClassName(asset.lifecycle_status)}`}>
|
||||
{formatLabel(asset.lifecycle_status)}
|
||||
</span>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="mt-4 rounded-2xl border border-dashed border-slate-300 bg-white px-4 py-6 text-sm leading-6 text-slate-500 dark:border-dark-700 dark:bg-dark-900 dark:text-slate-300">
|
||||
No assets are currently in this stage.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardBox>
|
||||
</SectionMain>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
ServiceDeskPage.getLayout = function getLayout(page: ReactElement) {
|
||||
return <LayoutAuthenticated>{page}</LayoutAuthenticated>;
|
||||
};
|
||||
Loading…
x
Reference in New Issue
Block a user