All files / src/pages/admin imports.tsx

89.18% Statements 33/37
85.71% Branches 36/42
100% Functions 10/10
100% Lines 29/29

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193              1x                 10x 10x 10x 10x 9x 9x 8x       9x 9x 9x   9x 10x     9x   9x 1x 2x   9x 1x 1x 1x 1x   9x 1x 1x 1x     9x                                                                                                                                                                       10x                 1x                                                                                              
import { Link } from "@tanstack/react-router";
import { useState } from "react";
import {
	useAdminImports,
	useBulkApproveImports,
} from "../../hooks/queries/useAdminImports";
 
const STATUS_STYLES: Record<string, string> = {
	queued: "bg-stone-100 text-stone-700",
	running: "bg-blue-100 text-blue-700",
	completed: "bg-green-100 text-green-700",
	failed: "bg-red-100 text-red-700",
	cancelled: "bg-stone-200 text-stone-600",
};
 
function formatRelativeTime(ts: number): string {
	const diff = Date.now() - ts;
	const minutes = Math.round(diff / 60_000);
	Iif (minutes < 1) return "just now";
	if (minutes < 60) return `${minutes}m ago`;
	const hours = Math.round(minutes / 60);
	if (hours < 24) return `${hours}h ago`;
	return new Date(ts).toLocaleDateString();
}
 
export function AdminImportsPage() {
	const { data: imports, isLoading, error } = useAdminImports();
	const [selected, setSelected] = useState<Set<string>>(new Set());
	const bulkApprove = useBulkApproveImports();
 
	const approvable = (imports ?? []).filter(
		(r) => r.status === "completed" && !r.approvedAt,
	);
	const allChecked =
		approvable.length > 0 && approvable.every((r) => selected.has(r.id));
 
	const toggleAll = () => {
		Iif (allChecked) setSelected(new Set());
		else setSelected(new Set(approvable.map((r) => r.id)));
	};
	const toggleOne = (id: string) => {
		const next = new Set(selected);
		Iif (next.has(id)) next.delete(id);
		else next.add(id);
		setSelected(next);
	};
	const onBulkApprove = async () => {
		Iif (selected.size === 0) return;
		await bulkApprove.mutateAsync(Array.from(selected));
		setSelected(new Set());
	};
 
	return (
		<div className="p-6 max-w-7xl mx-auto">
			<div className="flex items-center justify-between mb-6">
				<div>
					<h1 className="text-2xl font-semibold text-stone-900">
						Pro Imports
					</h1>
					<p className="text-sm text-stone-500 mt-1">
						Scrape pro profiles and projects from a website or Houzz.in URL,
						then review and approve before publishing.
					</p>
				</div>
				<Link
					to="/admin/imports/new"
					className="px-4 py-2 bg-orange-600 text-white rounded-md hover:bg-orange-700 text-sm font-medium"
				>
					New import
				</Link>
			</div>
 
			{isLoading && <p className="text-stone-500">Loading…</p>}
			{error && (
				<p className="text-red-600">
					Failed to load imports: {(error as Error).message}
				</p>
			)}
 
			{imports && imports.length === 0 && (
				<p className="text-stone-500 italic">
					No imports yet. Click "New import" to start scraping a pro.
				</p>
			)}
 
			{imports && imports.length > 0 && (
				<div className="bg-white border border-stone-200 rounded-lg overflow-hidden">
					{selected.size > 0 && (
						<div className="px-4 py-2 bg-orange-50 border-b border-orange-200 flex items-center justify-between">
							<span className="text-sm text-orange-900">
								{selected.size} import(s) selected
							</span>
							<button
								type="button"
								onClick={onBulkApprove}
								disabled={bulkApprove.isPending}
								className="px-3 py-1 bg-orange-600 text-white text-sm rounded hover:bg-orange-700 disabled:opacity-50"
							>
								{bulkApprove.isPending
									? "Approving…"
									: `Bulk approve (${selected.size})`}
							</button>
						</div>
					)}
					<table className="w-full">
						<thead className="bg-stone-50 border-b border-stone-200">
							<tr>
								<th className="px-4 py-2 text-left">
									<input
										type="checkbox"
										aria-label="Select all approvable imports"
										checked={allChecked}
										disabled={approvable.length === 0}
										onChange={toggleAll}
									/>
								</th>
								<th className="px-4 py-2 text-left text-xs font-medium text-stone-500 uppercase">
									Status
								</th>
								<th className="px-4 py-2 text-left text-xs font-medium text-stone-500 uppercase">
									Pro
								</th>
								<th className="px-4 py-2 text-left text-xs font-medium text-stone-500 uppercase">
									Source URL
								</th>
								<th className="px-4 py-2 text-left text-xs font-medium text-stone-500 uppercase">
									Projects / Photos
								</th>
								<th className="px-4 py-2 text-left text-xs font-medium text-stone-500 uppercase">
									Created
								</th>
								<th className="px-4 py-2"></th>
							</tr>
						</thead>
						<tbody className="divide-y divide-stone-100">
							{imports.map((row) => (
								<tr key={row.id} className="hover:bg-stone-50">
									<td className="px-4 py-3">
										<input
											type="checkbox"
											aria-label={`Select import ${row.id}`}
											checked={selected.has(row.id)}
											disabled={
												row.status !== "completed" || !!row.approvedAt
											}
											onChange={() => toggleOne(row.id)}
										/>
									</td>
									<td className="px-4 py-3">
										<span
											className={`inline-block px-2 py-0.5 text-xs rounded ${
												STATUS_STYLES[row.status] ?? "bg-stone-100"
											}`}
										>
											{row.status}
										</span>
									</td>
									<td className="px-4 py-3 text-sm text-stone-700">
										{row.proBusinessName ?? (
											<span className="font-mono text-stone-500">{row.proId}</span>
										)}
									</td>
									<td className="px-4 py-3 text-sm text-stone-600 max-w-xs truncate">
										{row.sourceUrl}
									</td>
									<td className="px-4 py-3 text-sm text-stone-600">
										{row.statsJson?.projectsFound ?? "—"} /{" "}
										{row.statsJson?.photosDownloaded ?? "—"}
									</td>
									<td className="px-4 py-3 text-sm text-stone-500">
										{formatRelativeTime(row.createdAt)}
									</td>
									<td className="px-4 py-3 text-right">
										<Link
											to="/admin/imports/$importId"
											params={{ importId: row.id }}
											className="text-orange-600 hover:text-orange-700 text-sm font-medium"
										>
											View
										</Link>
									</td>
								</tr>
							))}
						</tbody>
					</table>
				</div>
			)}
		</div>
	);
}
 
export default AdminImportsPage;