Autosave: 20260413-025244
This commit is contained in:
parent
6b8c666c65
commit
c9732c5db8
@ -19,7 +19,7 @@ export default function AsideMenu({
|
||||
<>
|
||||
<AsideMenuLayer
|
||||
menu={props.menu}
|
||||
className={`${isAsideMobileExpanded ? 'left-0' : '-left-60 lg:left-0'} ${
|
||||
className={`${isAsideMobileExpanded ? 'left-0' : '-left-72 lg:left-0'} ${
|
||||
!isAsideLgActive ? 'lg:hidden xl:flex' : ''
|
||||
}`}
|
||||
onAsideLgCloseClick={props.onAsideLgClose}
|
||||
|
||||
@ -46,9 +46,10 @@ const AsideMenuItem = ({ item, isDropdownList = false }: Props) => {
|
||||
<BaseIcon path={item.icon} className={`flex-none mx-3 ${activeClassAddon}`} size="18" />
|
||||
)}
|
||||
<span
|
||||
className={`grow text-ellipsis line-clamp-1 ${
|
||||
className={`min-w-0 grow break-words whitespace-normal leading-5 line-clamp-2 ${
|
||||
item.menu ? '' : 'pr-12'
|
||||
} ${activeClassAddon}`}
|
||||
title={item.label}
|
||||
>
|
||||
{item.label}
|
||||
</span>
|
||||
@ -63,7 +64,7 @@ const AsideMenuItem = ({ item, isDropdownList = false }: Props) => {
|
||||
)
|
||||
|
||||
const componentClass = [
|
||||
'flex cursor-pointer py-1.5 ',
|
||||
'flex items-start cursor-pointer py-1.5 min-h-[3rem]',
|
||||
isDropdownList ? 'px-6 text-sm' : '',
|
||||
item.color
|
||||
? getButtonColor(item.color, false, true)
|
||||
@ -77,12 +78,12 @@ const AsideMenuItem = ({ item, isDropdownList = false }: Props) => {
|
||||
<li className={'px-3 py-1.5'}>
|
||||
{item.withDevider && <hr className={`${borders} mb-3`} />}
|
||||
{item.href && (
|
||||
<Link href={item.href} target={item.target} className={componentClass}>
|
||||
<Link href={item.href} target={item.target} className={componentClass} title={item.label}>
|
||||
{asideMenuItemInnerContents}
|
||||
</Link>
|
||||
)}
|
||||
{!item.href && (
|
||||
<div className={componentClass} onClick={() => setIsDropdownActive(!isDropdownActive)}>
|
||||
<div className={componentClass} onClick={() => setIsDropdownActive(!isDropdownActive)} title={item.label}>
|
||||
{asideMenuItemInnerContents}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@ -29,7 +29,7 @@ export default function AsideMenuLayer({ menu, className = '', ...props }: Props
|
||||
return (
|
||||
<aside
|
||||
id='asideMenu'
|
||||
className={`${className} zzz lg:py-2 lg:pl-2 w-60 fixed flex z-40 top-0 h-screen transition-position overflow-hidden`}
|
||||
className={`${className} zzz lg:py-2 lg:pl-2 w-72 fixed flex z-40 top-0 h-screen transition-position overflow-hidden`}
|
||||
>
|
||||
<div
|
||||
className={`flex-1 flex flex-col overflow-hidden dark:bg-dark-900 ${asideStyle} ${corners}`}
|
||||
|
||||
196
frontend/src/components/JobLogPayPreview.tsx
Normal file
196
frontend/src/components/JobLogPayPreview.tsx
Normal file
@ -0,0 +1,196 @@
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import axios from 'axios';
|
||||
import { useFormikContext } from 'formik';
|
||||
import CardBox from './CardBox';
|
||||
import {
|
||||
calculateJobLogPayPreview,
|
||||
formatCurrency,
|
||||
formatDecimal,
|
||||
hasPayTypeDetails,
|
||||
hasWorkersCompDetails,
|
||||
resolveEntityId,
|
||||
} from '../helpers/jobLogPayPreview';
|
||||
|
||||
type Props = {
|
||||
payTypeOptions?: any[];
|
||||
};
|
||||
|
||||
const findById = (items: any[] = [], id: string | null) => items.find((item) => item?.id === id) || null;
|
||||
|
||||
export default function JobLogPayPreview({ payTypeOptions = [] }: Props) {
|
||||
const { values } = useFormikContext<any>();
|
||||
const [payTypeDetails, setPayTypeDetails] = useState<any>(null);
|
||||
const [workersCompClassDetails, setWorkersCompClassDetails] = useState<any>(null);
|
||||
|
||||
const selectedPayTypeId = resolveEntityId(values?.pay_type);
|
||||
const selectedWorkersCompClassId = resolveEntityId(values?.workersCompClass);
|
||||
|
||||
const selectedPayTypeFromOptions = useMemo(
|
||||
() => findById(payTypeOptions, selectedPayTypeId),
|
||||
[payTypeOptions, selectedPayTypeId],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
|
||||
if (hasPayTypeDetails(values?.pay_type)) {
|
||||
setPayTypeDetails(values.pay_type);
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}
|
||||
|
||||
if (selectedPayTypeFromOptions) {
|
||||
setPayTypeDetails(selectedPayTypeFromOptions);
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}
|
||||
|
||||
if (!selectedPayTypeId) {
|
||||
setPayTypeDetails(null);
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}
|
||||
|
||||
axios
|
||||
.get(`/pay_types/${selectedPayTypeId}`)
|
||||
.then(({ data }) => {
|
||||
if (active) {
|
||||
setPayTypeDetails(data);
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Failed to fetch pay type for job log preview:', error);
|
||||
if (active) {
|
||||
setPayTypeDetails(null);
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, [selectedPayTypeFromOptions, selectedPayTypeId, values?.pay_type]);
|
||||
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
|
||||
if (hasWorkersCompDetails(values?.workersCompClass)) {
|
||||
setWorkersCompClassDetails(values.workersCompClass);
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}
|
||||
|
||||
if (!selectedWorkersCompClassId) {
|
||||
setWorkersCompClassDetails(null);
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}
|
||||
|
||||
axios
|
||||
.get(`/workers_comp_classes/${selectedWorkersCompClassId}`)
|
||||
.then(({ data }) => {
|
||||
if (active) {
|
||||
setWorkersCompClassDetails(data);
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Failed to fetch workers comp class for job log preview:', error);
|
||||
if (active) {
|
||||
setWorkersCompClassDetails(null);
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, [selectedWorkersCompClassId, values?.workersCompClass]);
|
||||
|
||||
const preview = useMemo(
|
||||
() =>
|
||||
calculateJobLogPayPreview({
|
||||
payType: payTypeDetails,
|
||||
workersCompClass: workersCompClassDetails,
|
||||
hoursConducted: values?.hours_conducted,
|
||||
clientPaid: values?.client_paid,
|
||||
}),
|
||||
[payTypeDetails, workersCompClassDetails, values?.client_paid, values?.hours_conducted],
|
||||
);
|
||||
|
||||
return (
|
||||
<CardBox className="mb-6 border border-blue-100 bg-blue-50/70 dark:border-dark-700 dark:bg-dark-800">
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-col gap-1 md:flex-row md:items-end md:justify-between">
|
||||
<div>
|
||||
<p className="text-xs font-semibold uppercase tracking-wide text-blue-700 dark:text-blue-300">
|
||||
Earnings Preview
|
||||
</p>
|
||||
<h3 className="text-lg font-bold">Live estimate for this job</h3>
|
||||
</div>
|
||||
<div className="text-left md:text-right">
|
||||
<p className="text-xs uppercase text-gray-500 dark:text-gray-400">Estimated Job Pay</p>
|
||||
<p className="text-2xl font-bold text-green-700 dark:text-green-400">
|
||||
{formatCurrency(preview.estimatedPay)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-3 md:grid-cols-2 xl:grid-cols-4">
|
||||
<div className="rounded-lg border border-white/80 bg-white/80 p-3 dark:border-dark-700 dark:bg-dark-900">
|
||||
<p className="text-xs uppercase text-gray-500 dark:text-gray-400">Pay Type</p>
|
||||
<p className="font-semibold capitalize">{preview.payTypeName}</p>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border border-white/80 bg-white/80 p-3 dark:border-dark-700 dark:bg-dark-900">
|
||||
<p className="text-xs uppercase text-gray-500 dark:text-gray-400">Pay Method</p>
|
||||
<p className="font-semibold capitalize">{preview.payMethod || 'Not selected'}</p>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border border-white/80 bg-white/80 p-3 dark:border-dark-700 dark:bg-dark-900">
|
||||
<p className="text-xs uppercase text-gray-500 dark:text-gray-400">
|
||||
{preview.payMethod === 'commission' ? 'Client Paid' : 'Hours'}
|
||||
</p>
|
||||
<p className="font-semibold">
|
||||
{preview.payMethod === 'commission'
|
||||
? formatCurrency(preview.clientAmount)
|
||||
: formatDecimal(preview.hours)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border border-white/80 bg-white/80 p-3 dark:border-dark-700 dark:bg-dark-900">
|
||||
<p className="text-xs uppercase text-gray-500 dark:text-gray-400">Rate</p>
|
||||
<p className="font-semibold">
|
||||
{preview.payMethod === 'commission'
|
||||
? `${formatDecimal(preview.commissionRate)}%`
|
||||
: formatCurrency(preview.hourlyRate)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border border-dashed border-blue-200 bg-white/80 p-3 dark:border-dark-700 dark:bg-dark-900">
|
||||
<p className="text-xs uppercase text-gray-500 dark:text-gray-400">Calculation</p>
|
||||
<p className="font-medium">
|
||||
{preview.formulaLabel}
|
||||
{preview.hasEstimate ? ` = ${formatCurrency(preview.estimatedPay)}` : ''}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{preview.workersCompName && (
|
||||
<div className="rounded-lg border border-white/80 bg-white/80 p-3 dark:border-dark-700 dark:bg-dark-900">
|
||||
<p className="text-xs uppercase text-gray-500 dark:text-gray-400">Workers Comp Estimate</p>
|
||||
<p className="font-semibold">
|
||||
{preview.workersCompName} · {formatDecimal(preview.workersCompPercentage)}% ·{' '}
|
||||
{formatCurrency(preview.workersCompAmount)}
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
This is shown for visibility only and does not reduce the employee pay preview.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardBox>
|
||||
);
|
||||
}
|
||||
@ -9,6 +9,7 @@ import LoadingSpinner from "../LoadingSpinner";
|
||||
import Link from 'next/link';
|
||||
|
||||
import {hasPermission} from "../../helpers/userPermissions";
|
||||
import { calculateJobLogPayPreview, formatCurrency } from '../../helpers/jobLogPayPreview';
|
||||
|
||||
|
||||
type Props = {
|
||||
@ -47,7 +48,15 @@ const CardJob_logs = ({
|
||||
role='list'
|
||||
className='grid grid-cols-1 gap-x-6 gap-y-8 lg:grid-cols-3 2xl:grid-cols-4 xl:gap-x-8'
|
||||
>
|
||||
{!loading && job_logs.map((item, index) => (
|
||||
{!loading && job_logs.map((item, index) => {
|
||||
const payPreview = calculateJobLogPayPreview({
|
||||
payType: item.pay_type,
|
||||
workersCompClass: item.workersCompClass,
|
||||
hoursConducted: item.hours_conducted,
|
||||
clientPaid: item.client_paid,
|
||||
});
|
||||
|
||||
return (
|
||||
<li
|
||||
key={item.id}
|
||||
className={`overflow-hidden ${corners !== 'rounded-full'? corners : 'rounded-3xl'} border ${focusRing} border-gray-200 dark:border-dark-700 ${
|
||||
@ -161,6 +170,18 @@ const CardJob_logs = ({
|
||||
|
||||
|
||||
|
||||
<div className='flex justify-between gap-x-4 py-3'>
|
||||
<dt className=' text-gray-500 dark:text-dark-600'>EmployeePay</dt>
|
||||
<dd className='flex items-start gap-x-2'>
|
||||
<div className='font-medium line-clamp-4'>
|
||||
{ formatCurrency(payPreview.estimatedPay) }
|
||||
</div>
|
||||
</dd>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
|
||||
<div className='flex justify-between gap-x-4 py-3'>
|
||||
<dt className=' text-gray-500 dark:text-dark-600'>Vehicle</dt>
|
||||
<dd className='flex items-start gap-x-2'>
|
||||
@ -234,7 +255,8 @@ const CardJob_logs = ({
|
||||
|
||||
</dl>
|
||||
</li>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
{!loading && job_logs.length === 0 && (
|
||||
<div className='col-span-full flex items-center justify-center h-40'>
|
||||
<p className=''>No data to display</p>
|
||||
|
||||
@ -10,6 +10,7 @@ import LoadingSpinner from "../LoadingSpinner";
|
||||
import Link from 'next/link';
|
||||
|
||||
import {hasPermission} from "../../helpers/userPermissions";
|
||||
import { calculateJobLogPayPreview, formatCurrency } from '../../helpers/jobLogPayPreview';
|
||||
|
||||
|
||||
type Props = {
|
||||
@ -34,7 +35,15 @@ const ListJob_logs = ({ job_logs, loading, onDelete, currentPage, numPages, onPa
|
||||
<>
|
||||
<div className='relative overflow-x-auto p-4 space-y-4'>
|
||||
{loading && <LoadingSpinner />}
|
||||
{!loading && job_logs.map((item) => (
|
||||
{!loading && job_logs.map((item) => {
|
||||
const payPreview = calculateJobLogPayPreview({
|
||||
payType: item.pay_type,
|
||||
workersCompClass: item.workersCompClass,
|
||||
hoursConducted: item.hours_conducted,
|
||||
clientPaid: item.client_paid,
|
||||
});
|
||||
|
||||
return (
|
||||
<div key={item.id}>
|
||||
<CardBox hasTable isList className={'rounded shadow-none'}>
|
||||
<div className={`flex rounded dark:bg-dark-900 border border-stone-300 items-center overflow-hidden`}>
|
||||
@ -103,6 +112,14 @@ const ListJob_logs = ({ job_logs, loading, onDelete, currentPage, numPages, onPa
|
||||
|
||||
|
||||
|
||||
<div className={'flex-1 px-3'}>
|
||||
<p className={'text-xs text-gray-500 '}>EmployeePay</p>
|
||||
<p className={'line-clamp-2 font-semibold'}>{ formatCurrency(payPreview.estimatedPay) }</p>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
|
||||
<div className={'flex-1 px-3'}>
|
||||
<p className={'text-xs text-gray-500 '}>Vehicle</p>
|
||||
<p className={'line-clamp-2'}>{ dataFormatter.vehiclesOneListFormatter(item.vehicle) }</p>
|
||||
@ -163,7 +180,8 @@ const ListJob_logs = ({ job_logs, loading, onDelete, currentPage, numPages, onPa
|
||||
</div>
|
||||
</CardBox>
|
||||
</div>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
{!loading && job_logs.length === 0 && (
|
||||
<div className='col-span-full flex items-center justify-center h-40'>
|
||||
<p className=''>No data to display</p>
|
||||
|
||||
@ -14,6 +14,7 @@ import DataGridMultiSelect from "../DataGridMultiSelect";
|
||||
import ListActionsPopover from '../ListActionsPopover';
|
||||
|
||||
import {hasPermission} from "../../helpers/userPermissions";
|
||||
import { calculateJobLogPayPreview, formatCurrency } from '../../helpers/jobLogPayPreview';
|
||||
|
||||
type Params = (id: string) => void;
|
||||
|
||||
@ -172,6 +173,29 @@ export const loadColumns = async (
|
||||
|
||||
},
|
||||
|
||||
{
|
||||
field: 'employeePay',
|
||||
headerName: 'EmployeePay',
|
||||
flex: 1,
|
||||
minWidth: 140,
|
||||
filterable: false,
|
||||
headerClassName: 'datagrid--header',
|
||||
cellClassName: 'datagrid--cell',
|
||||
|
||||
editable: false,
|
||||
sortable: false,
|
||||
type: 'number',
|
||||
valueGetter: (params: GridValueGetterParams) =>
|
||||
calculateJobLogPayPreview({
|
||||
payType: params.row?.pay_type,
|
||||
workersCompClass: params.row?.workersCompClass,
|
||||
hoursConducted: params.row?.hours_conducted,
|
||||
clientPaid: params.row?.client_paid,
|
||||
}).estimatedPay,
|
||||
valueFormatter: ({ value }) => formatCurrency(value),
|
||||
|
||||
},
|
||||
|
||||
{
|
||||
field: 'vehicle',
|
||||
headerName: 'Vehicle',
|
||||
|
||||
104
frontend/src/helpers/jobLogPayPreview.ts
Normal file
104
frontend/src/helpers/jobLogPayPreview.ts
Normal file
@ -0,0 +1,104 @@
|
||||
const currencyFormatter = new Intl.NumberFormat('en-US', {
|
||||
style: 'currency',
|
||||
currency: 'USD',
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2,
|
||||
});
|
||||
|
||||
const numberFormatter = new Intl.NumberFormat('en-US', {
|
||||
minimumFractionDigits: 0,
|
||||
maximumFractionDigits: 2,
|
||||
});
|
||||
|
||||
export const coerceNumber = (value: unknown) => {
|
||||
if (value === null || value === undefined || value === '') {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const parsed = Number(value);
|
||||
|
||||
return Number.isFinite(parsed) ? parsed : 0;
|
||||
};
|
||||
|
||||
export const formatCurrency = (value: unknown) => currencyFormatter.format(coerceNumber(value));
|
||||
|
||||
export const formatDecimal = (value: unknown) => numberFormatter.format(coerceNumber(value));
|
||||
|
||||
export const resolveEntityId = (value: any) => {
|
||||
if (!value) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (typeof value === 'string') {
|
||||
return value;
|
||||
}
|
||||
|
||||
if (typeof value === 'object' && value.id) {
|
||||
return value.id;
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
export const hasPayTypeDetails = (payType: any) => {
|
||||
if (!payType || typeof payType !== 'object') {
|
||||
return false;
|
||||
}
|
||||
|
||||
return Boolean(payType.pay_method || payType.hourly_rate || payType.commission_rate);
|
||||
};
|
||||
|
||||
export const hasWorkersCompDetails = (workersCompClass: any) => {
|
||||
if (!workersCompClass || typeof workersCompClass !== 'object') {
|
||||
return false;
|
||||
}
|
||||
|
||||
return workersCompClass.percentage !== undefined && workersCompClass.percentage !== null;
|
||||
};
|
||||
|
||||
export const calculateJobLogPayPreview = ({
|
||||
payType,
|
||||
workersCompClass,
|
||||
hoursConducted,
|
||||
clientPaid,
|
||||
}: {
|
||||
payType?: any;
|
||||
workersCompClass?: any;
|
||||
hoursConducted?: unknown;
|
||||
clientPaid?: unknown;
|
||||
}) => {
|
||||
const payMethod = payType?.pay_method || null;
|
||||
const hours = coerceNumber(hoursConducted);
|
||||
const clientAmount = coerceNumber(clientPaid);
|
||||
const hourlyRate = coerceNumber(payType?.hourly_rate);
|
||||
const commissionRate = coerceNumber(payType?.commission_rate);
|
||||
const workersCompPercentage = coerceNumber(workersCompClass?.percentage);
|
||||
|
||||
let estimatedPay = 0;
|
||||
let formulaLabel = 'Select a pay type to preview earnings.';
|
||||
|
||||
if (payMethod === 'hourly') {
|
||||
estimatedPay = hours * hourlyRate;
|
||||
formulaLabel = `${formatDecimal(hours)} hours × ${formatCurrency(hourlyRate)}/hr`;
|
||||
} else if (payMethod === 'commission') {
|
||||
estimatedPay = clientAmount * (commissionRate / 100);
|
||||
formulaLabel = `${formatCurrency(clientAmount)} client paid × ${formatDecimal(commissionRate)}%`;
|
||||
}
|
||||
|
||||
const workersCompAmount = estimatedPay * (workersCompPercentage / 100);
|
||||
|
||||
return {
|
||||
payMethod,
|
||||
payTypeName: payType?.name || 'Not selected',
|
||||
hours,
|
||||
clientAmount,
|
||||
hourlyRate,
|
||||
commissionRate,
|
||||
estimatedPay,
|
||||
formulaLabel,
|
||||
workersCompName: workersCompClass?.name || null,
|
||||
workersCompPercentage,
|
||||
workersCompAmount,
|
||||
hasEstimate: payMethod === 'hourly' || payMethod === 'commission',
|
||||
};
|
||||
};
|
||||
@ -85,18 +85,18 @@ export default function LayoutAuthenticated({
|
||||
}, [router.events, dispatch])
|
||||
|
||||
|
||||
const layoutAsidePadding = 'xl:pl-60'
|
||||
const layoutAsidePadding = 'xl:pl-72'
|
||||
|
||||
return (
|
||||
<div className={`${darkMode ? 'dark' : ''} overflow-hidden lg:overflow-visible`}>
|
||||
<div
|
||||
className={`${layoutAsidePadding} ${
|
||||
isAsideMobileExpanded ? 'ml-60 lg:ml-0' : ''
|
||||
isAsideMobileExpanded ? 'ml-72 lg:ml-0' : ''
|
||||
} pt-14 min-h-screen w-screen transition-position lg:w-auto ${bgColor} dark:bg-dark-800 dark:text-slate-100`}
|
||||
>
|
||||
<NavBar
|
||||
menu={menuNavBar}
|
||||
className={`${layoutAsidePadding} ${isAsideMobileExpanded ? 'ml-60 lg:ml-0' : ''}`}
|
||||
className={`${layoutAsidePadding} ${isAsideMobileExpanded ? 'ml-72 lg:ml-0' : ''}`}
|
||||
>
|
||||
<NavBarItemPlain
|
||||
display="flex lg:hidden"
|
||||
|
||||
@ -128,6 +128,12 @@ const menuAside: MenuAsideItem[] = [
|
||||
icon: icon.mdiChartBar,
|
||||
permissions: 'UPDATE_USERS'
|
||||
},
|
||||
{
|
||||
href: '/admin-help',
|
||||
label: 'Admin Help',
|
||||
icon: icon.mdiHelpCircleOutline,
|
||||
permissions: 'UPDATE_USERS'
|
||||
},
|
||||
{
|
||||
href: '/profile',
|
||||
label: 'Profile',
|
||||
|
||||
304
frontend/src/pages/admin-help.tsx
Normal file
304
frontend/src/pages/admin-help.tsx
Normal file
@ -0,0 +1,304 @@
|
||||
import {
|
||||
mdiAccountCashOutline,
|
||||
mdiCashRegister,
|
||||
mdiClipboardCheckOutline,
|
||||
mdiClipboardListOutline,
|
||||
mdiCogOutline,
|
||||
mdiHelpCircleOutline,
|
||||
mdiOpenInNew,
|
||||
mdiPlayCircleOutline,
|
||||
mdiShieldCheckOutline,
|
||||
} from '@mdi/js';
|
||||
import Head from 'next/head';
|
||||
import React, { ReactElement } from 'react';
|
||||
|
||||
import BaseButton from '../components/BaseButton';
|
||||
import BaseDivider from '../components/BaseDivider';
|
||||
import BaseIcon from '../components/BaseIcon';
|
||||
import CardBox from '../components/CardBox';
|
||||
import SectionMain from '../components/SectionMain';
|
||||
import SectionTitleLineWithButton from '../components/SectionTitleLineWithButton';
|
||||
import { getPageTitle } from '../config';
|
||||
import LayoutAuthenticated from '../layouts/Authenticated';
|
||||
|
||||
type HelpSection = {
|
||||
id: string;
|
||||
title: string;
|
||||
icon: string;
|
||||
summary: string;
|
||||
steps: string[];
|
||||
checks?: string[];
|
||||
warning?: string;
|
||||
href?: string;
|
||||
hrefLabel?: string;
|
||||
};
|
||||
|
||||
const helpSections: HelpSection[] = [
|
||||
{
|
||||
id: 'getting-started',
|
||||
title: 'Getting started',
|
||||
icon: mdiPlayCircleOutline,
|
||||
summary:
|
||||
'Use this as the admin’s quick-start routine before you begin assigning pay or creating payroll runs.',
|
||||
steps: [
|
||||
'Confirm your users, customers, vehicles, pay types, and workers comp classes are up to date.',
|
||||
'Make sure employees know whether they should use Log Work or whether an admin is entering logs on their behalf.',
|
||||
'Decide how often payroll should be run and which date range each payroll run should cover.',
|
||||
'Review this help page the first few times so your workflow stays consistent.',
|
||||
],
|
||||
checks: [
|
||||
'Users have the correct role and active account status.',
|
||||
'Employees have at least one pay setup assigned before they start logging jobs.',
|
||||
'Admins know which menu pages they will use most often: Users, Pay types, Employee pay types, All Job Logs, and Payroll runs.',
|
||||
],
|
||||
href: '/dashboard',
|
||||
hrefLabel: 'Open Dashboard',
|
||||
},
|
||||
{
|
||||
id: 'assign-employee-pay',
|
||||
title: 'Assign employee pay',
|
||||
icon: mdiAccountCashOutline,
|
||||
summary:
|
||||
'This links an employee to the pay option they are allowed to use on jobs.',
|
||||
steps: [
|
||||
'Open Employee pay types.',
|
||||
'Create a new record and select the employee.',
|
||||
'Choose the pay type you want that employee to use, such as hourly or commission.',
|
||||
'Save the record and verify the employee can now select that pay option while logging work.',
|
||||
'If an employee should have multiple valid pay options, add each allowed pay type as its own assignment.',
|
||||
],
|
||||
checks: [
|
||||
'Use clear pay type names so employees can easily choose the right option while logging work.',
|
||||
'Remove outdated assignments if an employee should no longer use an old rate or method.',
|
||||
],
|
||||
href: '/employee_pay_types/employee_pay_types-list',
|
||||
hrefLabel: 'Open Employee Pay Types',
|
||||
},
|
||||
{
|
||||
id: 'set-up-pay-types',
|
||||
title: 'Set up pay types',
|
||||
icon: mdiCogOutline,
|
||||
summary:
|
||||
'Pay types control how job pay is calculated. Keep these clean and intentional so payroll stays predictable.',
|
||||
steps: [
|
||||
'Open Pay types and review the existing list before adding a new one.',
|
||||
'For hourly work, confirm the hourly rate is correct.',
|
||||
'For commission-based work, confirm the commission percentage is correct.',
|
||||
'Use consistent naming so admins and employees can tell similar pay types apart.',
|
||||
'Save changes, then test a sample job log if the pay logic is new or changed.',
|
||||
],
|
||||
checks: [
|
||||
'Avoid creating duplicate pay types with only slightly different names.',
|
||||
'When changing a rate, double-check whether you should edit an existing pay type or create a new one for future use.',
|
||||
],
|
||||
href: '/pay_types/pay_types-list',
|
||||
hrefLabel: 'Open Pay Types',
|
||||
},
|
||||
{
|
||||
id: 'review-job-logs',
|
||||
title: 'Review job logs before payroll',
|
||||
icon: mdiClipboardCheckOutline,
|
||||
summary:
|
||||
'A quick review before payroll helps catch missing hours, wrong pay types, or incomplete client-paid amounts.',
|
||||
steps: [
|
||||
'Open All Job Logs and filter by the payroll period you are about to run.',
|
||||
'Scan Employee Pay, hours, client paid, and pay type selections for anything that looks incorrect.',
|
||||
'Open any questionable record and verify the employee, customer, vehicle, and work details.',
|
||||
'Fix missing or incorrect values before creating the payroll run.',
|
||||
],
|
||||
checks: [
|
||||
'Hourly jobs should have hours entered.',
|
||||
'Commission jobs should have the client paid amount entered.',
|
||||
'Employees should not have duplicate or accidental logs for the same work.',
|
||||
],
|
||||
href: '/job_logs/job_logs-list',
|
||||
hrefLabel: 'Open All Job Logs',
|
||||
},
|
||||
{
|
||||
id: 'run-payroll',
|
||||
title: 'Run payroll',
|
||||
icon: mdiCashRegister,
|
||||
summary:
|
||||
'Create a payroll run only after the logs in that date range look clean.',
|
||||
steps: [
|
||||
'Open Payroll runs and create a new run.',
|
||||
'Set the payroll dates carefully so the run only includes the jobs you intend to pay.',
|
||||
'Save the payroll run and review the generated results.',
|
||||
'Open the payroll line items if you need job-by-job detail for what was included.',
|
||||
'If totals look wrong, go back to the source job logs, correct them, and then rerun or recreate payroll as needed.',
|
||||
],
|
||||
checks: [
|
||||
'Watch for an incorrect date range first; that is one of the most common causes of missing or extra pay.',
|
||||
'If one employee total looks off, inspect that employee’s individual job logs before changing anything else.',
|
||||
],
|
||||
href: '/payroll_runs/payroll_runs-list',
|
||||
hrefLabel: 'Open Payroll Runs',
|
||||
},
|
||||
{
|
||||
id: 'check-results',
|
||||
title: 'Check payroll results',
|
||||
icon: mdiClipboardListOutline,
|
||||
summary:
|
||||
'After a run is created, verify the output before treating it as final.',
|
||||
steps: [
|
||||
'Review the payroll run totals and compare them with the expected workload for that period.',
|
||||
'Open Payroll line items to confirm each employee has the right combination of job entries.',
|
||||
'Spot-check a few records manually using the job log Employee Pay values and the live preview logic on the log form.',
|
||||
'Use the Payroll Dashboard if you want a broader visual review of payroll activity.',
|
||||
],
|
||||
checks: [
|
||||
'Investigate unusual spikes or drops before approving the numbers internally.',
|
||||
'Keep a repeatable review routine so payroll checks stay consistent every pay period.',
|
||||
],
|
||||
href: '/reports',
|
||||
hrefLabel: 'Open Payroll Dashboard',
|
||||
},
|
||||
{
|
||||
id: 'troubleshooting',
|
||||
title: 'Troubleshooting and common issues',
|
||||
icon: mdiShieldCheckOutline,
|
||||
summary:
|
||||
'Use this checklist whenever the numbers do not look right or an employee cannot complete a job log correctly.',
|
||||
steps: [
|
||||
'If an employee cannot choose the right pay option, confirm the Employee pay types assignment exists.',
|
||||
'If live pay looks wrong on a job log, verify the pay type values and the job fields used for that method.',
|
||||
'If payroll totals look off, compare the payroll date range with the dates on the job logs included in the run.',
|
||||
'If a commission total looks too low or too high, check the client paid amount on the job log first.',
|
||||
'If an hourly total looks wrong, check hours entered and the selected hourly pay type.',
|
||||
],
|
||||
warning:
|
||||
'Best practice: fix the source job log or pay setup first, then regenerate or recreate the payroll output instead of trying to explain away a mismatch later.',
|
||||
href: '/payroll_line_items/payroll_line_items-list',
|
||||
hrefLabel: 'Open Payroll Line Items',
|
||||
},
|
||||
];
|
||||
|
||||
const AdminHelpPage = () => {
|
||||
return (
|
||||
<>
|
||||
<Head>
|
||||
<title>{getPageTitle('Admin Help')}</title>
|
||||
</Head>
|
||||
|
||||
<SectionMain>
|
||||
<SectionTitleLineWithButton
|
||||
icon={mdiHelpCircleOutline}
|
||||
title='Admin Help'
|
||||
main
|
||||
>
|
||||
{''}
|
||||
</SectionTitleLineWithButton>
|
||||
|
||||
<CardBox className='mb-6'>
|
||||
<div className='flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between'>
|
||||
<div className='max-w-3xl'>
|
||||
<h2 className='text-xl font-semibold mb-2'>Admin task checklist</h2>
|
||||
<p className='text-gray-600 dark:text-gray-300'>
|
||||
This page is a simple internal knowledge base for the most common admin workflows in the app.
|
||||
Use the quick links below to jump to a process, then open the related page directly when you are ready to do the task.
|
||||
</p>
|
||||
</div>
|
||||
<BaseButton
|
||||
href='/payroll_runs/payroll_runs-list'
|
||||
color='info'
|
||||
icon={mdiOpenInNew}
|
||||
label='Go to Payroll Runs'
|
||||
/>
|
||||
</div>
|
||||
|
||||
<BaseDivider />
|
||||
|
||||
<div className='flex flex-wrap gap-3'>
|
||||
{helpSections.map((section) => (
|
||||
<BaseButton
|
||||
key={section.id}
|
||||
asAnchor
|
||||
href={`#${section.id}`}
|
||||
color='whiteDark'
|
||||
small
|
||||
label={section.title}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</CardBox>
|
||||
|
||||
<div className='grid grid-cols-1 gap-6 xl:grid-cols-2'>
|
||||
{helpSections.map((section) => (
|
||||
<CardBox key={section.id} className='h-full'>
|
||||
<div id={section.id} className='scroll-mt-20'>
|
||||
<div className='flex items-start justify-between gap-4 mb-4'>
|
||||
<div className='flex items-start gap-3'>
|
||||
<BaseIcon path={section.icon} size={28} className='text-blue-600 dark:text-blue-400 mt-1' />
|
||||
<div>
|
||||
<h2 className='text-xl font-semibold'>{section.title}</h2>
|
||||
<p className='text-sm text-gray-600 dark:text-gray-300 mt-1'>
|
||||
{section.summary}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{section.href && section.hrefLabel ? (
|
||||
<BaseButton
|
||||
href={section.href}
|
||||
color='info'
|
||||
small
|
||||
icon={mdiOpenInNew}
|
||||
label={section.hrefLabel}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className='mb-4'>
|
||||
<div className='text-sm font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400 mb-2'>
|
||||
Checklist
|
||||
</div>
|
||||
<ol className='list-decimal pl-5 space-y-2 text-sm leading-6'>
|
||||
{section.steps.map((step) => (
|
||||
<li key={step}>{step}</li>
|
||||
))}
|
||||
</ol>
|
||||
</div>
|
||||
|
||||
{section.checks?.length ? (
|
||||
<div className='mb-4'>
|
||||
<div className='text-sm font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400 mb-2'>
|
||||
Verify before moving on
|
||||
</div>
|
||||
<ul className='list-disc pl-5 space-y-2 text-sm leading-6'>
|
||||
{section.checks.map((check) => (
|
||||
<li key={check}>{check}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{section.warning ? (
|
||||
<div className='rounded-xl border border-amber-200 bg-amber-50 px-4 py-3 text-sm leading-6 text-amber-900 dark:border-amber-500/40 dark:bg-amber-500/10 dark:text-amber-100'>
|
||||
<span className='font-semibold'>Admin note:</span> {section.warning}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</CardBox>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<CardBox className='mt-6'>
|
||||
<div className='flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between'>
|
||||
<div>
|
||||
<h2 className='text-lg font-semibold'>Suggested next improvement</h2>
|
||||
<p className='text-sm text-gray-600 dark:text-gray-300 mt-1'>
|
||||
If you want, this help page can later become searchable or editable so admins can maintain their own internal SOPs.
|
||||
</p>
|
||||
</div>
|
||||
<BaseButton href='/dashboard' color='whiteDark' label='Back to Dashboard' />
|
||||
</div>
|
||||
</CardBox>
|
||||
</SectionMain>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
AdminHelpPage.getLayout = function getLayout(page: ReactElement) {
|
||||
return <LayoutAuthenticated permission='UPDATE_USERS'>{page}</LayoutAuthenticated>;
|
||||
};
|
||||
|
||||
export default AdminHelpPage;
|
||||
@ -9,6 +9,7 @@ import CardBox from '../../components/CardBox'
|
||||
import LayoutAuthenticated from '../../layouts/Authenticated'
|
||||
import SectionMain from '../../components/SectionMain'
|
||||
import SectionTitleLineWithButton from '../../components/SectionTitleLineWithButton'
|
||||
import JobLogPayPreview from '../../components/JobLogPayPreview'
|
||||
import { getPageTitle } from '../../config'
|
||||
|
||||
import { Field, Form, Formik } from 'formik'
|
||||
@ -796,6 +797,8 @@ const EditJob_logsPage = () => {
|
||||
|
||||
></Field>
|
||||
</FormField>
|
||||
|
||||
<JobLogPayPreview />
|
||||
|
||||
|
||||
|
||||
|
||||
@ -5,6 +5,7 @@ import CardBox from '../../components/CardBox'
|
||||
import LayoutAuthenticated from '../../layouts/Authenticated'
|
||||
import SectionMain from '../../components/SectionMain'
|
||||
import SectionTitleLineWithButton from '../../components/SectionTitleLineWithButton'
|
||||
import JobLogPayPreview from '../../components/JobLogPayPreview'
|
||||
import { getPageTitle } from '../../config'
|
||||
|
||||
import { Field, Form, Formik } from 'formik'
|
||||
@ -495,6 +496,8 @@ const Job_logsNew = () => {
|
||||
<Field name="pay_type" id="pay_type" component={SelectField} options={[]} itemRef={'pay_types'}></Field>
|
||||
</FormField>
|
||||
|
||||
<JobLogPayPreview />
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@ -5,6 +5,7 @@ import CardBox from '../components/CardBox';
|
||||
import LayoutAuthenticated from '../layouts/Authenticated';
|
||||
import SectionMain from '../components/SectionMain';
|
||||
import SectionTitleLineWithButton from '../components/SectionTitleLineWithButton';
|
||||
import JobLogPayPreview from '../components/JobLogPayPreview';
|
||||
import { getPageTitle } from '../config';
|
||||
|
||||
import { Field, Form, Formik, FieldArray } from 'formik';
|
||||
@ -98,6 +99,7 @@ const LogWorkPage = () => {
|
||||
))}
|
||||
</Field>
|
||||
</FormField>
|
||||
<JobLogPayPreview payTypeOptions={assignedPayTypes} />
|
||||
<FormField label="Vehicle" labelFor="vehicle">
|
||||
<Field name="vehicle" id="vehicle" component={SelectField} options={[]} itemRef={'vehicles'} />
|
||||
</FormField>
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user