Autosave: 20260217-034702

This commit is contained in:
Flatlogic Bot 2026-02-17 03:47:02 +00:00
parent 71e8f75485
commit a7b1502197
7 changed files with 502 additions and 73 deletions

View File

@ -10,7 +10,7 @@ import { useAppSelector } from '../stores/hooks';
type Props = {
menu: MenuNavBarItem[]
className: string
children: ReactNode
children?: ReactNode
}
export default function NavBar({ menu, className = '', children }: Props) {
@ -38,20 +38,30 @@ export default function NavBar({ menu, className = '', children }: Props) {
className={`${className} top-0 inset-x-0 fixed ${bgColor} h-14 z-30 transition-position w-screen lg:w-auto dark:bg-dark-800`}
>
<div className={`flex lg:items-stretch ${containerMaxW} ${isScrolled && `border-b border-pavitra-400 dark:border-dark-700`}`}>
<div className="flex flex-1 items-stretch h-14">{children}</div>
<div className="flex-none items-stretch flex h-14 lg:hidden">
<NavBarItemPlain onClick={handleMenuNavBarToggleClick}>
<BaseIcon path={isMenuNavBarActive ? mdiClose : mdiDotsVertical} size="24" />
</NavBarItemPlain>
{/* Main Menu (Desktop) - Contains All Items including Profile - Centered */}
<div className="hidden lg:flex flex-1 items-stretch justify-center h-14">
<NavBarMenuList menu={menu} />
</div>
{/* Mobile Toggle */}
<div className="flex-none items-stretch flex h-14 lg:hidden ml-auto">
<NavBarItemPlain onClick={handleMenuNavBarToggleClick}>
<BaseIcon path={isMenuNavBarActive ? mdiClose : mdiDotsVertical} size="24" />
</NavBarItemPlain>
</div>
{/* Mobile Menu Dropdown */}
<div
className={`${
isMenuNavBarActive ? 'block' : 'hidden'
} flex items-center max-h-screen-menu overflow-y-auto lg:overflow-visible absolute w-screen top-14 left-0 ${bgColor} shadow-lg lg:w-auto lg:flex lg:static lg:shadow-none dark:bg-dark-800`}
} flex items-center max-h-screen-menu overflow-y-auto lg:hidden absolute w-screen top-14 left-0 ${bgColor} shadow-lg dark:bg-dark-800`}
>
<NavBarMenuList menu={menu} />
<div className="w-full">
<NavBarMenuList menu={menu} />
</div>
</div>
</div>
</nav>
)
}
}

View File

@ -0,0 +1,132 @@
import React, { useState, useEffect } from 'react';
import axios from 'axios';
import moment from 'moment';
import CardBox from './CardBox';
import BaseIcon from './BaseIcon';
import { mdiArrowLeft, mdiArrowRight, mdiCalendarCheck, mdiAlertCircle, mdiDoctor } from '@mdi/js';
interface TimeOffRequest {
id: string;
leave_type: string;
status: string;
starts_at: string;
ends_at: string;
requester: {
firstName: string;
lastName: string;
avatar: string;
email: string;
};
}
export default function StaffOffList() {
const [weekStart, setWeekStart] = useState(moment().startOf('isoWeek'));
const [requests, setRequests] = useState<TimeOffRequest[]>([]);
const [loading, setLoading] = useState(false);
const fetchRequests = async () => {
setLoading(true);
const start = weekStart.format('YYYY-MM-DD');
const end = weekStart.clone().endOf('isoWeek').format('YYYY-MM-DD');
try {
// Backend filter: requests that overlap with the week
// (starts_at <= endOfWeek) AND (ends_at >= startOfWeek)
// Using existing backend range filters which support [min, max]
// Passing null/undefined for open-ended ranges
const params = {
filter: JSON.stringify({
starts_atRange: [null, end],
ends_atRange: [start, null],
}),
};
const response = await axios.get('/time_off_requests', { params });
// Filter client-side for status/type criteria (backend might return more than needed due to basic filtering)
const filtered = response.data.rows.filter((r: any) => {
const isApproved = r.status === 'approved';
const isSpecialType = ['unplanned_pto', 'medical_leave'].includes(r.leave_type);
// Include if approved OR (unplanned/medical and not rejected/cancelled)
const isActive = !['rejected', 'cancelled'].includes(r.status);
return (isApproved || (isSpecialType && isActive));
});
setRequests(filtered);
} catch (error) {
console.error('Failed to fetch staff off requests', error);
} finally {
setLoading(false);
}
};
useEffect(() => {
fetchRequests();
}, [weekStart]);
const prevWeek = () => setWeekStart(weekStart.clone().subtract(1, 'weeks'));
const nextWeek = () => setWeekStart(weekStart.clone().add(1, 'weeks'));
const getIcon = (type: string) => {
switch (type) {
case 'medical_leave': return mdiDoctor;
case 'unplanned_pto': return mdiAlertCircle;
default: return mdiCalendarCheck;
}
};
const getColor = (type: string) => {
switch (type) {
case 'medical_leave': return 'text-red-500';
case 'unplanned_pto': return 'text-orange-500';
default: return 'text-green-500';
}
}
return (
<CardBox className="mb-6 h-full">
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-bold">Staff off this week</h3>
<div className="flex items-center space-x-2">
<button onClick={prevWeek} className="p-1 hover:bg-gray-100 rounded-full dark:hover:bg-slate-700">
<BaseIcon path={mdiArrowLeft} size={24} />
</button>
<span className="font-semibold text-sm">{weekStart.format('MMM D')} - {weekStart.clone().endOf('isoWeek').format('MMM D')}</span>
<button onClick={nextWeek} className="p-1 hover:bg-gray-100 rounded-full dark:hover:bg-slate-700">
<BaseIcon path={mdiArrowRight} size={24} />
</button>
</div>
</div>
{loading ? (
<div className="text-center py-4">Loading...</div>
) : requests.length === 0 ? (
<div className="text-center py-4 text-gray-500">No staff off this week.</div>
) : (
<div className="space-y-3">
{requests.map((r) => (
<div key={r.id} className="flex items-center justify-between p-3 bg-gray-50 rounded-lg dark:bg-slate-800">
<div className="flex items-center space-x-3">
<div className="w-10 h-10 bg-gray-200 dark:bg-slate-700 rounded-full flex items-center justify-center overflow-hidden">
<span className="text-gray-600 dark:text-gray-300 font-bold">
{r.requester?.firstName?.[0]}{r.requester?.lastName?.[0]}
</span>
</div>
<div>
<div className="font-medium">{r.requester?.firstName} {r.requester?.lastName}</div>
<div className={`text-xs flex items-center ${getColor(r.leave_type)}`}>
<BaseIcon path={getIcon(r.leave_type)} size={14} className="mr-1"/>
<span className="capitalize">{r.leave_type.replace('_', ' ')}</span>
</div>
</div>
</div>
<div className="text-sm text-gray-500 dark:text-gray-400 whitespace-nowrap ml-2">
{moment(r.starts_at).format('ddd D')} - {moment(r.ends_at).format('ddd D')}
</div>
</div>
))}
</div>
)}
</CardBox>
);
}

View File

@ -4,13 +4,11 @@ import menuNavBar from '../menuNavBar'
import NavBar from '../components/NavBar'
import FooterBar from '../components/FooterBar'
import { useAppDispatch, useAppSelector } from '../stores/hooks'
import Search from '../components/Search';
import { useRouter } from 'next/router'
import {findMe, logoutUser} from "../stores/authSlice";
import axios from 'axios';
import { mdiAlertCircle } from '@mdi/js';
import BaseIcon from '../components/BaseIcon';
import NavBarItemPlain from '../components/NavBarItemPlain';
import moment from 'moment';
import {hasPermission} from "../helpers/userPermissions";
@ -103,11 +101,7 @@ export default function LayoutAuthenticated({
<NavBar
menu={menuNavBar}
className={`${lockoutBanner ? 'top-12' : ''}`}
>
<NavBarItemPlain useMargin>
<Search />
</NavBarItemPlain>
</NavBar>
/>
<div className="max-w-7xl mx-auto">
{children}
</div>
@ -115,4 +109,4 @@ export default function LayoutAuthenticated({
</div>
</div>
)
}
}

View File

@ -26,6 +26,12 @@ const menuNavBar: MenuNavBarItem[] = [
icon: mdiViewDashboardOutline,
label: 'Home',
},
{
href: '/users/users-list',
label: 'Users',
icon: mdiAccountGroup,
permissions: 'READ_USERS'
},
{
label: 'PTO',
icon: mdiCalendarClock,
@ -119,6 +125,11 @@ const menuNavBar: MenuNavBarItem[] = [
label: 'My Profile',
href: '/profile',
},
{
icon: mdiThemeLightDark,
label: 'Dark Mode',
isToggleLightDark: true,
},
{
isDivider: true,
},
@ -129,12 +140,6 @@ const menuNavBar: MenuNavBarItem[] = [
},
],
},
{
icon: mdiThemeLightDark,
label: 'Light/Dark',
isDesktopNoLabel: true,
isToggleLightDark: true,
}
]
export const webPagesNavBar = [

View File

@ -13,6 +13,9 @@ import { useAppSelector } from '../stores/hooks'
import PTOStats from '../components/PTOStats'
import moment from 'moment'
import Link from 'next/link'
import StaffOffList from '../components/StaffOffList'
import Search from '../components/Search'
import { mdiPlusBox, mdiHospitalBox, mdiAlertDecagram } from '@mdi/js'
const Dashboard = () => {
const { currentUser } = useAppSelector((state) => state.auth)
@ -20,6 +23,7 @@ const Dashboard = () => {
const [summary, setSummary] = useState(null)
const [approvals, setApprovals] = useState([])
const [loading, setLoading] = useState(true)
const [greeting, setGreeting] = useState('Hello')
const fetchDashboardData = async () => {
setLoading(true)
@ -58,6 +62,13 @@ const Dashboard = () => {
}
}, [currentUser, selectedYear])
useEffect(() => {
const hour = new Date().getHours()
if (hour < 12) setGreeting('Good morning')
else if (hour < 18) setGreeting('Good afternoon')
else setGreeting('Good evening')
}, [])
const handleApprove = async (taskId) => {
try {
await axios.put(`/approval_tasks/${taskId}/approve`);
@ -77,18 +88,27 @@ const Dashboard = () => {
<title>{getPageTitle('Home')}</title>
</Head>
<SectionMain>
<SectionTitleLineWithButton icon={icon.mdiHome} title="Home" main>
<div className="flex items-center space-x-2">
<span className="text-sm text-gray-500">Year:</span>
<select
value={selectedYear}
onChange={(e) => setSelectedYear(parseInt(e.target.value))}
className="pl-2 pr-10 py-1 border rounded dark:bg-dark-800 dark:border-dark-700 dark:text-white"
>
{years.map(y => (
<option key={y} value={y}>{y}</option>
))}
</select>
<SectionTitleLineWithButton
icon={icon.mdiHome}
title={`${greeting}, ${currentUser?.firstName || 'User'}`}
main
>
<div className="flex items-center">
<div className="mr-6">
<Search />
</div>
<div className="flex items-center space-x-2">
<span className="text-sm text-gray-500">Year:</span>
<select
value={selectedYear}
onChange={(e) => setSelectedYear(parseInt(e.target.value))}
className="pl-2 pr-10 py-1 border rounded dark:bg-dark-800 dark:border-dark-700 dark:text-white"
>
{years.map(y => (
<option key={y} value={y}>{y}</option>
))}
</select>
</div>
</div>
</SectionTitleLineWithButton>
@ -102,60 +122,101 @@ const Dashboard = () => {
}}
/>
<div className="grid grid-cols-1 gap-6">
{/* Action Items (Approvals) */}
<CardBox className="flex-1" hasTable>
{/* Action Buttons */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 mb-6">
<CardBox className="flex flex-col items-center justify-center p-6 cursor-pointer hover:shadow-lg transition-shadow" >
<BaseButton
href="/time_off_requests/time_off_requests-new"
icon={mdiPlusBox}
label="Request Time Off"
color="info"
className="w-full mb-2"
iconSize={24}
/>
<p className="text-gray-500 text-sm text-center">Submit a standard PTO request.</p>
</CardBox>
<CardBox className="flex flex-col items-center justify-center p-6 cursor-pointer hover:shadow-lg transition-shadow">
<BaseButton
href="/time_off_requests/time_off_requests-new?leave_type=medical_leave"
icon={mdiHospitalBox}
label="Take Medical Day"
color="danger"
className="w-full mb-2"
iconSize={24}
/>
<p className="text-gray-500 text-sm text-center">Report a sick day or medical leave.</p>
</CardBox>
<CardBox className="flex flex-col items-center justify-center p-6 cursor-pointer hover:shadow-lg transition-shadow">
<BaseButton
href="/time_off_requests/time_off_requests-new?leave_type=unplanned_pto"
icon={mdiAlertDecagram}
label="Take Unplanned PTO"
color="warning"
className="w-full mb-2"
iconSize={24}
/>
<p className="text-gray-500 text-sm text-center">Log unplanned absence.</p>
</CardBox>
</div>
{/* Action Items (Approvals) - Full Width */}
<CardBox className="mb-6" hasTable>
<div className="p-4 border-b dark:border-dark-700 flex justify-between items-center">
<h3 className="font-bold">Action Items (Pending Approvals)</h3>
<Link href="/approval_tasks/approval_tasks-list" className="text-sm text-blue-500 hover:underline">
<h3 className="font-bold">Action Items (Pending Approvals)</h3>
<Link href="/approval_tasks/approval_tasks-list" className="text-sm text-blue-500 hover:underline">
View All
</Link>
</Link>
</div>
<div className="overflow-x-auto">
<table className="w-full text-sm text-left">
<table className="w-full text-sm text-left">
<thead>
<tr className="border-b dark:border-dark-700">
<tr className="border-b dark:border-dark-700">
<th className="p-4">Requester</th>
<th className="p-4">Type</th>
<th className="p-4">Dates</th>
<th className="p-4">Actions</th>
</tr>
</tr>
</thead>
<tbody>
{approvals.length > 0 ? (
{approvals.length > 0 ? (
approvals.map((task) => (
<tr key={task.id} className="border-b dark:border-dark-700">
<tr key={task.id} className="border-b dark:border-dark-700">
<td className="p-4">{task.time_off_request?.requester?.firstName} {task.time_off_request?.requester?.lastName}</td>
<td className="p-4 capitalize">{task.task_type?.replace(/_/g, ' ')}</td>
<td className="p-4">
{moment(task.time_off_request?.starts_at).format('MMM D')} - {moment(task.time_off_request?.ends_at).format('MMM D')}
{moment(task.time_off_request?.starts_at).format('MMM D')} - {moment(task.time_off_request?.ends_at).format('MMM D')}
</td>
<td className="p-4 whitespace-nowrap flex space-x-2">
<BaseButton
<BaseButton
color="info"
label="Review"
small
href={`/approval_tasks/approval_tasks-edit?id=${task.id}`}
/>
<BaseButton
/>
<BaseButton
color="success"
label="Approve"
small
onClick={() => handleApprove(task.id)}
/>
/>
</td>
</tr>
))
) : (
<tr>
<td colSpan={4} className="p-4 text-center text-gray-500">No pending approvals</td>
</tr>
)}
))
) : (
<tr>
<td colSpan={4} className="p-4 text-center text-gray-500">No pending approvals</td>
</tr>
)}
</tbody>
</table>
</table>
</div>
</CardBox>
</div>
</CardBox>
{/* Staff Off This Week - Full Width */}
<StaffOffList />
</SectionMain>
</>
)

View File

@ -1,12 +1,229 @@
import { useEffect } from 'react';
import { useRouter } from 'next/router';
import * as icon from '@mdi/js';
import Head from 'next/head'
import React, { useState, useEffect } from 'react'
import axios from 'axios';
import type { ReactElement } from 'react'
import LayoutAuthenticated from '../layouts/Authenticated'
import SectionMain from '../components/SectionMain'
import SectionTitleLineWithButton from '../components/SectionTitleLineWithButton'
import CardBox from '../components/CardBox'
import BaseButton from '../components/BaseButton'
import { getPageTitle } from '../config'
import { useAppSelector } from '../stores/hooks'
import PTOStats from '../components/PTOStats'
import moment from 'moment'
import Link from 'next/link'
import StaffOffList from '../components/StaffOffList'
import Search from '../components/Search'
import { mdiPlusBox, mdiHospitalBox, mdiAlertDecagram } from '@mdi/js'
export default function Starter() {
const router = useRouter();
const Dashboard = () => {
const { currentUser } = useAppSelector((state) => state.auth)
const [selectedYear, setSelectedYear] = useState(new Date().getFullYear())
const [summary, setSummary] = useState(null)
const [approvals, setApprovals] = useState([])
const [loading, setLoading] = useState(true)
const [greeting, setGreeting] = useState('Hello')
const fetchDashboardData = async () => {
setLoading(true)
try {
// Fetch PTO Summary for selected year
const summaryRes = await axios.get(`/yearly_leave_summaries`, {
params: {
filter: JSON.stringify({
userId: currentUser?.id,
calendar_year: selectedYear
})
}
})
setSummary(summaryRes.data.rows[0] || null)
// Fetch Pending Approvals if manager/admin
const approvalsRes = await axios.get(`/approval_tasks`, {
params: {
filter: JSON.stringify({
state: 'open'
})
}
})
setApprovals(approvalsRes.data.rows)
} catch (error) {
console.error('Error fetching dashboard data:', error)
} finally {
setLoading(false)
}
}
useEffect(() => {
router.replace('/login');
}, [router]);
if (currentUser) {
fetchDashboardData()
}
}, [currentUser, selectedYear])
return null;
}
useEffect(() => {
const hour = new Date().getHours()
if (hour < 12) setGreeting('Good morning')
else if (hour < 18) setGreeting('Good afternoon')
else setGreeting('Good evening')
}, [])
const handleApprove = async (taskId) => {
try {
await axios.put(`/approval_tasks/${taskId}/approve`);
// Refresh data
fetchDashboardData();
} catch (error) {
console.error('Error approving task:', error);
alert('Failed to approve task');
}
};
const years = [selectedYear - 1, selectedYear, selectedYear + 1, selectedYear + 2]
return (
<>
<Head>
<title>{getPageTitle('Home')}</title>
</Head>
<SectionMain>
<SectionTitleLineWithButton
icon={icon.mdiHome}
title={`${greeting}, ${currentUser?.firstName || 'User'}`}
main
>
<div className="flex items-center">
<div className="mr-6">
<Search />
</div>
<div className="flex items-center space-x-2">
<span className="text-sm text-gray-500">Year:</span>
<select
value={selectedYear}
onChange={(e) => setSelectedYear(parseInt(e.target.value))}
className="pl-2 pr-10 py-1 border rounded dark:bg-dark-800 dark:border-dark-700 dark:text-white"
>
{years.map(y => (
<option key={y} value={y}>{y}</option>
))}
</select>
</div>
</div>
</SectionTitleLineWithButton>
{/* PTO Summary Stats */}
<PTOStats
summary={summary || {
pto_pending_days: 0,
pto_scheduled_days: 0,
pto_available_days: 0,
medical_taken_days: 0
}}
/>
{/* Action Buttons */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 mb-6">
<CardBox className="flex flex-col items-center justify-center p-6 cursor-pointer hover:shadow-lg transition-shadow" >
<BaseButton
href="/time_off_requests/time_off_requests-new"
icon={mdiPlusBox}
label="Request Time Off"
color="info"
className="w-full mb-2"
iconSize={24}
/>
<p className="text-gray-500 text-sm text-center">Submit a standard PTO request.</p>
</CardBox>
<CardBox className="flex flex-col items-center justify-center p-6 cursor-pointer hover:shadow-lg transition-shadow">
<BaseButton
href="/time_off_requests/time_off_requests-new?leave_type=medical_leave"
icon={mdiHospitalBox}
label="Take Medical Day"
color="danger"
className="w-full mb-2"
iconSize={24}
/>
<p className="text-gray-500 text-sm text-center">Report a sick day or medical leave.</p>
</CardBox>
<CardBox className="flex flex-col items-center justify-center p-6 cursor-pointer hover:shadow-lg transition-shadow">
<BaseButton
href="/time_off_requests/time_off_requests-new?leave_type=unplanned_pto"
icon={mdiAlertDecagram}
label="Take Unplanned PTO"
color="warning"
className="w-full mb-2"
iconSize={24}
/>
<p className="text-gray-500 text-sm text-center">Log unplanned absence.</p>
</CardBox>
</div>
{/* Action Items (Approvals) - Full Width */}
<CardBox className="mb-6" hasTable>
<div className="p-4 border-b dark:border-dark-700 flex justify-between items-center">
<h3 className="font-bold">Action Items (Pending Approvals)</h3>
<Link href="/approval_tasks/approval_tasks-list" className="text-sm text-blue-500 hover:underline">
View All
</Link>
</div>
<div className="overflow-x-auto">
<table className="w-full text-sm text-left">
<thead>
<tr className="border-b dark:border-dark-700">
<th className="p-4">Requester</th>
<th className="p-4">Type</th>
<th className="p-4">Dates</th>
<th className="p-4">Actions</th>
</tr>
</thead>
<tbody>
{approvals.length > 0 ? (
approvals.map((task) => (
<tr key={task.id} className="border-b dark:border-dark-700">
<td className="p-4">{task.time_off_request?.requester?.firstName} {task.time_off_request?.requester?.lastName}</td>
<td className="p-4 capitalize">{task.task_type?.replace(/_/g, ' ')}</td>
<td className="p-4">
{moment(task.time_off_request?.starts_at).format('MMM D')} - {moment(task.time_off_request?.ends_at).format('MMM D')}
</td>
<td className="p-4 whitespace-nowrap flex space-x-2">
<BaseButton
color="info"
label="Review"
small
href={`/approval_tasks/approval_tasks-edit?id=${task.id}`}
/>
<BaseButton
color="success"
label="Approve"
small
onClick={() => handleApprove(task.id)}
/>
</td>
</tr>
))
) : (
<tr>
<td colSpan={4} className="p-4 text-center text-gray-500">No pending approvals</td>
</tr>
)}
</tbody>
</table>
</div>
</CardBox>
{/* Staff Off This Week - Full Width */}
<StaffOffList />
</SectionMain>
</>
)
}
Dashboard.getLayout = function getLayout(page: ReactElement) {
return <LayoutAuthenticated>{page}</LayoutAuthenticated>
}
export default Dashboard

View File

@ -93,12 +93,13 @@ const Time_off_requestsNew = () => {
if (currentUser) {
setFormInitialValues((prev) => ({
...prev,
requester: currentUser.id,
requester: currentUser,
submitted_at: moment().format('YYYY-MM-DDTHH:mm'),
date_requested: moment().format('YYYY-MM-DD')
date_requested: moment().format('YYYY-MM-DD'),
leave_type: (router.query.leave_type as string) || prev.leave_type
}));
}
}, [currentUser]);
}, [currentUser, router.query]);
const handleSubmit = async (data) => {
// Ensure hidden fields are set correctly if form didn't touch them
@ -131,6 +132,8 @@ const Time_off_requestsNew = () => {
payload.submitted_at = moment().format('YYYY-MM-DDTHH:mm');
if (currentUser && !payload.requester) {
payload.requester = currentUser.id;
} else if (typeof payload.requester === 'object' && payload.requester?.id) {
payload.requester = payload.requester.id;
}
await dispatch(create(payload))
@ -160,7 +163,14 @@ const Time_off_requestsNew = () => {
{/* Requester - Only visible to Admin */}
{isAdmin && (
<FormField label="Requester" labelFor="requester">
<Field name="requester" id="requester" component={SelectField} options={[]} itemRef={'users'}></Field>
<Field
name="requester"
id="requester"
component={SelectField}
options={currentUser}
itemRef={'users'}
showField="firstName"
></Field>
</FormField>
)}
@ -228,4 +238,4 @@ Time_off_requestsNew.getLayout = function getLayout(page: ReactElement) {
)
}
export default Time_off_requestsNew
export default Time_off_requestsNew