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:
@@ -2,6 +2,7 @@
|
||||
import { IndJob } from '@/lib/types';
|
||||
import { getStatusColor } from '@/utils/jobStatusUtils';
|
||||
import JobCard from './JobCard';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
|
||||
interface JobGroupProps {
|
||||
status: string;
|
||||
@@ -13,6 +14,7 @@ interface JobGroupProps {
|
||||
onUpdateProduced?: (jobId: string, produced: number) => void;
|
||||
onImportBOM?: (jobId: string, items: { name: string; quantity: number }[]) => void;
|
||||
isTracked?: boolean;
|
||||
isLoading?: boolean;
|
||||
}
|
||||
|
||||
const JobGroup: React.FC<JobGroupProps> = ({
|
||||
@@ -24,7 +26,8 @@ const JobGroup: React.FC<JobGroupProps> = ({
|
||||
onDelete,
|
||||
onUpdateProduced,
|
||||
onImportBOM,
|
||||
isTracked = false
|
||||
isTracked = false,
|
||||
isLoading = false
|
||||
}) => {
|
||||
return (
|
||||
<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">
|
||||
<span>{status}</span>
|
||||
<span className="text-gray-200 text-lg">({jobs.length} jobs)</span>
|
||||
{isLoading && <Loader2 className="w-4 h-4 animate-spin" />}
|
||||
</h3>
|
||||
<div className={`text-white text-lg transition-transform ${isCollapsed ? '-rotate-90' : 'rotate-0'}`}>
|
||||
⌄
|
||||
@@ -45,17 +49,24 @@ const JobGroup: React.FC<JobGroupProps> = ({
|
||||
|
||||
{!isCollapsed && (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{jobs.map(job => (
|
||||
<JobCard
|
||||
key={job.id}
|
||||
job={job}
|
||||
onEdit={onEdit}
|
||||
onDelete={onDelete}
|
||||
onUpdateProduced={onUpdateProduced}
|
||||
onImportBOM={onImportBOM}
|
||||
isTracked={isTracked}
|
||||
/>
|
||||
))}
|
||||
{isLoading ? (
|
||||
<div className="col-span-full flex items-center justify-center p-8 text-gray-400">
|
||||
<Loader2 className="w-6 h-6 animate-spin mr-2" />
|
||||
Loading jobs...
|
||||
</div>
|
||||
) : (
|
||||
jobs.map(job => (
|
||||
<JobCard
|
||||
key={job.id}
|
||||
job={job}
|
||||
onEdit={onEdit}
|
||||
onDelete={onDelete}
|
||||
onUpdateProduced={onUpdateProduced}
|
||||
onImportBOM={onImportBOM}
|
||||
isTracked={isTracked}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
@@ -1,4 +1,5 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
|
||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import { dataService } from '@/services/dataService';
|
||||
import { IndJob } from '@/lib/types';
|
||||
|
||||
@@ -6,17 +7,21 @@ export function useJobs() {
|
||||
const [jobs, setJobs] = useState<IndJob[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [loadingStatuses, setLoadingStatuses] = useState<Set<string>>(new Set());
|
||||
const initialLoadComplete = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
let mounted = true;
|
||||
|
||||
const loadJobs = async (visibleStatuses?: string[]) => {
|
||||
const loadJobs = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const loadedJobs = await dataService.loadJobs(visibleStatuses);
|
||||
// Load all jobs initially to get accurate totals
|
||||
const loadedJobs = await dataService.loadJobs();
|
||||
if (mounted) {
|
||||
setJobs(loadedJobs);
|
||||
setError(null);
|
||||
initialLoadComplete.current = true;
|
||||
}
|
||||
} catch (err) {
|
||||
if (mounted) {
|
||||
@@ -62,22 +67,38 @@ export function useJobs() {
|
||||
const createMultipleBillItems = useCallback(dataService.createMultipleBillItems.bind(dataService), []);
|
||||
|
||||
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 {
|
||||
setLoading(true);
|
||||
// Load jobs for specific statuses without showing global loading
|
||||
const loadedJobs = await dataService.loadJobs(visibleStatuses);
|
||||
setJobs(loadedJobs);
|
||||
setError(null);
|
||||
// Jobs will be updated via the subscription, no need to manually update state here
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to load jobs');
|
||||
} 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 {
|
||||
jobs,
|
||||
loading,
|
||||
error,
|
||||
loadingStatuses,
|
||||
createJob,
|
||||
updateJob,
|
||||
deleteJob,
|
||||
|
@@ -1,4 +1,4 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Input } from '@/components/ui/input';
|
||||
@@ -23,6 +23,7 @@ const Index = () => {
|
||||
jobs,
|
||||
loading,
|
||||
error,
|
||||
loadingStatuses,
|
||||
createJob,
|
||||
updateJob,
|
||||
deleteJob,
|
||||
@@ -43,6 +44,10 @@ const Index = () => {
|
||||
return saved ? JSON.parse(saved) : {};
|
||||
});
|
||||
|
||||
// Track scroll position to prevent jarring jumps
|
||||
const scrollPositionRef = useRef<number>(0);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === 'f') {
|
||||
@@ -55,6 +60,16 @@ const Index = () => {
|
||||
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) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-950 p-6 flex items-center justify-center">
|
||||
@@ -170,13 +185,22 @@ const Index = () => {
|
||||
return groups;
|
||||
}, {} 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] };
|
||||
setCollapsedGroups(newState);
|
||||
localStorage.setItem('jobGroupsCollapsed', JSON.stringify(newState));
|
||||
|
||||
if (collapsedGroups[status]) {
|
||||
loadJobsForStatuses([status]);
|
||||
// If expanding and not currently loading this 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 (
|
||||
<div className="container mx-auto p-4 space-y-4">
|
||||
<div ref={containerRef} className="container mx-auto p-4 space-y-4">
|
||||
<SearchOverlay
|
||||
isOpen={searchOpen}
|
||||
onClose={() => {
|
||||
@@ -323,6 +347,7 @@ const Index = () => {
|
||||
onDelete={handleDeleteJob}
|
||||
onUpdateProduced={handleUpdateProduced}
|
||||
onImportBOM={handleImportBOM}
|
||||
isLoading={loadingStatuses.has(status)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@@ -340,6 +365,7 @@ const Index = () => {
|
||||
onUpdateProduced={handleUpdateProduced}
|
||||
onImportBOM={handleImportBOM}
|
||||
isTracked={true}
|
||||
isLoading={loadingStatuses.has('Tracked')}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
@@ -1,3 +1,4 @@
|
||||
|
||||
import { IndJob } from '@/lib/types';
|
||||
import { IndJobRecord, IndJobRecordNoId, IndTransactionRecord, IndTransactionRecordNoId, IndBillitemRecord, IndBillitemRecordNoId } from '@/lib/pbtypes';
|
||||
import * as jobService from './jobService';
|
||||
@@ -11,6 +12,7 @@ export class DataService {
|
||||
private listeners: Set<() => void> = new Set();
|
||||
private loadPromise: Promise<IndJob[]> | null = null;
|
||||
private initialized: Promise<void>;
|
||||
private loadedStatuses: Set<string> = new Set();
|
||||
|
||||
private constructor() {
|
||||
// Initialize with admin login
|
||||
@@ -53,22 +55,38 @@ export class DataService {
|
||||
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) {
|
||||
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
|
||||
console.log('Loading jobs from database', visibleStatuses ? `for statuses: ${visibleStatuses.join(', ')}` : '');
|
||||
this.loadPromise = jobService.getJobs(visibleStatuses).then(jobs => {
|
||||
if (visibleStatuses) {
|
||||
// If filtering by statuses, merge with existing jobs
|
||||
const existingJobs = this.jobs.filter(job => !visibleStatuses.includes(job.status));
|
||||
this.jobs = [...existingJobs, ...jobs];
|
||||
// Mark these statuses as loaded
|
||||
visibleStatuses.forEach(status => this.loadedStatuses.add(status));
|
||||
|
||||
// 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 {
|
||||
// Loading all 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();
|
||||
}).finally(() => {
|
||||
this.loadPromise = null;
|
||||
|
Reference in New Issue
Block a user