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 { 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>
|
||||||
|
@@ -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,
|
||||||
|
@@ -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>
|
||||||
)}
|
)}
|
||||||
|
@@ -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;
|
||||||
|
Reference in New Issue
Block a user