mirror of
https://github.com/clucraft/PriceGhost.git
synced 2026-04-25 00:36:32 +02:00
Add per-product pause/resume checking feature
Users can now pause and resume price checking for individual products or in bulk via the Actions menu. Backend: - Added checking_paused column to products table - Scheduler skips products with checking_paused=true - Added POST /products/bulk/pause endpoint for bulk pause/resume Frontend: - Added Pause Checking and Resume Checking to bulk Actions menu - Added filter dropdown (All/Active/Paused) next to sort controls - Paused products show greyed out with pause icon and "Paused" label - Progress bar hidden when paused Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
1f668239bd
commit
26a802e3d0
6 changed files with 176 additions and 6 deletions
|
|
@ -191,6 +191,10 @@ async function runMigrations() {
|
|||
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'products' AND column_name = 'ai_extraction_disabled') THEN
|
||||
ALTER TABLE products ADD COLUMN ai_extraction_disabled BOOLEAN DEFAULT false;
|
||||
END IF;
|
||||
-- Per-product checking pause flag
|
||||
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'products' AND column_name = 'checking_paused') THEN
|
||||
ALTER TABLE products ADD COLUMN checking_paused BOOLEAN DEFAULT false;
|
||||
END IF;
|
||||
END $$;
|
||||
`);
|
||||
|
||||
|
|
|
|||
|
|
@ -344,6 +344,7 @@ export interface Product {
|
|||
notify_back_in_stock: boolean;
|
||||
ai_verification_disabled: boolean;
|
||||
ai_extraction_disabled: boolean;
|
||||
checking_paused: boolean;
|
||||
created_at: Date;
|
||||
}
|
||||
|
||||
|
|
@ -587,8 +588,8 @@ export const productQueries = {
|
|||
findDueForRefresh: async (): Promise<Product[]> => {
|
||||
const result = await pool.query(
|
||||
`SELECT * FROM products
|
||||
WHERE next_check_at IS NULL
|
||||
OR next_check_at < CURRENT_TIMESTAMP`
|
||||
WHERE (next_check_at IS NULL OR next_check_at < CURRENT_TIMESTAMP)
|
||||
AND (checking_paused IS NULL OR checking_paused = false)`
|
||||
);
|
||||
return result.rows;
|
||||
},
|
||||
|
|
@ -638,6 +639,15 @@ export const productQueries = {
|
|||
);
|
||||
return result.rows[0]?.ai_extraction_disabled === true;
|
||||
},
|
||||
|
||||
bulkSetCheckingPaused: async (ids: number[], userId: number, paused: boolean): Promise<number> => {
|
||||
if (ids.length === 0) return 0;
|
||||
const result = await pool.query(
|
||||
`UPDATE products SET checking_paused = $1 WHERE id = ANY($2) AND user_id = $3`,
|
||||
[paused, ids, userId]
|
||||
);
|
||||
return result.rowCount || 0;
|
||||
},
|
||||
};
|
||||
|
||||
// Price History types and queries
|
||||
|
|
|
|||
|
|
@ -268,4 +268,31 @@ router.delete('/:id', async (req: AuthRequest, res: Response) => {
|
|||
}
|
||||
});
|
||||
|
||||
// Bulk pause/resume checking
|
||||
router.post('/bulk/pause', async (req: AuthRequest, res: Response) => {
|
||||
try {
|
||||
const userId = req.userId!;
|
||||
const { ids, paused } = req.body;
|
||||
|
||||
if (!Array.isArray(ids) || ids.length === 0) {
|
||||
res.status(400).json({ error: 'Product IDs array is required' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof paused !== 'boolean') {
|
||||
res.status(400).json({ error: 'Paused status (boolean) is required' });
|
||||
return;
|
||||
}
|
||||
|
||||
const updated = await productQueries.bulkSetCheckingPaused(ids, userId, paused);
|
||||
res.json({
|
||||
message: `${updated} product(s) ${paused ? 'paused' : 'resumed'}`,
|
||||
updated
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error bulk updating pause status:', error);
|
||||
res.status(500).json({ error: 'Failed to update pause status' });
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
|
|
|
|||
|
|
@ -67,6 +67,7 @@ export interface Product {
|
|||
notify_back_in_stock: boolean;
|
||||
ai_verification_disabled: boolean;
|
||||
ai_extraction_disabled: boolean;
|
||||
checking_paused: boolean;
|
||||
created_at: string;
|
||||
current_price: number | null;
|
||||
currency: string | null;
|
||||
|
|
@ -138,6 +139,9 @@ export const productsApi = {
|
|||
}) => api.put<Product>(`/products/${id}`, data),
|
||||
|
||||
delete: (id: number) => api.delete(`/products/${id}`),
|
||||
|
||||
bulkPause: (ids: number[], paused: boolean) =>
|
||||
api.post<{ message: string; updated: number }>('/products/bulk/pause', { ids, paused }),
|
||||
};
|
||||
|
||||
// Prices API
|
||||
|
|
|
|||
|
|
@ -117,8 +117,10 @@ export default function ProductCard({ product, onDelete, onRefresh, isSelected,
|
|||
const isNearHistoricalLow = !isHistoricalLow && product.current_price && product.min_price &&
|
||||
product.current_price <= product.min_price * 1.05; // Within 5% of low
|
||||
|
||||
const isPaused = product.checking_paused;
|
||||
|
||||
return (
|
||||
<div className={`product-list-item ${isOutOfStock ? 'out-of-stock' : ''} ${isSelected ? 'selected' : ''}`}>
|
||||
<div className={`product-list-item ${isOutOfStock ? 'out-of-stock' : ''} ${isSelected ? 'selected' : ''} ${isPaused ? 'checking-paused' : ''}`}>
|
||||
<style>{`
|
||||
.product-list-item {
|
||||
position: relative;
|
||||
|
|
@ -306,6 +308,32 @@ export default function ProductCard({ product, onDelete, onRefresh, isSelected,
|
|||
filter: grayscale(50%);
|
||||
}
|
||||
|
||||
.product-list-item.checking-paused {
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.product-list-item.checking-paused .product-thumbnail {
|
||||
filter: grayscale(70%);
|
||||
}
|
||||
|
||||
.paused-indicator {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
padding: 0.125rem 0.375rem;
|
||||
background: var(--border);
|
||||
color: var(--text-secondary);
|
||||
border-radius: 0.25rem;
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 500;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.paused-indicator svg {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
}
|
||||
|
||||
.product-sparkline {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
|
@ -581,10 +609,20 @@ export default function ProductCard({ product, onDelete, onRefresh, isSelected,
|
|||
<div className="product-progress-container">
|
||||
<div
|
||||
className={`product-progress-bar ${isComplete ? 'complete' : ''}`}
|
||||
style={{ width: `${progress}%` }}
|
||||
style={{ width: isPaused ? 0 : `${progress}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="product-time-remaining">{timeRemaining}</span>
|
||||
{isPaused ? (
|
||||
<span className="paused-indicator">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<rect x="6" y="4" width="4" height="16" />
|
||||
<rect x="14" y="4" width="4" height="16" />
|
||||
</svg>
|
||||
Paused
|
||||
</span>
|
||||
) : (
|
||||
<span className="product-time-remaining">{timeRemaining}</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@ export default function Dashboard() {
|
|||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [pauseFilter, setPauseFilter] = useState<'all' | 'active' | 'paused'>('all');
|
||||
const [sortBy, setSortBy] = useState<SortOption>(() => {
|
||||
const saved = localStorage.getItem('dashboard_sort_by');
|
||||
return (saved as SortOption) || 'date_added';
|
||||
|
|
@ -253,6 +254,42 @@ export default function Dashboard() {
|
|||
}
|
||||
};
|
||||
|
||||
const handleBulkPause = async () => {
|
||||
setIsSavingBulk(true);
|
||||
setShowBulkActions(false);
|
||||
try {
|
||||
await productsApi.bulkPause(Array.from(selectedIds), true);
|
||||
setProducts(prev =>
|
||||
prev.map(p =>
|
||||
selectedIds.has(p.id) ? { ...p, checking_paused: true } : p
|
||||
)
|
||||
);
|
||||
setSelectedIds(new Set());
|
||||
} catch {
|
||||
alert('Failed to pause some products');
|
||||
} finally {
|
||||
setIsSavingBulk(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleBulkResume = async () => {
|
||||
setIsSavingBulk(true);
|
||||
setShowBulkActions(false);
|
||||
try {
|
||||
await productsApi.bulkPause(Array.from(selectedIds), false);
|
||||
setProducts(prev =>
|
||||
prev.map(p =>
|
||||
selectedIds.has(p.id) ? { ...p, checking_paused: false } : p
|
||||
)
|
||||
);
|
||||
setSelectedIds(new Set());
|
||||
} catch {
|
||||
alert('Failed to resume some products');
|
||||
} finally {
|
||||
setIsSavingBulk(false);
|
||||
}
|
||||
};
|
||||
|
||||
const clearSelection = () => {
|
||||
setSelectedIds(new Set());
|
||||
setShowBulkActions(false);
|
||||
|
|
@ -301,6 +338,13 @@ export default function Dashboard() {
|
|||
const filteredAndSortedProducts = useMemo(() => {
|
||||
let result = [...products];
|
||||
|
||||
// Filter by pause status
|
||||
if (pauseFilter === 'active') {
|
||||
result = result.filter(p => !p.checking_paused);
|
||||
} else if (pauseFilter === 'paused') {
|
||||
result = result.filter(p => p.checking_paused);
|
||||
}
|
||||
|
||||
// Filter by search query
|
||||
if (searchQuery.trim()) {
|
||||
const query = searchQuery.toLowerCase();
|
||||
|
|
@ -340,7 +384,7 @@ export default function Dashboard() {
|
|||
});
|
||||
|
||||
return result;
|
||||
}, [products, searchQuery, sortBy, sortOrder]);
|
||||
}, [products, pauseFilter, searchQuery, sortBy, sortOrder]);
|
||||
|
||||
return (
|
||||
<Layout>
|
||||
|
|
@ -431,6 +475,26 @@ export default function Dashboard() {
|
|||
box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.1);
|
||||
}
|
||||
|
||||
.filter-select {
|
||||
padding: 0.75rem 2rem 0.75rem 0.875rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 0.5rem;
|
||||
background: var(--surface);
|
||||
color: var(--text);
|
||||
font-size: 0.9375rem;
|
||||
cursor: pointer;
|
||||
appearance: none;
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12' fill='none' stroke='%236b7280' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M3 4.5L6 7.5L9 4.5'/%3E%3C/svg%3E");
|
||||
background-repeat: no-repeat;
|
||||
background-position: right 0.75rem center;
|
||||
}
|
||||
|
||||
.filter-select:focus {
|
||||
outline: none;
|
||||
border-color: var(--primary);
|
||||
box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.1);
|
||||
}
|
||||
|
||||
.sort-order-btn {
|
||||
padding: 0.75rem;
|
||||
border: 1px solid var(--border);
|
||||
|
|
@ -763,6 +827,15 @@ export default function Dashboard() {
|
|||
/>
|
||||
</div>
|
||||
<div className="sort-controls">
|
||||
<select
|
||||
className="filter-select"
|
||||
value={pauseFilter}
|
||||
onChange={(e) => setPauseFilter(e.target.value as 'all' | 'active' | 'paused')}
|
||||
>
|
||||
<option value="all">All Products</option>
|
||||
<option value="active">Active</option>
|
||||
<option value="paused">Paused</option>
|
||||
</select>
|
||||
<select
|
||||
className="sort-select"
|
||||
value={sortBy}
|
||||
|
|
@ -834,6 +907,20 @@ export default function Dashboard() {
|
|||
Enable Stock Alerts
|
||||
</button>
|
||||
<hr />
|
||||
<button onClick={handleBulkPause}>
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<rect x="6" y="4" width="4" height="16" />
|
||||
<rect x="14" y="4" width="4" height="16" />
|
||||
</svg>
|
||||
Pause Checking
|
||||
</button>
|
||||
<button onClick={handleBulkResume}>
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<polygon points="5 3 19 12 5 21 5 3" />
|
||||
</svg>
|
||||
Resume Checking
|
||||
</button>
|
||||
<hr />
|
||||
<button className="danger" onClick={handleBulkDelete}>
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M3 6h18" />
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue