Skip to content

Commit fe2f560

Browse files
committed
Merge branch 'feature/import-quality-review'
2 parents c8abe80 + 8bfe1f0 commit fe2f560

16 files changed

+1176
-248
lines changed

app/src/app/import/components/import-job-creator.tsx

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { useRouter } from "next/navigation";
66
import { useImportManager, ImportMode } from "@/atoms/import"; // Updated import
77
import { Button } from "@/components/ui/button";
88
import { Spinner } from "@/components/ui/spinner";
9+
import { Checkbox } from "@/components/ui/checkbox";
910

1011
interface ImportJobCreatorProps {
1112
importMode: ImportMode;
@@ -17,11 +18,12 @@ interface ImportJobCreatorProps {
1718
export function ImportJobCreator({ importMode, uploadPath, unitType, onJobCreated }: ImportJobCreatorProps) {
1819
const [isCreating, setIsCreating] = useState(false);
1920
const [error, setError] = useState<string | null>(null);
20-
const {
21-
createImportJob,
21+
const {
22+
createImportJob,
2223
importState,
2324
loadDefinitions,
2425
timeContext,
26+
setReview,
2527
} = useImportManager();
2628
const router = useRouter();
2729

@@ -72,8 +74,19 @@ export function ImportJobCreator({ importMode, uploadPath, unitType, onJobCreate
7274
</div>
7375
)}
7476

75-
<Button
76-
onClick={handleContinue}
77+
<div className="flex items-center space-x-2 mb-4">
78+
<Checkbox
79+
id="review-before-processing"
80+
checked={importState.review}
81+
onCheckedChange={(checked) => setReview(checked === true)}
82+
/>
83+
<label htmlFor="review-before-processing" className="text-sm cursor-pointer">
84+
Review before processing
85+
</label>
86+
</div>
87+
88+
<Button
89+
onClick={handleContinue}
7790
disabled={isCreating || !selectedDefinition}
7891
className="w-full"
7992
>

app/src/app/import/components/import-job-upload.tsx

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -200,7 +200,20 @@ export function ImportJobUpload({
200200
)}
201201

202202
{["upload_completed", "preparing_data", "analysing_data", "processing_data"].includes(state ?? '') && (
203-
<Progress value={import_completed_pct ?? 0} className="h-2 mt-2" />
203+
<div className="mt-2 space-y-2">
204+
<Progress value={import_completed_pct ?? 0} className="h-2" />
205+
<a href={`/import/jobs/${job.slug}/data`} className="text-sm text-blue-600 hover:text-blue-800 hover:underline">
206+
View Data →
207+
</a>
208+
</div>
209+
)}
210+
211+
{state === 'waiting_for_review' && job?.slug && (
212+
<div className="mt-4">
213+
<Button asChild className="bg-blue-600 hover:bg-blue-700 text-white">
214+
<a href={`/import/jobs/${job.slug}/data`}>Review Data</a>
215+
</Button>
216+
</div>
204217
)}
205218
</div>
206219

app/src/app/import/jobs/[jobSlug]/data/page.tsx

Lines changed: 141 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,8 @@ import {
1616
} from "@tanstack/react-table";
1717
import Link from "next/link";
1818
import { Button } from "@/components/ui/button";
19-
import { ChevronRight, AlertTriangle } from "lucide-react";
19+
import { ChevronRight, ThumbsUp, ThumbsDown } from "lucide-react";
20+
import { StackedProgress } from "@/components/ui/stacked-progress";
2021
import { useGuardedEffect } from "@/hooks/use-guarded-effect";
2122
import { useAtomValue } from "jotai";
2223
import { externalIdentTypesAtom } from "@/atoms/base-data";
@@ -29,6 +30,11 @@ import {
2930
} from "@/hooks/use-swr-with-auth-refresh";
3031
// Per instruction, improve typing for ImportJobDataRow to make 'state' work
3132
// in useDataTable. This is a first step, with 'any' types to be refined.
33+
const formatNumber = (num: number | null | undefined): string => {
34+
if (num === null || num === undefined) return "0";
35+
return num.toLocaleString('nb-NO');
36+
};
37+
3238
type ImportJobDataRow = {
3339
row_id: number;
3440
state: any;
@@ -110,7 +116,11 @@ const fetcher = async (key: string): Promise<any> => {
110116
queryBuilder = queryBuilder.not(key, 'is', null).not(key, 'eq', '{}');
111117
}
112118
} else if (['operation', 'state', 'action'].includes(key)) {
113-
queryBuilder = queryBuilder.in(key, values);
119+
if (values.length === 1 && values[0] === 'not_error') {
120+
queryBuilder = queryBuilder.neq(key, 'error');
121+
} else {
122+
queryBuilder = queryBuilder.in(key, values);
123+
}
114124
} else {
115125
// Text search for name, external idents, etc.
116126
queryBuilder = queryBuilder.ilike(key, `%${values[0]}%`);
@@ -479,7 +489,6 @@ export default function ImportJobDataPage() {
479489
{ label: 'Has value', value: 'not_null' },
480490
{ label: 'Is empty', value: 'is_null' },
481491
],
482-
isPrimary: true,
483492
};
484493
}
485494

@@ -522,29 +531,71 @@ export default function ImportJobDataPage() {
522531

523532
const isLoading = isJobLoading || (isTableDataLoading && !tableData) || awaitingAuthRefresh;
524533

534+
const qualityFilterIds = ['state', 'errors', 'invalid_codes'];
535+
536+
// Check if ok filter is active (quality-based: no errors, no warnings, not error state)
537+
const isOkFilterActive = React.useMemo(() => {
538+
const stateFilter = columnFilters.find(f => f.id === 'state');
539+
const errorsFilter = columnFilters.find(f => f.id === 'errors');
540+
const invalidCodesFilter = columnFilters.find(f => f.id === 'invalid_codes');
541+
return Array.isArray(stateFilter?.value) && stateFilter.value[0] === 'not_error'
542+
&& Array.isArray(errorsFilter?.value) && errorsFilter.value[0] === 'is_null'
543+
&& Array.isArray(invalidCodesFilter?.value) && invalidCodesFilter.value[0] === 'is_null';
544+
}, [columnFilters]);
545+
525546
// Check if error filter is active
526547
const isErrorFilterActive = React.useMemo(() => {
527548
const stateFilter = columnFilters.find(f => f.id === 'state');
528549
if (!stateFilter || !Array.isArray(stateFilter.value)) return false;
529550
return stateFilter.value.includes('error') && stateFilter.value.length === 1;
530551
}, [columnFilters]);
531552

532-
// Toggle error-only filter
553+
// Check if warning filter is active
554+
const isWarningFilterActive = React.useMemo(() => {
555+
const invalidCodesFilter = columnFilters.find(f => f.id === 'invalid_codes');
556+
return Array.isArray(invalidCodesFilter?.value) && invalidCodesFilter.value[0] === 'not_null';
557+
}, [columnFilters]);
558+
559+
// Toggle ok-only filter (clears error and warning filters)
560+
const toggleOkFilter = React.useCallback(() => {
561+
setColumnFilters(prev => {
562+
if (isOkFilterActive) {
563+
return prev.filter(f => !qualityFilterIds.includes(f.id));
564+
} else {
565+
const newFilters = prev.filter(f => !qualityFilterIds.includes(f.id));
566+
return [...newFilters,
567+
{ id: 'state', value: ['not_error'] },
568+
{ id: 'errors', value: ['is_null'] },
569+
{ id: 'invalid_codes', value: ['is_null'] },
570+
];
571+
}
572+
});
573+
}, [isOkFilterActive]);
574+
575+
// Toggle error-only filter (clears ok and warning filters)
533576
const toggleErrorFilter = React.useCallback(() => {
534577
setColumnFilters(prev => {
535-
const stateFilterIndex = prev.findIndex(f => f.id === 'state');
536-
537578
if (isErrorFilterActive) {
538-
// Remove the error filter
539-
return prev.filter(f => f.id !== 'state');
579+
return prev.filter(f => !qualityFilterIds.includes(f.id));
540580
} else {
541-
// Add error filter (replace any existing state filter)
542-
const newFilters = prev.filter(f => f.id !== 'state');
543-
return [...newFilters, { id: 'state', value: ['error'] }];
581+
const newFilters = prev.filter(f => !qualityFilterIds.includes(f.id));
582+
return [...newFilters, { id: 'state', value: ['error'] }, { id: 'errors', value: ['not_null'] }];
544583
}
545584
});
546585
}, [isErrorFilterActive]);
547586

587+
// Toggle warning-only filter (clears ok and error filters)
588+
const toggleWarningFilter = React.useCallback(() => {
589+
setColumnFilters(prev => {
590+
if (isWarningFilterActive) {
591+
return prev.filter(f => !qualityFilterIds.includes(f.id));
592+
} else {
593+
const newFilters = prev.filter(f => !qualityFilterIds.includes(f.id));
594+
return [...newFilters, { id: 'invalid_codes', value: ['not_null'] }];
595+
}
596+
});
597+
}, [isWarningFilterActive]);
598+
548599
// Show error (JWT errors are automatically suppressed while refreshing by the hook)
549600
if (jobError) {
550601
return (
@@ -606,7 +657,43 @@ export default function ImportJobDataPage() {
606657
</div>
607658
<p className="text-sm text-gray-500 mt-1">Description: {job.description ?? 'N/A'} | Table: {job.data_table_name}</p>
608659
</div>
609-
660+
661+
662+
663+
{/* Approve/Reject bar for review workflow */}
664+
{job.state === 'waiting_for_review' && (
665+
<div className="flex items-center gap-3 p-3 bg-amber-50 border border-amber-200 rounded-lg">
666+
<span className="text-sm font-medium text-amber-800 flex-grow">This job is waiting for your review.</span>
667+
<Button
668+
variant="outline"
669+
size="sm"
670+
className="border-red-300 text-red-600 hover:bg-red-50"
671+
onClick={async () => {
672+
const client = await getBrowserRestClient();
673+
const { error } = await client.from("import_job").update({ state: 'rejected' as any }).eq("id", job.id);
674+
if (error) { console.error("Reject failed:", error); alert(`Error: ${error.message}`); }
675+
else { mutateJob(); }
676+
}}
677+
>
678+
<ThumbsDown className="mr-1 h-4 w-4" />
679+
Reject
680+
</Button>
681+
<Button
682+
size="sm"
683+
className="bg-green-600 hover:bg-green-700 text-white"
684+
onClick={async () => {
685+
const client = await getBrowserRestClient();
686+
const { error } = await client.from("import_job").update({ state: 'approved' as any }).eq("id", job.id);
687+
if (error) { console.error("Approve failed:", error); alert(`Error: ${error.message}`); }
688+
else { mutateJob(); }
689+
}}
690+
>
691+
<ThumbsUp className="mr-1 h-4 w-4" />
692+
Approve
693+
</Button>
694+
</div>
695+
)}
696+
610697
{tableError && (
611698
<div className="p-4 bg-red-50 border border-red-200 rounded-md text-red-700">
612699
Failed to load table data: {tableError.message}
@@ -636,18 +723,48 @@ export default function ImportJobDataPage() {
636723
}}
637724
>
638725
<DataTableToolbar table={table}>
639-
<Button
640-
variant={isErrorFilterActive ? "default" : "outline"}
641-
size="sm"
642-
className={isErrorFilterActive
643-
? "h-8 bg-red-600 hover:bg-red-700 text-white"
644-
: "h-8 border-dashed text-red-600 hover:bg-red-50 hover:text-red-700"
645-
}
646-
onClick={toggleErrorFilter}
647-
>
648-
<AlertTriangle className="mr-1 h-4 w-4" />
649-
{isErrorFilterActive ? "Showing Errors" : "Show Errors Only"}
650-
</Button>
726+
{(() => {
727+
const okCount = (job?.total_rows ?? 0) - (job?.error_count ?? 0) - (job?.warning_count ?? 0);
728+
return okCount > 0 ? (
729+
<Button
730+
variant={isOkFilterActive ? "default" : "outline"}
731+
size="sm"
732+
className={isOkFilterActive
733+
? "h-8 bg-green-600 hover:bg-green-700 text-white"
734+
: "h-8 border-dashed text-green-700 hover:bg-green-50"
735+
}
736+
onClick={toggleOkFilter}
737+
>
738+
<span className="font-mono">{formatNumber(okCount)}</span>&nbsp;ok
739+
</Button>
740+
) : null;
741+
})()}
742+
{(job?.warning_count ?? 0) > 0 && (
743+
<Button
744+
variant={isWarningFilterActive ? "default" : "outline"}
745+
size="sm"
746+
className={isWarningFilterActive
747+
? "h-8 bg-amber-500 hover:bg-amber-600 text-white"
748+
: "h-8 border-dashed text-amber-600 hover:bg-amber-50"
749+
}
750+
onClick={toggleWarningFilter}
751+
>
752+
<span className="font-mono">{formatNumber(job?.warning_count)}</span>&nbsp;warn
753+
</Button>
754+
)}
755+
{(job?.error_count ?? 0) > 0 && (
756+
<Button
757+
variant={isErrorFilterActive ? "default" : "outline"}
758+
size="sm"
759+
className={isErrorFilterActive
760+
? "h-8 bg-red-600 hover:bg-red-700 text-white"
761+
: "h-8 border-dashed text-red-600 hover:bg-red-50"
762+
}
763+
onClick={toggleErrorFilter}
764+
>
765+
<span className="font-mono">{formatNumber(job?.error_count)}</span>&nbsp;err
766+
</Button>
767+
)}
651768
</DataTableToolbar>
652769
</DataTable>
653770
}

0 commit comments

Comments
 (0)