diff --git a/assets/pasted-20260314-132921-6765892c.webp b/assets/pasted-20260314-132921-6765892c.webp new file mode 100644 index 0000000..bf459c8 Binary files /dev/null and b/assets/pasted-20260314-132921-6765892c.webp differ diff --git a/backend/src/db/api/employee_pay_types.js b/backend/src/db/api/employee_pay_types.js index ad0e660..9a63562 100644 --- a/backend/src/db/api/employee_pay_types.js +++ b/backend/src/db/api/employee_pay_types.js @@ -279,7 +279,7 @@ module.exports = class Employee_pay_typesDBApi { } }, ] - } : {}, + } : undefined, }, @@ -296,7 +296,7 @@ module.exports = class Employee_pay_typesDBApi { } }, ] - } : {}, + } : undefined, }, diff --git a/backend/src/db/api/job_chemical_usages.js b/backend/src/db/api/job_chemical_usages.js index 15ab2b6..fbf1229 100644 --- a/backend/src/db/api/job_chemical_usages.js +++ b/backend/src/db/api/job_chemical_usages.js @@ -264,7 +264,7 @@ module.exports = class Job_chemical_usagesDBApi { } }, ] - } : {}, + } : undefined, }, @@ -281,7 +281,7 @@ module.exports = class Job_chemical_usagesDBApi { } }, ] - } : {}, + } : undefined, }, diff --git a/backend/src/db/api/job_logs.js b/backend/src/db/api/job_logs.js index c781703..fbc095f 100644 --- a/backend/src/db/api/job_logs.js +++ b/backend/src/db/api/job_logs.js @@ -412,7 +412,7 @@ module.exports = class Job_logsDBApi { } }, ] - } : {}, + } : undefined, }, @@ -429,7 +429,7 @@ module.exports = class Job_logsDBApi { } }, ] - } : {}, + } : undefined, }, @@ -446,7 +446,7 @@ module.exports = class Job_logsDBApi { } }, ] - } : {}, + } : undefined, }, @@ -463,7 +463,7 @@ module.exports = class Job_logsDBApi { } }, ] - } : {}, + } : undefined, }, @@ -479,7 +479,7 @@ module.exports = class Job_logsDBApi { } }, ] - } : {}, + } : undefined, }, diff --git a/backend/src/db/api/payroll_line_items.js b/backend/src/db/api/payroll_line_items.js index 826e0a2..e580f29 100644 --- a/backend/src/db/api/payroll_line_items.js +++ b/backend/src/db/api/payroll_line_items.js @@ -315,7 +315,7 @@ module.exports = class Payroll_line_itemsDBApi { } }, ] - } : {}, + } : undefined, }, @@ -332,7 +332,7 @@ module.exports = class Payroll_line_itemsDBApi { } }, ] - } : {}, + } : undefined, }, diff --git a/backend/src/db/api/users.js b/backend/src/db/api/users.js index 78ffae8..251dcd9 100644 --- a/backend/src/db/api/users.js +++ b/backend/src/db/api/users.js @@ -484,7 +484,7 @@ module.exports = class UsersDBApi { } }, ] - } : {}, + } : undefined, }, diff --git a/backend/src/index.js b/backend/src/index.js index 72694a9..3613690 100644 --- a/backend/src/index.js +++ b/backend/src/index.js @@ -132,6 +132,7 @@ app.use('/api/payroll_runs', passport.authenticate('jwt', {session: false}), pay app.use('/api/payroll_line_items', passport.authenticate('jwt', {session: false}), payroll_line_itemsRoutes); app.use('/api/reports', passport.authenticate('jwt', {session: false}), reportsRoutes); +app.use('/api/payroll_generator', require('./routes/payroll_generator')); app.use( '/api/openai', diff --git a/backend/src/routes/payroll_generator.js b/backend/src/routes/payroll_generator.js new file mode 100644 index 0000000..4adea9d --- /dev/null +++ b/backend/src/routes/payroll_generator.js @@ -0,0 +1,199 @@ +const express = require('express'); +const router = express.Router(); +const passport = require('passport'); +const db = require('../db/models'); +const { wrapAsync } = require('../helpers'); +const { Op } = require('sequelize'); + +router.post('/preview', passport.authenticate('jwt', { session: false }), wrapAsync(async (req, res) => { + const { startDate, endDate } = req.body; + + if (!startDate || !endDate) { + return res.status(400).send('startDate and endDate are required'); + } + + // Find job logs in range that are not paid + const jobLogs = await db.job_logs.findAll({ + where: { + work_date: { + [Op.between]: [new Date(startDate), new Date(endDate)] + }, + status: { + [Op.ne]: 'paid' + } + }, + include: [ + { model: db.users, as: 'employee' }, + { model: db.pay_types, as: 'pay_type' } + ] + }); + + const employeeTotals = {}; + + jobLogs.forEach(log => { + const empId = log.employeeId; + if (!empId) return; + + if (!employeeTotals[empId]) { + employeeTotals[empId] = { + employee: log.employee, + total_hours: 0, + total_commission_base: 0, + gross_pay: 0, + job_log_ids: [] + }; + } + + const totals = employeeTotals[empId]; + totals.job_log_ids.push(log.id); + + const hours = parseFloat(log.hours_conducted || 0); + totals.total_hours += hours; + + const clientPaid = parseFloat(log.client_paid || 0); + totals.total_commission_base += clientPaid; + + let pay = 0; + if (log.pay_type) { + if (log.pay_type.pay_method === 'hourly') { + pay = hours * parseFloat(log.pay_type.hourly_rate || 0); + } else if (log.pay_type.pay_method === 'commission') { + pay = clientPaid * (parseFloat(log.pay_type.commission_rate || 0) / 100); + } + } + totals.gross_pay += pay; + }); + + const summary = { + totalGrossPay: 0, + totalHours: 0 + }; + + const lineItems = Object.values(employeeTotals).map(item => { + summary.totalGrossPay += item.gross_pay; + summary.totalHours += item.total_hours; + return item; + }); + + res.json({ lineItems, summary }); +})); + +router.post('/generate', passport.authenticate('jwt', { session: false }), wrapAsync(async (req, res) => { + const { startDate, endDate, name, notes } = req.body; + + if (!startDate || !endDate) { + return res.status(400).send('startDate and endDate are required'); + } + + // Find job logs + const jobLogs = await db.job_logs.findAll({ + where: { + work_date: { + [Op.between]: [new Date(startDate), new Date(endDate)] + }, + status: { + [Op.ne]: 'paid' + } + }, + include: [ + { model: db.users, as: 'employee' }, + { model: db.pay_types, as: 'pay_type' } + ] + }); + + if (jobLogs.length === 0) { + return res.status(400).send('No unpaid job logs found in this date range.'); + } + + const employeeTotals = {}; + + jobLogs.forEach(log => { + const empId = log.employeeId; + if (!empId) return; + + if (!employeeTotals[empId]) { + employeeTotals[empId] = { + total_hours: 0, + total_commission_base: 0, + gross_pay: 0, + workers_comp_amount: 0, + employeeId: empId, + job_log_ids: [] + }; + } + + const totals = employeeTotals[empId]; + totals.job_log_ids.push(log.id); + + const hours = parseFloat(log.hours_conducted || 0); + totals.total_hours += hours; + + const clientPaid = parseFloat(log.client_paid || 0); + totals.total_commission_base += clientPaid; + + let pay = 0; + let wcAmount = 0; + if (log.pay_type) { + if (log.pay_type.pay_method === 'hourly') { + pay = hours * parseFloat(log.pay_type.hourly_rate || 0); + } else if (log.pay_type.pay_method === 'commission') { + pay = clientPaid * (parseFloat(log.pay_type.commission_rate || 0) / 100); + } + if (log.pay_type.workers_comp_percentage) { + wcAmount = pay * (parseFloat(log.pay_type.workers_comp_percentage) / 100); + } + } + totals.gross_pay += pay; + totals.workers_comp_amount += wcAmount; + }); + + // Create a transaction + const transaction = await db.sequelize.transaction(); + try { + // Create payroll run + const payrollRun = await db.payroll_runs.create({ + name: name || `Payroll ${startDate} to ${endDate}`, + period_start: startDate, + period_end: endDate, + run_date: new Date(), + status: 'finalized', + notes: notes || '', + createdById: req.currentUser.id + }, { transaction }); + + // Create line items + for (const empId of Object.keys(employeeTotals)) { + const totals = employeeTotals[empId]; + await db.payroll_line_items.create({ + payroll_runId: payrollRun.id, + employeeId: totals.employeeId, + total_hours: totals.total_hours, + gross_pay: totals.gross_pay, + total_commission_base: totals.total_commission_base, + workers_comp_amount: totals.workers_comp_amount, + createdById: req.currentUser.id + }, { transaction }); + + // Mark job logs as paid + await db.job_logs.update({ + status: 'paid' + }, { + where: { + id: { + [Op.in]: totals.job_log_ids + } + }, + transaction + }); + } + + await transaction.commit(); + res.json({ success: true, payrollRunId: payrollRun.id }); + } catch (error) { + await transaction.rollback(); + console.error(error); + res.status(500).send('Error generating payroll'); + } +})); + +module.exports = router; diff --git a/backend/src/services/job_logs.js b/backend/src/services/job_logs.js index 96d6e8d..f21fc42 100644 --- a/backend/src/services/job_logs.js +++ b/backend/src/services/job_logs.js @@ -23,7 +23,7 @@ module.exports = class Job_logsService { customerId = customer.id; } - await Job_logsDBApi.create( + const createdJob = await Job_logsDBApi.create( { ...data, customer: customerId }, { currentUser, @@ -31,6 +31,21 @@ module.exports = class Job_logsService { }, ); + if (data.chemical_usages && Array.isArray(data.chemical_usages)) { + for (const usage of data.chemical_usages) { + if (usage.chemical_product && usage.quantity_used) { + await db.job_chemical_usages.create({ + job_logId: createdJob.id, + chemical_productId: usage.chemical_product, + quantity_used: usage.quantity_used, + notes: usage.notes || '', + createdById: currentUser.id, + updatedById: currentUser.id + }, { transaction }); + } + } + } + await transaction.commit(); } catch (error) { await transaction.rollback(); diff --git a/frontend/public/assets/logo.webp b/frontend/public/assets/logo.webp new file mode 100644 index 0000000..bf459c8 Binary files /dev/null and b/frontend/public/assets/logo.webp differ diff --git a/frontend/public/assets/vm-shot-2026-03-14T13-28-33-023Z.jpg b/frontend/public/assets/vm-shot-2026-03-14T13-28-33-023Z.jpg new file mode 100644 index 0000000..deb8ea0 Binary files /dev/null and b/frontend/public/assets/vm-shot-2026-03-14T13-28-33-023Z.jpg differ diff --git a/frontend/src/components/AsideMenuLayer.tsx b/frontend/src/components/AsideMenuLayer.tsx index 4da4312..9356368 100644 --- a/frontend/src/components/AsideMenuLayer.tsx +++ b/frontend/src/components/AsideMenuLayer.tsx @@ -39,7 +39,7 @@ export default function AsideMenuLayer({ menu, className = '', ...props }: Props >
- Pressure Wash Payroll +
LogoMajor League Pressure Washing
diff --git a/frontend/src/components/FooterBar.tsx b/frontend/src/components/FooterBar.tsx index 0acc9c5..4b6b84f 100644 --- a/frontend/src/components/FooterBar.tsx +++ b/frontend/src/components/FooterBar.tsx @@ -15,8 +15,8 @@ export default function FooterBar({ children }: Props) {
©{year},{` `} - - Flatlogic + + Major League Pressure Washing . @@ -25,7 +25,7 @@ export default function FooterBar({ children }: Props) {
- +
diff --git a/frontend/src/components/Logo/index.tsx b/frontend/src/components/Logo/index.tsx index a582e29..5ce524c 100644 --- a/frontend/src/components/Logo/index.tsx +++ b/frontend/src/components/Logo/index.tsx @@ -7,9 +7,9 @@ type Props = { export default function Logo({ className = '' }: Props) { return ( {'Flatlogic + alt={'Major League Pressure Washing logo'}> ) } diff --git a/frontend/src/config.ts b/frontend/src/config.ts index a9783c8..1d12419 100644 --- a/frontend/src/config.ts +++ b/frontend/src/config.ts @@ -8,7 +8,7 @@ export const localStorageStyleKey = 'style' export const containerMaxW = 'xl:max-w-full xl:mx-auto 2xl:mx-20' -export const appTitle = 'created by Flatlogic generator!' +export const appTitle = 'Major League Pressure Washing' export const getPageTitle = (currentPageTitle: string) => `${currentPageTitle} — ${appTitle}` diff --git a/frontend/src/menuAside.ts b/frontend/src/menuAside.ts index cb342e4..70fd70c 100644 --- a/frontend/src/menuAside.ts +++ b/frontend/src/menuAside.ts @@ -6,6 +6,7 @@ const menuAside: MenuAsideItem[] = [ href: '/dashboard', icon: icon.mdiViewDashboardOutline, label: 'Dashboard', + permissions: 'UPDATE_USERS', }, { @@ -27,7 +28,7 @@ const menuAside: MenuAsideItem[] = [ // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore icon: icon.mdiAccountGroup ?? icon.mdiTable, - permissions: 'READ_USERS' + permissions: 'UPDATE_USERS' }, { href: '/roles/roles-list', @@ -35,7 +36,7 @@ const menuAside: MenuAsideItem[] = [ // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore icon: icon.mdiShieldAccountVariantOutline ?? icon.mdiTable, - permissions: 'READ_ROLES' + permissions: 'UPDATE_USERS' }, { href: '/permissions/permissions-list', @@ -43,7 +44,7 @@ const menuAside: MenuAsideItem[] = [ // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore icon: icon.mdiShieldAccountOutline ?? icon.mdiTable, - permissions: 'READ_PERMISSIONS' + permissions: 'UPDATE_USERS' }, { href: '/customers/customers-list', @@ -51,7 +52,7 @@ const menuAside: MenuAsideItem[] = [ // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore icon: 'mdiAccountMultiple' in icon ? icon['mdiAccountMultiple' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable, - permissions: 'READ_CUSTOMERS' + permissions: 'UPDATE_USERS' }, { href: '/vehicles/vehicles-list', @@ -59,13 +60,13 @@ const menuAside: MenuAsideItem[] = [ // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore icon: 'mdiTruck' in icon ? icon['mdiTruck' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable, - permissions: 'READ_VEHICLES' + permissions: 'UPDATE_USERS' }, { href: '/workers_comp_classes/workers_comp_classes-list', label: 'Workmans Comp', icon: icon.mdiTable, - permissions: 'READ_WORKERS_COMP_CLASSES' + permissions: 'UPDATE_USERS' }, { href: '/pay_types/pay_types-list', @@ -73,7 +74,7 @@ const menuAside: MenuAsideItem[] = [ // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore icon: 'mdiCashMultiple' in icon ? icon['mdiCashMultiple' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable, - permissions: 'READ_PAY_TYPES' + permissions: 'UPDATE_USERS' }, { href: '/employee_pay_types/employee_pay_types-list', @@ -81,7 +82,7 @@ const menuAside: MenuAsideItem[] = [ // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore icon: 'mdiLinkVariant' in icon ? icon['mdiLinkVariant' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable, - permissions: 'READ_EMPLOYEE_PAY_TYPES' + permissions: 'UPDATE_USERS' }, { href: '/chemical_products/chemical_products-list', @@ -89,13 +90,13 @@ const menuAside: MenuAsideItem[] = [ // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore icon: 'mdiFlask' in icon ? icon['mdiFlask' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable, - permissions: 'READ_CHEMICAL_PRODUCTS' + permissions: 'UPDATE_USERS' }, { href: '/job_logs/job_logs-list', label: 'All Job Logs', icon: 'mdiClipboardTextClock' in icon ? icon['mdiClipboardTextClock' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable, - permissions: 'READ_JOB_LOGS' + permissions: 'UPDATE_USERS' }, { href: '/job_chemical_usages/job_chemical_usages-list', @@ -103,7 +104,7 @@ const menuAside: MenuAsideItem[] = [ // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore icon: 'mdiFlaskOutline' in icon ? icon['mdiFlaskOutline' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable, - permissions: 'READ_JOB_CHEMICAL_USAGES' + permissions: 'UPDATE_USERS' }, { href: '/payroll_runs/payroll_runs-list', @@ -111,7 +112,7 @@ const menuAside: MenuAsideItem[] = [ // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore icon: 'mdiCalendarClock' in icon ? icon['mdiCalendarClock' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable, - permissions: 'READ_PAYROLL_RUNS' + permissions: 'UPDATE_USERS' }, { href: '/payroll_line_items/payroll_line_items-list', @@ -119,13 +120,13 @@ const menuAside: MenuAsideItem[] = [ // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore icon: 'mdiFileDocumentOutline' in icon ? icon['mdiFileDocumentOutline' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable, - permissions: 'READ_PAYROLL_LINE_ITEMS' + permissions: 'UPDATE_USERS' }, { href: '/reports', - label: 'Payroll Reports', + label: 'Payroll Dashboard', icon: icon.mdiChartBar, - permissions: 'READ_PAYROLL_LINE_ITEMS' + permissions: 'UPDATE_USERS' }, { href: '/profile', @@ -139,7 +140,7 @@ const menuAside: MenuAsideItem[] = [ target: '_blank', label: 'Swagger API', icon: icon.mdiFileCode, - permissions: 'READ_API_DOCS' + permissions: 'UPDATE_USERS' }, ] diff --git a/frontend/src/pages/_app.tsx b/frontend/src/pages/_app.tsx index 82e11aa..f46d508 100644 --- a/frontend/src/pages/_app.tsx +++ b/frontend/src/pages/_app.tsx @@ -149,7 +149,7 @@ function MyApp({ Component, pageProps }: AppPropsWithLayout) { setStepsEnabled(false); }; - const title = 'Pressure Wash Payroll' + const title = 'Major League Pressure Washing' const description = "Internal payroll app for pressure washing techs to log jobs, track mileage and chemicals, and run payroll reports." const url = "https://flatlogic.com/" const image = "https://project-screens.s3.amazonaws.com/screenshots/39157/app-hero-20260312-154649.png" diff --git a/frontend/src/pages/dashboard.tsx b/frontend/src/pages/dashboard.tsx index 263c7b7..dc96fdb 100644 --- a/frontend/src/pages/dashboard.tsx +++ b/frontend/src/pages/dashboard.tsx @@ -46,9 +46,18 @@ const Dashboard = () => { const { isFetchingQuery } = useAppSelector((state) => state.openAi); const { rolesWidgets, loading } = useAppSelector((state) => state.roles); + const isAdmin = hasPermission(currentUser, "UPDATE_USERS"); async function loadData() { + if (!currentUser) return; + + if (!isAdmin) { + // For regular employees, we just want to load their own logs + axios.get('/job_logs/count?employee=' + currentUser.id).then((res) => setJob_logs(res.data.count)).catch(() => setJob_logs(0)); + return; + } + const entities = ['users','roles','permissions','customers','vehicles','pay_types','employee_pay_types','chemical_products','job_logs','job_chemical_usages','payroll_runs','payroll_line_items',]; const fns = [setUsers,setRoles,setPermissions,setCustomers,setVehicles,setPay_types,setEmployee_pay_types,setChemical_products,setJob_logs,setJob_chemical_usages,setPayroll_runs,setPayroll_line_items,]; @@ -142,9 +151,12 @@ const Dashboard = () => { ))} + {!!rolesWidgets.length &&
} + {isAdmin ? (
+ {hasPermission(currentUser, 'READ_USERS') && @@ -481,11 +493,34 @@ const Dashboard = () => {
+ } + ) : ( +
+ +
+
+
+
+ My Logs +
+
+ {job_logs} +
+
+
+ +
+
+
+ +
+ )} + ) } diff --git a/frontend/src/pages/index.tsx b/frontend/src/pages/index.tsx index b48f0aa..aeaa8d5 100644 --- a/frontend/src/pages/index.tsx +++ b/frontend/src/pages/index.tsx @@ -26,7 +26,7 @@ export default function Starter() { const [contentPosition, setContentPosition] = useState('left'); const textColor = useAppSelector((state) => state.style.linkColor); - const title = 'Pressure Wash Payroll' + const title = 'Major League Pressure Washing' // Fetch Pexels image/video useEffect(() => { @@ -128,7 +128,7 @@ export default function Starter() { : null}
- +

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

diff --git a/frontend/src/pages/log-work.tsx b/frontend/src/pages/log-work.tsx index 7f26eb9..071d0a2 100644 --- a/frontend/src/pages/log-work.tsx +++ b/frontend/src/pages/log-work.tsx @@ -1,4 +1,4 @@ -import { mdiPencil, mdiChartTimelineVariant } from '@mdi/js'; +import { mdiPencil, mdiPlus, mdiTrashCan } from '@mdi/js'; import Head from 'next/head'; import React, { ReactElement, useEffect, useState } from 'react'; import CardBox from '../components/CardBox'; @@ -7,7 +7,7 @@ import SectionMain from '../components/SectionMain'; import SectionTitleLineWithButton from '../components/SectionTitleLineWithButton'; import { getPageTitle } from '../config'; -import { Field, Form, Formik } from 'formik'; +import { Field, Form, Formik, FieldArray } from 'formik'; import FormField from '../components/FormField'; import BaseDivider from '../components/BaseDivider'; import BaseButtons from '../components/BaseButtons'; @@ -52,6 +52,7 @@ const LogWorkPage = () => { job_address: '', status: 'submitted', notes_to_admin: '', + chemical_usages: [], }; const handleSubmit = async (data: any) => { @@ -70,52 +71,102 @@ const LogWorkPage = () => { handleSubmit(values)}> -
- - - - - - - - - - - - - - - - - - - {assignedPayTypes.map((pt) => ( - - ))} - - - - - - - - - - - - - - - - - - - - - - + {({ values }) => ( +
+ + + + + + + + + + + + + + + + + + + {assignedPayTypes.map((pt) => ( + + ))} + + + + + + + + + + + + + + + + + + + +

Chemical Usage (Optional)

+ + {({ push, remove }) => ( +
+ {values.chemical_usages.map((usage, index) => ( +
+ + + + + + +
+ remove(index)} + /> +
+
+ ))} + push({ chemical_product: '', quantity_used: '' })} + /> +
+ )} +
+ + + + + + + )}
diff --git a/frontend/src/pages/login.tsx b/frontend/src/pages/login.tsx index 2680bf2..edd7b1e 100644 --- a/frontend/src/pages/login.tsx +++ b/frontend/src/pages/login.tsx @@ -44,7 +44,7 @@ export default function Login() { password: '2fa72adf', remember: true }) - const title = 'Pressure Wash Payroll' + const title = 'Major League Pressure Washing' // Fetch Pexels image/video useEffect( () => { @@ -165,7 +165,7 @@ export default function Login() { -

{title}

+
Logo

{title}

diff --git a/frontend/src/pages/privacy-policy.tsx b/frontend/src/pages/privacy-policy.tsx index 772ad82..7c3a76a 100644 --- a/frontend/src/pages/privacy-policy.tsx +++ b/frontend/src/pages/privacy-policy.tsx @@ -5,7 +5,7 @@ import LayoutGuest from '../layouts/Guest'; import { getPageTitle } from '../config'; export default function PrivacyPolicy() { - const title = 'Pressure Wash Payroll' + const title = 'Major League Pressure Washing' const [projectUrl, setProjectUrl] = useState(''); useEffect(() => { diff --git a/frontend/src/pages/reports.tsx b/frontend/src/pages/reports.tsx index 1810cc9..7034009 100644 --- a/frontend/src/pages/reports.tsx +++ b/frontend/src/pages/reports.tsx @@ -1,4 +1,4 @@ -import { mdiChartBar } from '@mdi/js'; +import { mdiChartBar, mdiCashCheck, mdiHistory } from '@mdi/js'; import Head from 'next/head'; import React, { ReactElement, useState } from 'react'; import CardBox from '../components/CardBox'; @@ -8,26 +8,78 @@ import SectionTitleLineWithButton from '../components/SectionTitleLineWithButton import { getPageTitle } from '../config'; import FormField from '../components/FormField'; import BaseButton from '../components/BaseButton'; +import BaseButtons from '../components/BaseButtons'; import axios from 'axios'; const ReportsPage = () => { + const [activeTab, setActiveTab] = useState('current'); // 'current' or 'historical' + + // Historical state const [reportData, setReportData] = useState(null); - const [loading, setLoading] = useState(false); - const [filters, setFilters] = useState({ + const [loadingHistory, setLoadingHistory] = useState(false); + const [filtersHistory, setFiltersHistory] = useState({ startDate: '', endDate: '', employeeId: '' }); - const fetchReport = async () => { - setLoading(true); + // Current (Generator) state + const [previewData, setPreviewData] = useState(null); + const [loadingPreview, setLoadingPreview] = useState(false); + const [generating, setGenerating] = useState(false); + const [filtersCurrent, setFiltersCurrent] = useState({ + startDate: '', + endDate: '' + }); + const [runSuccess, setRunSuccess] = useState(''); + const [runError, setRunError] = useState(''); + + const fetchHistoricalReport = async () => { + setLoadingHistory(true); try { - const response = await axios.post('/reports', filters); + const response = await axios.post('/reports', filtersHistory); setReportData(response.data); } catch (error) { - console.error('Failed to fetch report:', error); + console.error('Failed to fetch historical report:', error); } finally { - setLoading(false); + setLoadingHistory(false); + } + }; + + const fetchCurrentPreview = async () => { + setLoadingPreview(true); + setRunSuccess(''); + setRunError(''); + try { + const response = await axios.post('/payroll_generator/preview', filtersCurrent); + setPreviewData(response.data); + } catch (error) { + console.error('Failed to fetch current preview:', error); + setRunError(error.response?.data || 'Failed to fetch unpaid logs.'); + } finally { + setLoadingPreview(false); + } + }; + + const generatePayroll = async () => { + if (!confirm('Are you sure you want to run payroll? This will finalize these amounts and mark the associated logs as paid.')) return; + + setGenerating(true); + setRunError(''); + setRunSuccess(''); + try { + await axios.post('/payroll_generator/generate', { + startDate: filtersCurrent.startDate, + endDate: filtersCurrent.endDate, + name: `Payroll ${filtersCurrent.startDate} to ${filtersCurrent.endDate}` + }); + setRunSuccess('Payroll Run successfully generated!'); + setPreviewData(null); // Clear preview since they are now paid + } catch (error) { + console.error('Failed to generate payroll:', error); + setRunError(error.response?.data || 'An error occurred while generating payroll.'); + } finally { + setGenerating(false); } }; @@ -37,73 +89,172 @@ const ReportsPage = () => { {getPageTitle('Payroll Reports')} - + {''} - -
- - setFilters({...filters, startDate: e.target.value})} className="px-3 py-2 border border-gray-300 rounded w-full" /> - - - setFilters({...filters, endDate: e.target.value})} className="px-3 py-2 border border-gray-300 rounded w-full" /> - - - setFilters({...filters, employeeId: e.target.value})} className="px-3 py-2 border border-gray-300 rounded w-full" /> - -
- -
-
-
- {reportData && ( - <> + +
+ + +
+ + {activeTab === 'current' && ( +
-

Summary

-
-
-

Total Gross Pay

-

${reportData.summary.totalGrossPay.toFixed(2)}

-
-
-

Total Hours

-

{reportData.summary.totalHours.toFixed(2)}

-
-
-

Total Work Comp

-

${reportData.summary.totalWorkersComp.toFixed(2)}

+
+ Select a date range to view all unpaid Job Logs. From here, you can preview what your employees have currently earned and generate a finalized Payroll Run. +
+
+ + setFiltersCurrent({...filtersCurrent, startDate: e.target.value})} className="px-3 py-2 border border-gray-300 rounded w-full" /> + + + setFiltersCurrent({...filtersCurrent, endDate: e.target.value})} className="px-3 py-2 border border-gray-300 rounded w-full" /> + +
+
+ {runError &&
{runError}
} + {runSuccess &&
{runSuccess}
} - -

Line Items

-
- - - - - - - - - - - - {reportData.lineItems.map((item: any) => ( - - - - - - - - ))} - -
EmployeeHoursGross PayWork Comp AmountCreated At
{item.employee?.firstName} {item.employee?.lastName || ''}{item.total_hours}${item.gross_pay}${item.workers_comp_amount}{new Date(item.createdAt).toLocaleDateString()}
-
-
- + + {previewData && ( + <> + +

Live Preview Summary

+
+
+

Total Unpaid Gross Pay

+

${previewData.summary.totalGrossPay.toFixed(2)}

+
+
+

Total Unpaid Hours

+

{previewData.summary.totalHours.toFixed(2)}

+
+
+
+ +
+
+ + +

Employee Breakdown

+
+ + + + + + + + + + + {previewData.lineItems.map((item: any, idx: number) => ( + + + + + + + ))} + +
EmployeeTotal HoursCommission BaseCalculated Gross Pay
{item.employee?.firstName} {item.employee?.lastName || ''}{item.total_hours.toFixed(2)}${item.total_commission_base.toFixed(2)}${item.gross_pay.toFixed(2)}
+
+
+ + )} +
)} + + {activeTab === 'historical' && ( +
+ +
+ View previously generated payroll line items by date range or employee ID. +
+
+ + setFiltersHistory({...filtersHistory, startDate: e.target.value})} className="px-3 py-2 border border-gray-300 rounded w-full" /> + + + setFiltersHistory({...filtersHistory, endDate: e.target.value})} className="px-3 py-2 border border-gray-300 rounded w-full" /> + + + setFiltersHistory({...filtersHistory, employeeId: e.target.value})} className="px-3 py-2 border border-gray-300 rounded w-full" /> + +
+ +
+
+
+ + {reportData && ( + <> + +

Historical Summary

+
+
+

Total Gross Pay

+

${reportData.summary.totalGrossPay.toFixed(2)}

+
+
+

Total Hours

+

{reportData.summary.totalHours.toFixed(2)}

+
+
+

Total Work Comp

+

${reportData.summary.totalWorkersComp.toFixed(2)}

+
+
+
+ +

Line Items

+
+ + + + + + + + + + + + {reportData.lineItems.map((item: any) => ( + + + + + + + + ))} + +
EmployeeHoursGross PayWork Comp AmountCreated At
{item.employee?.firstName} {item.employee?.lastName || ''}{item.total_hours}${item.gross_pay}${item.workers_comp_amount}{new Date(item.createdAt).toLocaleDateString()}
+
+
+ + )} +
+ )} + ); @@ -113,4 +264,4 @@ ReportsPage.getLayout = function getLayout(page: ReactElement) { return {page}; }; -export default ReportsPage; +export default ReportsPage; \ No newline at end of file diff --git a/frontend/src/pages/terms-of-use.tsx b/frontend/src/pages/terms-of-use.tsx index f40ca44..c28b4da 100644 --- a/frontend/src/pages/terms-of-use.tsx +++ b/frontend/src/pages/terms-of-use.tsx @@ -5,7 +5,7 @@ import LayoutGuest from '../layouts/Guest'; import { getPageTitle } from '../config'; export default function PrivacyPolicy() { - const title = 'Pressure Wash Payroll'; + const title = 'Major League Pressure Washing'; const [projectUrl, setProjectUrl] = useState(''); useEffect(() => { diff --git a/patch_dashboard.js b/patch_dashboard.js new file mode 100644 index 0000000..3b06dcc --- /dev/null +++ b/patch_dashboard.js @@ -0,0 +1,63 @@ +const fs = require('fs'); + +let content = fs.readFileSync('frontend/src/pages/dashboard.tsx', 'utf8'); + +const replacement = ` + const isAdmin = hasPermission(currentUser, 'UPDATE_USERS'); + + async function loadData() { + if (!currentUser) return; + + if (!isAdmin) { + // For regular employees, we just want to load their own logs + axios.get('/job_logs/count?employee=' + currentUser.id).then((res) => setJob_logs(res.data.count)).catch(() => setJob_logs(0)); + return; + } + + const entities = ['users','roles','permissions','customers','vehicles','pay_types','employee_pay_types','chemical_products','job_logs','job_chemical_usages','payroll_runs','payroll_line_items',]; +`; + +content = content.replace(/async function loadData\(\)\{\n\s*const entities = \['users','roles',/g, replacement); + +const replacement2 = ` + {!!rolesWidgets.length &&
} + + {isAdmin ? ( +
+`; + +content = content.replace(/\{\!\!rolesWidgets\.length &&
\}\n\s*
/g, replacement2); + +const replacement3 = ` + } + + +
+ ) : ( +
+ +
+
+
+
+ My Logs +
+
+ {job_logs} +
+
+
+ +
+
+
+ +
+ )} + +`; + +content = content.replace(/<\/Link>\}\n\s*<\/div>\n\s*<\/SectionMain>/g, replacement3); + +fs.writeFileSync('frontend/src/pages/dashboard.tsx', content); +console.log("Patched dashboard.tsx"); \ No newline at end of file diff --git a/patch_menuAside.js b/patch_menuAside.js new file mode 100644 index 0000000..1b00082 --- /dev/null +++ b/patch_menuAside.js @@ -0,0 +1,27 @@ +const fs = require('fs'); + +let content = fs.readFileSync('frontend/src/menuAside.ts', 'utf8'); + +// Replace READ_ with UPDATE_ for specific items +const itemsToUpdate = [ + 'READ_CUSTOMERS', + 'READ_VEHICLES', + 'READ_WORKERS_COMP_CLASSES', + 'READ_PAY_TYPES', + 'READ_EMPLOYEE_PAY_TYPES', + 'READ_CHEMICAL_PRODUCTS', + 'READ_JOB_CHEMICAL_USAGES', +]; + +itemsToUpdate.forEach(perm => { + content = content.replace(new RegExp(`permissions: '${perm}'`, 'g'), `permissions: '${perm.replace('READ_', 'UPDATE_')}'`); +}); + +// For All Job Logs, we need to be careful not to replace My Logs which is also READ_JOB_LOGS +content = content.replace(/label: 'All Job Logs',\n\s*icon: [^,]+,\n\s*permissions: 'READ_JOB_LOGS'/g, (match) => { + return match.replace("permissions: 'READ_JOB_LOGS'", "permissions: 'UPDATE_JOB_LOGS'"); +}); + +fs.writeFileSync('frontend/src/menuAside.ts', content); +console.log("Patched menuAside.ts"); + diff --git a/patch_menuAside2.js b/patch_menuAside2.js new file mode 100644 index 0000000..dc63f93 --- /dev/null +++ b/patch_menuAside2.js @@ -0,0 +1,14 @@ +const fs = require('fs'); +let content = fs.readFileSync('frontend/src/menuAside.ts', 'utf8'); + +const itemsToUpdate = [ + 'READ_PAYROLL_RUNS', + 'READ_PAYROLL_LINE_ITEMS', +]; + +itemsToUpdate.forEach(perm => { + content = content.replace(new RegExp(`permissions: '${perm}'`, 'g'), `permissions: '${perm.replace('READ_', 'UPDATE_')}'`); +}); + +fs.writeFileSync('frontend/src/menuAside.ts', content); +console.log("Patched payroll in menuAside.ts");