Autosave: 20260217-034702
This commit is contained in:
parent
71e8f75485
commit
a7b1502197
@ -10,7 +10,7 @@ import { useAppSelector } from '../stores/hooks';
|
|||||||
type Props = {
|
type Props = {
|
||||||
menu: MenuNavBarItem[]
|
menu: MenuNavBarItem[]
|
||||||
className: string
|
className: string
|
||||||
children: ReactNode
|
children?: ReactNode
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function NavBar({ menu, className = '', children }: Props) {
|
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`}
|
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 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">
|
{/* Main Menu (Desktop) - Contains All Items including Profile - Centered */}
|
||||||
<NavBarItemPlain onClick={handleMenuNavBarToggleClick}>
|
<div className="hidden lg:flex flex-1 items-stretch justify-center h-14">
|
||||||
<BaseIcon path={isMenuNavBarActive ? mdiClose : mdiDotsVertical} size="24" />
|
<NavBarMenuList menu={menu} />
|
||||||
</NavBarItemPlain>
|
|
||||||
</div>
|
</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
|
<div
|
||||||
className={`${
|
className={`${
|
||||||
isMenuNavBarActive ? 'block' : 'hidden'
|
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>
|
||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
132
frontend/src/components/StaffOffList.tsx
Normal file
132
frontend/src/components/StaffOffList.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -4,13 +4,11 @@ import menuNavBar from '../menuNavBar'
|
|||||||
import NavBar from '../components/NavBar'
|
import NavBar from '../components/NavBar'
|
||||||
import FooterBar from '../components/FooterBar'
|
import FooterBar from '../components/FooterBar'
|
||||||
import { useAppDispatch, useAppSelector } from '../stores/hooks'
|
import { useAppDispatch, useAppSelector } from '../stores/hooks'
|
||||||
import Search from '../components/Search';
|
|
||||||
import { useRouter } from 'next/router'
|
import { useRouter } from 'next/router'
|
||||||
import {findMe, logoutUser} from "../stores/authSlice";
|
import {findMe, logoutUser} from "../stores/authSlice";
|
||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
import { mdiAlertCircle } from '@mdi/js';
|
import { mdiAlertCircle } from '@mdi/js';
|
||||||
import BaseIcon from '../components/BaseIcon';
|
import BaseIcon from '../components/BaseIcon';
|
||||||
import NavBarItemPlain from '../components/NavBarItemPlain';
|
|
||||||
import moment from 'moment';
|
import moment from 'moment';
|
||||||
|
|
||||||
import {hasPermission} from "../helpers/userPermissions";
|
import {hasPermission} from "../helpers/userPermissions";
|
||||||
@ -103,11 +101,7 @@ export default function LayoutAuthenticated({
|
|||||||
<NavBar
|
<NavBar
|
||||||
menu={menuNavBar}
|
menu={menuNavBar}
|
||||||
className={`${lockoutBanner ? 'top-12' : ''}`}
|
className={`${lockoutBanner ? 'top-12' : ''}`}
|
||||||
>
|
/>
|
||||||
<NavBarItemPlain useMargin>
|
|
||||||
<Search />
|
|
||||||
</NavBarItemPlain>
|
|
||||||
</NavBar>
|
|
||||||
<div className="max-w-7xl mx-auto">
|
<div className="max-w-7xl mx-auto">
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
@ -115,4 +109,4 @@ export default function LayoutAuthenticated({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@ -26,6 +26,12 @@ const menuNavBar: MenuNavBarItem[] = [
|
|||||||
icon: mdiViewDashboardOutline,
|
icon: mdiViewDashboardOutline,
|
||||||
label: 'Home',
|
label: 'Home',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
href: '/users/users-list',
|
||||||
|
label: 'Users',
|
||||||
|
icon: mdiAccountGroup,
|
||||||
|
permissions: 'READ_USERS'
|
||||||
|
},
|
||||||
{
|
{
|
||||||
label: 'PTO',
|
label: 'PTO',
|
||||||
icon: mdiCalendarClock,
|
icon: mdiCalendarClock,
|
||||||
@ -119,6 +125,11 @@ const menuNavBar: MenuNavBarItem[] = [
|
|||||||
label: 'My Profile',
|
label: 'My Profile',
|
||||||
href: '/profile',
|
href: '/profile',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
icon: mdiThemeLightDark,
|
||||||
|
label: 'Dark Mode',
|
||||||
|
isToggleLightDark: true,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
isDivider: true,
|
isDivider: true,
|
||||||
},
|
},
|
||||||
@ -129,12 +140,6 @@ const menuNavBar: MenuNavBarItem[] = [
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
|
||||||
icon: mdiThemeLightDark,
|
|
||||||
label: 'Light/Dark',
|
|
||||||
isDesktopNoLabel: true,
|
|
||||||
isToggleLightDark: true,
|
|
||||||
}
|
|
||||||
]
|
]
|
||||||
|
|
||||||
export const webPagesNavBar = [
|
export const webPagesNavBar = [
|
||||||
|
|||||||
@ -13,6 +13,9 @@ import { useAppSelector } from '../stores/hooks'
|
|||||||
import PTOStats from '../components/PTOStats'
|
import PTOStats from '../components/PTOStats'
|
||||||
import moment from 'moment'
|
import moment from 'moment'
|
||||||
import Link from 'next/link'
|
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 Dashboard = () => {
|
||||||
const { currentUser } = useAppSelector((state) => state.auth)
|
const { currentUser } = useAppSelector((state) => state.auth)
|
||||||
@ -20,6 +23,7 @@ const Dashboard = () => {
|
|||||||
const [summary, setSummary] = useState(null)
|
const [summary, setSummary] = useState(null)
|
||||||
const [approvals, setApprovals] = useState([])
|
const [approvals, setApprovals] = useState([])
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [greeting, setGreeting] = useState('Hello')
|
||||||
|
|
||||||
const fetchDashboardData = async () => {
|
const fetchDashboardData = async () => {
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
@ -58,6 +62,13 @@ const Dashboard = () => {
|
|||||||
}
|
}
|
||||||
}, [currentUser, selectedYear])
|
}, [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) => {
|
const handleApprove = async (taskId) => {
|
||||||
try {
|
try {
|
||||||
await axios.put(`/approval_tasks/${taskId}/approve`);
|
await axios.put(`/approval_tasks/${taskId}/approve`);
|
||||||
@ -77,18 +88,27 @@ const Dashboard = () => {
|
|||||||
<title>{getPageTitle('Home')}</title>
|
<title>{getPageTitle('Home')}</title>
|
||||||
</Head>
|
</Head>
|
||||||
<SectionMain>
|
<SectionMain>
|
||||||
<SectionTitleLineWithButton icon={icon.mdiHome} title="Home" main>
|
<SectionTitleLineWithButton
|
||||||
<div className="flex items-center space-x-2">
|
icon={icon.mdiHome}
|
||||||
<span className="text-sm text-gray-500">Year:</span>
|
title={`${greeting}, ${currentUser?.firstName || 'User'}`}
|
||||||
<select
|
main
|
||||||
value={selectedYear}
|
>
|
||||||
onChange={(e) => setSelectedYear(parseInt(e.target.value))}
|
<div className="flex items-center">
|
||||||
className="pl-2 pr-10 py-1 border rounded dark:bg-dark-800 dark:border-dark-700 dark:text-white"
|
<div className="mr-6">
|
||||||
>
|
<Search />
|
||||||
{years.map(y => (
|
</div>
|
||||||
<option key={y} value={y}>{y}</option>
|
<div className="flex items-center space-x-2">
|
||||||
))}
|
<span className="text-sm text-gray-500">Year:</span>
|
||||||
</select>
|
<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>
|
</div>
|
||||||
</SectionTitleLineWithButton>
|
</SectionTitleLineWithButton>
|
||||||
|
|
||||||
@ -102,60 +122,101 @@ const Dashboard = () => {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 gap-6">
|
{/* Action Buttons */}
|
||||||
{/* Action Items (Approvals) */}
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 mb-6">
|
||||||
<CardBox className="flex-1" hasTable>
|
<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">
|
<div className="p-4 border-b dark:border-dark-700 flex justify-between items-center">
|
||||||
<h3 className="font-bold">Action Items (Pending Approvals)</h3>
|
<h3 className="font-bold">Action Items (Pending Approvals)</h3>
|
||||||
<Link href="/approval_tasks/approval_tasks-list" className="text-sm text-blue-500 hover:underline">
|
<Link href="/approval_tasks/approval_tasks-list" className="text-sm text-blue-500 hover:underline">
|
||||||
View All
|
View All
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto">
|
||||||
<table className="w-full text-sm text-left">
|
<table className="w-full text-sm text-left">
|
||||||
<thead>
|
<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">Requester</th>
|
||||||
<th className="p-4">Type</th>
|
<th className="p-4">Type</th>
|
||||||
<th className="p-4">Dates</th>
|
<th className="p-4">Dates</th>
|
||||||
<th className="p-4">Actions</th>
|
<th className="p-4">Actions</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{approvals.length > 0 ? (
|
{approvals.length > 0 ? (
|
||||||
approvals.map((task) => (
|
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">{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 capitalize">{task.task_type?.replace(/_/g, ' ')}</td>
|
||||||
<td className="p-4">
|
<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>
|
||||||
<td className="p-4 whitespace-nowrap flex space-x-2">
|
<td className="p-4 whitespace-nowrap flex space-x-2">
|
||||||
<BaseButton
|
<BaseButton
|
||||||
color="info"
|
color="info"
|
||||||
label="Review"
|
label="Review"
|
||||||
small
|
small
|
||||||
href={`/approval_tasks/approval_tasks-edit?id=${task.id}`}
|
href={`/approval_tasks/approval_tasks-edit?id=${task.id}`}
|
||||||
/>
|
/>
|
||||||
<BaseButton
|
<BaseButton
|
||||||
color="success"
|
color="success"
|
||||||
label="Approve"
|
label="Approve"
|
||||||
small
|
small
|
||||||
onClick={() => handleApprove(task.id)}
|
onClick={() => handleApprove(task.id)}
|
||||||
/>
|
/>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
|
||||||
))
|
|
||||||
) : (
|
|
||||||
<tr>
|
|
||||||
<td colSpan={4} className="p-4 text-center text-gray-500">No pending approvals</td>
|
|
||||||
</tr>
|
</tr>
|
||||||
)}
|
))
|
||||||
|
) : (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={4} className="p-4 text-center text-gray-500">No pending approvals</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
</CardBox>
|
</CardBox>
|
||||||
</div>
|
|
||||||
|
{/* Staff Off This Week - Full Width */}
|
||||||
|
<StaffOffList />
|
||||||
|
|
||||||
</SectionMain>
|
</SectionMain>
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
|
|||||||
@ -1,12 +1,229 @@
|
|||||||
import { useEffect } from 'react';
|
import * as icon from '@mdi/js';
|
||||||
import { useRouter } from 'next/router';
|
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 Dashboard = () => {
|
||||||
const router = useRouter();
|
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(() => {
|
useEffect(() => {
|
||||||
router.replace('/login');
|
if (currentUser) {
|
||||||
}, [router]);
|
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
|
||||||
@ -93,12 +93,13 @@ const Time_off_requestsNew = () => {
|
|||||||
if (currentUser) {
|
if (currentUser) {
|
||||||
setFormInitialValues((prev) => ({
|
setFormInitialValues((prev) => ({
|
||||||
...prev,
|
...prev,
|
||||||
requester: currentUser.id,
|
requester: currentUser,
|
||||||
submitted_at: moment().format('YYYY-MM-DDTHH:mm'),
|
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) => {
|
const handleSubmit = async (data) => {
|
||||||
// Ensure hidden fields are set correctly if form didn't touch them
|
// 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');
|
payload.submitted_at = moment().format('YYYY-MM-DDTHH:mm');
|
||||||
if (currentUser && !payload.requester) {
|
if (currentUser && !payload.requester) {
|
||||||
payload.requester = currentUser.id;
|
payload.requester = currentUser.id;
|
||||||
|
} else if (typeof payload.requester === 'object' && payload.requester?.id) {
|
||||||
|
payload.requester = payload.requester.id;
|
||||||
}
|
}
|
||||||
|
|
||||||
await dispatch(create(payload))
|
await dispatch(create(payload))
|
||||||
@ -160,7 +163,14 @@ const Time_off_requestsNew = () => {
|
|||||||
{/* Requester - Only visible to Admin */}
|
{/* Requester - Only visible to Admin */}
|
||||||
{isAdmin && (
|
{isAdmin && (
|
||||||
<FormField label="Requester" labelFor="requester">
|
<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>
|
</FormField>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@ -228,4 +238,4 @@ Time_off_requestsNew.getLayout = function getLayout(page: ReactElement) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export default Time_off_requestsNew
|
export default Time_off_requestsNew
|
||||||
Loading…
x
Reference in New Issue
Block a user