Fix jarring job loading and scrolling

Improve job loading to ensure total revenue is accurate and prevent page refresh/scroll to top when expanding job categories.
This commit is contained in:
gpt-engineer-app[bot]
2025-07-09 13:04:27 +00:00
committed by PhatPhuckDave
parent 6ce39c89d0
commit 8b431eaeca
4 changed files with 106 additions and 30 deletions

View File

@@ -2,6 +2,7 @@
import { IndJob } from '@/lib/types'; import { IndJob } from '@/lib/types';
import { getStatusColor } from '@/utils/jobStatusUtils'; import { getStatusColor } from '@/utils/jobStatusUtils';
import JobCard from './JobCard'; import JobCard from './JobCard';
import { Loader2 } from 'lucide-react';
interface JobGroupProps { interface JobGroupProps {
status: string; status: string;
@@ -13,6 +14,7 @@ interface JobGroupProps {
onUpdateProduced?: (jobId: string, produced: number) => void; onUpdateProduced?: (jobId: string, produced: number) => void;
onImportBOM?: (jobId: string, items: { name: string; quantity: number }[]) => void; onImportBOM?: (jobId: string, items: { name: string; quantity: number }[]) => void;
isTracked?: boolean; isTracked?: boolean;
isLoading?: boolean;
} }
const JobGroup: React.FC<JobGroupProps> = ({ const JobGroup: React.FC<JobGroupProps> = ({
@@ -24,7 +26,8 @@ const JobGroup: React.FC<JobGroupProps> = ({
onDelete, onDelete,
onUpdateProduced, onUpdateProduced,
onImportBOM, onImportBOM,
isTracked = false isTracked = false,
isLoading = false
}) => { }) => {
return ( return (
<div className="space-y-4"> <div className="space-y-4">
@@ -36,6 +39,7 @@ const JobGroup: React.FC<JobGroupProps> = ({
<h3 className="text-xl font-semibold text-white flex items-center gap-3"> <h3 className="text-xl font-semibold text-white flex items-center gap-3">
<span>{status}</span> <span>{status}</span>
<span className="text-gray-200 text-lg">({jobs.length} jobs)</span> <span className="text-gray-200 text-lg">({jobs.length} jobs)</span>
{isLoading && <Loader2 className="w-4 h-4 animate-spin" />}
</h3> </h3>
<div className={`text-white text-lg transition-transform ${isCollapsed ? '-rotate-90' : 'rotate-0'}`}> <div className={`text-white text-lg transition-transform ${isCollapsed ? '-rotate-90' : 'rotate-0'}`}>
@@ -45,17 +49,24 @@ const JobGroup: React.FC<JobGroupProps> = ({
{!isCollapsed && ( {!isCollapsed && (
<div className="grid grid-cols-1 md:grid-cols-2 gap-4"> <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{jobs.map(job => ( {isLoading ? (
<JobCard <div className="col-span-full flex items-center justify-center p-8 text-gray-400">
key={job.id} <Loader2 className="w-6 h-6 animate-spin mr-2" />
job={job} Loading jobs...
onEdit={onEdit} </div>
onDelete={onDelete} ) : (
onUpdateProduced={onUpdateProduced} jobs.map(job => (
onImportBOM={onImportBOM} <JobCard
isTracked={isTracked} key={job.id}
/> job={job}
))} onEdit={onEdit}
onDelete={onDelete}
onUpdateProduced={onUpdateProduced}
onImportBOM={onImportBOM}
isTracked={isTracked}
/>
))
)}
</div> </div>
)} )}
</div> </div>

View File

@@ -1,4 +1,5 @@
import { useState, useEffect, useCallback } from 'react';
import { useState, useEffect, useCallback, useRef } from 'react';
import { dataService } from '@/services/dataService'; import { dataService } from '@/services/dataService';
import { IndJob } from '@/lib/types'; import { IndJob } from '@/lib/types';
@@ -6,17 +7,21 @@ export function useJobs() {
const [jobs, setJobs] = useState<IndJob[]>([]); const [jobs, setJobs] = useState<IndJob[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [loadingStatuses, setLoadingStatuses] = useState<Set<string>>(new Set());
const initialLoadComplete = useRef(false);
useEffect(() => { useEffect(() => {
let mounted = true; let mounted = true;
const loadJobs = async (visibleStatuses?: string[]) => { const loadJobs = async () => {
try { try {
setLoading(true); setLoading(true);
const loadedJobs = await dataService.loadJobs(visibleStatuses); // Load all jobs initially to get accurate totals
const loadedJobs = await dataService.loadJobs();
if (mounted) { if (mounted) {
setJobs(loadedJobs); setJobs(loadedJobs);
setError(null); setError(null);
initialLoadComplete.current = true;
} }
} catch (err) { } catch (err) {
if (mounted) { if (mounted) {
@@ -62,22 +67,38 @@ export function useJobs() {
const createMultipleBillItems = useCallback(dataService.createMultipleBillItems.bind(dataService), []); const createMultipleBillItems = useCallback(dataService.createMultipleBillItems.bind(dataService), []);
const loadJobsForStatuses = useCallback(async (visibleStatuses: string[]) => { const loadJobsForStatuses = useCallback(async (visibleStatuses: string[]) => {
// Prevent multiple concurrent loads of the same status
const statusesToLoad = visibleStatuses.filter(status => !loadingStatuses.has(status));
if (statusesToLoad.length === 0) return;
// Mark statuses as loading
setLoadingStatuses(prev => {
const newSet = new Set(prev);
statusesToLoad.forEach(status => newSet.add(status));
return newSet;
});
try { try {
setLoading(true); // Load jobs for specific statuses without showing global loading
const loadedJobs = await dataService.loadJobs(visibleStatuses); const loadedJobs = await dataService.loadJobs(visibleStatuses);
setJobs(loadedJobs); // Jobs will be updated via the subscription, no need to manually update state here
setError(null);
} catch (err) { } catch (err) {
setError(err instanceof Error ? err.message : 'Failed to load jobs'); setError(err instanceof Error ? err.message : 'Failed to load jobs');
} finally { } finally {
setLoading(false); // Remove statuses from loading set
setLoadingStatuses(prev => {
const newSet = new Set(prev);
statusesToLoad.forEach(status => newSet.delete(status));
return newSet;
});
} }
}, []); }, [loadingStatuses]);
return { return {
jobs, jobs,
loading, loading,
error, error,
loadingStatuses,
createJob, createJob,
updateJob, updateJob,
deleteJob, deleteJob,

View File

@@ -1,4 +1,4 @@
import { useState, useEffect } from 'react'; import { useState, useEffect, useRef } from 'react';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
@@ -23,6 +23,7 @@ const Index = () => {
jobs, jobs,
loading, loading,
error, error,
loadingStatuses,
createJob, createJob,
updateJob, updateJob,
deleteJob, deleteJob,
@@ -43,6 +44,10 @@ const Index = () => {
return saved ? JSON.parse(saved) : {}; return saved ? JSON.parse(saved) : {};
}); });
// Track scroll position to prevent jarring jumps
const scrollPositionRef = useRef<number>(0);
const containerRef = useRef<HTMLDivElement>(null);
useEffect(() => { useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => { const handleKeyDown = (e: KeyboardEvent) => {
if ((e.ctrlKey || e.metaKey) && e.key === 'f') { if ((e.ctrlKey || e.metaKey) && e.key === 'f') {
@@ -55,6 +60,16 @@ const Index = () => {
return () => window.removeEventListener('keydown', handleKeyDown); return () => window.removeEventListener('keydown', handleKeyDown);
}, []); }, []);
// Save scroll position before updates
useEffect(() => {
const handleScroll = () => {
scrollPositionRef.current = window.scrollY;
};
window.addEventListener('scroll', handleScroll);
return () => window.removeEventListener('scroll', handleScroll);
}, []);
if (loading) { if (loading) {
return ( return (
<div className="min-h-screen bg-gray-950 p-6 flex items-center justify-center"> <div className="min-h-screen bg-gray-950 p-6 flex items-center justify-center">
@@ -170,13 +185,22 @@ const Index = () => {
return groups; return groups;
}, {} as Record<string, IndJob[]>); }, {} as Record<string, IndJob[]>);
const toggleGroup = (status: string) => { const toggleGroup = async (status: string) => {
// Save current scroll position
const currentScrollY = window.scrollY;
const newState = { ...collapsedGroups, [status]: !collapsedGroups[status] }; const newState = { ...collapsedGroups, [status]: !collapsedGroups[status] };
setCollapsedGroups(newState); setCollapsedGroups(newState);
localStorage.setItem('jobGroupsCollapsed', JSON.stringify(newState)); localStorage.setItem('jobGroupsCollapsed', JSON.stringify(newState));
if (collapsedGroups[status]) { // If expanding and not currently loading this status
loadJobsForStatuses([status]); if (collapsedGroups[status] && !loadingStatuses.has(status)) {
await loadJobsForStatuses([status]);
// Restore scroll position after a brief delay to allow for rendering
setTimeout(() => {
window.scrollTo(0, currentScrollY);
}, 50);
} }
}; };
@@ -208,7 +232,7 @@ const Index = () => {
} }
return ( return (
<div className="container mx-auto p-4 space-y-4"> <div ref={containerRef} className="container mx-auto p-4 space-y-4">
<SearchOverlay <SearchOverlay
isOpen={searchOpen} isOpen={searchOpen}
onClose={() => { onClose={() => {
@@ -323,6 +347,7 @@ const Index = () => {
onDelete={handleDeleteJob} onDelete={handleDeleteJob}
onUpdateProduced={handleUpdateProduced} onUpdateProduced={handleUpdateProduced}
onImportBOM={handleImportBOM} onImportBOM={handleImportBOM}
isLoading={loadingStatuses.has(status)}
/> />
))} ))}
</div> </div>
@@ -340,6 +365,7 @@ const Index = () => {
onUpdateProduced={handleUpdateProduced} onUpdateProduced={handleUpdateProduced}
onImportBOM={handleImportBOM} onImportBOM={handleImportBOM}
isTracked={true} isTracked={true}
isLoading={loadingStatuses.has('Tracked')}
/> />
</div> </div>
)} )}

View File

@@ -1,3 +1,4 @@
import { IndJob } from '@/lib/types'; import { IndJob } from '@/lib/types';
import { IndJobRecord, IndJobRecordNoId, IndTransactionRecord, IndTransactionRecordNoId, IndBillitemRecord, IndBillitemRecordNoId } from '@/lib/pbtypes'; import { IndJobRecord, IndJobRecordNoId, IndTransactionRecord, IndTransactionRecordNoId, IndBillitemRecord, IndBillitemRecordNoId } from '@/lib/pbtypes';
import * as jobService from './jobService'; import * as jobService from './jobService';
@@ -11,6 +12,7 @@ export class DataService {
private listeners: Set<() => void> = new Set(); private listeners: Set<() => void> = new Set();
private loadPromise: Promise<IndJob[]> | null = null; private loadPromise: Promise<IndJob[]> | null = null;
private initialized: Promise<void>; private initialized: Promise<void>;
private loadedStatuses: Set<string> = new Set();
private constructor() { private constructor() {
// Initialize with admin login // Initialize with admin login
@@ -53,22 +55,38 @@ export class DataService {
return this.loadPromise; return this.loadPromise;
} }
// If we already have jobs loaded and no specific statuses requested, return them immediately // If we already have all jobs loaded and no specific statuses requested, return them immediately
if (this.jobs.length > 0 && !visibleStatuses) { if (this.jobs.length > 0 && !visibleStatuses) {
return Promise.resolve(this.getJobs()); return Promise.resolve(this.getJobs());
} }
// If requesting specific statuses that are already loaded, return current jobs
if (visibleStatuses && visibleStatuses.every(status => this.loadedStatuses.has(status))) {
return Promise.resolve(this.getJobs());
}
// Start a new load // Start a new load
console.log('Loading jobs from database', visibleStatuses ? `for statuses: ${visibleStatuses.join(', ')}` : ''); console.log('Loading jobs from database', visibleStatuses ? `for statuses: ${visibleStatuses.join(', ')}` : '');
this.loadPromise = jobService.getJobs(visibleStatuses).then(jobs => { this.loadPromise = jobService.getJobs(visibleStatuses).then(jobs => {
if (visibleStatuses) { if (visibleStatuses) {
// If filtering by statuses, merge with existing jobs // Mark these statuses as loaded
const existingJobs = this.jobs.filter(job => !visibleStatuses.includes(job.status)); visibleStatuses.forEach(status => this.loadedStatuses.add(status));
this.jobs = [...existingJobs, ...jobs];
// Merge with existing jobs, replacing jobs with same IDs
const existingJobIds = new Set(jobs.map(job => job.id));
const otherJobs = this.jobs.filter(job => !existingJobIds.has(job.id));
this.jobs = [...otherJobs, ...jobs];
} else { } else {
// Loading all jobs
this.jobs = jobs; this.jobs = jobs;
// Mark all unique statuses as loaded
const allStatuses = new Set(jobs.map(job => job.status));
allStatuses.forEach(status => this.loadedStatuses.add(status));
} }
this.notifyListeners();
// Use setTimeout to defer the notification to prevent immediate re-renders
setTimeout(() => this.notifyListeners(), 0);
return this.getJobs(); return this.getJobs();
}).finally(() => { }).finally(() => {
this.loadPromise = null; this.loadPromise = null;